Switch to file scopped namespaces

This commit is contained in:
Zoe Roux 2024-03-23 00:14:34 +01:00
parent 35e37bbe76
commit 18e301f26a
No known key found for this signature in database
99 changed files with 8936 additions and 9141 deletions

View File

@ -16,6 +16,8 @@ dotnet_diagnostic.IDE0055.severity = none
dotnet_diagnostic.IDE0058.severity = none dotnet_diagnostic.IDE0058.severity = none
dotnet_diagnostic.IDE0130.severity = none dotnet_diagnostic.IDE0130.severity = none
# Convert to file-scoped namespace
csharp_style_namespace_declarations = file_scoped:warning
# Sort using and Import directives with System.* appearing first # Sort using and Import directives with System.* appearing first
dotnet_sort_system_directives_first = true dotnet_sort_system_directives_first = true
csharp_using_directive_placement = outside_namespace:warning csharp_using_directive_placement = outside_namespace:warning

View File

@ -18,64 +18,63 @@
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// An interface to interact with the database. Every repository is mapped through here.
/// </summary>
public interface ILibraryManager
{ {
IRepository<T> Repository<T>()
where T : IResource, IQuery;
/// <summary> /// <summary>
/// An interface to interact with the database. Every repository is mapped through here. /// The repository that handle libraries items (a wrapper around shows and collections).
/// </summary> /// </summary>
public interface ILibraryManager IRepository<ILibraryItem> LibraryItems { get; }
{
IRepository<T> Repository<T>()
where T : IResource, IQuery;
/// <summary> /// <summary>
/// The repository that handle libraries items (a wrapper around shows and collections). /// The repository that handle new items.
/// </summary> /// </summary>
IRepository<ILibraryItem> LibraryItems { get; } IRepository<INews> News { get; }
/// <summary> /// <summary>
/// The repository that handle new items. /// The repository that handle watched items.
/// </summary> /// </summary>
IRepository<INews> News { get; } IWatchStatusRepository WatchStatus { get; }
/// <summary> /// <summary>
/// The repository that handle watched items. /// The repository that handle collections.
/// </summary> /// </summary>
IWatchStatusRepository WatchStatus { get; } IRepository<Collection> Collections { get; }
/// <summary> /// <summary>
/// The repository that handle collections. /// The repository that handle shows.
/// </summary> /// </summary>
IRepository<Collection> Collections { get; } IRepository<Movie> Movies { get; }
/// <summary> /// <summary>
/// The repository that handle shows. /// The repository that handle shows.
/// </summary> /// </summary>
IRepository<Movie> Movies { get; } IRepository<Show> Shows { get; }
/// <summary> /// <summary>
/// The repository that handle shows. /// The repository that handle seasons.
/// </summary> /// </summary>
IRepository<Show> Shows { get; } IRepository<Season> Seasons { get; }
/// <summary> /// <summary>
/// The repository that handle seasons. /// The repository that handle episodes.
/// </summary> /// </summary>
IRepository<Season> Seasons { get; } IRepository<Episode> Episodes { get; }
/// <summary> /// <summary>
/// The repository that handle episodes. /// The repository that handle studios.
/// </summary> /// </summary>
IRepository<Episode> Episodes { get; } IRepository<Studio> Studios { get; }
/// <summary> /// <summary>
/// The repository that handle studios. /// The repository that handle users.
/// </summary> /// </summary>
IRepository<Studio> Studios { get; } IRepository<User> Users { get; }
/// <summary>
/// The repository that handle users.
/// </summary>
IRepository<User> Users { get; }
}
} }

View File

@ -19,29 +19,28 @@
using Kyoo.Abstractions.Models.Permissions; using Kyoo.Abstractions.Models.Permissions;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// A service to validate permissions.
/// </summary>
public interface IPermissionValidator
{ {
/// <summary> /// <summary>
/// A service to validate permissions. /// Create an IAuthorizationFilter that will be used to validate permissions.
/// This can registered with any lifetime.
/// </summary> /// </summary>
public interface IPermissionValidator /// <param name="attribute">The permission attribute to validate.</param>
{ /// <returns>An authorization filter used to validate the permission.</returns>
/// <summary> IFilterMetadata Create(PermissionAttribute attribute);
/// Create an IAuthorizationFilter that will be used to validate permissions.
/// This can registered with any lifetime.
/// </summary>
/// <param name="attribute">The permission attribute to validate.</param>
/// <returns>An authorization filter used to validate the permission.</returns>
IFilterMetadata Create(PermissionAttribute attribute);
/// <summary> /// <summary>
/// Create an IAuthorizationFilter that will be used to validate permissions. /// Create an IAuthorizationFilter that will be used to validate permissions.
/// This can registered with any lifetime. /// This can registered with any lifetime.
/// </summary> /// </summary>
/// <param name="attribute"> /// <param name="attribute">
/// A partial attribute to validate. See <see cref="PartialPermissionAttribute"/>. /// A partial attribute to validate. See <see cref="PartialPermissionAttribute"/>.
/// </param> /// </param>
/// <returns>An authorization filter used to validate the permission.</returns> /// <returns>An authorization filter used to validate the permission.</returns>
IFilterMetadata Create(PartialPermissionAttribute attribute); IFilterMetadata Create(PartialPermissionAttribute attribute);
}
} }

View File

@ -21,46 +21,45 @@ using System.Collections.Generic;
using Autofac; using Autofac;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// A common interface used to discord plugins
/// </summary>
/// <remarks>
/// You can inject services in the IPlugin constructor.
/// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment.
/// </remarks>
public interface IPlugin
{ {
/// <summary> /// <summary>
/// A common interface used to discord plugins /// The name of the plugin
/// </summary> /// </summary>
/// <remarks> string Name { get; }
/// You can inject services in the IPlugin constructor.
/// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment. /// <summary>
/// </remarks> /// An optional configuration step to allow a plugin to change asp net configurations.
public interface IPlugin /// </summary>
/// <seealso cref="SA"/>
IEnumerable<IStartupAction> ConfigureSteps => ArraySegment<IStartupAction>.Empty;
/// <summary>
/// A configure method that will be run on plugin's startup.
/// </summary>
/// <param name="builder">The autofac service container to register services.</param>
void Configure(ContainerBuilder builder)
{ {
/// <summary> // Skipped
/// The name of the plugin }
/// </summary>
string Name { get; }
/// <summary> /// <summary>
/// An optional configuration step to allow a plugin to change asp net configurations. /// A configure method that will be run on plugin's startup.
/// </summary> /// This is available for libraries that build upon a <see cref="IServiceCollection"/>, for more precise
/// <seealso cref="SA"/> /// configuration use <see cref="Configure(Autofac.ContainerBuilder)"/>.
IEnumerable<IStartupAction> ConfigureSteps => ArraySegment<IStartupAction>.Empty; /// </summary>
/// <param name="services">A service container to register new services.</param>
/// <summary> void Configure(IServiceCollection services)
/// A configure method that will be run on plugin's startup. {
/// </summary> // Skipped
/// <param name="builder">The autofac service container to register services.</param>
void Configure(ContainerBuilder builder)
{
// Skipped
}
/// <summary>
/// A configure method that will be run on plugin's startup.
/// This is available for libraries that build upon a <see cref="IServiceCollection"/>, for more precise
/// configuration use <see cref="Configure(Autofac.ContainerBuilder)"/>.
/// </summary>
/// <param name="services">A service container to register new services.</param>
void Configure(IServiceCollection services)
{
// Skipped
}
} }
} }

View File

@ -20,51 +20,50 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Exceptions;
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// A manager to load plugins and retrieve information from them.
/// </summary>
public interface IPluginManager
{ {
/// <summary> /// <summary>
/// A manager to load plugins and retrieve information from them. /// Get a single plugin that match the type and name given.
/// </summary> /// </summary>
public interface IPluginManager /// <param name="name">The name of the plugin</param>
{ /// <typeparam name="T">The type of the plugin</typeparam>
/// <summary> /// <exception cref="ItemNotFoundException">If no plugins match the query</exception>
/// Get a single plugin that match the type and name given. /// <returns>A plugin that match the queries</returns>
/// </summary> public T GetPlugin<T>(string name);
/// <param name="name">The name of the plugin</param>
/// <typeparam name="T">The type of the plugin</typeparam>
/// <exception cref="ItemNotFoundException">If no plugins match the query</exception>
/// <returns>A plugin that match the queries</returns>
public T GetPlugin<T>(string name);
/// <summary> /// <summary>
/// Get all plugins of the given type. /// Get all plugins of the given type.
/// </summary> /// </summary>
/// <typeparam name="T">The type of plugins to get</typeparam> /// <typeparam name="T">The type of plugins to get</typeparam>
/// <returns>A list of plugins matching the given type or an empty list of none match.</returns> /// <returns>A list of plugins matching the given type or an empty list of none match.</returns>
public ICollection<T> GetPlugins<T>(); public ICollection<T> GetPlugins<T>();
/// <summary> /// <summary>
/// Get all plugins currently running on Kyoo. This also includes deleted plugins if the app as not been restarted. /// Get all plugins currently running on Kyoo. This also includes deleted plugins if the app as not been restarted.
/// </summary> /// </summary>
/// <returns>All plugins currently loaded.</returns> /// <returns>All plugins currently loaded.</returns>
public ICollection<IPlugin> GetAllPlugins(); public ICollection<IPlugin> GetAllPlugins();
/// <summary> /// <summary>
/// Load plugins and their dependencies from the plugin directory. /// Load plugins and their dependencies from the plugin directory.
/// </summary> /// </summary>
/// <param name="plugins"> /// <param name="plugins">
/// An initial plugin list to use. /// An initial plugin list to use.
/// You should not try to put plugins from the plugins directory here as they will get automatically loaded. /// You should not try to put plugins from the plugins directory here as they will get automatically loaded.
/// </param> /// </param>
public void LoadPlugins(ICollection<IPlugin> plugins); public void LoadPlugins(ICollection<IPlugin> plugins);
/// <summary> /// <summary>
/// Load plugins and their dependencies from the plugin directory. /// Load plugins and their dependencies from the plugin directory.
/// </summary> /// </summary>
/// <param name="plugins"> /// <param name="plugins">
/// An initial plugin list to use. /// An initial plugin list to use.
/// You should not try to put plugins from the plugins directory here as they will get automatically loaded. /// You should not try to put plugins from the plugins directory here as they will get automatically loaded.
/// </param> /// </param>
public void LoadPlugins(params Type[] plugins); public void LoadPlugins(params Type[] plugins);
}
} }

View File

@ -23,249 +23,245 @@ using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Abstractions.Models.Utils; using Kyoo.Abstractions.Models.Utils;
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// A common repository for every resources.
/// </summary>
/// <typeparam name="T">The resource's type that this repository manage.</typeparam>
public interface IRepository<T> : IBaseRepository
where T : IResource, IQuery
{ {
/// <summary> /// <summary>
/// A common repository for every resources. /// The event handler type for all events of this repository.
/// </summary> /// </summary>
/// <typeparam name="T">The resource's type that this repository manage.</typeparam> /// <param name="resource">The resource created/modified/deleted</param>
public interface IRepository<T> : IBaseRepository /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
where T : IResource, IQuery public delegate Task ResourceEventHandler(T resource);
{
/// <summary>
/// The event handler type for all events of this repository.
/// </summary>
/// <param name="resource">The resource created/modified/deleted</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public delegate Task ResourceEventHandler(T resource);
/// <summary>
/// Get a resource from it's ID.
/// </summary>
/// <param name="id">The id of the resource</param>
/// <param name="include">The related fields to include.</param>
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
/// <returns>The resource found</returns>
Task<T> Get(Guid id, Include<T>? include = default);
/// <summary>
/// Get a resource from it's slug.
/// </summary>
/// <param name="slug">The slug of the resource</param>
/// <param name="include">The related fields to include.</param>
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
/// <returns>The resource found</returns>
Task<T> Get(string slug, Include<T>? include = default);
/// <summary>
/// Get the first resource that match the predicate.
/// </summary>
/// <param name="filter">A predicate to filter the resource.</param>
/// <param name="include">The related fields to include.</param>
/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param>
/// <param name="reverse">Reverse the sort.</param>
/// <param name="afterId">Select the first element after this id if it was in a list.</param>
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
/// <returns>The resource found</returns>
Task<T> Get(
Filter<T> filter,
Include<T>? include = default,
Sort<T>? sortBy = default,
bool reverse = false,
Guid? afterId = default
);
/// <summary>
/// Get a resource from it's ID or null if it is not found.
/// </summary>
/// <param name="id">The id of the resource</param>
/// <param name="include">The related fields to include.</param>
/// <returns>The resource found</returns>
Task<T?> GetOrDefault(Guid id, Include<T>? include = default);
/// <summary>
/// Get a resource from it's slug or null if it is not found.
/// </summary>
/// <param name="slug">The slug of the resource</param>
/// <param name="include">The related fields to include.</param>
/// <returns>The resource found</returns>
Task<T?> GetOrDefault(string slug, Include<T>? include = default);
/// <summary>
/// Get the first resource that match the predicate or null if it is not found.
/// </summary>
/// <param name="filter">A predicate to filter the resource.</param>
/// <param name="include">The related fields to include.</param>
/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param>
/// <param name="reverse">Reverse the sort.</param>
/// <param name="afterId">Select the first element after this id if it was in a list.</param>
/// <returns>The resource found</returns>
Task<T?> GetOrDefault(
Filter<T>? filter,
Include<T>? include = default,
Sort<T>? sortBy = default,
bool reverse = false,
Guid? afterId = default
);
/// <summary>
/// Search for resources with the database.
/// </summary>
/// <param name="query">The query string.</param>
/// <param name="include">The related fields to include.</param>
/// <returns>A list of resources found</returns>
Task<ICollection<T>> Search(string query, Include<T>? include = default);
/// <summary>
/// Get every resources that match all filters
/// </summary>
/// <param name="filter">A filter predicate</param>
/// <param name="sort">Sort information about the query (sort by, sort order)</param>
/// <param name="include">The related fields to include.</param>
/// <param name="limit">How pagination should be done (where to start and how many to return)</param>
/// <returns>A list of resources that match every filters</returns>
Task<ICollection<T>> GetAll(
Filter<T>? filter = null,
Sort<T>? sort = default,
Include<T>? include = default,
Pagination? limit = default
);
/// <summary>
/// Get the number of resources that match the filter's predicate.
/// </summary>
/// <param name="filter">A filter predicate</param>
/// <returns>How many resources matched that filter</returns>
Task<int> GetCount(Filter<T>? filter = null);
/// <summary>
/// Map a list of ids to a list of items (keep the order).
/// </summary>
/// <param name="ids">The list of items id.</param>
/// <param name="include">The related fields to include.</param>
/// <returns>A list of resources mapped from ids.</returns>
Task<ICollection<T>> FromIds(IList<Guid> ids, Include<T>? include = default);
/// <summary>
/// Create a new resource.
/// </summary>
/// <param name="obj">The item to register</param>
/// <returns>The resource registers and completed by database's information (related items and so on)</returns>
Task<T> Create(T obj);
/// <summary>
/// Create a new resource if it does not exist already. If it does, the existing value is returned instead.
/// </summary>
/// <param name="obj">The object to create</param>
/// <returns>The newly created item or the existing value if it existed.</returns>
Task<T> CreateIfNotExists(T obj);
/// <summary>
/// Called when a resource has been created.
/// </summary>
static event ResourceEventHandler OnCreated;
/// <summary>
/// Callback that should be called after a resource has been created.
/// </summary>
/// <param name="obj">The resource newly created.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected static Task OnResourceCreated(T obj) =>
OnCreated?.Invoke(obj) ?? Task.CompletedTask;
/// <summary>
/// Edit a resource and replace every property
/// </summary>
/// <param name="edited">The resource to edit, it's ID can't change.</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The resource edited and completed by database's information (related items and so on)</returns>
Task<T> Edit(T edited);
/// <summary>
/// Edit only specific properties of a resource
/// </summary>
/// <param name="id">The id of the resource to edit</param>
/// <param name="patch">
/// A method that will be called when you need to update every properties that you want to
/// persist.
/// </param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The resource edited and completed by database's information (related items and so on)</returns>
Task<T> Patch(Guid id, Func<T, T> patch);
/// <summary>
/// Called when a resource has been edited.
/// </summary>
static event ResourceEventHandler OnEdited;
/// <summary>
/// Callback that should be called after a resource has been edited.
/// </summary>
/// <param name="obj">The resource newly edited.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected static Task OnResourceEdited(T obj) =>
OnEdited?.Invoke(obj) ?? Task.CompletedTask;
/// <summary>
/// Delete a resource by it's ID
/// </summary>
/// <param name="id">The ID of the resource</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task Delete(Guid id);
/// <summary>
/// Delete a resource by it's slug
/// </summary>
/// <param name="slug">The slug of the resource</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task Delete(string slug);
/// <summary>
/// Delete a resource
/// </summary>
/// <param name="obj">The resource to delete</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task Delete(T obj);
/// <summary>
/// Delete all resources that match the predicate.
/// </summary>
/// <param name="filter">A predicate to filter resources to delete. Every resource that match this will be deleted.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DeleteAll(Filter<T> filter);
/// <summary>
/// Called when a resource has been edited.
/// </summary>
static event ResourceEventHandler OnDeleted;
/// <summary>
/// Callback that should be called after a resource has been deleted.
/// </summary>
/// <param name="obj">The resource newly deleted.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected static Task OnResourceDeleted(T obj) =>
OnDeleted?.Invoke(obj) ?? Task.CompletedTask;
}
/// <summary> /// <summary>
/// A base class for repositories. Every service implementing this will be handled by the <see cref="ILibraryManager"/>. /// Get a resource from it's ID.
/// </summary> /// </summary>
public interface IBaseRepository /// <param name="id">The id of the resource</param>
{ /// <param name="include">The related fields to include.</param>
/// <summary> /// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
/// The type for witch this repository is responsible or null if non applicable. /// <returns>The resource found</returns>
/// </summary> Task<T> Get(Guid id, Include<T>? include = default);
Type RepositoryType { get; }
}
public interface IUserRepository : IRepository<User> /// <summary>
{ /// Get a resource from it's slug.
Task<User?> GetByExternalId(string provider, string id); /// </summary>
Task<User> AddExternalToken(Guid userId, string provider, ExternalToken token); /// <param name="slug">The slug of the resource</param>
Task<User> DeleteExternalToken(Guid userId, string provider); /// <param name="include">The related fields to include.</param>
} /// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
/// <returns>The resource found</returns>
Task<T> Get(string slug, Include<T>? include = default);
/// <summary>
/// Get the first resource that match the predicate.
/// </summary>
/// <param name="filter">A predicate to filter the resource.</param>
/// <param name="include">The related fields to include.</param>
/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param>
/// <param name="reverse">Reverse the sort.</param>
/// <param name="afterId">Select the first element after this id if it was in a list.</param>
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
/// <returns>The resource found</returns>
Task<T> Get(
Filter<T> filter,
Include<T>? include = default,
Sort<T>? sortBy = default,
bool reverse = false,
Guid? afterId = default
);
/// <summary>
/// Get a resource from it's ID or null if it is not found.
/// </summary>
/// <param name="id">The id of the resource</param>
/// <param name="include">The related fields to include.</param>
/// <returns>The resource found</returns>
Task<T?> GetOrDefault(Guid id, Include<T>? include = default);
/// <summary>
/// Get a resource from it's slug or null if it is not found.
/// </summary>
/// <param name="slug">The slug of the resource</param>
/// <param name="include">The related fields to include.</param>
/// <returns>The resource found</returns>
Task<T?> GetOrDefault(string slug, Include<T>? include = default);
/// <summary>
/// Get the first resource that match the predicate or null if it is not found.
/// </summary>
/// <param name="filter">A predicate to filter the resource.</param>
/// <param name="include">The related fields to include.</param>
/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param>
/// <param name="reverse">Reverse the sort.</param>
/// <param name="afterId">Select the first element after this id if it was in a list.</param>
/// <returns>The resource found</returns>
Task<T?> GetOrDefault(
Filter<T>? filter,
Include<T>? include = default,
Sort<T>? sortBy = default,
bool reverse = false,
Guid? afterId = default
);
/// <summary>
/// Search for resources with the database.
/// </summary>
/// <param name="query">The query string.</param>
/// <param name="include">The related fields to include.</param>
/// <returns>A list of resources found</returns>
Task<ICollection<T>> Search(string query, Include<T>? include = default);
/// <summary>
/// Get every resources that match all filters
/// </summary>
/// <param name="filter">A filter predicate</param>
/// <param name="sort">Sort information about the query (sort by, sort order)</param>
/// <param name="include">The related fields to include.</param>
/// <param name="limit">How pagination should be done (where to start and how many to return)</param>
/// <returns>A list of resources that match every filters</returns>
Task<ICollection<T>> GetAll(
Filter<T>? filter = null,
Sort<T>? sort = default,
Include<T>? include = default,
Pagination? limit = default
);
/// <summary>
/// Get the number of resources that match the filter's predicate.
/// </summary>
/// <param name="filter">A filter predicate</param>
/// <returns>How many resources matched that filter</returns>
Task<int> GetCount(Filter<T>? filter = null);
/// <summary>
/// Map a list of ids to a list of items (keep the order).
/// </summary>
/// <param name="ids">The list of items id.</param>
/// <param name="include">The related fields to include.</param>
/// <returns>A list of resources mapped from ids.</returns>
Task<ICollection<T>> FromIds(IList<Guid> ids, Include<T>? include = default);
/// <summary>
/// Create a new resource.
/// </summary>
/// <param name="obj">The item to register</param>
/// <returns>The resource registers and completed by database's information (related items and so on)</returns>
Task<T> Create(T obj);
/// <summary>
/// Create a new resource if it does not exist already. If it does, the existing value is returned instead.
/// </summary>
/// <param name="obj">The object to create</param>
/// <returns>The newly created item or the existing value if it existed.</returns>
Task<T> CreateIfNotExists(T obj);
/// <summary>
/// Called when a resource has been created.
/// </summary>
static event ResourceEventHandler OnCreated;
/// <summary>
/// Callback that should be called after a resource has been created.
/// </summary>
/// <param name="obj">The resource newly created.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected static Task OnResourceCreated(T obj) => OnCreated?.Invoke(obj) ?? Task.CompletedTask;
/// <summary>
/// Edit a resource and replace every property
/// </summary>
/// <param name="edited">The resource to edit, it's ID can't change.</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The resource edited and completed by database's information (related items and so on)</returns>
Task<T> Edit(T edited);
/// <summary>
/// Edit only specific properties of a resource
/// </summary>
/// <param name="id">The id of the resource to edit</param>
/// <param name="patch">
/// A method that will be called when you need to update every properties that you want to
/// persist.
/// </param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The resource edited and completed by database's information (related items and so on)</returns>
Task<T> Patch(Guid id, Func<T, T> patch);
/// <summary>
/// Called when a resource has been edited.
/// </summary>
static event ResourceEventHandler OnEdited;
/// <summary>
/// Callback that should be called after a resource has been edited.
/// </summary>
/// <param name="obj">The resource newly edited.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected static Task OnResourceEdited(T obj) => OnEdited?.Invoke(obj) ?? Task.CompletedTask;
/// <summary>
/// Delete a resource by it's ID
/// </summary>
/// <param name="id">The ID of the resource</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task Delete(Guid id);
/// <summary>
/// Delete a resource by it's slug
/// </summary>
/// <param name="slug">The slug of the resource</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task Delete(string slug);
/// <summary>
/// Delete a resource
/// </summary>
/// <param name="obj">The resource to delete</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task Delete(T obj);
/// <summary>
/// Delete all resources that match the predicate.
/// </summary>
/// <param name="filter">A predicate to filter resources to delete. Every resource that match this will be deleted.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DeleteAll(Filter<T> filter);
/// <summary>
/// Called when a resource has been edited.
/// </summary>
static event ResourceEventHandler OnDeleted;
/// <summary>
/// Callback that should be called after a resource has been deleted.
/// </summary>
/// <param name="obj">The resource newly deleted.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected static Task OnResourceDeleted(T obj) => OnDeleted?.Invoke(obj) ?? Task.CompletedTask;
}
/// <summary>
/// A base class for repositories. Every service implementing this will be handled by the <see cref="ILibraryManager"/>.
/// </summary>
public interface IBaseRepository
{
/// <summary>
/// The type for witch this repository is responsible or null if non applicable.
/// </summary>
Type RepositoryType { get; }
}
public interface IUserRepository : IRepository<User>
{
Task<User?> GetByExternalId(string provider, string id);
Task<User> AddExternalToken(Guid userId, string provider, ExternalToken token);
Task<User> DeleteExternalToken(Guid userId, string provider);
} }

View File

@ -21,59 +21,58 @@ using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// Download images and retrieve the path of those images for a resource.
/// </summary>
public interface IThumbnailsManager
{ {
/// <summary> /// <summary>
/// Download images and retrieve the path of those images for a resource. /// Download images of a specified item.
/// If no images is available to download, do nothing and silently return.
/// </summary> /// </summary>
public interface IThumbnailsManager /// <param name="item">
{ /// The item to cache images.
/// <summary> /// </param>
/// Download images of a specified item. /// <typeparam name="T">The type of the item</typeparam>
/// If no images is available to download, do nothing and silently return. /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
/// </summary> Task DownloadImages<T>(T item)
/// <param name="item"> where T : IThumbnails;
/// The item to cache images.
/// </param>
/// <typeparam name="T">The type of the item</typeparam>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DownloadImages<T>(T item)
where T : IThumbnails;
/// <summary> /// <summary>
/// Retrieve the local path of an image of the given item. /// Retrieve the local path of an image of the given item.
/// </summary> /// </summary>
/// <param name="item">The item to retrieve the poster from.</param> /// <param name="item">The item to retrieve the poster from.</param>
/// <param name="image">The ID of the image.</param> /// <param name="image">The ID of the image.</param>
/// <param name="quality">The quality of the image</param> /// <param name="quality">The quality of the image</param>
/// <typeparam name="T">The type of the item</typeparam> /// <typeparam name="T">The type of the item</typeparam>
/// <returns>The path of the image for the given resource or null if it does not exists.</returns> /// <returns>The path of the image for the given resource or null if it does not exists.</returns>
string GetImagePath<T>(T item, string image, ImageQuality quality) string GetImagePath<T>(T item, string image, ImageQuality quality)
where T : IThumbnails; where T : IThumbnails;
/// <summary> /// <summary>
/// Delete images associated with the item. /// Delete images associated with the item.
/// </summary> /// </summary>
/// <param name="item"> /// <param name="item">
/// The item with cached images. /// The item with cached images.
/// </param> /// </param>
/// <typeparam name="T">The type of the item</typeparam> /// <typeparam name="T">The type of the item</typeparam>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task DeleteImages<T>(T item) Task DeleteImages<T>(T item)
where T : IThumbnails; where T : IThumbnails;
/// <summary> /// <summary>
/// Set the user's profile picture /// Set the user's profile picture
/// </summary> /// </summary>
/// <param name="userId">The id of the user. </param> /// <param name="userId">The id of the user. </param>
/// <returns>The byte stream of the image. Null if no image exist.</returns> /// <returns>The byte stream of the image. Null if no image exist.</returns>
Task<Stream> GetUserImage(Guid userId); Task<Stream> GetUserImage(Guid userId);
/// <summary> /// <summary>
/// Set the user's profile picture /// Set the user's profile picture
/// </summary> /// </summary>
/// <param name="userId">The id of the user. </param> /// <param name="userId">The id of the user. </param>
/// <param name="image">The byte stream of the image. Null to delete the image.</param> /// <param name="image">The byte stream of the image. Null to delete the image.</param>
Task SetUserImage(Guid userId, Stream? image); Task SetUserImage(Guid userId, Stream? image);
}
} }

View File

@ -19,256 +19,252 @@
using System; using System;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// A list of constant priorities used for <see cref="IStartupAction"/>'s <see cref="IStartupAction.Priority"/>.
/// It also contains helper methods for creating new <see cref="StartupAction"/>.
/// </summary>
public static class SA
{ {
/// <summary> /// <summary>
/// A list of constant priorities used for <see cref="IStartupAction"/>'s <see cref="IStartupAction.Priority"/>. /// The highest predefined priority existing for <see cref="StartupAction"/>.
/// It also contains helper methods for creating new <see cref="StartupAction"/>.
/// </summary> /// </summary>
public static class SA public const int Before = 5000;
/// <summary>
/// Items defining routing (see IApplicationBuilder.UseRouting use this priority.
/// </summary>
public const int Routing = 4000;
/// <summary>
/// Actions defining new static files router use this priority.
/// </summary>
public const int StaticFiles = 3000;
/// <summary>
/// Actions calling IApplicationBuilder.UseAuthentication use this priority.
/// </summary>
public const int Authentication = 2000;
/// <summary>
/// Actions calling IApplicationBuilder.UseAuthorization use this priority.
/// </summary>
public const int Authorization = 1000;
/// <summary>
/// Action adding endpoint should use this priority (with a negative modificator if there is a catchall).
/// </summary>
public const int Endpoint = 0;
/// <summary>
/// The lowest predefined priority existing for <see cref="StartupAction"/>.
/// It should run after all other actions.
/// </summary>
public const int After = -1000;
/// <summary>
/// Create a new <see cref="StartupAction"/>.
/// </summary>
/// <param name="action">The action to run</param>
/// <param name="priority">The priority of the new action</param>
/// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction New(Action action, int priority) => new(action, priority);
/// <summary>
/// Create a new <see cref="StartupAction"/>.
/// </summary>
/// <param name="action">The action to run</param>
/// <param name="priority">The priority of the new action</param>
/// <typeparam name="T">A dependency that this action will use.</typeparam>
/// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction<T> New<T>(Action<T> action, int priority)
where T : notnull => new(action, priority);
/// <summary>
/// Create a new <see cref="StartupAction"/>.
/// </summary>
/// <param name="action">The action to run</param>
/// <param name="priority">The priority of the new action</param>
/// <typeparam name="T">A dependency that this action will use.</typeparam>
/// <typeparam name="T2">A second dependency that this action will use.</typeparam>
/// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction<T, T2> New<T, T2>(Action<T, T2> action, int priority)
where T : notnull
where T2 : notnull => new(action, priority);
/// <summary>
/// Create a new <see cref="StartupAction"/>.
/// </summary>
/// <param name="action">The action to run</param>
/// <param name="priority">The priority of the new action</param>
/// <typeparam name="T">A dependency that this action will use.</typeparam>
/// <typeparam name="T2">A second dependency that this action will use.</typeparam>
/// <typeparam name="T3">A third dependency that this action will use.</typeparam>
/// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction<T, T2, T3> New<T, T2, T3>(Action<T, T2, T3> action, int priority)
where T : notnull
where T2 : notnull
where T3 : notnull => new(action, priority);
/// <summary>
/// A <see cref="IStartupAction"/> with no dependencies.
/// </summary>
public class StartupAction : IStartupAction
{ {
/// <summary> /// <summary>
/// The highest predefined priority existing for <see cref="StartupAction"/>. /// The action to execute at startup.
/// </summary> /// </summary>
public const int Before = 5000; private readonly Action _action;
/// <summary> /// <inheritdoc />
/// Items defining routing (see IApplicationBuilder.UseRouting use this priority. public int Priority { get; }
/// </summary>
public const int Routing = 4000;
/// <summary>
/// Actions defining new static files router use this priority.
/// </summary>
public const int StaticFiles = 3000;
/// <summary>
/// Actions calling IApplicationBuilder.UseAuthentication use this priority.
/// </summary>
public const int Authentication = 2000;
/// <summary>
/// Actions calling IApplicationBuilder.UseAuthorization use this priority.
/// </summary>
public const int Authorization = 1000;
/// <summary>
/// Action adding endpoint should use this priority (with a negative modificator if there is a catchall).
/// </summary>
public const int Endpoint = 0;
/// <summary>
/// The lowest predefined priority existing for <see cref="StartupAction"/>.
/// It should run after all other actions.
/// </summary>
public const int After = -1000;
/// <summary> /// <summary>
/// Create a new <see cref="StartupAction"/>. /// Create a new <see cref="StartupAction"/>.
/// </summary> /// </summary>
/// <param name="action">The action to run</param> /// <param name="action">The action to execute on startup.</param>
/// <param name="priority">The priority of the new action</param> /// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
/// <returns>A new <see cref="StartupAction"/></returns> public StartupAction(Action action, int priority)
public static StartupAction New(Action action, int priority) => new(action, priority);
/// <summary>
/// Create a new <see cref="StartupAction"/>.
/// </summary>
/// <param name="action">The action to run</param>
/// <param name="priority">The priority of the new action</param>
/// <typeparam name="T">A dependency that this action will use.</typeparam>
/// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction<T> New<T>(Action<T> action, int priority)
where T : notnull => new(action, priority);
/// <summary>
/// Create a new <see cref="StartupAction"/>.
/// </summary>
/// <param name="action">The action to run</param>
/// <param name="priority">The priority of the new action</param>
/// <typeparam name="T">A dependency that this action will use.</typeparam>
/// <typeparam name="T2">A second dependency that this action will use.</typeparam>
/// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction<T, T2> New<T, T2>(Action<T, T2> action, int priority)
where T : notnull
where T2 : notnull => new(action, priority);
/// <summary>
/// Create a new <see cref="StartupAction"/>.
/// </summary>
/// <param name="action">The action to run</param>
/// <param name="priority">The priority of the new action</param>
/// <typeparam name="T">A dependency that this action will use.</typeparam>
/// <typeparam name="T2">A second dependency that this action will use.</typeparam>
/// <typeparam name="T3">A third dependency that this action will use.</typeparam>
/// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction<T, T2, T3> New<T, T2, T3>(
Action<T, T2, T3> action,
int priority
)
where T : notnull
where T2 : notnull
where T3 : notnull => new(action, priority);
/// <summary>
/// A <see cref="IStartupAction"/> with no dependencies.
/// </summary>
public class StartupAction : IStartupAction
{ {
/// <summary> _action = action;
/// The action to execute at startup. Priority = priority;
/// </summary>
private readonly Action _action;
/// <inheritdoc />
public int Priority { get; }
/// <summary>
/// Create a new <see cref="StartupAction"/>.
/// </summary>
/// <param name="action">The action to execute on startup.</param>
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
public StartupAction(Action action, int priority)
{
_action = action;
Priority = priority;
}
/// <inheritdoc />
public void Run(IServiceProvider provider)
{
_action.Invoke();
}
} }
/// <summary> /// <inheritdoc />
/// A <see cref="IStartupAction"/> with one dependencies. public void Run(IServiceProvider provider)
/// </summary>
/// <typeparam name="T">The dependency to use.</typeparam>
public class StartupAction<T> : IStartupAction
where T : notnull
{ {
/// <summary> _action.Invoke();
/// The action to execute at startup.
/// </summary>
private readonly Action<T> _action;
/// <inheritdoc />
public int Priority { get; }
/// <summary>
/// Create a new <see cref="StartupAction{T}"/>.
/// </summary>
/// <param name="action">The action to execute on startup.</param>
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
public StartupAction(Action<T> action, int priority)
{
_action = action;
Priority = priority;
}
/// <inheritdoc />
public void Run(IServiceProvider provider)
{
_action.Invoke(provider.GetRequiredService<T>());
}
}
/// <summary>
/// A <see cref="IStartupAction"/> with two dependencies.
/// </summary>
/// <typeparam name="T">The dependency to use.</typeparam>
/// <typeparam name="T2">The second dependency to use.</typeparam>
public class StartupAction<T, T2> : IStartupAction
where T : notnull
where T2 : notnull
{
/// <summary>
/// The action to execute at startup.
/// </summary>
private readonly Action<T, T2> _action;
/// <inheritdoc />
public int Priority { get; }
/// <summary>
/// Create a new <see cref="StartupAction{T, T2}"/>.
/// </summary>
/// <param name="action">The action to execute on startup.</param>
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
public StartupAction(Action<T, T2> action, int priority)
{
_action = action;
Priority = priority;
}
/// <inheritdoc />
public void Run(IServiceProvider provider)
{
_action.Invoke(provider.GetRequiredService<T>(), provider.GetRequiredService<T2>());
}
}
/// <summary>
/// A <see cref="IStartupAction"/> with three dependencies.
/// </summary>
/// <typeparam name="T">The dependency to use.</typeparam>
/// <typeparam name="T2">The second dependency to use.</typeparam>
/// <typeparam name="T3">The third dependency to use.</typeparam>
public class StartupAction<T, T2, T3> : IStartupAction
where T : notnull
where T2 : notnull
where T3 : notnull
{
/// <summary>
/// The action to execute at startup.
/// </summary>
private readonly Action<T, T2, T3> _action;
/// <inheritdoc />
public int Priority { get; }
/// <summary>
/// Create a new <see cref="StartupAction{T, T2, T3}"/>.
/// </summary>
/// <param name="action">The action to execute on startup.</param>
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
public StartupAction(Action<T, T2, T3> action, int priority)
{
_action = action;
Priority = priority;
}
/// <inheritdoc />
public void Run(IServiceProvider provider)
{
_action.Invoke(
provider.GetRequiredService<T>(),
provider.GetRequiredService<T2>(),
provider.GetRequiredService<T3>()
);
}
} }
} }
/// <summary> /// <summary>
/// An action executed on kyoo's startup to initialize the asp-net container. /// A <see cref="IStartupAction"/> with one dependencies.
/// </summary> /// </summary>
/// <remarks> /// <typeparam name="T">The dependency to use.</typeparam>
/// This is the base interface, see <see cref="SA.StartupAction"/> for a simpler use of this. public class StartupAction<T> : IStartupAction
/// </remarks> where T : notnull
public interface IStartupAction
{ {
/// <summary> /// <summary>
/// The priority of this action. The actions will be executed on descending priority order. /// The action to execute at startup.
/// If two actions have the same priority, their order is undefined.
/// </summary> /// </summary>
int Priority { get; } private readonly Action<T> _action;
/// <inheritdoc />
public int Priority { get; }
/// <summary> /// <summary>
/// Run this action to configure the container, a service provider containing all services can be used. /// Create a new <see cref="StartupAction{T}"/>.
/// </summary> /// </summary>
/// <param name="provider">The service provider containing all services can be used.</param> /// <param name="action">The action to execute on startup.</param>
void Run(IServiceProvider provider); /// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
public StartupAction(Action<T> action, int priority)
{
_action = action;
Priority = priority;
}
/// <inheritdoc />
public void Run(IServiceProvider provider)
{
_action.Invoke(provider.GetRequiredService<T>());
}
}
/// <summary>
/// A <see cref="IStartupAction"/> with two dependencies.
/// </summary>
/// <typeparam name="T">The dependency to use.</typeparam>
/// <typeparam name="T2">The second dependency to use.</typeparam>
public class StartupAction<T, T2> : IStartupAction
where T : notnull
where T2 : notnull
{
/// <summary>
/// The action to execute at startup.
/// </summary>
private readonly Action<T, T2> _action;
/// <inheritdoc />
public int Priority { get; }
/// <summary>
/// Create a new <see cref="StartupAction{T, T2}"/>.
/// </summary>
/// <param name="action">The action to execute on startup.</param>
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
public StartupAction(Action<T, T2> action, int priority)
{
_action = action;
Priority = priority;
}
/// <inheritdoc />
public void Run(IServiceProvider provider)
{
_action.Invoke(provider.GetRequiredService<T>(), provider.GetRequiredService<T2>());
}
}
/// <summary>
/// A <see cref="IStartupAction"/> with three dependencies.
/// </summary>
/// <typeparam name="T">The dependency to use.</typeparam>
/// <typeparam name="T2">The second dependency to use.</typeparam>
/// <typeparam name="T3">The third dependency to use.</typeparam>
public class StartupAction<T, T2, T3> : IStartupAction
where T : notnull
where T2 : notnull
where T3 : notnull
{
/// <summary>
/// The action to execute at startup.
/// </summary>
private readonly Action<T, T2, T3> _action;
/// <inheritdoc />
public int Priority { get; }
/// <summary>
/// Create a new <see cref="StartupAction{T, T2, T3}"/>.
/// </summary>
/// <param name="action">The action to execute on startup.</param>
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
public StartupAction(Action<T, T2, T3> action, int priority)
{
_action = action;
Priority = priority;
}
/// <inheritdoc />
public void Run(IServiceProvider provider)
{
_action.Invoke(
provider.GetRequiredService<T>(),
provider.GetRequiredService<T2>(),
provider.GetRequiredService<T3>()
);
}
} }
} }
/// <summary>
/// An action executed on kyoo's startup to initialize the asp-net container.
/// </summary>
/// <remarks>
/// This is the base interface, see <see cref="SA.StartupAction"/> for a simpler use of this.
/// </remarks>
public interface IStartupAction
{
/// <summary>
/// The priority of this action. The actions will be executed on descending priority order.
/// If two actions have the same priority, their order is undefined.
/// </summary>
int Priority { get; }
/// <summary>
/// Run this action to configure the container, a service provider containing all services can be used.
/// </summary>
/// <param name="provider">The service provider containing all services can be used.</param>
void Run(IServiceProvider provider);
}

View File

@ -23,43 +23,42 @@ using System.Security.Claims;
using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Exceptions;
using Kyoo.Authentication.Models; using Kyoo.Authentication.Models;
namespace Kyoo.Authentication namespace Kyoo.Authentication;
/// <summary>
/// Extension methods.
/// </summary>
public static class Extensions
{ {
/// <summary> /// <summary>
/// Extension methods. /// Get the permissions of an user.
/// </summary> /// </summary>
public static class Extensions /// <param name="user">The user</param>
/// <returns>The list of permissions</returns>
public static ICollection<string> GetPermissions(this ClaimsPrincipal user)
{ {
/// <summary> return user.Claims.FirstOrDefault(x => x.Type == Claims.Permissions)?.Value.Split(',')
/// Get the permissions of an user. ?? Array.Empty<string>();
/// </summary> }
/// <param name="user">The user</param>
/// <returns>The list of permissions</returns>
public static ICollection<string> GetPermissions(this ClaimsPrincipal user)
{
return user.Claims.FirstOrDefault(x => x.Type == Claims.Permissions)?.Value.Split(',')
?? Array.Empty<string>();
}
/// <summary> /// <summary>
/// Get the id of the current user or null if unlogged or invalid. /// Get the id of the current user or null if unlogged or invalid.
/// </summary> /// </summary>
/// <param name="user">The user.</param> /// <param name="user">The user.</param>
/// <returns>The id of the user or null.</returns> /// <returns>The id of the user or null.</returns>
public static Guid? GetId(this ClaimsPrincipal user) public static Guid? GetId(this ClaimsPrincipal user)
{ {
Claim? value = user.FindFirst(Claims.Id); Claim? value = user.FindFirst(Claims.Id);
if (Guid.TryParse(value?.Value, out Guid id)) if (Guid.TryParse(value?.Value, out Guid id))
return id; return id;
return null; return null;
} }
public static Guid GetIdOrThrow(this ClaimsPrincipal user) public static Guid GetIdOrThrow(this ClaimsPrincipal user)
{ {
Guid? ret = user.GetId(); Guid? ret = user.GetId();
if (ret == null) if (ret == null)
throw new UnauthorizedException(); throw new UnauthorizedException();
return ret.Value; return ret.Value;
}
} }
} }

View File

@ -18,35 +18,34 @@
using System; using System;
namespace Kyoo.Abstractions.Models.Attributes namespace Kyoo.Abstractions.Models.Attributes;
/// <summary>
/// An attribute to specify on apis to specify it's documentation's name and category.
/// If this is applied on a method, the specified method will be exploded from the controller's page and be
/// included on the specified tag page.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class ApiDefinitionAttribute : Attribute
{ {
/// <summary> /// <summary>
/// An attribute to specify on apis to specify it's documentation's name and category. /// The public name of this api.
/// If this is applied on a method, the specified method will be exploded from the controller's page and be
/// included on the specified tag page.
/// </summary> /// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public string Name { get; }
public class ApiDefinitionAttribute : Attribute
/// <summary>
/// The name of the group in witch this API is. You can also specify a custom sort order using the following
/// format: <code>order:name</code>. Everything before the first <c>:</c> will be removed but kept for
/// th alphabetical ordering.
/// </summary>
public string? Group { get; set; }
/// <summary>
/// Create a new <see cref="ApiDefinitionAttribute"/>.
/// </summary>
/// <param name="name">The name of the api that will be used on the documentation page.</param>
public ApiDefinitionAttribute(string name)
{ {
/// <summary> Name = name;
/// The public name of this api.
/// </summary>
public string Name { get; }
/// <summary>
/// The name of the group in witch this API is. You can also specify a custom sort order using the following
/// format: <code>order:name</code>. Everything before the first <c>:</c> will be removed but kept for
/// th alphabetical ordering.
/// </summary>
public string? Group { get; set; }
/// <summary>
/// Create a new <see cref="ApiDefinitionAttribute"/>.
/// </summary>
/// <param name="name">The name of the api that will be used on the documentation page.</param>
public ApiDefinitionAttribute(string name)
{
Name = name;
}
} }
} }

View File

@ -18,11 +18,10 @@
using System; using System;
namespace Kyoo.Abstractions.Models.Attributes namespace Kyoo.Abstractions.Models.Attributes;
{
/// <summary> /// <summary>
/// An attribute to inform that the property is computed automatically and can't be assigned manually. /// An attribute to inform that the property is computed automatically and can't be assigned manually.
/// </summary> /// </summary>
[AttributeUsage(AttributeTargets.Property)] [AttributeUsage(AttributeTargets.Property)]
public class ComputedAttribute : NotMergeableAttribute { } public class ComputedAttribute : NotMergeableAttribute { }
}

View File

@ -18,37 +18,36 @@
using System; using System;
namespace Kyoo.Abstractions.Models.Attributes namespace Kyoo.Abstractions.Models.Attributes;
/// <summary>
/// The targeted relation can be loaded.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class LoadableRelationAttribute : Attribute
{ {
/// <summary> /// <summary>
/// The targeted relation can be loaded. /// The name of the field containing the related resource's ID.
/// </summary> /// </summary>
[AttributeUsage(AttributeTargets.Property)] public string? RelationID { get; }
public class LoadableRelationAttribute : Attribute
public string? Sql { get; set; }
public string? On { get; set; }
public string? Projected { get; set; }
/// <summary>
/// Create a new <see cref="LoadableRelationAttribute"/>.
/// </summary>
public LoadableRelationAttribute() { }
/// <summary>
/// Create a new <see cref="LoadableRelationAttribute"/> with a baking relationID field.
/// </summary>
/// <param name="relationID">The name of the RelationID field.</param>
public LoadableRelationAttribute(string relationID)
{ {
/// <summary> RelationID = relationID;
/// The name of the field containing the related resource's ID.
/// </summary>
public string? RelationID { get; }
public string? Sql { get; set; }
public string? On { get; set; }
public string? Projected { get; set; }
/// <summary>
/// Create a new <see cref="LoadableRelationAttribute"/>.
/// </summary>
public LoadableRelationAttribute() { }
/// <summary>
/// Create a new <see cref="LoadableRelationAttribute"/> with a baking relationID field.
/// </summary>
/// <param name="relationID">The name of the RelationID field.</param>
public LoadableRelationAttribute(string relationID)
{
RelationID = relationID;
}
} }
} }

View File

@ -18,23 +18,22 @@
using System; using System;
namespace Kyoo.Abstractions.Models.Attributes namespace Kyoo.Abstractions.Models.Attributes;
/// <summary>
/// Specify that a property can't be merged.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class NotMergeableAttribute : Attribute { }
/// <summary>
/// An interface with a method called when this object is merged.
/// </summary>
public interface IOnMerge
{ {
/// <summary> /// <summary>
/// Specify that a property can't be merged. /// This function is called after the object has been merged.
/// </summary> /// </summary>
[AttributeUsage(AttributeTargets.Property)] /// <param name="merged">The object that has been merged with this.</param>
public class NotMergeableAttribute : Attribute { } void OnMerge(object merged);
/// <summary>
/// An interface with a method called when this object is merged.
/// </summary>
public interface IOnMerge
{
/// <summary>
/// This function is called after the object has been merged.
/// </summary>
/// <param name="merged">The object that has been merged with this.</param>
void OnMerge(object merged);
}
} }

View File

@ -21,68 +21,67 @@ using Kyoo.Abstractions.Controllers;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Abstractions.Models.Permissions namespace Kyoo.Abstractions.Models.Permissions;
/// <summary>
/// Specify one part of a permissions needed for the API (the kind or the type).
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class PartialPermissionAttribute : Attribute, IFilterFactory
{ {
/// <summary> /// <summary>
/// Specify one part of a permissions needed for the API (the kind or the type). /// The needed permission type.
/// </summary> /// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] public string? Type { get; }
public class PartialPermissionAttribute : Attribute, IFilterFactory
/// <summary>
/// The needed permission kind.
/// </summary>
public Kind? Kind { get; }
/// <summary>
/// The group of this permission.
/// </summary>
public Group Group { get; set; }
/// <summary>
/// Ask a permission to run an action.
/// </summary>
/// <remarks>
/// With this attribute, you can only specify a type or a kind.
/// To have a valid permission attribute, you must specify the kind and the permission using two attributes.
/// Those attributes can be dispatched at different places (one on the class, one on the method for example).
/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will
/// lead to unspecified behaviors.
/// </remarks>
/// <param name="type">The type of the action</param>
public PartialPermissionAttribute(string type)
{ {
/// <summary> Type = type.ToLower();
/// The needed permission type.
/// </summary>
public string? Type { get; }
/// <summary>
/// The needed permission kind.
/// </summary>
public Kind? Kind { get; }
/// <summary>
/// The group of this permission.
/// </summary>
public Group Group { get; set; }
/// <summary>
/// Ask a permission to run an action.
/// </summary>
/// <remarks>
/// With this attribute, you can only specify a type or a kind.
/// To have a valid permission attribute, you must specify the kind and the permission using two attributes.
/// Those attributes can be dispatched at different places (one on the class, one on the method for example).
/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will
/// lead to unspecified behaviors.
/// </remarks>
/// <param name="type">The type of the action</param>
public PartialPermissionAttribute(string type)
{
Type = type.ToLower();
}
/// <summary>
/// Ask a permission to run an action.
/// </summary>
/// <remarks>
/// With this attribute, you can only specify a type or a kind.
/// To have a valid permission attribute, you must specify the kind and the permission using two attributes.
/// Those attributes can be dispatched at different places (one on the class, one on the method for example).
/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will
/// lead to unspecified behaviors.
/// </remarks>
/// <param name="permission">The kind of permission needed.</param>
public PartialPermissionAttribute(Kind permission)
{
Kind = permission;
}
/// <inheritdoc />
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
{
return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this);
}
/// <inheritdoc />
public bool IsReusable => true;
} }
/// <summary>
/// Ask a permission to run an action.
/// </summary>
/// <remarks>
/// With this attribute, you can only specify a type or a kind.
/// To have a valid permission attribute, you must specify the kind and the permission using two attributes.
/// Those attributes can be dispatched at different places (one on the class, one on the method for example).
/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will
/// lead to unspecified behaviors.
/// </remarks>
/// <param name="permission">The kind of permission needed.</param>
public PartialPermissionAttribute(Kind permission)
{
Kind = permission;
}
/// <inheritdoc />
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
{
return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this);
}
/// <inheritdoc />
public bool IsReusable => true;
} }

View File

@ -21,117 +21,116 @@ using Kyoo.Abstractions.Controllers;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Abstractions.Models.Permissions namespace Kyoo.Abstractions.Models.Permissions;
/// <summary>
/// The kind of permission needed.
/// </summary>
public enum Kind
{ {
/// <summary> /// <summary>
/// Allow the user to read for this kind of data.
/// </summary>
Read,
/// <summary>
/// Allow the user to write for this kind of data.
/// </summary>
Write,
/// <summary>
/// Allow the user to create this kind of data.
/// </summary>
Create,
/// <summary>
/// Allow the user to delete this kind of data.
/// </summary>
Delete,
/// <summary>
/// Allow the user to play this file.
/// </summary>
Play,
}
/// <summary>
/// The group of the permission.
/// </summary>
public enum Group
{
/// <summary>
/// Default group indicating no value.
/// </summary>
None,
/// <summary>
/// Allow all operations on basic items types.
/// </summary>
Overall,
/// <summary>
/// Allow operation on sensitive items like libraries path, configurations and so on.
/// </summary>
Admin
}
/// <summary>
/// Specify permissions needed for the API.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class PermissionAttribute : Attribute, IFilterFactory
{
/// <summary>
/// The needed permission as string.
/// </summary>
public string Type { get; }
/// <summary>
/// The needed permission kind.
/// </summary>
public Kind Kind { get; }
/// <summary>
/// The group of this permission.
/// </summary>
public Group Group { get; }
/// <summary>
/// Ask a permission to run an action.
/// </summary>
/// <param name="type">
/// The type of the action
/// </param>
/// <param name="permission">
/// The kind of permission needed. /// The kind of permission needed.
/// </summary> /// </param>
public enum Kind /// <param name="group">
/// The group of this permission (allow grouped permission like overall.read
/// for all read permissions of this group).
/// </param>
public PermissionAttribute(string type, Kind permission, Group group = Group.Overall)
{ {
/// <summary> Type = type.ToLower();
/// Allow the user to read for this kind of data. Kind = permission;
/// </summary> Group = group;
Read,
/// <summary>
/// Allow the user to write for this kind of data.
/// </summary>
Write,
/// <summary>
/// Allow the user to create this kind of data.
/// </summary>
Create,
/// <summary>
/// Allow the user to delete this kind of data.
/// </summary>
Delete,
/// <summary>
/// Allow the user to play this file.
/// </summary>
Play,
} }
/// <summary> /// <inheritdoc />
/// The group of the permission. public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
/// </summary>
public enum Group
{ {
/// <summary> return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this);
/// Default group indicating no value.
/// </summary>
None,
/// <summary>
/// Allow all operations on basic items types.
/// </summary>
Overall,
/// <summary>
/// Allow operation on sensitive items like libraries path, configurations and so on.
/// </summary>
Admin
} }
/// <inheritdoc />
public bool IsReusable => true;
/// <summary> /// <summary>
/// Specify permissions needed for the API. /// Return this permission attribute as a string.
/// </summary> /// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] /// <returns>The string representation.</returns>
public class PermissionAttribute : Attribute, IFilterFactory public string AsPermissionString()
{ {
/// <summary> return Type;
/// The needed permission as string.
/// </summary>
public string Type { get; }
/// <summary>
/// The needed permission kind.
/// </summary>
public Kind Kind { get; }
/// <summary>
/// The group of this permission.
/// </summary>
public Group Group { get; }
/// <summary>
/// Ask a permission to run an action.
/// </summary>
/// <param name="type">
/// The type of the action
/// </param>
/// <param name="permission">
/// The kind of permission needed.
/// </param>
/// <param name="group">
/// The group of this permission (allow grouped permission like overall.read
/// for all read permissions of this group).
/// </param>
public PermissionAttribute(string type, Kind permission, Group group = Group.Overall)
{
Type = type.ToLower();
Kind = permission;
Group = group;
}
/// <inheritdoc />
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
{
return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this);
}
/// <inheritdoc />
public bool IsReusable => true;
/// <summary>
/// Return this permission attribute as a string.
/// </summary>
/// <returns>The string representation.</returns>
public string AsPermissionString()
{
return Type;
}
} }
} }

View File

@ -18,14 +18,13 @@
using System; using System;
namespace Kyoo.Abstractions.Models.Permissions namespace Kyoo.Abstractions.Models.Permissions;
/// <summary>
/// The annotated route can only be accessed by a logged in user.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class UserOnlyAttribute : Attribute
{ {
/// <summary> // TODO: Implement a Filter Attribute to make this work. For now, this attribute is only useful as documentation.
/// The annotated route can only be accessed by a logged in user.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class UserOnlyAttribute : Attribute
{
// TODO: Implement a Filter Attribute to make this work. For now, this attribute is only useful as documentation.
}
} }

View File

@ -19,35 +19,34 @@
using System; using System;
using System.Runtime.Serialization; using System.Runtime.Serialization;
namespace Kyoo.Abstractions.Models.Exceptions namespace Kyoo.Abstractions.Models.Exceptions;
/// <summary>
/// An exception raised when an item already exists in the database.
/// </summary>
[Serializable]
public class DuplicatedItemException : Exception
{ {
/// <summary> /// <summary>
/// An exception raised when an item already exists in the database. /// The existing object.
/// </summary> /// </summary>
[Serializable] public object? Existing { get; }
public class DuplicatedItemException : Exception
/// <summary>
/// Create a new <see cref="DuplicatedItemException"/> with the default message.
/// </summary>
/// <param name="existing">The existing object.</param>
public DuplicatedItemException(object? existing = null)
: base("Already exists in the database.")
{ {
/// <summary> Existing = existing;
/// The existing object.
/// </summary>
public object? Existing { get; }
/// <summary>
/// Create a new <see cref="DuplicatedItemException"/> with the default message.
/// </summary>
/// <param name="existing">The existing object.</param>
public DuplicatedItemException(object? existing = null)
: base("Already exists in the database.")
{
Existing = existing;
}
/// <summary>
/// The serialization constructor.
/// </summary>
/// <param name="info">Serialization infos</param>
/// <param name="context">The serialization context</param>
protected DuplicatedItemException(SerializationInfo info, StreamingContext context)
: base(info, context) { }
} }
/// <summary>
/// The serialization constructor.
/// </summary>
/// <param name="info">Serialization infos</param>
/// <param name="context">The serialization context</param>
protected DuplicatedItemException(SerializationInfo info, StreamingContext context)
: base(info, context) { }
} }

View File

@ -19,33 +19,32 @@
using System; using System;
using System.Runtime.Serialization; using System.Runtime.Serialization;
namespace Kyoo.Abstractions.Models.Exceptions namespace Kyoo.Abstractions.Models.Exceptions;
/// <summary>
/// An exception raised when an item could not be found.
/// </summary>
[Serializable]
public class ItemNotFoundException : Exception
{ {
/// <summary> /// <summary>
/// An exception raised when an item could not be found. /// Create a default <see cref="ItemNotFoundException"/> with no message.
/// </summary> /// </summary>
[Serializable] public ItemNotFoundException()
public class ItemNotFoundException : Exception : base("Item not found") { }
{
/// <summary>
/// Create a default <see cref="ItemNotFoundException"/> with no message.
/// </summary>
public ItemNotFoundException()
: base("Item not found") { }
/// <summary> /// <summary>
/// Create a new <see cref="ItemNotFoundException"/> with a message /// Create a new <see cref="ItemNotFoundException"/> with a message
/// </summary> /// </summary>
/// <param name="message">The message of the exception</param> /// <param name="message">The message of the exception</param>
public ItemNotFoundException(string message) public ItemNotFoundException(string message)
: base(message) { } : base(message) { }
/// <summary> /// <summary>
/// The serialization constructor /// The serialization constructor
/// </summary> /// </summary>
/// <param name="info">Serialization infos</param> /// <param name="info">Serialization infos</param>
/// <param name="context">The serialization context</param> /// <param name="context">The serialization context</param>
protected ItemNotFoundException(SerializationInfo info, StreamingContext context) protected ItemNotFoundException(SerializationInfo info, StreamingContext context)
: base(info, context) { } : base(info, context) { }
}
} }

View File

@ -19,18 +19,17 @@
using System; using System;
using System.Runtime.Serialization; using System.Runtime.Serialization;
namespace Kyoo.Abstractions.Models.Exceptions namespace Kyoo.Abstractions.Models.Exceptions;
[Serializable]
public class UnauthorizedException : Exception
{ {
[Serializable] public UnauthorizedException()
public class UnauthorizedException : Exception : base("User not authenticated or token invalid.") { }
{
public UnauthorizedException()
: base("User not authenticated or token invalid.") { }
public UnauthorizedException(string message) public UnauthorizedException(string message)
: base(message) { } : base(message) { }
protected UnauthorizedException(SerializationInfo info, StreamingContext context) protected UnauthorizedException(SerializationInfo info, StreamingContext context)
: base(info, context) { } : base(info, context) { }
}
} }

View File

@ -16,30 +16,29 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// A genre that allow one to specify categories for shows.
/// </summary>
public enum Genre
{ {
/// <summary> Action,
/// A genre that allow one to specify categories for shows. Adventure,
/// </summary> Animation,
public enum Genre Comedy,
{ Crime,
Action, Documentary,
Adventure, Drama,
Animation, Family,
Comedy, Fantasy,
Crime, History,
Documentary, Horror,
Drama, Music,
Family, Mystery,
Fantasy, Romance,
History, ScienceFiction,
Horror, Thriller,
Music, War,
Mystery, Western,
Romance,
ScienceFiction,
Thriller,
War,
Western,
}
} }

View File

@ -16,21 +16,20 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// ID and link of an item on an external provider.
/// </summary>
public class MetadataId
{ {
/// <summary> /// <summary>
/// ID and link of an item on an external provider. /// The ID of the resource on the external provider.
/// </summary> /// </summary>
public class MetadataId public string DataId { get; set; }
{
/// <summary>
/// The ID of the resource on the external provider.
/// </summary>
public string DataId { get; set; }
/// <summary> /// <summary>
/// The URL of the resource on the external provider. /// The URL of the resource on the external provider.
/// </summary> /// </summary>
public string? Link { get; set; } public string? Link { get; set; }
}
} }

View File

@ -20,93 +20,86 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using Kyoo.Utils; using Kyoo.Utils;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// A page of resource that contains information about the pagination of resources.
/// </summary>
/// <typeparam name="T">The type of resource contained in this page.</typeparam>
public class Page<T>
where T : IResource
{ {
/// <summary> /// <summary>
/// A page of resource that contains information about the pagination of resources. /// The link of the current page.
/// </summary> /// </summary>
/// <typeparam name="T">The type of resource contained in this page.</typeparam> public string This { get; }
public class Page<T>
where T : IResource /// <summary>
/// The link of the first page.
/// </summary>
public string First { get; }
/// <summary>
/// The link of the previous page.
/// </summary>
public string? Previous { get; }
/// <summary>
/// The link of the next page.
/// </summary>
public string? Next { get; }
/// <summary>
/// The number of items in the current page.
/// </summary>
public int Count => Items.Count;
/// <summary>
/// The list of items in the page.
/// </summary>
public ICollection<T> Items { get; }
/// <summary>
/// Create a new <see cref="Page{T}"/>.
/// </summary>
/// <param name="items">The list of items in the page.</param>
/// <param name="this">The link of the current page.</param>
/// <param name="previous">The link of the previous page.</param>
/// <param name="next">The link of the next page.</param>
/// <param name="first">The link of the first page.</param>
public Page(ICollection<T> items, string @this, string? previous, string? next, string first)
{ {
/// <summary> Items = items;
/// The link of the current page. This = @this;
/// </summary> Previous = previous;
public string This { get; } Next = next;
First = first;
}
/// <summary> /// <summary>
/// The link of the first page. /// Create a new <see cref="Page{T}"/> and compute the urls.
/// </summary> /// </summary>
public string First { get; } /// <param name="items">The list of items in the page.</param>
/// <param name="url">The base url of the resources available from this page.</param>
/// <summary> /// <param name="query">The list of query strings of the current page</param>
/// The link of the previous page. /// <param name="limit">The number of items requested for the current page.</param>
/// </summary> public Page(ICollection<T> items, string url, Dictionary<string, string> query, int limit)
public string? Previous { get; } {
Items = items;
/// <summary> This = url + query.ToQueryString();
/// The link of the next page. if (items.Count > 0 && query.ContainsKey("afterID"))
/// </summary>
public string? Next { get; }
/// <summary>
/// The number of items in the current page.
/// </summary>
public int Count => Items.Count;
/// <summary>
/// The list of items in the page.
/// </summary>
public ICollection<T> Items { get; }
/// <summary>
/// Create a new <see cref="Page{T}"/>.
/// </summary>
/// <param name="items">The list of items in the page.</param>
/// <param name="this">The link of the current page.</param>
/// <param name="previous">The link of the previous page.</param>
/// <param name="next">The link of the next page.</param>
/// <param name="first">The link of the first page.</param>
public Page(
ICollection<T> items,
string @this,
string? previous,
string? next,
string first
)
{ {
Items = items; query["afterID"] = items.First().Id.ToString();
This = @this; query["reverse"] = "true";
Previous = previous; Previous = url + query.ToQueryString();
Next = next;
First = first;
} }
query.Remove("reverse");
/// <summary> if (items.Count == limit && limit > 0)
/// Create a new <see cref="Page{T}"/> and compute the urls.
/// </summary>
/// <param name="items">The list of items in the page.</param>
/// <param name="url">The base url of the resources available from this page.</param>
/// <param name="query">The list of query strings of the current page</param>
/// <param name="limit">The number of items requested for the current page.</param>
public Page(ICollection<T> items, string url, Dictionary<string, string> query, int limit)
{ {
Items = items; query["afterID"] = items.Last().Id.ToString();
This = url + query.ToQueryString(); Next = url + query.ToQueryString();
if (items.Count > 0 && query.ContainsKey("afterID"))
{
query["afterID"] = items.First().Id.ToString();
query["reverse"] = "true";
Previous = url + query.ToQueryString();
}
query.Remove("reverse");
if (items.Count == limit && limit > 0)
{
query["afterID"] = items.Last().Id.ToString();
Next = url + query.ToQueryString();
}
query.Remove("afterID");
First = url + query.ToQueryString();
} }
query.Remove("afterID");
First = url + query.ToQueryString();
} }
} }

View File

@ -23,69 +23,68 @@ using System.Text.Json.Serialization;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Utils; using Kyoo.Utils;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// A class representing collections of <see cref="Show"/>.
/// </summary>
public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, ILibraryItem
{ {
public static Sort DefaultSort => new Sort<Collection>.By(nameof(Collection.Name));
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[MaxLength(256)]
public string Slug { get; set; }
/// <summary> /// <summary>
/// A class representing collections of <see cref="Show"/>. /// The name of this collection.
/// </summary> /// </summary>
public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, ILibraryItem public string Name { get; set; }
/// <summary>
/// The description of this collection.
/// </summary>
public string? Overview { get; set; }
/// <inheritdoc />
public DateTime AddedDate { get; set; }
/// <inheritdoc />
public Image? Poster { get; set; }
/// <inheritdoc />
public Image? Thumbnail { get; set; }
/// <inheritdoc />
public Image? Logo { get; set; }
/// <summary>
/// The list of movies contained in this collection.
/// </summary>
[JsonIgnore]
public ICollection<Movie>? Movies { get; set; }
/// <summary>
/// The list of shows contained in this collection.
/// </summary>
[JsonIgnore]
public ICollection<Show>? Shows { get; set; }
/// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
public Collection() { }
[JsonConstructor]
public Collection(string name)
{ {
public static Sort DefaultSort => new Sort<Collection>.By(nameof(Collection.Name)); if (name != null)
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[MaxLength(256)]
public string Slug { get; set; }
/// <summary>
/// The name of this collection.
/// </summary>
public string Name { get; set; }
/// <summary>
/// The description of this collection.
/// </summary>
public string? Overview { get; set; }
/// <inheritdoc />
public DateTime AddedDate { get; set; }
/// <inheritdoc />
public Image? Poster { get; set; }
/// <inheritdoc />
public Image? Thumbnail { get; set; }
/// <inheritdoc />
public Image? Logo { get; set; }
/// <summary>
/// The list of movies contained in this collection.
/// </summary>
[JsonIgnore]
public ICollection<Movie>? Movies { get; set; }
/// <summary>
/// The list of shows contained in this collection.
/// </summary>
[JsonIgnore]
public ICollection<Show>? Shows { get; set; }
/// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
public Collection() { }
[JsonConstructor]
public Collection(string name)
{ {
if (name != null) Slug = Utility.ToSlug(name);
{ Name = name;
Slug = Utility.ToSlug(name);
Name = name;
}
} }
} }
} }

View File

@ -26,280 +26,274 @@ using EntityFrameworkCore.Projectables;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// A class to represent a single show's episode.
/// </summary>
public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews
{ {
/// <summary> // Use absolute numbers by default and fallback to season/episodes if it does not exists.
/// A class to represent a single show's episode. public static Sort DefaultSort =>
/// </summary> new Sort<Episode>.Conglomerate(
public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews new Sort<Episode>.By(x => x.AbsoluteNumber),
new Sort<Episode>.By(x => x.SeasonNumber),
new Sort<Episode>.By(x => x.EpisodeNumber)
);
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[Computed]
[MaxLength(256)]
public string Slug
{ {
// Use absolute numbers by default and fallback to season/episodes if it does not exists. get
public static Sort DefaultSort =>
new Sort<Episode>.Conglomerate(
new Sort<Episode>.By(x => x.AbsoluteNumber),
new Sort<Episode>.By(x => x.SeasonNumber),
new Sort<Episode>.By(x => x.EpisodeNumber)
);
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[Computed]
[MaxLength(256)]
public string Slug
{ {
get if (ShowSlug != null || Show?.Slug != null)
{ return GetSlug(ShowSlug ?? Show!.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber);
if (ShowSlug != null || Show?.Slug != null) return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber);
return GetSlug( }
ShowSlug ?? Show!.Slug, private set
SeasonNumber, {
EpisodeNumber, Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)e(?<episode>\d+)");
AbsoluteNumber
);
return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber);
}
private set
{
Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)e(?<episode>\d+)");
if (match.Success)
{
ShowSlug = match.Groups["show"].Value;
SeasonNumber = int.Parse(match.Groups["season"].Value);
EpisodeNumber = int.Parse(match.Groups["episode"].Value);
}
else
{
match = Regex.Match(value, @"(?<show>.+)-(?<absolute>\d+)");
if (match.Success) if (match.Success)
{ {
ShowSlug = match.Groups["show"].Value; ShowSlug = match.Groups["show"].Value;
SeasonNumber = int.Parse(match.Groups["season"].Value); AbsoluteNumber = int.Parse(match.Groups["absolute"].Value);
EpisodeNumber = int.Parse(match.Groups["episode"].Value);
} }
else else
{ ShowSlug = value;
match = Regex.Match(value, @"(?<show>.+)-(?<absolute>\d+)"); SeasonNumber = null;
if (match.Success) EpisodeNumber = null;
{
ShowSlug = match.Groups["show"].Value;
AbsoluteNumber = int.Parse(match.Groups["absolute"].Value);
}
else
ShowSlug = value;
SeasonNumber = null;
EpisodeNumber = null;
}
} }
} }
}
/// <summary> /// <summary>
/// The slug of the Show that contain this episode. If this is not set, this episode is ill-formed. /// The slug of the Show that contain this episode. If this is not set, this episode is ill-formed.
/// </summary> /// </summary>
[JsonIgnore] [JsonIgnore]
public string? ShowSlug { private get; set; } public string? ShowSlug { private get; set; }
/// <summary> /// <summary>
/// The ID of the Show containing this episode. /// The ID of the Show containing this episode.
/// </summary> /// </summary>
public Guid ShowId { get; set; } public Guid ShowId { get; set; }
/// <summary> /// <summary>
/// The show that contains this episode. /// The show that contains this episode.
/// </summary> /// </summary>
[LoadableRelation(nameof(ShowId))] [LoadableRelation(nameof(ShowId))]
public Show? Show { get; set; } public Show? Show { get; set; }
/// <summary> /// <summary>
/// The ID of the Season containing this episode. /// The ID of the Season containing this episode.
/// </summary> /// </summary>
public Guid? SeasonId { get; set; } public Guid? SeasonId { get; set; }
/// <summary> /// <summary>
/// The season that contains this episode. /// The season that contains this episode.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This can be null if the season is unknown and the episode is only identified /// This can be null if the season is unknown and the episode is only identified
/// by it's <see cref="AbsoluteNumber"/>. /// by it's <see cref="AbsoluteNumber"/>.
/// </remarks> /// </remarks>
[LoadableRelation(nameof(SeasonId))] [LoadableRelation(nameof(SeasonId))]
public Season? Season { get; set; } public Season? Season { get; set; }
/// <summary> /// <summary>
/// The season in witch this episode is in. /// The season in witch this episode is in.
/// </summary> /// </summary>
public int? SeasonNumber { get; set; } public int? SeasonNumber { get; set; }
/// <summary> /// <summary>
/// The number of this episode in it's season. /// The number of this episode in it's season.
/// </summary> /// </summary>
public int? EpisodeNumber { get; set; } public int? EpisodeNumber { get; set; }
/// <summary> /// <summary>
/// The absolute number of this episode. It's an episode number that is not reset to 1 after a new season. /// The absolute number of this episode. It's an episode number that is not reset to 1 after a new season.
/// </summary> /// </summary>
public int? AbsoluteNumber { get; set; } public int? AbsoluteNumber { get; set; }
/// <summary> /// <summary>
/// The path of the video file for this episode. /// The path of the video file for this episode.
/// </summary> /// </summary>
public string Path { get; set; } public string Path { get; set; }
/// <summary> /// <summary>
/// The title of this episode. /// The title of this episode.
/// </summary> /// </summary>
public string? Name { get; set; } public string? Name { get; set; }
/// <summary> /// <summary>
/// The overview of this episode. /// The overview of this episode.
/// </summary> /// </summary>
public string? Overview { get; set; } public string? Overview { get; set; }
/// <summary> /// <summary>
/// How long is this episode? (in minutes) /// How long is this episode? (in minutes)
/// </summary> /// </summary>
public int? Runtime { get; set; } public int? Runtime { get; set; }
/// <summary> /// <summary>
/// The release date of this episode. It can be null if unknown. /// The release date of this episode. It can be null if unknown.
/// </summary> /// </summary>
public DateTime? ReleaseDate { get; set; } public DateTime? ReleaseDate { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public DateTime AddedDate { get; set; } public DateTime AddedDate { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public Image? Poster { get; set; } public Image? Poster { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public Image? Thumbnail { get; set; } public Image? Thumbnail { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public Image? Logo { get; set; } public Image? Logo { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
/// <summary> /// <summary>
/// The previous episode that should be seen before viewing this one. /// The previous episode that should be seen before viewing this one.
/// </summary> /// </summary>
[Projectable(UseMemberBody = nameof(_PreviousEpisode), OnlyOnInclude = true)] [Projectable(UseMemberBody = nameof(_PreviousEpisode), OnlyOnInclude = true)]
[LoadableRelation( [LoadableRelation(
// language=PostgreSQL // language=PostgreSQL
Sql = """ Sql = """
select select
pe.* -- Episode as pe pe.* -- Episode as pe
from from
episodes as "pe" episodes as "pe"
where where
pe.show_id = "this".show_id pe.show_id = "this".show_id
and (pe.absolute_number < "this".absolute_number and (pe.absolute_number < "this".absolute_number
or pe.season_number < "this".season_number or pe.season_number < "this".season_number
or (pe.season_number = "this".season_number or (pe.season_number = "this".season_number
and e.episode_number < "this".episode_number)) and e.episode_number < "this".episode_number))
order by order by
pe.absolute_number desc nulls last, pe.absolute_number desc nulls last,
pe.season_number desc, pe.season_number desc,
pe.episode_number desc pe.episode_number desc
limit 1 limit 1
""" """
)] )]
public Episode? PreviousEpisode { get; set; } public Episode? PreviousEpisode { get; set; }
private Episode? _PreviousEpisode => private Episode? _PreviousEpisode =>
Show! Show!
.Episodes!.OrderBy(x => x.AbsoluteNumber == null) .Episodes!.OrderBy(x => x.AbsoluteNumber == null)
.ThenByDescending(x => x.AbsoluteNumber) .ThenByDescending(x => x.AbsoluteNumber)
.ThenByDescending(x => x.SeasonNumber) .ThenByDescending(x => x.SeasonNumber)
.ThenByDescending(x => x.EpisodeNumber) .ThenByDescending(x => x.EpisodeNumber)
.FirstOrDefault(x => .FirstOrDefault(x =>
x.AbsoluteNumber < AbsoluteNumber x.AbsoluteNumber < AbsoluteNumber
|| x.SeasonNumber < SeasonNumber || x.SeasonNumber < SeasonNumber
|| (x.SeasonNumber == SeasonNumber && x.EpisodeNumber < EpisodeNumber) || (x.SeasonNumber == SeasonNumber && x.EpisodeNumber < EpisodeNumber)
); );
/// <summary> /// <summary>
/// The next episode to watch after this one. /// The next episode to watch after this one.
/// </summary> /// </summary>
[Projectable(UseMemberBody = nameof(_NextEpisode), OnlyOnInclude = true)] [Projectable(UseMemberBody = nameof(_NextEpisode), OnlyOnInclude = true)]
[LoadableRelation( [LoadableRelation(
// language=PostgreSQL // language=PostgreSQL
Sql = """ Sql = """
select select
ne.* -- Episode as ne ne.* -- Episode as ne
from from
episodes as "ne" episodes as "ne"
where where
ne.show_id = "this".show_id ne.show_id = "this".show_id
and (ne.absolute_number > "this".absolute_number and (ne.absolute_number > "this".absolute_number
or ne.season_number > "this".season_number or ne.season_number > "this".season_number
or (ne.season_number = "this".season_number or (ne.season_number = "this".season_number
and e.episode_number > "this".episode_number)) and e.episode_number > "this".episode_number))
order by order by
ne.absolute_number, ne.absolute_number,
ne.season_number, ne.season_number,
ne.episode_number ne.episode_number
limit 1 limit 1
""" """
)] )]
public Episode? NextEpisode { get; set; } public Episode? NextEpisode { get; set; }
private Episode? _NextEpisode => private Episode? _NextEpisode =>
Show! Show!
.Episodes!.OrderBy(x => x.AbsoluteNumber) .Episodes!.OrderBy(x => x.AbsoluteNumber)
.ThenBy(x => x.SeasonNumber) .ThenBy(x => x.SeasonNumber)
.ThenBy(x => x.EpisodeNumber) .ThenBy(x => x.EpisodeNumber)
.FirstOrDefault(x => .FirstOrDefault(x =>
x.AbsoluteNumber > AbsoluteNumber x.AbsoluteNumber > AbsoluteNumber
|| x.SeasonNumber > SeasonNumber || x.SeasonNumber > SeasonNumber
|| (x.SeasonNumber == SeasonNumber && x.EpisodeNumber > EpisodeNumber) || (x.SeasonNumber == SeasonNumber && x.EpisodeNumber > EpisodeNumber)
); );
[JsonIgnore] [JsonIgnore]
public ICollection<EpisodeWatchStatus>? Watched { get; set; } public ICollection<EpisodeWatchStatus>? Watched { get; set; }
/// <summary> /// <summary>
/// Metadata of what an user as started/planned to watch. /// Metadata of what an user as started/planned to watch.
/// </summary> /// </summary>
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] [Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
[LoadableRelation( [LoadableRelation(
Sql = "episode_watch_status", Sql = "episode_watch_status",
On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]" On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]"
)] )]
public EpisodeWatchStatus? WatchStatus { get; set; } public EpisodeWatchStatus? WatchStatus { get; set; }
// There is a global query filter to filter by user so we just need to do single. // There is a global query filter to filter by user so we just need to do single.
private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault(); private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
/// <summary> /// <summary>
/// Links to watch this episode. /// Links to watch this episode.
/// </summary> /// </summary>
public VideoLinks Links => public VideoLinks Links =>
new() { Direct = $"/episode/{Slug}/direct", Hls = $"/episode/{Slug}/master.m3u8", }; new() { Direct = $"/episode/{Slug}/direct", Hls = $"/episode/{Slug}/master.m3u8", };
/// <summary> /// <summary>
/// Get the slug of an episode. /// Get the slug of an episode.
/// </summary> /// </summary>
/// <param name="showSlug">The slug of the show. It can't be null.</param> /// <param name="showSlug">The slug of the show. It can't be null.</param>
/// <param name="seasonNumber"> /// <param name="seasonNumber">
/// The season in which the episode is. /// The season in which the episode is.
/// If this is a movie or if the episode should be referred by it's absolute number, set this to null. /// If this is a movie or if the episode should be referred by it's absolute number, set this to null.
/// </param> /// </param>
/// <param name="episodeNumber"> /// <param name="episodeNumber">
/// The number of the episode in it's season. /// The number of the episode in it's season.
/// If this is a movie or if the episode should be referred by it's absolute number, set this to null. /// If this is a movie or if the episode should be referred by it's absolute number, set this to null.
/// </param> /// </param>
/// <param name="absoluteNumber"> /// <param name="absoluteNumber">
/// The absolute number of this show. /// The absolute number of this show.
/// If you don't know it or this is a movie, use null /// If you don't know it or this is a movie, use null
/// </param> /// </param>
/// <returns>The slug corresponding to the given arguments</returns> /// <returns>The slug corresponding to the given arguments</returns>
public static string GetSlug( public static string GetSlug(
string showSlug, string showSlug,
int? seasonNumber, int? seasonNumber,
int? episodeNumber, int? episodeNumber,
int? absoluteNumber = null int? absoluteNumber = null
) )
{
return seasonNumber switch
{ {
return seasonNumber switch null when absoluteNumber == null => showSlug,
{ null => $"{showSlug}-{absoluteNumber}",
null when absoluteNumber == null => showSlug, _ => $"{showSlug}-s{seasonNumber}e{episodeNumber}"
null => $"{showSlug}-{absoluteNumber}", };
_ => $"{showSlug}-s{seasonNumber}e{episodeNumber}"
};
}
} }
} }

View File

@ -18,16 +18,15 @@
using System; using System;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// An interface applied to resources.
/// </summary>
public interface IAddedDate
{ {
/// <summary> /// <summary>
/// An interface applied to resources. /// The date at which this resource was added to kyoo.
/// </summary> /// </summary>
public interface IAddedDate public DateTime AddedDate { get; set; }
{
/// <summary>
/// The date at which this resource was added to kyoo.
/// </summary>
public DateTime AddedDate { get; set; }
}
} }

View File

@ -18,16 +18,15 @@
using System.Collections.Generic; using System.Collections.Generic;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// An interface applied to resources containing external metadata.
/// </summary>
public interface IMetadata
{ {
/// <summary> /// <summary>
/// An interface applied to resources containing external metadata. /// The link to metadata providers that this show has. See <see cref="MetadataId"/> for more information.
/// </summary> /// </summary>
public interface IMetadata public Dictionary<string, MetadataId> ExternalId { get; set; }
{
/// <summary>
/// The link to metadata providers that this show has. See <see cref="MetadataId"/> for more information.
/// </summary>
public Dictionary<string, MetadataId> ExternalId { get; set; }
}
} }

View File

@ -20,31 +20,30 @@ using System;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// An interface to represent a resource that can be retrieved from the database.
/// </summary>
public interface IResource : IQuery
{ {
/// <summary> /// <summary>
/// An interface to represent a resource that can be retrieved from the database. /// A unique ID for this type of resource. This can't be changed and duplicates are not allowed.
/// </summary> /// </summary>
public interface IResource : IQuery /// <remarks>
{ /// You don't need to specify an ID manually when creating a new resource,
/// <summary> /// this field is automatically assigned by the <see cref="IRepository{T}"/>.
/// A unique ID for this type of resource. This can't be changed and duplicates are not allowed. /// </remarks>
/// </summary> public Guid Id { get; set; }
/// <remarks>
/// You don't need to specify an ID manually when creating a new resource,
/// this field is automatically assigned by the <see cref="IRepository{T}"/>.
/// </remarks>
public Guid Id { get; set; }
/// <summary> /// <summary>
/// A human-readable identifier that can be used instead of an ID. /// A human-readable identifier that can be used instead of an ID.
/// A slug must be unique for a type of resource but it can be changed. /// A slug must be unique for a type of resource but it can be changed.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// There is no setter for a slug since it can be computed from other fields. /// There is no setter for a slug since it can be computed from other fields.
/// For example, a season slug is {ShowSlug}-s{SeasonNumber}. /// For example, a season slug is {ShowSlug}-s{SeasonNumber}.
/// </remarks> /// </remarks>
[MaxLength(256)] [MaxLength(256)]
public string Slug { get; } public string Slug { get; }
}
} }

View File

@ -23,105 +23,101 @@ using System.Globalization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// An interface representing items that contains images (like posters, thumbnails, logo, banners...)
/// </summary>
public interface IThumbnails
{ {
/// <summary> /// <summary>
/// An interface representing items that contains images (like posters, thumbnails, logo, banners...) /// A poster is a 2/3 format image with the cover of the resource.
/// </summary> /// </summary>
public interface IThumbnails public Image? Poster { get; set; }
{
/// <summary>
/// A poster is a 2/3 format image with the cover of the resource.
/// </summary>
public Image? Poster { get; set; }
/// <summary>
/// A thumbnail is a 16/9 format image, it could ether be used as a background or as a preview but it usually
/// is not an official image.
/// </summary>
public Image? Thumbnail { get; set; }
/// <summary>
/// A logo is a small image representing the resource.
/// </summary>
public Image? Logo { get; set; }
}
[TypeConverter(typeof(ImageConvertor))]
[SqlFirstColumn(nameof(Source))]
public class Image
{
/// <summary>
/// The original image from another server.
/// </summary>
public string Source { get; set; }
/// <summary>
/// A hash to display as placeholder while the image is loading.
/// </summary>
[MaxLength(32)]
public string Blurhash { get; set; }
public Image() { }
[JsonConstructor]
public Image(string source, string? blurhash = null)
{
Source = source;
Blurhash = blurhash ?? "000000";
}
public class ImageConvertor : TypeConverter
{
/// <inheritdoc />
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
{
if (sourceType == typeof(string))
return true;
return base.CanConvertFrom(context, sourceType);
}
/// <inheritdoc />
public override object ConvertFrom(
ITypeDescriptorContext? context,
CultureInfo? culture,
object value
)
{
if (value is not string source)
return base.ConvertFrom(context, culture, value)!;
return new Image(source);
}
/// <inheritdoc />
public override bool CanConvertTo(
ITypeDescriptorContext? context,
Type? destinationType
)
{
return false;
}
}
}
/// <summary> /// <summary>
/// The quality of an image /// A thumbnail is a 16/9 format image, it could ether be used as a background or as a preview but it usually
/// is not an official image.
/// </summary> /// </summary>
public enum ImageQuality public Image? Thumbnail { get; set; }
/// <summary>
/// A logo is a small image representing the resource.
/// </summary>
public Image? Logo { get; set; }
}
[TypeConverter(typeof(ImageConvertor))]
[SqlFirstColumn(nameof(Source))]
public class Image
{
/// <summary>
/// The original image from another server.
/// </summary>
public string Source { get; set; }
/// <summary>
/// A hash to display as placeholder while the image is loading.
/// </summary>
[MaxLength(32)]
public string Blurhash { get; set; }
public Image() { }
[JsonConstructor]
public Image(string source, string? blurhash = null)
{ {
/// <summary> Source = source;
/// Small Blurhash = blurhash ?? "000000";
/// </summary> }
Low,
/// <summary> public class ImageConvertor : TypeConverter
/// Medium {
/// </summary> /// <inheritdoc />
Medium, public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
{
if (sourceType == typeof(string))
return true;
return base.CanConvertFrom(context, sourceType);
}
/// <summary> /// <inheritdoc />
/// Large public override object ConvertFrom(
/// </summary> ITypeDescriptorContext? context,
High, CultureInfo? culture,
object value
)
{
if (value is not string source)
return base.ConvertFrom(context, culture, value)!;
return new Image(source);
}
/// <inheritdoc />
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
{
return false;
}
} }
} }
/// <summary>
/// The quality of an image
/// </summary>
public enum ImageQuality
{
/// <summary>
/// Small
/// </summary>
Low,
/// <summary>
/// Medium
/// </summary>
Medium,
/// <summary>
/// Large
/// </summary>
High,
}

View File

@ -27,163 +27,162 @@ using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Utils; using Kyoo.Utils;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// A series or a movie.
/// </summary>
public class Movie
: IQuery,
IResource,
IMetadata,
IThumbnails,
IAddedDate,
ILibraryItem,
INews,
IWatchlist
{ {
public static Sort DefaultSort => new Sort<Movie>.By(x => x.Name);
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[MaxLength(256)]
public string Slug { get; set; }
/// <summary> /// <summary>
/// A series or a movie. /// The title of this show.
/// </summary> /// </summary>
public class Movie public string Name { get; set; }
: IQuery,
IResource, /// <summary>
IMetadata, /// A catchphrase for this movie.
IThumbnails, /// </summary>
IAddedDate, public string? Tagline { get; set; }
ILibraryItem,
INews, /// <summary>
IWatchlist /// The list of alternative titles of this show.
/// </summary>
public string[] Aliases { get; set; } = Array.Empty<string>();
/// <summary>
/// The path of the movie video file.
/// </summary>
public string Path { get; set; }
/// <summary>
/// The summary of this show.
/// </summary>
public string? Overview { get; set; }
/// <summary>
/// A list of tags that match this movie.
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// The list of genres (themes) this show has.
/// </summary>
public Genre[] Genres { get; set; } = Array.Empty<Genre>();
/// <summary>
/// Is this show airing, not aired yet or finished?
/// </summary>
public Status Status { get; set; }
/// <summary>
/// How well this item is rated? (from 0 to 100).
/// </summary>
public int Rating { get; set; }
/// <summary>
/// How long is this movie? (in minutes)
/// </summary>
public int? Runtime { get; set; }
/// <summary>
/// The date this movie aired.
/// </summary>
public DateTime? AirDate { get; set; }
/// <inheritdoc />
public DateTime AddedDate { get; set; }
/// <inheritdoc />
public Image? Poster { get; set; }
/// <inheritdoc />
public Image? Thumbnail { get; set; }
/// <inheritdoc />
public Image? Logo { get; set; }
[JsonIgnore]
[Column("air_date")]
public DateTime? StartAir => AirDate;
[JsonIgnore]
[Column("air_date")]
public DateTime? EndAir => AirDate;
/// <summary>
/// A video of a few minutes that tease the content.
/// </summary>
public string? Trailer { get; set; }
/// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
/// <summary>
/// The ID of the Studio that made this show.
/// </summary>
[JsonIgnore]
public Guid? StudioId { get; set; }
/// <summary>
/// The Studio that made this show.
/// </summary>
[LoadableRelation(nameof(StudioId))]
public Studio? Studio { get; set; }
/// <summary>
/// The list of collections that contains this show.
/// </summary>
[JsonIgnore]
public ICollection<Collection>? Collections { get; set; }
/// <summary>
/// Links to watch this movie.
/// </summary>
public VideoLinks Links =>
new() { Direct = $"/movie/{Slug}/direct", Hls = $"/movie/{Slug}/master.m3u8", };
[JsonIgnore]
public ICollection<MovieWatchStatus>? Watched { get; set; }
/// <summary>
/// Metadata of what an user as started/planned to watch.
/// </summary>
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
[LoadableRelation(
Sql = "movie_watch_status",
On = "movie_id = \"this\".id and \"relation\".user_id = [current_user]"
)]
public MovieWatchStatus? WatchStatus { get; set; }
// There is a global query filter to filter by user so we just need to do single.
private MovieWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
public Movie() { }
[JsonConstructor]
public Movie(string name)
{ {
public static Sort DefaultSort => new Sort<Movie>.By(x => x.Name); if (name != null)
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[MaxLength(256)]
public string Slug { get; set; }
/// <summary>
/// The title of this show.
/// </summary>
public string Name { get; set; }
/// <summary>
/// A catchphrase for this movie.
/// </summary>
public string? Tagline { get; set; }
/// <summary>
/// The list of alternative titles of this show.
/// </summary>
public string[] Aliases { get; set; } = Array.Empty<string>();
/// <summary>
/// The path of the movie video file.
/// </summary>
public string Path { get; set; }
/// <summary>
/// The summary of this show.
/// </summary>
public string? Overview { get; set; }
/// <summary>
/// A list of tags that match this movie.
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// The list of genres (themes) this show has.
/// </summary>
public Genre[] Genres { get; set; } = Array.Empty<Genre>();
/// <summary>
/// Is this show airing, not aired yet or finished?
/// </summary>
public Status Status { get; set; }
/// <summary>
/// How well this item is rated? (from 0 to 100).
/// </summary>
public int Rating { get; set; }
/// <summary>
/// How long is this movie? (in minutes)
/// </summary>
public int? Runtime { get; set; }
/// <summary>
/// The date this movie aired.
/// </summary>
public DateTime? AirDate { get; set; }
/// <inheritdoc />
public DateTime AddedDate { get; set; }
/// <inheritdoc />
public Image? Poster { get; set; }
/// <inheritdoc />
public Image? Thumbnail { get; set; }
/// <inheritdoc />
public Image? Logo { get; set; }
[JsonIgnore]
[Column("air_date")]
public DateTime? StartAir => AirDate;
[JsonIgnore]
[Column("air_date")]
public DateTime? EndAir => AirDate;
/// <summary>
/// A video of a few minutes that tease the content.
/// </summary>
public string? Trailer { get; set; }
/// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
/// <summary>
/// The ID of the Studio that made this show.
/// </summary>
[JsonIgnore]
public Guid? StudioId { get; set; }
/// <summary>
/// The Studio that made this show.
/// </summary>
[LoadableRelation(nameof(StudioId))]
public Studio? Studio { get; set; }
/// <summary>
/// The list of collections that contains this show.
/// </summary>
[JsonIgnore]
public ICollection<Collection>? Collections { get; set; }
/// <summary>
/// Links to watch this movie.
/// </summary>
public VideoLinks Links =>
new() { Direct = $"/movie/{Slug}/direct", Hls = $"/movie/{Slug}/master.m3u8", };
[JsonIgnore]
public ICollection<MovieWatchStatus>? Watched { get; set; }
/// <summary>
/// Metadata of what an user as started/planned to watch.
/// </summary>
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
[LoadableRelation(
Sql = "movie_watch_status",
On = "movie_id = \"this\".id and \"relation\".user_id = [current_user]"
)]
public MovieWatchStatus? WatchStatus { get; set; }
// There is a global query filter to filter by user so we just need to do single.
private MovieWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
public Movie() { }
[JsonConstructor]
public Movie(string name)
{ {
if (name != null) Slug = Utility.ToSlug(name);
{ Name = name;
Slug = Utility.ToSlug(name);
Name = name;
}
} }
} }
} }

View File

@ -26,124 +26,123 @@ using EntityFrameworkCore.Projectables;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// A season of a <see cref="Show"/>.
/// </summary>
public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate
{ {
/// <summary> public static Sort DefaultSort => new Sort<Season>.By(x => x.SeasonNumber);
/// A season of a <see cref="Show"/>.
/// </summary> /// <inheritdoc />
public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate public Guid Id { get; set; }
/// <inheritdoc />
[Computed]
[MaxLength(256)]
public string Slug
{ {
public static Sort DefaultSort => new Sort<Season>.By(x => x.SeasonNumber); get
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[Computed]
[MaxLength(256)]
public string Slug
{ {
get if (ShowSlug == null && Show == null)
{ return $"{ShowId}-s{SeasonNumber}";
if (ShowSlug == null && Show == null) return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}";
return $"{ShowId}-s{SeasonNumber}";
return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}";
}
private set
{
Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)");
if (!match.Success)
throw new ArgumentException(
"Invalid season slug. Format: {showSlug}-s{seasonNumber}"
);
ShowSlug = match.Groups["show"].Value;
SeasonNumber = int.Parse(match.Groups["season"].Value);
}
} }
private set
{
Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)");
/// <summary> if (!match.Success)
/// The slug of the Show that contain this episode. If this is not set, this season is ill-formed. throw new ArgumentException(
/// </summary> "Invalid season slug. Format: {showSlug}-s{seasonNumber}"
[JsonIgnore] );
public string? ShowSlug { private get; set; } ShowSlug = match.Groups["show"].Value;
SeasonNumber = int.Parse(match.Groups["season"].Value);
/// <summary> }
/// The ID of the Show containing this season.
/// </summary>
public Guid ShowId { get; set; }
/// <summary>
/// The show that contains this season.
/// </summary>
[LoadableRelation(nameof(ShowId))]
public Show? Show { get; set; }
/// <summary>
/// The number of this season. This can be set to 0 to indicate specials.
/// </summary>
public int SeasonNumber { get; set; }
/// <summary>
/// The title of this season.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// A quick overview of this season.
/// </summary>
public string? Overview { get; set; }
/// <summary>
/// The starting air date of this season.
/// </summary>
public DateTime? StartDate { get; set; }
/// <inheritdoc />
public DateTime AddedDate { get; set; }
/// <summary>
/// The ending date of this season.
/// </summary>
public DateTime? EndDate { get; set; }
/// <inheritdoc />
public Image? Poster { get; set; }
/// <inheritdoc />
public Image? Thumbnail { get; set; }
/// <inheritdoc />
public Image? Logo { get; set; }
/// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
/// <summary>
/// The list of episodes that this season contains.
/// </summary>
[JsonIgnore]
public ICollection<Episode>? Episodes { get; set; }
/// <summary>
/// The number of episodes in this season.
/// </summary>
[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)]
[NotMapped]
[LoadableRelation(
// language=PostgreSQL
Projected = """
(
select
count(*)::int
from
episodes as e
where
e.season_id = id) as episodes_count
"""
)]
public int EpisodesCount { get; set; }
private int _EpisodesCount => Episodes!.Count;
} }
/// <summary>
/// The slug of the Show that contain this episode. If this is not set, this season is ill-formed.
/// </summary>
[JsonIgnore]
public string? ShowSlug { private get; set; }
/// <summary>
/// The ID of the Show containing this season.
/// </summary>
public Guid ShowId { get; set; }
/// <summary>
/// The show that contains this season.
/// </summary>
[LoadableRelation(nameof(ShowId))]
public Show? Show { get; set; }
/// <summary>
/// The number of this season. This can be set to 0 to indicate specials.
/// </summary>
public int SeasonNumber { get; set; }
/// <summary>
/// The title of this season.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// A quick overview of this season.
/// </summary>
public string? Overview { get; set; }
/// <summary>
/// The starting air date of this season.
/// </summary>
public DateTime? StartDate { get; set; }
/// <inheritdoc />
public DateTime AddedDate { get; set; }
/// <summary>
/// The ending date of this season.
/// </summary>
public DateTime? EndDate { get; set; }
/// <inheritdoc />
public Image? Poster { get; set; }
/// <inheritdoc />
public Image? Thumbnail { get; set; }
/// <inheritdoc />
public Image? Logo { get; set; }
/// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
/// <summary>
/// The list of episodes that this season contains.
/// </summary>
[JsonIgnore]
public ICollection<Episode>? Episodes { get; set; }
/// <summary>
/// The number of episodes in this season.
/// </summary>
[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)]
[NotMapped]
[LoadableRelation(
// language=PostgreSQL
Projected = """
(
select
count(*)::int
from
episodes as e
where
e.season_id = id) as episodes_count
"""
)]
public int EpisodesCount { get; set; }
private int _EpisodesCount => Episodes!.Count;
} }

View File

@ -27,254 +27,253 @@ using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Utils; using Kyoo.Utils;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// A series or a movie.
/// </summary>
public class Show
: IQuery,
IResource,
IMetadata,
IOnMerge,
IThumbnails,
IAddedDate,
ILibraryItem,
IWatchlist
{ {
public static Sort DefaultSort => new Sort<Show>.By(x => x.Name);
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[MaxLength(256)]
public string Slug { get; set; }
/// <summary> /// <summary>
/// A series or a movie. /// The title of this show.
/// </summary> /// </summary>
public class Show public string Name { get; set; }
: IQuery,
IResource,
IMetadata,
IOnMerge,
IThumbnails,
IAddedDate,
ILibraryItem,
IWatchlist
{
public static Sort DefaultSort => new Sort<Show>.By(x => x.Name);
/// <inheritdoc /> /// <summary>
public Guid Id { get; set; } /// A catchphrase for this show.
/// </summary>
public string? Tagline { get; set; }
/// <inheritdoc /> /// <summary>
[MaxLength(256)] /// The list of alternative titles of this show.
public string Slug { get; set; } /// </summary>
public List<string> Aliases { get; set; } = new();
/// <summary> /// <summary>
/// The title of this show. /// The summary of this show.
/// </summary> /// </summary>
public string Name { get; set; } public string? Overview { get; set; }
/// <summary> /// <summary>
/// A catchphrase for this show. /// A list of tags that match this movie.
/// </summary> /// </summary>
public string? Tagline { get; set; } public List<string> Tags { get; set; } = new();
/// <summary> /// <summary>
/// The list of alternative titles of this show. /// The list of genres (themes) this show has.
/// </summary> /// </summary>
public List<string> Aliases { get; set; } = new(); public List<Genre> Genres { get; set; } = new();
/// <summary> /// <summary>
/// The summary of this show. /// Is this show airing, not aired yet or finished?
/// </summary> /// </summary>
public string? Overview { get; set; } public Status Status { get; set; }
/// <summary> /// <summary>
/// A list of tags that match this movie. /// How well this item is rated? (from 0 to 100).
/// </summary> /// </summary>
public List<string> Tags { get; set; } = new(); public int Rating { get; set; }
/// <summary> /// <summary>
/// The list of genres (themes) this show has. /// The date this show started airing. It can be null if this is unknown.
/// </summary> /// </summary>
public List<Genre> Genres { get; set; } = new(); public DateTime? StartAir { get; set; }
/// <summary> /// <summary>
/// Is this show airing, not aired yet or finished? /// The date this show finished airing.
/// </summary> /// It can also be null if this is unknown.
public Status Status { get; set; } /// </summary>
public DateTime? EndAir { get; set; }
/// <summary> /// <inheritdoc />
/// How well this item is rated? (from 0 to 100). public DateTime AddedDate { get; set; }
/// </summary>
public int Rating { get; set; }
/// <summary> /// <inheritdoc />
/// The date this show started airing. It can be null if this is unknown. public Image? Poster { get; set; }
/// </summary>
public DateTime? StartAir { get; set; }
/// <summary> /// <inheritdoc />
/// The date this show finished airing. public Image? Thumbnail { get; set; }
/// It can also be null if this is unknown.
/// </summary>
public DateTime? EndAir { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public DateTime AddedDate { get; set; } public Image? Logo { get; set; }
/// <inheritdoc /> /// <summary>
public Image? Poster { get; set; } /// A video of a few minutes that tease the content.
/// </summary>
public string? Trailer { get; set; }
/// <inheritdoc /> [JsonIgnore]
public Image? Thumbnail { get; set; } [Column("start_air")]
public DateTime? AirDate => StartAir;
/// <inheritdoc /> /// <inheritdoc />
public Image? Logo { get; set; } public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
/// <summary> /// <summary>
/// A video of a few minutes that tease the content. /// The ID of the Studio that made this show.
/// </summary> /// </summary>
public string? Trailer { get; set; } public Guid? StudioId { get; set; }
[JsonIgnore] /// <summary>
[Column("start_air")] /// The Studio that made this show.
public DateTime? AirDate => StartAir; /// </summary>
[LoadableRelation(nameof(StudioId))]
public Studio? Studio { get; set; }
/// <inheritdoc /> /// <summary>
public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); /// The different seasons in this show. If this is a movie, this list is always null or empty.
/// </summary>
[JsonIgnore]
public ICollection<Season>? Seasons { get; set; }
/// <summary> /// <summary>
/// The ID of the Studio that made this show. /// The list of episodes in this show.
/// </summary> /// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null).
public Guid? StudioId { get; set; } /// Having an episode is necessary to store metadata and tracks.
/// </summary>
[JsonIgnore]
public ICollection<Episode>? Episodes { get; set; }
/// <summary> /// <summary>
/// The Studio that made this show. /// The list of collections that contains this show.
/// </summary> /// </summary>
[LoadableRelation(nameof(StudioId))] [JsonIgnore]
public Studio? Studio { get; set; } public ICollection<Collection>? Collections { get; set; }
/// <summary> /// <summary>
/// The different seasons in this show. If this is a movie, this list is always null or empty. /// The first episode of this show.
/// </summary> /// </summary>
[JsonIgnore] [Projectable(UseMemberBody = nameof(_FirstEpisode), OnlyOnInclude = true)]
public ICollection<Season>? Seasons { get; set; } [LoadableRelation(
// language=PostgreSQL
/// <summary> Sql = """
/// The list of episodes in this show. select
/// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null). fe.* -- Episode as fe
/// Having an episode is necessary to store metadata and tracks. from (
/// </summary>
[JsonIgnore]
public ICollection<Episode>? Episodes { get; set; }
/// <summary>
/// The list of collections that contains this show.
/// </summary>
[JsonIgnore]
public ICollection<Collection>? Collections { get; set; }
/// <summary>
/// The first episode of this show.
/// </summary>
[Projectable(UseMemberBody = nameof(_FirstEpisode), OnlyOnInclude = true)]
[LoadableRelation(
// language=PostgreSQL
Sql = """
select select
fe.* -- Episode as fe e.*,
from ( row_number() over (partition by e.show_id order by e.absolute_number, e.season_number, e.episode_number) as number
select from
e.*, episodes as e) as "fe"
row_number() over (partition by e.show_id order by e.absolute_number, e.season_number, e.episode_number) as number where
from fe.number <= 1
episodes as e) as "fe" """,
On = "show_id = \"this\".id"
)]
public Episode? FirstEpisode { get; set; }
private Episode? _FirstEpisode =>
Episodes!
.OrderBy(x => x.AbsoluteNumber)
.ThenBy(x => x.SeasonNumber)
.ThenBy(x => x.EpisodeNumber)
.FirstOrDefault();
/// <summary>
/// The number of episodes in this show.
/// </summary>
[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)]
[NotMapped]
[LoadableRelation(
// language=PostgreSQL
Projected = """
(
select
count(*)::int
from
episodes as e
where where
fe.number <= 1 e.show_id = "this".id) as episodes_count
""", """
On = "show_id = \"this\".id" )]
)] public int EpisodesCount { get; set; }
public Episode? FirstEpisode { get; set; }
private Episode? _FirstEpisode => private int _EpisodesCount => Episodes!.Count;
Episodes!
.OrderBy(x => x.AbsoluteNumber)
.ThenBy(x => x.SeasonNumber)
.ThenBy(x => x.EpisodeNumber)
.FirstOrDefault();
/// <summary> [JsonIgnore]
/// The number of episodes in this show. public ICollection<ShowWatchStatus>? Watched { get; set; }
/// </summary>
[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)]
[NotMapped]
[LoadableRelation(
// language=PostgreSQL
Projected = """
(
select
count(*)::int
from
episodes as e
where
e.show_id = "this".id) as episodes_count
"""
)]
public int EpisodesCount { get; set; }
private int _EpisodesCount => Episodes!.Count; /// <summary>
/// Metadata of what an user as started/planned to watch.
/// </summary>
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
[LoadableRelation(
Sql = "show_watch_status",
On = "show_id = \"this\".id and \"relation\".user_id = [current_user]"
)]
public ShowWatchStatus? WatchStatus { get; set; }
[JsonIgnore] // There is a global query filter to filter by user so we just need to do single.
public ICollection<ShowWatchStatus>? Watched { get; set; } private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
/// <summary> /// <inheritdoc />
/// Metadata of what an user as started/planned to watch. public void OnMerge(object merged)
/// </summary> {
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)] if (Seasons != null)
[LoadableRelation(
Sql = "show_watch_status",
On = "show_id = \"this\".id and \"relation\".user_id = [current_user]"
)]
public ShowWatchStatus? WatchStatus { get; set; }
// There is a global query filter to filter by user so we just need to do single.
private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
/// <inheritdoc />
public void OnMerge(object merged)
{ {
if (Seasons != null) foreach (Season season in Seasons)
{ season.Show = this;
foreach (Season season in Seasons)
season.Show = this;
}
if (Episodes != null)
{
foreach (Episode episode in Episodes)
episode.Show = this;
}
} }
public Show() { } if (Episodes != null)
[JsonConstructor]
public Show(string name)
{ {
if (name != null) foreach (Episode episode in Episodes)
{ episode.Show = this;
Slug = Utility.ToSlug(name);
Name = name;
}
} }
} }
/// <summary> public Show() { }
/// The enum containing show's status.
/// </summary> [JsonConstructor]
public enum Status public Show(string name)
{ {
/// <summary> if (name != null)
/// The status of the show is not known. {
/// </summary> Slug = Utility.ToSlug(name);
Unknown, Name = name;
}
/// <summary>
/// The show has finished airing.
/// </summary>
Finished,
/// <summary>
/// The show is still actively airing.
/// </summary>
Airing,
/// <summary>
/// This show has not aired yet but has been announced.
/// </summary>
Planned
} }
} }
/// <summary>
/// The enum containing show's status.
/// </summary>
public enum Status
{
/// <summary>
/// The status of the show is not known.
/// </summary>
Unknown,
/// <summary>
/// The show has finished airing.
/// </summary>
Finished,
/// <summary>
/// The show is still actively airing.
/// </summary>
Airing,
/// <summary>
/// This show has not aired yet but has been announced.
/// </summary>
Planned
}

View File

@ -23,59 +23,58 @@ using System.Text.Json.Serialization;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Utils; using Kyoo.Utils;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// A studio that make shows.
/// </summary>
public class Studio : IQuery, IResource, IMetadata
{ {
public static Sort DefaultSort => new Sort<Studio>.By(x => x.Name);
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[MaxLength(256)]
public string Slug { get; set; }
/// <summary> /// <summary>
/// A studio that make shows. /// The name of this studio.
/// </summary> /// </summary>
public class Studio : IQuery, IResource, IMetadata public string Name { get; set; }
/// <summary>
/// The list of shows that are made by this studio.
/// </summary>
[JsonIgnore]
public ICollection<Show>? Shows { get; set; }
/// <summary>
/// The list of movies that are made by this studio.
/// </summary>
[JsonIgnore]
public ICollection<Movie>? Movies { get; set; }
/// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
/// <summary>
/// Create a new, empty, <see cref="Studio"/>.
/// </summary>
public Studio() { }
/// <summary>
/// Create a new <see cref="Studio"/> with a specific name, the slug is calculated automatically.
/// </summary>
/// <param name="name">The name of the studio.</param>
[JsonConstructor]
public Studio(string name)
{ {
public static Sort DefaultSort => new Sort<Studio>.By(x => x.Name); if (name != null)
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[MaxLength(256)]
public string Slug { get; set; }
/// <summary>
/// The name of this studio.
/// </summary>
public string Name { get; set; }
/// <summary>
/// The list of shows that are made by this studio.
/// </summary>
[JsonIgnore]
public ICollection<Show>? Shows { get; set; }
/// <summary>
/// The list of movies that are made by this studio.
/// </summary>
[JsonIgnore]
public ICollection<Movie>? Movies { get; set; }
/// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
/// <summary>
/// Create a new, empty, <see cref="Studio"/>.
/// </summary>
public Studio() { }
/// <summary>
/// Create a new <see cref="Studio"/> with a specific name, the slug is calculated automatically.
/// </summary>
/// <param name="name">The name of the studio.</param>
[JsonConstructor]
public Studio(string name)
{ {
if (name != null) Slug = Utility.ToSlug(name);
{ Name = name;
Slug = Utility.ToSlug(name);
Name = name;
}
} }
} }
} }

View File

@ -23,95 +23,94 @@ using System.Text.Json.Serialization;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Utils; using Kyoo.Utils;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// A single user of the app.
/// </summary>
public class User : IQuery, IResource, IAddedDate
{ {
public static Sort DefaultSort => new Sort<User>.By(x => x.Username);
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[MaxLength(256)]
public string Slug { get; set; }
/// <summary> /// <summary>
/// A single user of the app. /// A username displayed to the user.
/// </summary> /// </summary>
public class User : IQuery, IResource, IAddedDate public string Username { get; set; }
/// <summary>
/// The user email address.
/// </summary>
public string Email { get; set; }
/// <summary>
/// The user password (hashed, it can't be read like that). The hashing format is implementation defined.
/// </summary>
[JsonIgnore]
public string? Password { get; set; }
/// <summary>
/// Does the user can sign-in with a password or only via oidc?
/// </summary>
public bool HasPassword => Password != null;
/// <summary>
/// The list of permissions of the user. The format of this is implementation dependent.
/// </summary>
public string[] Permissions { get; set; } = Array.Empty<string>();
/// <inheritdoc />
public DateTime AddedDate { get; set; }
/// <summary>
/// User settings
/// </summary>
public Dictionary<string, string> Settings { get; set; } = new();
/// <summary>
/// User accounts on other services.
/// </summary>
public Dictionary<string, ExternalToken> ExternalId { get; set; } = new();
public User() { }
[JsonConstructor]
public User(string username)
{ {
public static Sort DefaultSort => new Sort<User>.By(x => x.Username); if (username != null)
/// <inheritdoc />
public Guid Id { get; set; }
/// <inheritdoc />
[MaxLength(256)]
public string Slug { get; set; }
/// <summary>
/// A username displayed to the user.
/// </summary>
public string Username { get; set; }
/// <summary>
/// The user email address.
/// </summary>
public string Email { get; set; }
/// <summary>
/// The user password (hashed, it can't be read like that). The hashing format is implementation defined.
/// </summary>
[JsonIgnore]
public string? Password { get; set; }
/// <summary>
/// Does the user can sign-in with a password or only via oidc?
/// </summary>
public bool HasPassword => Password != null;
/// <summary>
/// The list of permissions of the user. The format of this is implementation dependent.
/// </summary>
public string[] Permissions { get; set; } = Array.Empty<string>();
/// <inheritdoc />
public DateTime AddedDate { get; set; }
/// <summary>
/// User settings
/// </summary>
public Dictionary<string, string> Settings { get; set; } = new();
/// <summary>
/// User accounts on other services.
/// </summary>
public Dictionary<string, ExternalToken> ExternalId { get; set; } = new();
public User() { }
[JsonConstructor]
public User(string username)
{ {
if (username != null) Slug = Utility.ToSlug(username);
{ Username = username;
Slug = Utility.ToSlug(username);
Username = username;
}
} }
} }
}
public class ExternalToken
{ public class ExternalToken
/// <summary> {
/// The id of this user on the external service. /// <summary>
/// </summary> /// The id of this user on the external service.
public string Id { get; set; } /// </summary>
public string Id { get; set; }
/// <summary>
/// The username on the external service. /// <summary>
/// </summary> /// The username on the external service.
public string Username { get; set; } /// </summary>
public string Username { get; set; }
/// <summary>
/// The link to the user profile on this website. Null if it does not exist. /// <summary>
/// </summary> /// The link to the user profile on this website. Null if it does not exist.
public string? ProfileUrl { get; set; } /// </summary>
public string? ProfileUrl { get; set; }
/// <summary>
/// A jwt token used to interact with the service. /// <summary>
/// Do not forget to refresh it when using it if necessary. /// A jwt token used to interact with the service.
/// </summary> /// Do not forget to refresh it when using it if necessary.
public JwtToken Token { get; set; } /// </summary>
} public JwtToken Token { get; set; }
} }

View File

@ -20,214 +20,213 @@ using System;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// Has the user started watching, is it planned?
/// </summary>
public enum WatchStatus
{ {
/// <summary>
/// The user has already watched this.
/// </summary>
Completed,
/// <summary>
/// The user started watching this but has not finished.
/// </summary>
Watching,
/// <summary>
/// The user does not plan to continue watching.
/// </summary>
Droped,
/// <summary>
/// The user has not started watching this but plans to.
/// </summary>
Planned,
}
/// <summary>
/// Metadata of what an user as started/planned to watch.
/// </summary>
[SqlFirstColumn(nameof(UserId))]
public class MovieWatchStatus : IAddedDate
{
/// <summary>
/// The ID of the user that started watching this episode.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// The user that started watching this episode.
/// </summary>
[JsonIgnore]
public User User { get; set; }
/// <summary>
/// The ID of the movie started.
/// </summary>
public Guid MovieId { get; set; }
/// <summary>
/// The <see cref="Movie"/> started.
/// </summary>
[JsonIgnore]
public Movie Movie { get; set; }
/// <inheritdoc/>
public DateTime AddedDate { get; set; }
/// <summary>
/// The date at which this item was played.
/// </summary>
public DateTime? PlayedDate { get; set; }
/// <summary> /// <summary>
/// Has the user started watching, is it planned? /// Has the user started watching, is it planned?
/// </summary> /// </summary>
public enum WatchStatus public WatchStatus Status { get; set; }
{
/// <summary>
/// The user has already watched this.
/// </summary>
Completed,
/// <summary>
/// The user started watching this but has not finished.
/// </summary>
Watching,
/// <summary>
/// The user does not plan to continue watching.
/// </summary>
Droped,
/// <summary>
/// The user has not started watching this but plans to.
/// </summary>
Planned,
}
/// <summary> /// <summary>
/// Metadata of what an user as started/planned to watch. /// Where the player has stopped watching the movie (in seconds).
/// </summary> /// </summary>
[SqlFirstColumn(nameof(UserId))] /// <remarks>
public class MovieWatchStatus : IAddedDate /// Null if the status is not Watching.
{ /// </remarks>
/// <summary> public int? WatchedTime { get; set; }
/// The ID of the user that started watching this episode.
/// </summary>
public Guid UserId { get; set; }
/// <summary> /// <summary>
/// The user that started watching this episode. /// Where the player has stopped watching the movie (in percentage between 0 and 100).
/// </summary> /// </summary>
[JsonIgnore] /// <remarks>
public User User { get; set; } /// Null if the status is not Watching.
/// </remarks>
/// <summary> public int? WatchedPercent { get; set; }
/// The ID of the movie started. }
/// </summary>
public Guid MovieId { get; set; } [SqlFirstColumn(nameof(UserId))]
public class EpisodeWatchStatus : IAddedDate
/// <summary> {
/// The <see cref="Movie"/> started. /// <summary>
/// </summary> /// The ID of the user that started watching this episode.
[JsonIgnore] /// </summary>
public Movie Movie { get; set; } public Guid UserId { get; set; }
/// <inheritdoc/> /// <summary>
public DateTime AddedDate { get; set; } /// The user that started watching this episode.
/// </summary>
/// <summary> [JsonIgnore]
/// The date at which this item was played. public User User { get; set; }
/// </summary>
public DateTime? PlayedDate { get; set; } /// <summary>
/// The ID of the episode started.
/// <summary> /// </summary>
/// Has the user started watching, is it planned? public Guid? EpisodeId { get; set; }
/// </summary>
public WatchStatus Status { get; set; } /// <summary>
/// The <see cref="Episode"/> started.
/// <summary> /// </summary>
/// Where the player has stopped watching the movie (in seconds). [JsonIgnore]
/// </summary> public Episode Episode { get; set; }
/// <remarks>
/// Null if the status is not Watching. /// <inheritdoc/>
/// </remarks> public DateTime AddedDate { get; set; }
public int? WatchedTime { get; set; }
/// <summary>
/// <summary> /// The date at which this item was played.
/// Where the player has stopped watching the movie (in percentage between 0 and 100). /// </summary>
/// </summary> public DateTime? PlayedDate { get; set; }
/// <remarks>
/// Null if the status is not Watching. /// <summary>
/// </remarks> /// Has the user started watching, is it planned?
public int? WatchedPercent { get; set; } /// </summary>
} public WatchStatus Status { get; set; }
[SqlFirstColumn(nameof(UserId))] /// <summary>
public class EpisodeWatchStatus : IAddedDate /// Where the player has stopped watching the episode (in seconds).
{ /// </summary>
/// <summary> /// <remarks>
/// The ID of the user that started watching this episode. /// Null if the status is not Watching.
/// </summary> /// </remarks>
public Guid UserId { get; set; } public int? WatchedTime { get; set; }
/// <summary> /// <summary>
/// The user that started watching this episode. /// Where the player has stopped watching the episode (in percentage between 0 and 100).
/// </summary> /// </summary>
[JsonIgnore] /// <remarks>
public User User { get; set; } /// Null if the status is not Watching or if the next episode is not started.
/// </remarks>
/// <summary> public int? WatchedPercent { get; set; }
/// The ID of the episode started. }
/// </summary>
public Guid? EpisodeId { get; set; } [SqlFirstColumn(nameof(UserId))]
public class ShowWatchStatus : IAddedDate
/// <summary> {
/// The <see cref="Episode"/> started. /// <summary>
/// </summary> /// The ID of the user that started watching this episode.
[JsonIgnore] /// </summary>
public Episode Episode { get; set; } public Guid UserId { get; set; }
/// <inheritdoc/> /// <summary>
public DateTime AddedDate { get; set; } /// The user that started watching this episode.
/// </summary>
/// <summary> [JsonIgnore]
/// The date at which this item was played. public User User { get; set; }
/// </summary>
public DateTime? PlayedDate { get; set; } /// <summary>
/// The ID of the show started.
/// <summary> /// </summary>
/// Has the user started watching, is it planned? public Guid ShowId { get; set; }
/// </summary>
public WatchStatus Status { get; set; } /// <summary>
/// The <see cref="Show"/> started.
/// <summary> /// </summary>
/// Where the player has stopped watching the episode (in seconds). [JsonIgnore]
/// </summary> public Show Show { get; set; }
/// <remarks>
/// Null if the status is not Watching. /// <inheritdoc/>
/// </remarks> public DateTime AddedDate { get; set; }
public int? WatchedTime { get; set; }
/// <summary>
/// <summary> /// The date at which this item was played.
/// Where the player has stopped watching the episode (in percentage between 0 and 100). /// </summary>
/// </summary> public DateTime? PlayedDate { get; set; }
/// <remarks>
/// Null if the status is not Watching or if the next episode is not started. /// <summary>
/// </remarks> /// Has the user started watching, is it planned?
public int? WatchedPercent { get; set; } /// </summary>
} public WatchStatus Status { get; set; }
[SqlFirstColumn(nameof(UserId))] /// <summary>
public class ShowWatchStatus : IAddedDate /// The number of episodes the user has not seen.
{ /// </summary>
/// <summary> public int UnseenEpisodesCount { get; set; }
/// The ID of the user that started watching this episode.
/// </summary> /// <summary>
public Guid UserId { get; set; } /// The ID of the episode started.
/// </summary>
/// <summary> public Guid? NextEpisodeId { get; set; }
/// The user that started watching this episode.
/// </summary> /// <summary>
[JsonIgnore] /// The next <see cref="Episode"/> to watch.
public User User { get; set; } /// </summary>
public Episode? NextEpisode { get; set; }
/// <summary>
/// The ID of the show started. /// <summary>
/// </summary> /// Where the player has stopped watching the episode (in seconds).
public Guid ShowId { get; set; } /// </summary>
/// <remarks>
/// <summary> /// Null if the status is not Watching or if the next episode is not started.
/// The <see cref="Show"/> started. /// </remarks>
/// </summary> public int? WatchedTime { get; set; }
[JsonIgnore]
public Show Show { get; set; } /// <summary>
/// Where the player has stopped watching the episode (in percentage between 0 and 100).
/// <inheritdoc/> /// </summary>
public DateTime AddedDate { get; set; } /// <remarks>
/// Null if the status is not Watching or if the next episode is not started.
/// <summary> /// </remarks>
/// The date at which this item was played. public int? WatchedPercent { get; set; }
/// </summary>
public DateTime? PlayedDate { get; set; }
/// <summary>
/// Has the user started watching, is it planned?
/// </summary>
public WatchStatus Status { get; set; }
/// <summary>
/// The number of episodes the user has not seen.
/// </summary>
public int UnseenEpisodesCount { get; set; }
/// <summary>
/// The ID of the episode started.
/// </summary>
public Guid? NextEpisodeId { get; set; }
/// <summary>
/// The next <see cref="Episode"/> to watch.
/// </summary>
public Episode? NextEpisode { get; set; }
/// <summary>
/// Where the player has stopped watching the episode (in seconds).
/// </summary>
/// <remarks>
/// Null if the status is not Watching or if the next episode is not started.
/// </remarks>
public int? WatchedTime { get; set; }
/// <summary>
/// Where the player has stopped watching the episode (in percentage between 0 and 100).
/// </summary>
/// <remarks>
/// Null if the status is not Watching or if the next episode is not started.
/// </remarks>
public int? WatchedPercent { get; set; }
}
} }

View File

@ -18,37 +18,36 @@
using System.Collections.Generic; using System.Collections.Generic;
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// Results of a search request.
/// </summary>
/// <typeparam name="T">The search item's type.</typeparam>
public class SearchPage<T> : Page<T>
where T : IResource
{ {
/// <summary> public SearchPage(
/// Results of a search request. SearchResult result,
/// </summary> string @this,
/// <typeparam name="T">The search item's type.</typeparam> string? previous,
public class SearchPage<T> : Page<T> string? next,
where T : IResource string first
)
: base(result.Items, @this, previous, next, first)
{ {
public SearchPage( Query = result.Query;
SearchResult result, }
string @this,
string? previous,
string? next,
string first
)
: base(result.Items, @this, previous, next, first)
{
Query = result.Query;
}
/// <summary> /// <summary>
/// The query of the search request. /// The query of the search request.
/// </summary> /// </summary>
public string? Query { get; init; } public string? Query { get; init; }
public class SearchResult public class SearchResult
{ {
public string? Query { get; set; } public string? Query { get; set; }
public ICollection<T> Items { get; set; } public ICollection<T> Items { get; set; }
}
} }
} }

View File

@ -16,41 +16,40 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Authentication.Models namespace Kyoo.Authentication.Models;
/// <summary>
/// List of well known claims of kyoo
/// </summary>
public static class Claims
{ {
/// <summary> /// <summary>
/// List of well known claims of kyoo /// The id of the user
/// </summary> /// </summary>
public static class Claims public static string Id => "id";
{
/// <summary>
/// The id of the user
/// </summary>
public static string Id => "id";
/// <summary> /// <summary>
/// The name of the user /// The name of the user
/// </summary> /// </summary>
public static string Name => "name"; public static string Name => "name";
/// <summary> /// <summary>
/// The email of the user. /// The email of the user.
/// </summary> /// </summary>
public static string Email => "email"; public static string Email => "email";
/// <summary> /// <summary>
/// The list of permissions that the user has. /// The list of permissions that the user has.
/// </summary> /// </summary>
public static string Permissions => "permissions"; public static string Permissions => "permissions";
/// <summary> /// <summary>
/// The type of the token (either "access" or "refresh"). /// The type of the token (either "access" or "refresh").
/// </summary> /// </summary>
public static string Type => "type"; public static string Type => "type";
/// <summary> /// <summary>
/// A guid used to identify a specific refresh token. This is only useful for the server to revokate tokens. /// A guid used to identify a specific refresh token. This is only useful for the server to revokate tokens.
/// </summary> /// </summary>
public static string Guid => "guid"; public static string Guid => "guid";
}
} }

View File

@ -18,43 +18,42 @@
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Abstractions.Models.Utils namespace Kyoo.Abstractions.Models.Utils;
/// <summary>
/// A class containing constant numbers.
/// </summary>
public static class Constants
{ {
/// <summary> /// <summary>
/// A class containing constant numbers. /// A property to use on a Microsoft.AspNet.MVC.Route.Order property to mark it as an alternative route
/// that won't be included on the swagger.
/// </summary> /// </summary>
public static class Constants public const int AlternativeRoute = 1;
{
/// <summary>
/// A property to use on a Microsoft.AspNet.MVC.Route.Order property to mark it as an alternative route
/// that won't be included on the swagger.
/// </summary>
public const int AlternativeRoute = 1;
/// <summary> /// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by users. /// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by users.
/// </summary> /// </summary>
public const string UsersGroup = "0:Users"; public const string UsersGroup = "0:Users";
/// <summary> /// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for main resources of kyoo. /// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for main resources of kyoo.
/// </summary> /// </summary>
public const string ResourcesGroup = "1:Resources"; public const string ResourcesGroup = "1:Resources";
/// <summary> /// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. /// A group name for <see cref="ApiDefinitionAttribute"/>.
/// It should be used for sub resources of kyoo that help define the main resources. /// It should be used for sub resources of kyoo that help define the main resources.
/// </summary> /// </summary>
public const string MetadataGroup = "2:Metadata"; public const string MetadataGroup = "2:Metadata";
/// <summary> /// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints useful for playback. /// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints useful for playback.
/// </summary> /// </summary>
public const string WatchGroup = "3:Watch"; public const string WatchGroup = "3:Watch";
/// <summary> /// <summary>
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins. /// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins.
/// </summary> /// </summary>
public const string AdminGroup = "4:Admin"; public const string AdminGroup = "4:Admin";
}
} }

View File

@ -24,225 +24,222 @@ using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
namespace Kyoo.Abstractions.Models.Utils namespace Kyoo.Abstractions.Models.Utils;
/// <summary>
/// A class that represent a resource. It is made to be used as a parameter in a query and not used somewhere else
/// on the application.
/// This class allow routes to be used via ether IDs or Slugs, this is suitable for every <see cref="IResource"/>.
/// </summary>
[TypeConverter(typeof(IdentifierConvertor))]
public class Identifier
{ {
/// <summary> /// <summary>
/// A class that represent a resource. It is made to be used as a parameter in a query and not used somewhere else /// The ID of the resource or null if the slug is specified.
/// on the application.
/// This class allow routes to be used via ether IDs or Slugs, this is suitable for every <see cref="IResource"/>.
/// </summary> /// </summary>
[TypeConverter(typeof(IdentifierConvertor))] private readonly Guid? _id;
public class Identifier
/// <summary>
/// The slug of the resource or null if the id is specified.
/// </summary>
private readonly string? _slug;
/// <summary>
/// Create a new <see cref="Identifier"/> for the given id.
/// </summary>
/// <param name="id">The id of the resource.</param>
public Identifier(Guid id)
{ {
/// <summary> _id = id;
/// The ID of the resource or null if the slug is specified. }
/// </summary>
private readonly Guid? _id;
/// <summary> /// <summary>
/// The slug of the resource or null if the id is specified. /// Create a new <see cref="Identifier"/> for the given slug.
/// </summary> /// </summary>
private readonly string? _slug; /// <param name="slug">The slug of the resource.</param>
public Identifier(string slug)
{
_slug = slug;
}
/// <summary> /// <summary>
/// Create a new <see cref="Identifier"/> for the given id. /// Pattern match out of the identifier to a resource.
/// </summary> /// </summary>
/// <param name="id">The id of the resource.</param> /// <param name="idFunc">The function to match the ID to a type <typeparamref name="T"/>.</param>
public Identifier(Guid id) /// <param name="slugFunc">The function to match the slug to a type <typeparamref name="T"/>.</param>
/// <typeparam name="T">The return type that will be converted to from an ID or a slug.</typeparam>
/// <returns>
/// The result of the <paramref name="idFunc"/> or <paramref name="slugFunc"/> depending on the pattern.
/// </returns>
/// <example>
/// Example usage:
/// <code lang="csharp">
/// T ret = await identifier.Match(
/// id => _repository.GetOrDefault(id),
/// slug => _repository.GetOrDefault(slug)
/// );
/// </code>
/// </example>
public T Match<T>(Func<Guid, T> idFunc, Func<string, T> slugFunc)
{
return _id.HasValue ? idFunc(_id.Value) : slugFunc(_slug!);
}
/// <summary>
/// Match a custom type to an identifier. This can be used for wrapped resources (see example for more details).
/// </summary>
/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param>
/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param>
/// <typeparam name="T">The type to match against this identifier.</typeparam>
/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns>
/// <example>
/// <code lang="csharp">
/// identifier.Matcher&lt;Season&gt;(x => x.ShowID, x => x.Show.Slug)
/// </code>
/// </example>
public Filter<T> Matcher<T>(
Expression<Func<T, Guid>> idGetter,
Expression<Func<T, string>> slugGetter
)
{
ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug);
BinaryExpression equal = Expression.Equal(
_id.HasValue ? idGetter.Body : slugGetter.Body,
self
);
ICollection<ParameterExpression> parameters = _id.HasValue
? idGetter.Parameters
: slugGetter.Parameters;
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters);
return new Filter<T>.Lambda(lambda);
}
/// <summary>
/// A matcher overload for nullable IDs. See
/// <see cref="Matcher{T}(Expression{Func{T,Guid}},Expression{Func{T,string}})"/>
/// for more details.
/// </summary>
/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param>
/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param>
/// <typeparam name="T">The type to match against this identifier.</typeparam>
/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns>
public Filter<T> Matcher<T>(
Expression<Func<T, Guid?>> idGetter,
Expression<Func<T, string>> slugGetter
)
{
ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug);
BinaryExpression equal = Expression.Equal(
_id.HasValue ? idGetter.Body : slugGetter.Body,
self
);
ICollection<ParameterExpression> parameters = _id.HasValue
? idGetter.Parameters
: slugGetter.Parameters;
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters);
return new Filter<T>.Lambda(lambda);
}
/// <summary>
/// Return true if this <see cref="Identifier"/> match a resource.
/// </summary>
/// <param name="resource">The resource to match</param>
/// <returns>
/// <c>true</c> if the <paramref name="resource"/> match this identifier, <c>false</c> otherwise.
/// </returns>
public bool IsSame(IResource resource)
{
return Match(id => resource.Id == id, slug => resource.Slug == slug);
}
/// <summary>
/// Return a filter to get this <see cref="Identifier"/> match a given resource.
/// </summary>
/// <typeparam name="T">The type of resource to match against.</typeparam>
/// <returns>
/// <c>true</c> if the given resource match this identifier, <c>false</c> otherwise.
/// </returns>
public Filter<T> IsSame<T>()
where T : IResource
{
return _id.HasValue ? new Filter<T>.Eq("Id", _id.Value) : new Filter<T>.Eq("Slug", _slug!);
}
public bool Is(Guid uid)
{
return _id.HasValue && _id.Value == uid;
}
public bool Is(string slug)
{
return !_id.HasValue && _slug == slug;
}
private Expression<Func<T, bool>> _IsSameExpression<T>()
where T : IResource
{
return _id.HasValue ? x => x.Id == _id.Value : x => x.Slug == _slug;
}
/// <summary>
/// Return an expression that return true if this <see cref="Identifier"/> is containing in a collection.
/// </summary>
/// <param name="listGetter">An expression to retrieve the list to check.</param>
/// <typeparam name="T">The type that contain the list to check.</typeparam>
/// <typeparam name="T2">The type of resource to check this identifier against.</typeparam>
/// <returns>An expression to check if this <see cref="Identifier"/> is contained.</returns>
public Filter<T> IsContainedIn<T, T2>(Expression<Func<T, IEnumerable<T2>?>> listGetter)
where T2 : IResource
{
MethodInfo method = typeof(Enumerable)
.GetMethods()
.Where(x => x.Name == nameof(Enumerable.Any))
.FirstOrDefault(x => x.GetParameters().Length == 2)!
.MakeGenericMethod(typeof(T2));
MethodCallExpression call = Expression.Call(
null,
method,
listGetter.Body,
_IsSameExpression<T2>()
);
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(
call,
listGetter.Parameters
);
return new Filter<T>.Lambda(lambda);
}
/// <inheritdoc />
public override string ToString()
{
return _id.HasValue ? _id.Value.ToString() : _slug!;
}
/// <summary>
/// A custom <see cref="TypeConverter"/> used to convert int or strings to an <see cref="Identifier"/>.
/// </summary>
public class IdentifierConvertor : TypeConverter
{
/// <inheritdoc />
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
{ {
_id = id; if (sourceType == typeof(Guid) || sourceType == typeof(string))
} return true;
return base.CanConvertFrom(context, sourceType);
/// <summary>
/// Create a new <see cref="Identifier"/> for the given slug.
/// </summary>
/// <param name="slug">The slug of the resource.</param>
public Identifier(string slug)
{
_slug = slug;
}
/// <summary>
/// Pattern match out of the identifier to a resource.
/// </summary>
/// <param name="idFunc">The function to match the ID to a type <typeparamref name="T"/>.</param>
/// <param name="slugFunc">The function to match the slug to a type <typeparamref name="T"/>.</param>
/// <typeparam name="T">The return type that will be converted to from an ID or a slug.</typeparam>
/// <returns>
/// The result of the <paramref name="idFunc"/> or <paramref name="slugFunc"/> depending on the pattern.
/// </returns>
/// <example>
/// Example usage:
/// <code lang="csharp">
/// T ret = await identifier.Match(
/// id => _repository.GetOrDefault(id),
/// slug => _repository.GetOrDefault(slug)
/// );
/// </code>
/// </example>
public T Match<T>(Func<Guid, T> idFunc, Func<string, T> slugFunc)
{
return _id.HasValue ? idFunc(_id.Value) : slugFunc(_slug!);
}
/// <summary>
/// Match a custom type to an identifier. This can be used for wrapped resources (see example for more details).
/// </summary>
/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param>
/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param>
/// <typeparam name="T">The type to match against this identifier.</typeparam>
/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns>
/// <example>
/// <code lang="csharp">
/// identifier.Matcher&lt;Season&gt;(x => x.ShowID, x => x.Show.Slug)
/// </code>
/// </example>
public Filter<T> Matcher<T>(
Expression<Func<T, Guid>> idGetter,
Expression<Func<T, string>> slugGetter
)
{
ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug);
BinaryExpression equal = Expression.Equal(
_id.HasValue ? idGetter.Body : slugGetter.Body,
self
);
ICollection<ParameterExpression> parameters = _id.HasValue
? idGetter.Parameters
: slugGetter.Parameters;
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters);
return new Filter<T>.Lambda(lambda);
}
/// <summary>
/// A matcher overload for nullable IDs. See
/// <see cref="Matcher{T}(Expression{Func{T,Guid}},Expression{Func{T,string}})"/>
/// for more details.
/// </summary>
/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param>
/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param>
/// <typeparam name="T">The type to match against this identifier.</typeparam>
/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns>
public Filter<T> Matcher<T>(
Expression<Func<T, Guid?>> idGetter,
Expression<Func<T, string>> slugGetter
)
{
ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug);
BinaryExpression equal = Expression.Equal(
_id.HasValue ? idGetter.Body : slugGetter.Body,
self
);
ICollection<ParameterExpression> parameters = _id.HasValue
? idGetter.Parameters
: slugGetter.Parameters;
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters);
return new Filter<T>.Lambda(lambda);
}
/// <summary>
/// Return true if this <see cref="Identifier"/> match a resource.
/// </summary>
/// <param name="resource">The resource to match</param>
/// <returns>
/// <c>true</c> if the <paramref name="resource"/> match this identifier, <c>false</c> otherwise.
/// </returns>
public bool IsSame(IResource resource)
{
return Match(id => resource.Id == id, slug => resource.Slug == slug);
}
/// <summary>
/// Return a filter to get this <see cref="Identifier"/> match a given resource.
/// </summary>
/// <typeparam name="T">The type of resource to match against.</typeparam>
/// <returns>
/// <c>true</c> if the given resource match this identifier, <c>false</c> otherwise.
/// </returns>
public Filter<T> IsSame<T>()
where T : IResource
{
return _id.HasValue
? new Filter<T>.Eq("Id", _id.Value)
: new Filter<T>.Eq("Slug", _slug!);
}
public bool Is(Guid uid)
{
return _id.HasValue && _id.Value == uid;
}
public bool Is(string slug)
{
return !_id.HasValue && _slug == slug;
}
private Expression<Func<T, bool>> _IsSameExpression<T>()
where T : IResource
{
return _id.HasValue ? x => x.Id == _id.Value : x => x.Slug == _slug;
}
/// <summary>
/// Return an expression that return true if this <see cref="Identifier"/> is containing in a collection.
/// </summary>
/// <param name="listGetter">An expression to retrieve the list to check.</param>
/// <typeparam name="T">The type that contain the list to check.</typeparam>
/// <typeparam name="T2">The type of resource to check this identifier against.</typeparam>
/// <returns>An expression to check if this <see cref="Identifier"/> is contained.</returns>
public Filter<T> IsContainedIn<T, T2>(Expression<Func<T, IEnumerable<T2>?>> listGetter)
where T2 : IResource
{
MethodInfo method = typeof(Enumerable)
.GetMethods()
.Where(x => x.Name == nameof(Enumerable.Any))
.FirstOrDefault(x => x.GetParameters().Length == 2)!
.MakeGenericMethod(typeof(T2));
MethodCallExpression call = Expression.Call(
null,
method,
listGetter.Body,
_IsSameExpression<T2>()
);
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(
call,
listGetter.Parameters
);
return new Filter<T>.Lambda(lambda);
} }
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() public override object ConvertFrom(
ITypeDescriptorContext? context,
CultureInfo? culture,
object value
)
{ {
return _id.HasValue ? _id.Value.ToString() : _slug!; if (value is Guid id)
} return new Identifier(id);
if (value is not string slug)
/// <summary> return base.ConvertFrom(context, culture, value)!;
/// A custom <see cref="TypeConverter"/> used to convert int or strings to an <see cref="Identifier"/>. return Guid.TryParse(slug, out id) ? new Identifier(id) : new Identifier(slug);
/// </summary>
public class IdentifierConvertor : TypeConverter
{
/// <inheritdoc />
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
{
if (sourceType == typeof(Guid) || sourceType == typeof(string))
return true;
return base.CanConvertFrom(context, sourceType);
}
/// <inheritdoc />
public override object ConvertFrom(
ITypeDescriptorContext? context,
CultureInfo? culture,
object value
)
{
if (value is Guid id)
return new Identifier(id);
if (value is not string slug)
return base.ConvertFrom(context, culture, value)!;
return Guid.TryParse(slug, out id) ? new Identifier(id) : new Identifier(slug);
}
} }
} }
} }

View File

@ -18,56 +18,55 @@
using System; using System;
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// Information about the pagination. How many items should be displayed and where to start.
/// </summary>
public class Pagination
{ {
/// <summary> /// <summary>
/// Information about the pagination. How many items should be displayed and where to start. /// The count of items to return.
/// </summary> /// </summary>
public class Pagination public int Limit { get; set; }
/// <summary>
/// Where to start? Using the given sort.
/// </summary>
public Guid? AfterID { get; set; }
/// <summary>
/// Should the previous page be returned instead of the next?
/// </summary>
public bool Reverse { get; set; }
/// <summary>
/// Create a new <see cref="Pagination"/> with default values.
/// </summary>
public Pagination()
{ {
/// <summary> Limit = 50;
/// The count of items to return. AfterID = null;
/// </summary> Reverse = false;
public int Limit { get; set; }
/// <summary>
/// Where to start? Using the given sort.
/// </summary>
public Guid? AfterID { get; set; }
/// <summary>
/// Should the previous page be returned instead of the next?
/// </summary>
public bool Reverse { get; set; }
/// <summary>
/// Create a new <see cref="Pagination"/> with default values.
/// </summary>
public Pagination()
{
Limit = 50;
AfterID = null;
Reverse = false;
}
/// <summary>
/// Create a new <see cref="Pagination"/> instance.
/// </summary>
/// <param name="count">Set the <see cref="Limit"/> value</param>
/// <param name="afterID">Set the <see cref="AfterID"/> value. If not specified, it will start from the start</param>
/// <param name="reverse">Should the previous page be returned instead of the next?</param>
public Pagination(int count, Guid? afterID = null, bool reverse = false)
{
Limit = count;
AfterID = afterID;
Reverse = reverse;
}
/// <summary>
/// Implicitly create a new pagination from a limit number.
/// </summary>
/// <param name="limit">Set the <see cref="Limit"/> value</param>
/// <returns>A new <see cref="Pagination"/> instance</returns>
public static implicit operator Pagination(int limit) => new(limit);
} }
/// <summary>
/// Create a new <see cref="Pagination"/> instance.
/// </summary>
/// <param name="count">Set the <see cref="Limit"/> value</param>
/// <param name="afterID">Set the <see cref="AfterID"/> value. If not specified, it will start from the start</param>
/// <param name="reverse">Should the previous page be returned instead of the next?</param>
public Pagination(int count, Guid? afterID = null, bool reverse = false)
{
Limit = count;
AfterID = afterID;
Reverse = reverse;
}
/// <summary>
/// Implicitly create a new pagination from a limit number.
/// </summary>
/// <param name="limit">Set the <see cref="Limit"/> value</param>
/// <returns>A new <see cref="Pagination"/> instance</returns>
public static implicit operator Pagination(int limit) => new(limit);
} }

View File

@ -19,42 +19,38 @@
using System; using System;
using System.Linq; using System.Linq;
namespace Kyoo.Abstractions.Models.Utils namespace Kyoo.Abstractions.Models.Utils;
/// <summary>
/// The list of errors that where made in the request.
/// </summary>
public class RequestError
{ {
/// <summary> /// <summary>
/// The list of errors that where made in the request. /// The list of errors that where made in the request.
/// </summary> /// </summary>
public class RequestError /// <example><c>["InvalidFilter: no field 'startYear' on a collection"]</c></example>
public string[] Errors { get; set; }
/// <summary>
/// Create a new <see cref="RequestError"/> with one error.
/// </summary>
/// <param name="error">The error to specify in the response.</param>
public RequestError(string error)
{ {
/// <summary> if (error == null)
/// The list of errors that where made in the request. throw new ArgumentNullException(nameof(error));
/// </summary> Errors = new[] { error };
/// <example><c>["InvalidFilter: no field 'startYear' on a collection"]</c></example> }
public string[] Errors { get; set; }
/// <summary> /// <summary>
/// Create a new <see cref="RequestError"/> with one error. /// Create a new <see cref="RequestError"/> with multiple errors.
/// </summary> /// </summary>
/// <param name="error">The error to specify in the response.</param> /// <param name="errors">The errors to specify in the response.</param>
public RequestError(string error) public RequestError(string[] errors)
{ {
if (error == null) if (errors == null || !errors.Any())
throw new ArgumentNullException(nameof(error)); throw new ArgumentException("Errors must be non null and not empty", nameof(errors));
Errors = new[] { error }; Errors = errors;
}
/// <summary>
/// Create a new <see cref="RequestError"/> with multiple errors.
/// </summary>
/// <param name="errors">The errors to specify in the response.</param>
public RequestError(string[] errors)
{
if (errors == null || !errors.Any())
throw new ArgumentException(
"Errors must be non null and not empty",
nameof(errors)
);
Errors = errors;
}
} }
} }

View File

@ -16,21 +16,20 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers;
/// <summary>
/// Information about the pagination. How many items should be displayed and where to start.
/// </summary>
public class SearchPagination
{ {
/// <summary> /// <summary>
/// Information about the pagination. How many items should be displayed and where to start. /// The count of items to return.
/// </summary> /// </summary>
public class SearchPagination public int Limit { get; set; } = 50;
{
/// <summary>
/// The count of items to return.
/// </summary>
public int Limit { get; set; } = 50;
/// <summary> /// <summary>
/// Where to start? How many items to skip? /// Where to start? How many items to skip?
/// </summary> /// </summary>
public int? Skip { get; set; } public int? Skip { get; set; }
}
} }

View File

@ -25,111 +25,109 @@ using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Utils; using Kyoo.Utils;
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers;
{
public record Sort;
public record Sort;
/// <summary>
/// Information about how a query should be sorted. What factor should decide the sort and in which order.
/// </summary>
/// <typeparam name="T">For witch type this sort applies</typeparam>
public record Sort<T> : Sort
where T : IQuery
{
/// <summary> /// <summary>
/// Information about how a query should be sorted. What factor should decide the sort and in which order. /// Sort by a specific key
/// </summary> /// </summary>
/// <typeparam name="T">For witch type this sort applies</typeparam> /// <param name="Key">The sort keys. This members will be used to sort the results.</param>
public record Sort<T> : Sort /// <param name="Desendant">
where T : IQuery /// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order.
/// </param>
public record By(string Key, bool Desendant = false) : Sort<T>
{ {
/// <summary> /// <summary>
/// Sort by a specific key /// Sort by a specific key
/// </summary> /// </summary>
/// <param name="Key">The sort keys. This members will be used to sort the results.</param> /// <param name="key">The sort keys. This members will be used to sort the results.</param>
/// <param name="Desendant"> /// <param name="desendant">
/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order. /// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order.
/// </param> /// </param>
public record By(string Key, bool Desendant = false) : Sort<T> public By(Expression<Func<T, object?>> key, bool desendant = false)
: this(Utility.GetPropertyName(key), desendant) { }
}
/// <summary>
/// Sort by multiple keys.
/// </summary>
/// <param name="List">The list of keys to sort by.</param>
public record Conglomerate(params Sort<T>[] List) : Sort<T>;
/// <summary>Sort randomly items</summary>
public record Random(uint Seed) : Sort<T>
{
public Random()
: this(0)
{ {
/// <summary> uint seed = BitConverter.ToUInt32(
/// Sort by a specific key BitConverter.GetBytes(new System.Random().Next(int.MinValue, int.MaxValue)),
/// </summary> 0
/// <param name="key">The sort keys. This members will be used to sort the results.</param> );
/// <param name="desendant"> Seed = seed;
/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order.
/// </param>
public By(Expression<Func<T, object?>> key, bool desendant = false)
: this(Utility.GetPropertyName(key), desendant) { }
}
/// <summary>
/// Sort by multiple keys.
/// </summary>
/// <param name="List">The list of keys to sort by.</param>
public record Conglomerate(params Sort<T>[] List) : Sort<T>;
/// <summary>Sort randomly items</summary>
public record Random(uint Seed) : Sort<T>
{
public Random()
: this(0)
{
uint seed = BitConverter.ToUInt32(
BitConverter.GetBytes(new System.Random().Next(int.MinValue, int.MaxValue)),
0
);
Seed = seed;
}
}
/// <summary>The default sort method for the given type.</summary>
public record Default : Sort<T>
{
public void Deconstruct(out Sort<T> value)
{
value = (Sort<T>)T.DefaultSort;
}
}
/// <summary>
/// Create a new <see cref="Sort{T}"/> instance from a key's name (case insensitive).
/// </summary>
/// <param name="sortBy">A key name with an optional order specifier. Format: "key:asc", "key:desc" or "key".</param>
/// <param name="seed">The random seed.</param>
/// <exception cref="ArgumentException">An invalid key or sort specifier as been given.</exception>
/// <returns>A <see cref="Sort{T}"/> for the given string</returns>
public static Sort<T> From(string? sortBy, uint seed)
{
if (string.IsNullOrEmpty(sortBy) || sortBy == "default")
return new Default();
if (sortBy == "random")
return new Random(seed);
if (sortBy.Contains(','))
return new Conglomerate(sortBy.Split(',').Select(x => From(x, seed)).ToArray());
if (sortBy.StartsWith("random:"))
return new Random(uint.Parse(sortBy["random:".Length..]));
string key = sortBy.Contains(':') ? sortBy[..sortBy.IndexOf(':')] : sortBy;
string? order = sortBy.Contains(':') ? sortBy[(sortBy.IndexOf(':') + 1)..] : null;
bool desendant = order switch
{
"desc" => true,
"asc" => false,
null => false,
_
=> throw new ValidationException(
$"The sort order, if set, should be :asc or :desc but it was :{order}."
)
};
Type[] types =
typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) };
PropertyInfo? property = types
.Select(x =>
x.GetProperty(
key,
BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance
)
)
.FirstOrDefault(x => x != null);
if (property == null)
throw new ValidationException("The given sort key is not valid.");
return new By(property.Name, desendant);
} }
} }
/// <summary>The default sort method for the given type.</summary>
public record Default : Sort<T>
{
public void Deconstruct(out Sort<T> value)
{
value = (Sort<T>)T.DefaultSort;
}
}
/// <summary>
/// Create a new <see cref="Sort{T}"/> instance from a key's name (case insensitive).
/// </summary>
/// <param name="sortBy">A key name with an optional order specifier. Format: "key:asc", "key:desc" or "key".</param>
/// <param name="seed">The random seed.</param>
/// <exception cref="ArgumentException">An invalid key or sort specifier as been given.</exception>
/// <returns>A <see cref="Sort{T}"/> for the given string</returns>
public static Sort<T> From(string? sortBy, uint seed)
{
if (string.IsNullOrEmpty(sortBy) || sortBy == "default")
return new Default();
if (sortBy == "random")
return new Random(seed);
if (sortBy.Contains(','))
return new Conglomerate(sortBy.Split(',').Select(x => From(x, seed)).ToArray());
if (sortBy.StartsWith("random:"))
return new Random(uint.Parse(sortBy["random:".Length..]));
string key = sortBy.Contains(':') ? sortBy[..sortBy.IndexOf(':')] : sortBy;
string? order = sortBy.Contains(':') ? sortBy[(sortBy.IndexOf(':') + 1)..] : null;
bool desendant = order switch
{
"desc" => true,
"asc" => false,
null => false,
_
=> throw new ValidationException(
$"The sort order, if set, should be :asc or :desc but it was :{order}."
)
};
Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) };
PropertyInfo? property = types
.Select(x =>
x.GetProperty(
key,
BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance
)
)
.FirstOrDefault(x => x != null);
if (property == null)
throw new ValidationException("The given sort key is not valid.");
return new By(property.Name, desendant);
}
} }

View File

@ -16,21 +16,20 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Abstractions.Models namespace Kyoo.Abstractions.Models;
/// <summary>
/// The links to see a movie or an episode.
/// </summary>
public class VideoLinks
{ {
/// <summary> /// <summary>
/// The links to see a movie or an episode. /// The direct link to the unprocessed video (pristine quality).
/// </summary> /// </summary>
public class VideoLinks public string Direct { get; set; }
{
/// <summary>
/// The direct link to the unprocessed video (pristine quality).
/// </summary>
public string Direct { get; set; }
/// <summary> /// <summary>
/// The link to an HLS master playlist containing all qualities available for this video. /// The link to an HLS master playlist containing all qualities available for this video.
/// </summary> /// </summary>
public string Hls { get; set; } public string Hls { get; set; }
}
} }

View File

@ -21,56 +21,55 @@ using Autofac.Builder;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Utils; using Kyoo.Utils;
namespace Kyoo.Abstractions namespace Kyoo.Abstractions;
/// <summary>
/// A static class with helper functions to setup external modules
/// </summary>
public static class Module
{ {
/// <summary> /// <summary>
/// A static class with helper functions to setup external modules /// Register a new repository to the container.
/// </summary> /// </summary>
public static class Module /// <param name="builder">The container</param>
/// <typeparam name="T">The type of the repository.</typeparam>
/// <remarks>
/// If your repository implements a special interface, please use <see cref="RegisterRepository{T,T2}"/>
/// </remarks>
/// <returns>The initial container.</returns>
public static IRegistrationBuilder<
T,
ConcreteReflectionActivatorData,
SingleRegistrationStyle
> RegisterRepository<T>(this ContainerBuilder builder)
where T : IBaseRepository
{ {
/// <summary> return builder
/// Register a new repository to the container. .RegisterType<T>()
/// </summary> .AsSelf()
/// <param name="builder">The container</param> .As<IBaseRepository>()
/// <typeparam name="T">The type of the repository.</typeparam> .As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))!)
/// <remarks> .InstancePerLifetimeScope();
/// If your repository implements a special interface, please use <see cref="RegisterRepository{T,T2}"/> }
/// </remarks>
/// <returns>The initial container.</returns>
public static IRegistrationBuilder<
T,
ConcreteReflectionActivatorData,
SingleRegistrationStyle
> RegisterRepository<T>(this ContainerBuilder builder)
where T : IBaseRepository
{
return builder
.RegisterType<T>()
.AsSelf()
.As<IBaseRepository>()
.As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))!)
.InstancePerLifetimeScope();
}
/// <summary> /// <summary>
/// Register a new repository with a custom mapping to the container. /// Register a new repository with a custom mapping to the container.
/// </summary> /// </summary>
/// <param name="builder">The container</param> /// <param name="builder">The container</param>
/// <typeparam name="T">The custom mapping you have for your repository.</typeparam> /// <typeparam name="T">The custom mapping you have for your repository.</typeparam>
/// <typeparam name="T2">The type of the repository.</typeparam> /// <typeparam name="T2">The type of the repository.</typeparam>
/// <remarks> /// <remarks>
/// If your repository does not implements a special interface, please use <see cref="RegisterRepository{T}"/> /// If your repository does not implements a special interface, please use <see cref="RegisterRepository{T}"/>
/// </remarks> /// </remarks>
/// <returns>The initial container.</returns> /// <returns>The initial container.</returns>
public static IRegistrationBuilder< public static IRegistrationBuilder<
T2, T2,
ConcreteReflectionActivatorData, ConcreteReflectionActivatorData,
SingleRegistrationStyle SingleRegistrationStyle
> RegisterRepository<T, T2>(this ContainerBuilder builder) > RegisterRepository<T, T2>(this ContainerBuilder builder)
where T : notnull where T : notnull
where T2 : IBaseRepository, T where T2 : IBaseRepository, T
{ {
return builder.RegisterRepository<T2>().AsSelf().As<T>(); return builder.RegisterRepository<T2>().AsSelf().As<T>();
}
} }
} }

View File

@ -19,53 +19,52 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace Kyoo.Utils namespace Kyoo.Utils;
/// <summary>
/// A set of extensions class for enumerable.
/// </summary>
public static class EnumerableExtensions
{ {
/// <summary> /// <summary>
/// A set of extensions class for enumerable. /// If the enumerable is empty, execute an action.
/// </summary> /// </summary>
public static class EnumerableExtensions /// <param name="self">The enumerable to check</param>
/// <param name="action">The action to execute is the list is empty</param>
/// <typeparam name="T">The type of items inside the list</typeparam>
/// <returns>The iterator proxied, there is no dual iterations.</returns>
public static IEnumerable<T> IfEmpty<T>(this IEnumerable<T> self, Action action)
{ {
/// <summary> static IEnumerable<T> Generator(IEnumerable<T> self, Action action)
/// If the enumerable is empty, execute an action.
/// </summary>
/// <param name="self">The enumerable to check</param>
/// <param name="action">The action to execute is the list is empty</param>
/// <typeparam name="T">The type of items inside the list</typeparam>
/// <returns>The iterator proxied, there is no dual iterations.</returns>
public static IEnumerable<T> IfEmpty<T>(this IEnumerable<T> self, Action action)
{ {
static IEnumerable<T> Generator(IEnumerable<T> self, Action action) using IEnumerator<T> enumerator = self.GetEnumerator();
if (!enumerator.MoveNext())
{ {
using IEnumerator<T> enumerator = self.GetEnumerator(); action();
yield break;
if (!enumerator.MoveNext())
{
action();
yield break;
}
do
{
yield return enumerator.Current;
} while (enumerator.MoveNext());
} }
return Generator(self, action); do
{
yield return enumerator.Current;
} while (enumerator.MoveNext());
} }
/// <summary> return Generator(self, action);
/// A foreach used as a function with a little specificity: the list can be null. }
/// </summary>
/// <param name="self">The list to enumerate. If this is null, the function result in a no-op</param> /// <summary>
/// <param name="action">The action to execute for each arguments</param> /// A foreach used as a function with a little specificity: the list can be null.
/// <typeparam name="T">The type of items in the list</typeparam> /// </summary>
public static void ForEach<T>(this IEnumerable<T>? self, Action<T> action) /// <param name="self">The list to enumerate. If this is null, the function result in a no-op</param>
{ /// <param name="action">The action to execute for each arguments</param>
if (self == null) /// <typeparam name="T">The type of items in the list</typeparam>
return; public static void ForEach<T>(this IEnumerable<T>? self, Action<T> action)
foreach (T i in self) {
action(i); if (self == null)
} return;
foreach (T i in self)
action(i);
} }
} }

View File

@ -22,113 +22,112 @@ using System.Linq;
using System.Reflection; using System.Reflection;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
namespace Kyoo.Utils namespace Kyoo.Utils;
/// <summary>
/// A class containing helper methods to merge objects.
/// </summary>
public static class Merger
{ {
/// <summary> /// <summary>
/// A class containing helper methods to merge objects. /// Merge two dictionary, if the same key is found on both dictionary, the values of the second one is kept.
/// </summary> /// </summary>
public static class Merger /// <param name="first">The first dictionary to merge</param>
/// <param name="second">The second dictionary to merge</param>
/// <param name="hasChanged">
/// <c>true</c> if a new items has been added to the dictionary, <c>false</c> otherwise.
/// </param>
/// <typeparam name="T">The type of the keys in dictionaries</typeparam>
/// <typeparam name="T2">The type of values in the dictionaries</typeparam>
/// <returns>
/// A dictionary with the missing elements of <paramref name="second"/>
/// set to those of <paramref name="first"/>.
/// </returns>
public static IDictionary<T, T2>? CompleteDictionaries<T, T2>(
IDictionary<T, T2>? first,
IDictionary<T, T2>? second,
out bool hasChanged
)
{ {
/// <summary> if (first == null)
/// Merge two dictionary, if the same key is found on both dictionary, the values of the second one is kept.
/// </summary>
/// <param name="first">The first dictionary to merge</param>
/// <param name="second">The second dictionary to merge</param>
/// <param name="hasChanged">
/// <c>true</c> if a new items has been added to the dictionary, <c>false</c> otherwise.
/// </param>
/// <typeparam name="T">The type of the keys in dictionaries</typeparam>
/// <typeparam name="T2">The type of values in the dictionaries</typeparam>
/// <returns>
/// A dictionary with the missing elements of <paramref name="second"/>
/// set to those of <paramref name="first"/>.
/// </returns>
public static IDictionary<T, T2>? CompleteDictionaries<T, T2>(
IDictionary<T, T2>? first,
IDictionary<T, T2>? second,
out bool hasChanged
)
{ {
if (first == null) hasChanged = true;
{
hasChanged = true;
return second;
}
hasChanged = false;
if (second == null)
return first;
hasChanged = second.Any(x =>
!first.ContainsKey(x.Key) || x.Value?.Equals(first[x.Key]) == false
);
foreach ((T key, T2 value) in first)
second.TryAdd(key, value);
return second; return second;
} }
/// <summary> hasChanged = false;
/// Set every non-default values of seconds to the corresponding property of second. if (second == null)
/// Dictionaries are handled like anonymous objects with a property per key/pair value
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>
/// </summary>
/// <example>
/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"}
/// </example>
/// <param name="first">
/// The object to complete
/// </param>
/// <param name="second">
/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
/// </param>
/// <param name="where">
/// Filter fields that will be merged
/// </param>
/// <typeparam name="T">Fields of T will be completed</typeparam>
/// <returns><paramref name="first"/></returns>
public static T Complete<T>(T first, T? second, Func<PropertyInfo, bool>? where = null)
{
if (second == null)
return first;
Type type = typeof(T);
IEnumerable<PropertyInfo> properties = type.GetProperties()
.Where(x =>
x is { CanRead: true, CanWrite: true }
&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null
);
if (where != null)
properties = properties.Where(where);
foreach (PropertyInfo property in properties)
{
object? value = property.GetValue(second);
if (value?.Equals(property.GetValue(first)) == true)
continue;
if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>)))
{
Type[] dictionaryTypes = Utility
.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))!
.GenericTypeArguments;
object?[] parameters = { property.GetValue(first), value, false };
object newDictionary = Utility.RunGenericMethod<object>(
typeof(Merger),
nameof(CompleteDictionaries),
dictionaryTypes,
parameters
)!;
if ((bool)parameters[2]!)
property.SetValue(first, newDictionary);
}
else
property.SetValue(first, value);
}
if (first is IOnMerge merge)
merge.OnMerge(second);
return first; return first;
hasChanged = second.Any(x =>
!first.ContainsKey(x.Key) || x.Value?.Equals(first[x.Key]) == false
);
foreach ((T key, T2 value) in first)
second.TryAdd(key, value);
return second;
}
/// <summary>
/// Set every non-default values of seconds to the corresponding property of second.
/// Dictionaries are handled like anonymous objects with a property per key/pair value
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>
/// </summary>
/// <example>
/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"}
/// </example>
/// <param name="first">
/// The object to complete
/// </param>
/// <param name="second">
/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
/// </param>
/// <param name="where">
/// Filter fields that will be merged
/// </param>
/// <typeparam name="T">Fields of T will be completed</typeparam>
/// <returns><paramref name="first"/></returns>
public static T Complete<T>(T first, T? second, Func<PropertyInfo, bool>? where = null)
{
if (second == null)
return first;
Type type = typeof(T);
IEnumerable<PropertyInfo> properties = type.GetProperties()
.Where(x =>
x is { CanRead: true, CanWrite: true }
&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null
);
if (where != null)
properties = properties.Where(where);
foreach (PropertyInfo property in properties)
{
object? value = property.GetValue(second);
if (value?.Equals(property.GetValue(first)) == true)
continue;
if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>)))
{
Type[] dictionaryTypes = Utility
.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))!
.GenericTypeArguments;
object?[] parameters = { property.GetValue(first), value, false };
object newDictionary = Utility.RunGenericMethod<object>(
typeof(Merger),
nameof(CompleteDictionaries),
dictionaryTypes,
parameters
)!;
if ((bool)parameters[2]!)
property.SetValue(first, newDictionary);
}
else
property.SetValue(first, value);
} }
if (first is IOnMerge merge)
merge.OnMerge(second);
return first;
} }
} }

View File

@ -25,341 +25,338 @@ using System.Reflection;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace Kyoo.Utils namespace Kyoo.Utils;
/// <summary>
/// A set of utility functions that can be used everywhere.
/// </summary>
public static class Utility
{ {
/// <summary> /// <summary>
/// A set of utility functions that can be used everywhere. /// Convert a string to snake case. Stollen from
/// https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.cs
/// </summary> /// </summary>
public static class Utility /// <param name="name">The string to convert.</param>
/// <returns>The string in snake case</returns>
public static string ToSnakeCase(this string name)
{ {
/// <summary> StringBuilder builder = new(name.Length + Math.Min(2, name.Length / 5));
/// Convert a string to snake case. Stollen from UnicodeCategory? previousCategory = default;
/// https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.cs
/// </summary> for (int currentIndex = 0; currentIndex < name.Length; currentIndex++)
/// <param name="name">The string to convert.</param>
/// <returns>The string in snake case</returns>
public static string ToSnakeCase(this string name)
{ {
StringBuilder builder = new(name.Length + Math.Min(2, name.Length / 5)); char currentChar = name[currentIndex];
UnicodeCategory? previousCategory = default; if (currentChar == '_')
for (int currentIndex = 0; currentIndex < name.Length; currentIndex++)
{ {
char currentChar = name[currentIndex]; builder.Append('_');
if (currentChar == '_') previousCategory = null;
{ continue;
builder.Append('_'); }
previousCategory = null;
continue;
}
UnicodeCategory currentCategory = char.GetUnicodeCategory(currentChar); UnicodeCategory currentCategory = char.GetUnicodeCategory(currentChar);
switch (currentCategory) switch (currentCategory)
{ {
case UnicodeCategory.UppercaseLetter: case UnicodeCategory.UppercaseLetter:
case UnicodeCategory.TitlecaseLetter: case UnicodeCategory.TitlecaseLetter:
if ( if (
previousCategory == UnicodeCategory.SpaceSeparator previousCategory == UnicodeCategory.SpaceSeparator
|| previousCategory == UnicodeCategory.LowercaseLetter || previousCategory == UnicodeCategory.LowercaseLetter
|| ( || (
previousCategory != UnicodeCategory.DecimalDigitNumber previousCategory != UnicodeCategory.DecimalDigitNumber
&& previousCategory != null && previousCategory != null
&& currentIndex > 0 && currentIndex > 0
&& currentIndex + 1 < name.Length && currentIndex + 1 < name.Length
&& char.IsLower(name[currentIndex + 1]) && char.IsLower(name[currentIndex + 1])
)
) )
{ )
builder.Append('_'); {
} builder.Append('_');
}
currentChar = char.ToLowerInvariant(currentChar); currentChar = char.ToLowerInvariant(currentChar);
break; break;
case UnicodeCategory.LowercaseLetter: case UnicodeCategory.LowercaseLetter:
case UnicodeCategory.DecimalDigitNumber: case UnicodeCategory.DecimalDigitNumber:
if (previousCategory == UnicodeCategory.SpaceSeparator) if (previousCategory == UnicodeCategory.SpaceSeparator)
{ {
builder.Append('_'); builder.Append('_');
} }
break; break;
default: default:
if (previousCategory != null) if (previousCategory != null)
{ {
previousCategory = UnicodeCategory.SpaceSeparator; previousCategory = UnicodeCategory.SpaceSeparator;
} }
continue; continue;
}
builder.Append(currentChar);
previousCategory = currentCategory;
} }
return builder.ToString(); builder.Append(currentChar);
previousCategory = currentCategory;
} }
/// <summary> return builder.ToString();
/// Is the lambda expression a member (like x => x.Body). }
/// </summary>
/// <param name="ex">The expression that should be checked</param>
/// <returns>True if the expression is a member, false otherwise</returns>
public static bool IsPropertyExpression(LambdaExpression ex)
{
return ex.Body is MemberExpression
|| (
ex.Body.NodeType == ExpressionType.Convert
&& ((UnaryExpression)ex.Body).Operand is MemberExpression
);
}
/// <summary> /// <summary>
/// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows) /// Is the lambda expression a member (like x => x.Body).
/// </summary> /// </summary>
/// <param name="ex">The expression</param> /// <param name="ex">The expression that should be checked</param>
/// <returns>The name of the expression</returns> /// <returns>True if the expression is a member, false otherwise</returns>
/// <exception cref="ArgumentException">If the expression is not a property, ArgumentException is thrown.</exception> public static bool IsPropertyExpression(LambdaExpression ex)
public static string GetPropertyName(LambdaExpression ex) {
{ return ex.Body is MemberExpression
if (!IsPropertyExpression(ex)) || (
throw new ArgumentException($"{ex} is not a property expression.");
MemberExpression? member =
ex.Body.NodeType == ExpressionType.Convert ex.Body.NodeType == ExpressionType.Convert
? ((UnaryExpression)ex.Body).Operand as MemberExpression && ((UnaryExpression)ex.Body).Operand is MemberExpression
: ex.Body as MemberExpression;
return member!.Member.Name;
}
/// <summary>
/// Slugify a string (Replace spaces by -, Uniformize accents)
/// </summary>
/// <param name="str">The string to slugify</param>
/// <returns>The slug version of the given string</returns>
public static string ToSlug(string str)
{
str = str.ToLowerInvariant();
string normalizedString = str.Normalize(NormalizationForm.FormD);
StringBuilder stringBuilder = new();
foreach (char c in normalizedString)
{
UnicodeCategory unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
stringBuilder.Append(c);
}
str = stringBuilder.ToString().Normalize(NormalizationForm.FormC);
str = Regex.Replace(str, @"\s", "-", RegexOptions.Compiled);
str = Regex.Replace(str, @"[^\w\s\p{Pd}]", string.Empty, RegexOptions.Compiled);
str = str.Trim('-', '_');
str = Regex.Replace(str, @"([-_]){2,}", "$1", RegexOptions.Compiled);
return str;
}
/// <summary>
/// Return every <see cref="Type"/> in the inheritance tree of the parameter (interfaces are not returned)
/// </summary>
/// <param name="self">The starting type</param>
/// <returns>A list of types</returns>
public static IEnumerable<Type> GetInheritanceTree(this Type self)
{
for (Type? type = self; type != null; type = type.BaseType)
yield return type;
}
/// <summary>
/// Check if <paramref name="type"/> inherit from a generic type <paramref name="genericType"/>.
/// </summary>
/// <param name="type">The type to check</param>
/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable&lt;&gt;).</param>
/// <returns>True if obj inherit from genericType. False otherwise</returns>
public static bool IsOfGenericType(Type type, Type genericType)
{
if (!genericType.IsGenericType)
throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
IEnumerable<Type> types = genericType.IsInterface
? type.GetInterfaces()
: type.GetInheritanceTree();
return types
.Prepend(type)
.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
}
/// <summary>
/// Get the generic definition of <paramref name="genericType"/>.
/// For example, calling this function with List&lt;string&gt; and typeof(IEnumerable&lt;&gt;) will return IEnumerable&lt;string&gt;
/// </summary>
/// <param name="type">The type to check</param>
/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable&lt;&gt;).</param>
/// <returns>The generic definition of genericType that type inherit or null if type does not implement the generic type.</returns>
/// <exception cref="ArgumentException"><paramref name="genericType"/> must be a generic type</exception>
public static Type? GetGenericDefinition(Type type, Type genericType)
{
if (!genericType.IsGenericType)
throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
IEnumerable<Type> types = genericType.IsInterface
? type.GetInterfaces()
: type.GetInheritanceTree();
return types
.Prepend(type)
.FirstOrDefault(x =>
x.IsGenericType && x.GetGenericTypeDefinition() == genericType
);
}
/// <summary>
/// Retrieve a method from an <see cref="Type"/> with the given name and respect the
/// amount of parameters and generic parameters. This works for polymorphic methods.
/// </summary>
/// <param name="type">
/// The type owning the method. For non static methods, this is the <c>this</c>.
/// </param>
/// <param name="flag">
/// The binding flags of the method. This allow you to specify public/private and so on.
/// </param>
/// <param name="name">
/// The name of the method.
/// </param>
/// <param name="generics">
/// The list of generic parameters.
/// </param>
/// <param name="args">
/// The list of parameters.
/// </param>
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
/// <returns>The method handle of the matching method.</returns>
public static MethodInfo GetMethod(
Type type,
BindingFlags flag,
string name,
Type[] generics,
object?[] args
)
{
MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public)
.Where(x => x.Name == name)
.Where(x => x.GetGenericArguments().Length == generics.Length)
.Where(x => x.GetParameters().Length == args.Length)
.IfEmpty(() =>
{
throw new ArgumentException(
$"A method named {name} with "
+ $"{args.Length} arguments and {generics.Length} generic "
+ $"types could not be found on {type.Name}."
);
})
// TODO this won't work but I don't know why.
// .Where(x =>
// {
// int i = 0;
// return x.GetGenericArguments().All(y => y.IsAssignableFrom(generics[i++]));
// })
// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the generics specified."))
// TODO this won't work for Type<T> because T is specified in arguments but not in the parameters type.
// .Where(x =>
// {
// int i = 0;
// return x.GetParameters().All(y => y.ParameterType.IsInstanceOfType(args[i++]));
// })
// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the parameters's types."))
.Take(2)
.ToArray();
if (methods.Length == 1)
return methods[0];
throw new ArgumentException(
$"Multiple methods named {name} match the generics and parameters constraints."
); );
} }
/// <summary> /// <summary>
/// Run a generic static method for a runtime <see cref="Type"/>. /// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows)
/// </summary> /// </summary>
/// <example> /// <param name="ex">The expression</param>
/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type, /// <returns>The name of the expression</returns>
/// you could do: /// <exception cref="ArgumentException">If the expression is not a property, ArgumentException is thrown.</exception>
/// <code lang="C#"> public static string GetPropertyName(LambdaExpression ex)
/// Utility.RunGenericMethod&lt;object&gt;( {
/// typeof(Utility), if (!IsPropertyExpression(ex))
/// nameof(MergeLists), throw new ArgumentException($"{ex} is not a property expression.");
/// enumerableType, MemberExpression? member =
/// oldValue, newValue, equalityComparer) ex.Body.NodeType == ExpressionType.Convert
/// </code> ? ((UnaryExpression)ex.Body).Operand as MemberExpression
/// </example> : ex.Body as MemberExpression;
/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param> return member!.Member.Name;
/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param> }
/// <param name="type">The generic type to run the method with.</param>
/// <param name="args">The list of arguments of the method</param>
/// <typeparam name="T">
/// The return type of the method. You can put <see cref="object"/> for an unknown one.
/// </typeparam>
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
/// <returns>The return of the method you wanted to run.</returns>
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type[],object[])"/>
public static T? RunGenericMethod<T>(
Type owner,
string methodName,
Type type,
params object[] args
)
{
return RunGenericMethod<T>(owner, methodName, new[] { type }, args);
}
/// <summary> /// <summary>
/// Run a generic static method for a multiple runtime <see cref="Type"/>. /// Slugify a string (Replace spaces by -, Uniformize accents)
/// If your generic method only needs one type, see /// </summary>
/// <see cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/> /// <param name="str">The string to slugify</param>
/// </summary> /// <returns>The slug version of the given string</returns>
/// <example> public static string ToSlug(string str)
/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type, {
/// you could do: str = str.ToLowerInvariant();
/// <code>
/// Utility.RunGenericMethod&lt;object&gt;( string normalizedString = str.Normalize(NormalizationForm.FormD);
/// typeof(Utility), StringBuilder stringBuilder = new();
/// nameof(MergeLists), foreach (char c in normalizedString)
/// enumerableType,
/// oldValue, newValue, equalityComparer)
/// </code>
/// </example>
/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param>
/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param>
/// <param name="types">The list of generic types to run the method with.</param>
/// <param name="args">The list of arguments of the method</param>
/// <typeparam name="T">
/// The return type of the method. You can put <see cref="object"/> for an unknown one.
/// </typeparam>
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
/// <returns>The return of the method you wanted to run.</returns>
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
public static T? RunGenericMethod<T>(
Type owner,
string methodName,
Type[] types,
params object?[] args
)
{ {
if (types.Length < 1) UnicodeCategory unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
stringBuilder.Append(c);
}
str = stringBuilder.ToString().Normalize(NormalizationForm.FormC);
str = Regex.Replace(str, @"\s", "-", RegexOptions.Compiled);
str = Regex.Replace(str, @"[^\w\s\p{Pd}]", string.Empty, RegexOptions.Compiled);
str = str.Trim('-', '_');
str = Regex.Replace(str, @"([-_]){2,}", "$1", RegexOptions.Compiled);
return str;
}
/// <summary>
/// Return every <see cref="Type"/> in the inheritance tree of the parameter (interfaces are not returned)
/// </summary>
/// <param name="self">The starting type</param>
/// <returns>A list of types</returns>
public static IEnumerable<Type> GetInheritanceTree(this Type self)
{
for (Type? type = self; type != null; type = type.BaseType)
yield return type;
}
/// <summary>
/// Check if <paramref name="type"/> inherit from a generic type <paramref name="genericType"/>.
/// </summary>
/// <param name="type">The type to check</param>
/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable&lt;&gt;).</param>
/// <returns>True if obj inherit from genericType. False otherwise</returns>
public static bool IsOfGenericType(Type type, Type genericType)
{
if (!genericType.IsGenericType)
throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
IEnumerable<Type> types = genericType.IsInterface
? type.GetInterfaces()
: type.GetInheritanceTree();
return types
.Prepend(type)
.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
}
/// <summary>
/// Get the generic definition of <paramref name="genericType"/>.
/// For example, calling this function with List&lt;string&gt; and typeof(IEnumerable&lt;&gt;) will return IEnumerable&lt;string&gt;
/// </summary>
/// <param name="type">The type to check</param>
/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable&lt;&gt;).</param>
/// <returns>The generic definition of genericType that type inherit or null if type does not implement the generic type.</returns>
/// <exception cref="ArgumentException"><paramref name="genericType"/> must be a generic type</exception>
public static Type? GetGenericDefinition(Type type, Type genericType)
{
if (!genericType.IsGenericType)
throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
IEnumerable<Type> types = genericType.IsInterface
? type.GetInterfaces()
: type.GetInheritanceTree();
return types
.Prepend(type)
.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
}
/// <summary>
/// Retrieve a method from an <see cref="Type"/> with the given name and respect the
/// amount of parameters and generic parameters. This works for polymorphic methods.
/// </summary>
/// <param name="type">
/// The type owning the method. For non static methods, this is the <c>this</c>.
/// </param>
/// <param name="flag">
/// The binding flags of the method. This allow you to specify public/private and so on.
/// </param>
/// <param name="name">
/// The name of the method.
/// </param>
/// <param name="generics">
/// The list of generic parameters.
/// </param>
/// <param name="args">
/// The list of parameters.
/// </param>
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
/// <returns>The method handle of the matching method.</returns>
public static MethodInfo GetMethod(
Type type,
BindingFlags flag,
string name,
Type[] generics,
object?[] args
)
{
MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public)
.Where(x => x.Name == name)
.Where(x => x.GetGenericArguments().Length == generics.Length)
.Where(x => x.GetParameters().Length == args.Length)
.IfEmpty(() =>
{
throw new ArgumentException( throw new ArgumentException(
$"The {nameof(types)} array is empty. At least one type is needed." $"A method named {name} with "
+ $"{args.Length} arguments and {generics.Length} generic "
+ $"types could not be found on {type.Name}."
); );
MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args); })
return (T?)method.MakeGenericMethod(types).Invoke(null, args); // TODO this won't work but I don't know why.
} // .Where(x =>
// {
// int i = 0;
// return x.GetGenericArguments().All(y => y.IsAssignableFrom(generics[i++]));
// })
// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the generics specified."))
/// <summary> // TODO this won't work for Type<T> because T is specified in arguments but not in the parameters type.
/// Convert a dictionary to a query string. // .Where(x =>
/// </summary> // {
/// <param name="query">The list of query parameters.</param> // int i = 0;
/// <returns>A valid query string with all items in the dictionary.</returns> // return x.GetParameters().All(y => y.ParameterType.IsInstanceOfType(args[i++]));
public static string ToQueryString(this Dictionary<string, string> query) // })
{ // .IfEmpty(() => throw new NullReferenceException($"No method {name} match the parameters's types."))
if (!query.Any()) .Take(2)
return string.Empty; .ToArray();
return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}"));
} if (methods.Length == 1)
return methods[0];
throw new ArgumentException(
$"Multiple methods named {name} match the generics and parameters constraints."
);
}
/// <summary>
/// Run a generic static method for a runtime <see cref="Type"/>.
/// </summary>
/// <example>
/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type,
/// you could do:
/// <code lang="C#">
/// Utility.RunGenericMethod&lt;object&gt;(
/// typeof(Utility),
/// nameof(MergeLists),
/// enumerableType,
/// oldValue, newValue, equalityComparer)
/// </code>
/// </example>
/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param>
/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param>
/// <param name="type">The generic type to run the method with.</param>
/// <param name="args">The list of arguments of the method</param>
/// <typeparam name="T">
/// The return type of the method. You can put <see cref="object"/> for an unknown one.
/// </typeparam>
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
/// <returns>The return of the method you wanted to run.</returns>
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type[],object[])"/>
public static T? RunGenericMethod<T>(
Type owner,
string methodName,
Type type,
params object[] args
)
{
return RunGenericMethod<T>(owner, methodName, new[] { type }, args);
}
/// <summary>
/// Run a generic static method for a multiple runtime <see cref="Type"/>.
/// If your generic method only needs one type, see
/// <see cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
/// </summary>
/// <example>
/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type,
/// you could do:
/// <code>
/// Utility.RunGenericMethod&lt;object&gt;(
/// typeof(Utility),
/// nameof(MergeLists),
/// enumerableType,
/// oldValue, newValue, equalityComparer)
/// </code>
/// </example>
/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param>
/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param>
/// <param name="types">The list of generic types to run the method with.</param>
/// <param name="args">The list of arguments of the method</param>
/// <typeparam name="T">
/// The return type of the method. You can put <see cref="object"/> for an unknown one.
/// </typeparam>
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
/// <returns>The return of the method you wanted to run.</returns>
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
public static T? RunGenericMethod<T>(
Type owner,
string methodName,
Type[] types,
params object?[] args
)
{
if (types.Length < 1)
throw new ArgumentException(
$"The {nameof(types)} array is empty. At least one type is needed."
);
MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args);
return (T?)method.MakeGenericMethod(types).Invoke(null, args);
}
/// <summary>
/// Convert a dictionary to a query string.
/// </summary>
/// <param name="query">The list of query parameters.</param>
/// <returns>A valid query string with all items in the dictionary.</returns>
public static string ToQueryString(this Dictionary<string, string> query)
{
if (!query.Any())
return string.Empty;
return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}"));
} }
} }

View File

@ -32,158 +32,151 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace Kyoo.Authentication namespace Kyoo.Authentication;
/// <summary>
/// A module that enable OpenID authentication for Kyoo.
/// </summary>
/// <remarks>
/// Create a new authentication module instance and use the given configuration.
/// </remarks>
public class AuthenticationModule(
IConfiguration configuration,
ILogger<AuthenticationModule> logger
) : IPlugin
{ {
/// <inheritdoc />
public string Name => "Authentication";
/// <summary> /// <summary>
/// A module that enable OpenID authentication for Kyoo. /// The configuration to use.
/// </summary> /// </summary>
/// <remarks> private readonly IConfiguration _configuration = configuration;
/// Create a new authentication module instance and use the given configuration.
/// </remarks> /// <inheritdoc />
public class AuthenticationModule( public void Configure(ContainerBuilder builder)
IConfiguration configuration,
ILogger<AuthenticationModule> logger
) : IPlugin
{ {
/// <inheritdoc /> builder.RegisterType<PermissionValidator>().As<IPermissionValidator>().SingleInstance();
public string Name => "Authentication"; builder.RegisterType<TokenController>().As<ITokenController>().SingleInstance();
}
/// <summary> /// <inheritdoc />
/// The configuration to use. public void Configure(IServiceCollection services)
/// </summary> {
private readonly IConfiguration _configuration = configuration; string secret = _configuration.GetValue(
"AUTHENTICATION_SECRET",
/// <inheritdoc /> AuthenticationOption.DefaultSecret
public void Configure(ContainerBuilder builder) )!;
{ PermissionOption options =
builder.RegisterType<PermissionValidator>().As<IPermissionValidator>().SingleInstance(); new()
builder.RegisterType<TokenController>().As<ITokenController>().SingleInstance(); {
} Default = _configuration
.GetValue("UNLOGGED_PERMISSIONS", "")!
/// <inheritdoc /> .Split(',')
public void Configure(IServiceCollection services) .Where(x => x.Length > 0)
{ .ToArray(),
string secret = _configuration.GetValue( NewUser = _configuration
"AUTHENTICATION_SECRET", .GetValue("DEFAULT_PERMISSIONS", "overall.read,overall.play")!
AuthenticationOption.DefaultSecret .Split(','),
)!; RequireVerification = _configuration.GetValue("REQUIRE_ACCOUNT_VERIFICATION", true),
PermissionOption options = PublicUrl =
new() _configuration.GetValue<string?>("PUBLIC_URL") ?? "http://localhost:8901",
{ ApiKeys = _configuration.GetValue("KYOO_APIKEYS", string.Empty)!.Split(','),
Default = _configuration OIDC = _configuration
.GetValue("UNLOGGED_PERMISSIONS", "")! .AsEnumerable()
.Split(',') .Where((pair) => pair.Key.StartsWith("OIDC_"))
.Where(x => x.Length > 0) .Aggregate(
.ToArray(), new Dictionary<string, OidcProvider>(),
NewUser = _configuration (acc, val) =>
.GetValue("DEFAULT_PERMISSIONS", "overall.read,overall.play")! {
.Split(','), if (val.Value is null)
RequireVerification = _configuration.GetValue( return acc;
"REQUIRE_ACCOUNT_VERIFICATION", if (val.Key.Split("_") is not ["OIDC", string provider, string key])
true
),
PublicUrl =
_configuration.GetValue<string?>("PUBLIC_URL") ?? "http://localhost:8901",
ApiKeys = _configuration.GetValue("KYOO_APIKEYS", string.Empty)!.Split(','),
OIDC = _configuration
.AsEnumerable()
.Where((pair) => pair.Key.StartsWith("OIDC_"))
.Aggregate(
new Dictionary<string, OidcProvider>(),
(acc, val) =>
{ {
if (val.Value is null) logger.LogError("Invalid oidc config value: {Key}", val.Key);
return acc;
if (val.Key.Split("_") is not ["OIDC", string provider, string key])
{
logger.LogError("Invalid oidc config value: {Key}", val.Key);
return acc;
}
provider = provider.ToLowerInvariant();
key = key.ToLowerInvariant();
if (!acc.ContainsKey(provider))
acc.Add(provider, new(provider));
switch (key)
{
case "clientid":
acc[provider].ClientId = val.Value;
break;
case "secret":
acc[provider].Secret = val.Value;
break;
case "scope":
acc[provider].Scope = val.Value;
break;
case "authorization":
acc[provider].AuthorizationUrl = val.Value;
break;
case "token":
acc[provider].TokenUrl = val.Value;
break;
case "userinfo":
case "profile":
acc[provider].ProfileUrl = val.Value;
break;
case "name":
acc[provider].DisplayName = val.Value;
break;
case "logo":
acc[provider].LogoUrl = val.Value;
break;
default:
logger.LogError("Invalid oidc config value: {Key}", key);
return acc;
}
return acc; return acc;
} }
), provider = provider.ToLowerInvariant();
}; key = key.ToLowerInvariant();
services.AddSingleton(options);
services.AddSingleton(
new AuthenticationOption() { Secret = secret, Permissions = options, }
);
services if (!acc.ContainsKey(provider))
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) acc.Add(provider, new(provider));
.AddJwtBearer(options => switch (key)
{
options.Events = new()
{
OnMessageReceived = (ctx) =>
{
string prefix = "Bearer ";
if (
ctx.Request.Headers.TryGetValue(
"Authorization",
out StringValues val
)
&& val.ToString() is string auth
&& auth.StartsWith(prefix)
)
{ {
ctx.Token ??= auth[prefix.Length..]; case "clientid":
acc[provider].ClientId = val.Value;
break;
case "secret":
acc[provider].Secret = val.Value;
break;
case "scope":
acc[provider].Scope = val.Value;
break;
case "authorization":
acc[provider].AuthorizationUrl = val.Value;
break;
case "token":
acc[provider].TokenUrl = val.Value;
break;
case "userinfo":
case "profile":
acc[provider].ProfileUrl = val.Value;
break;
case "name":
acc[provider].DisplayName = val.Value;
break;
case "logo":
acc[provider].LogoUrl = val.Value;
break;
default:
logger.LogError("Invalid oidc config value: {Key}", key);
return acc;
} }
ctx.Token ??= ctx.Request.Cookies["X-Bearer"]; return acc;
return Task.CompletedTask;
} }
}; ),
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret))
};
});
}
/// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps =>
new IStartupAction[]
{
SA.New<IApplicationBuilder>(app => app.UseAuthentication(), SA.Authentication),
}; };
services.AddSingleton(options);
services.AddSingleton(
new AuthenticationOption() { Secret = secret, Permissions = options, }
);
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Events = new()
{
OnMessageReceived = (ctx) =>
{
string prefix = "Bearer ";
if (
ctx.Request.Headers.TryGetValue("Authorization", out StringValues val)
&& val.ToString() is string auth
&& auth.StartsWith(prefix)
)
{
ctx.Token ??= auth[prefix.Length..];
}
ctx.Token ??= ctx.Request.Cookies["X-Bearer"];
return Task.CompletedTask;
}
};
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret))
};
});
} }
/// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps =>
new IStartupAction[]
{
SA.New<IApplicationBuilder>(app => app.UseAuthentication(), SA.Authentication),
};
} }

View File

@ -21,34 +21,33 @@ using System.Threading.Tasks;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace Kyoo.Authentication namespace Kyoo.Authentication;
/// <summary>
/// The service that controls jwt creation and validation.
/// </summary>
public interface ITokenController
{ {
/// <summary> /// <summary>
/// The service that controls jwt creation and validation. /// Create a new access token for the given user.
/// </summary> /// </summary>
public interface ITokenController /// <param name="user">The user to create a token for.</param>
{ /// <param name="expireIn">When this token will expire.</param>
/// <summary> /// <returns>A new, valid access token.</returns>
/// Create a new access token for the given user. string CreateAccessToken(User user, out TimeSpan expireIn);
/// </summary>
/// <param name="user">The user to create a token for.</param>
/// <param name="expireIn">When this token will expire.</param>
/// <returns>A new, valid access token.</returns>
string CreateAccessToken(User user, out TimeSpan expireIn);
/// <summary> /// <summary>
/// Create a new refresh token for the given user. /// Create a new refresh token for the given user.
/// </summary> /// </summary>
/// <param name="user">The user to create a token for.</param> /// <param name="user">The user to create a token for.</param>
/// <returns>A new, valid refresh token.</returns> /// <returns>A new, valid refresh token.</returns>
Task<string> CreateRefreshToken(User user); Task<string> CreateRefreshToken(User user);
/// <summary> /// <summary>
/// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to. /// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to.
/// </summary> /// </summary>
/// <param name="refreshToken">The refresh token to validate.</param> /// <param name="refreshToken">The refresh token to validate.</param>
/// <exception cref="SecurityTokenException">The given refresh token is not valid.</exception> /// <exception cref="SecurityTokenException">The given refresh token is not valid.</exception>
/// <returns>The id of the token's user.</returns> /// <returns>The id of the token's user.</returns>
Guid GetRefreshTokenUserID(string refreshToken); Guid GetRefreshTokenUserID(string refreshToken);
}
} }

View File

@ -32,267 +32,253 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
namespace Kyoo.Authentication namespace Kyoo.Authentication;
/// <summary>
/// A permission validator to validate permission with user Permission array
/// or the default array from the configurations if the user is not logged.
/// </summary>
public class PermissionValidator : IPermissionValidator
{ {
/// <summary> /// <summary>
/// A permission validator to validate permission with user Permission array /// The permissions options to retrieve default permissions.
/// or the default array from the configurations if the user is not logged.
/// </summary> /// </summary>
public class PermissionValidator : IPermissionValidator private readonly PermissionOption _options;
/// <summary>
/// Create a new factory with the given options.
/// </summary>
/// <param name="options">The option containing default values.</param>
public PermissionValidator(PermissionOption options)
{ {
_options = options;
}
/// <inheritdoc />
public IFilterMetadata Create(PermissionAttribute attribute)
{
return new PermissionValidatorFilter(
attribute.Type,
attribute.Kind,
attribute.Group,
_options
);
}
/// <inheritdoc />
public IFilterMetadata Create(PartialPermissionAttribute attribute)
{
return new PermissionValidatorFilter(
((object?)attribute.Type ?? attribute.Kind)!,
attribute.Group,
_options
);
}
/// <summary>
/// The authorization filter used by <see cref="PermissionValidator"/>.
/// </summary>
private class PermissionValidatorFilter : IAsyncAuthorizationFilter
{
/// <summary>
/// The permission to validate.
/// </summary>
private readonly string? _permission;
/// <summary>
/// The kind of permission needed.
/// </summary>
private readonly Kind? _kind;
/// <summary>
/// The group of he permission.
/// </summary>
private Group _group;
/// <summary> /// <summary>
/// The permissions options to retrieve default permissions. /// The permissions options to retrieve default permissions.
/// </summary> /// </summary>
private readonly PermissionOption _options; private readonly PermissionOption _options;
/// <summary> /// <summary>
/// Create a new factory with the given options. /// Create a new permission validator with the given options.
/// </summary> /// </summary>
/// <param name="permission">The permission to validate.</param>
/// <param name="kind">The kind of permission needed.</param>
/// <param name="group">The group of the permission.</param>
/// <param name="options">The option containing default values.</param> /// <param name="options">The option containing default values.</param>
public PermissionValidator(PermissionOption options) public PermissionValidatorFilter(
string permission,
Kind kind,
Group group,
PermissionOption options
)
{ {
_permission = permission;
_kind = kind;
_group = group;
_options = options;
}
/// <summary>
/// Create a new permission validator with the given options.
/// </summary>
/// <param name="partialInfo">The partial permission to validate.</param>
/// <param name="group">The group of the permission.</param>
/// <param name="options">The option containing default values.</param>
public PermissionValidatorFilter(object partialInfo, Group? group, PermissionOption options)
{
switch (partialInfo)
{
case Kind kind:
_kind = kind;
break;
case string perm:
_permission = perm;
break;
default:
throw new ArgumentException(
$"{nameof(partialInfo)} can only be a permission string or a kind."
);
}
if (group is not null and not Group.None)
_group = group.Value;
_options = options; _options = options;
} }
/// <inheritdoc /> /// <inheritdoc />
public IFilterMetadata Create(PermissionAttribute attribute) public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{ {
return new PermissionValidatorFilter( string? permission = _permission;
attribute.Type, Kind? kind = _kind;
attribute.Kind,
attribute.Group,
_options
);
}
/// <inheritdoc /> if (permission == null || kind == null)
public IFilterMetadata Create(PartialPermissionAttribute attribute)
{
return new PermissionValidatorFilter(
((object?)attribute.Type ?? attribute.Kind)!,
attribute.Group,
_options
);
}
/// <summary>
/// The authorization filter used by <see cref="PermissionValidator"/>.
/// </summary>
private class PermissionValidatorFilter : IAsyncAuthorizationFilter
{
/// <summary>
/// The permission to validate.
/// </summary>
private readonly string? _permission;
/// <summary>
/// The kind of permission needed.
/// </summary>
private readonly Kind? _kind;
/// <summary>
/// The group of he permission.
/// </summary>
private Group _group;
/// <summary>
/// The permissions options to retrieve default permissions.
/// </summary>
private readonly PermissionOption _options;
/// <summary>
/// Create a new permission validator with the given options.
/// </summary>
/// <param name="permission">The permission to validate.</param>
/// <param name="kind">The kind of permission needed.</param>
/// <param name="group">The group of the permission.</param>
/// <param name="options">The option containing default values.</param>
public PermissionValidatorFilter(
string permission,
Kind kind,
Group group,
PermissionOption options
)
{ {
_permission = permission; if (context.HttpContext.Items["PermissionGroup"] is Group group and not Group.None)
_kind = kind; _group = group;
_group = group; else if (_group == Group.None)
_options = options; _group = Group.Overall;
} else
context.HttpContext.Items["PermissionGroup"] = _group;
/// <summary> switch (context.HttpContext.Items["PermissionType"])
/// Create a new permission validator with the given options.
/// </summary>
/// <param name="partialInfo">The partial permission to validate.</param>
/// <param name="group">The group of the permission.</param>
/// <param name="options">The option containing default values.</param>
public PermissionValidatorFilter(
object partialInfo,
Group? group,
PermissionOption options
)
{
switch (partialInfo)
{ {
case Kind kind:
_kind = kind;
break;
case string perm: case string perm:
_permission = perm; permission = perm;
break; break;
case Kind kin:
kind = kin;
break;
case null when kind != null:
context.HttpContext.Items["PermissionType"] = kind;
return;
case null when permission != null:
context.HttpContext.Items["PermissionType"] = permission;
return;
default: default:
throw new ArgumentException( throw new ArgumentException(
$"{nameof(partialInfo)} can only be a permission string or a kind." "Multiple non-matching partial permission attribute "
+ "are not supported."
); );
} }
if (group is not null and not Group.None)
_group = group.Value;
_options = options;
}
/// <inheritdoc />
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
string? permission = _permission;
Kind? kind = _kind;
if (permission == null || kind == null) if (permission == null || kind == null)
{ {
if ( throw new ArgumentException(
context.HttpContext.Items["PermissionGroup"] "The permission type or kind is still missing after two partial "
is Group group + "permission attributes, this is unsupported."
and not Group.None );
)
_group = group;
else if (_group == Group.None)
_group = Group.Overall;
else
context.HttpContext.Items["PermissionGroup"] = _group;
switch (context.HttpContext.Items["PermissionType"])
{
case string perm:
permission = perm;
break;
case Kind kin:
kind = kin;
break;
case null when kind != null:
context.HttpContext.Items["PermissionType"] = kind;
return;
case null when permission != null:
context.HttpContext.Items["PermissionType"] = permission;
return;
default:
throw new ArgumentException(
"Multiple non-matching partial permission attribute "
+ "are not supported."
);
}
if (permission == null || kind == null)
{
throw new ArgumentException(
"The permission type or kind is still missing after two partial "
+ "permission attributes, this is unsupported."
);
}
} }
}
string permStr = $"{permission.ToLower()}.{kind.ToString()!.ToLower()}"; string permStr = $"{permission.ToLower()}.{kind.ToString()!.ToLower()}";
string overallStr = $"{_group.ToString().ToLower()}.{kind.ToString()!.ToLower()}"; string overallStr = $"{_group.ToString().ToLower()}.{kind.ToString()!.ToLower()}";
AuthenticateResult res = _ApiKeyCheck(context); AuthenticateResult res = _ApiKeyCheck(context);
if (res.None) if (res.None)
res = await _JwtCheck(context); res = await _JwtCheck(context);
if (res.Succeeded) if (res.Succeeded)
{ {
ICollection<string> permissions = res.Principal.GetPermissions(); ICollection<string> permissions = res.Principal.GetPermissions();
if (permissions.All(x => x != permStr && x != overallStr)) if (permissions.All(x => x != permStr && x != overallStr))
context.Result = _ErrorResult(
$"Missing permission {permStr} or {overallStr}",
StatusCodes.Status403Forbidden
);
}
else if (res.None)
{
ICollection<string> permissions = _options.Default ?? Array.Empty<string>();
if (permissions.All(x => x != permStr && x != overallStr))
{
context.Result = _ErrorResult(
$"Unlogged user does not have permission {permStr} or {overallStr}",
StatusCodes.Status401Unauthorized
);
}
}
else if (res.Failure != null)
context.Result = _ErrorResult( context.Result = _ErrorResult(
res.Failure.Message, $"Missing permission {permStr} or {overallStr}",
StatusCodes.Status403Forbidden StatusCodes.Status403Forbidden
); );
else }
else if (res.None)
{
ICollection<string> permissions = _options.Default ?? Array.Empty<string>();
if (permissions.All(x => x != permStr && x != overallStr))
{
context.Result = _ErrorResult( context.Result = _ErrorResult(
"Authentication panic", $"Unlogged user does not have permission {permStr} or {overallStr}",
StatusCodes.Status500InternalServerError StatusCodes.Status401Unauthorized
); );
}
} }
else if (res.Failure != null)
private AuthenticateResult _ApiKeyCheck(ActionContext context) context.Result = _ErrorResult(res.Failure.Message, StatusCodes.Status403Forbidden);
{ else
if ( context.Result = _ErrorResult(
!context.HttpContext.Request.Headers.TryGetValue( "Authentication panic",
"X-API-Key", StatusCodes.Status500InternalServerError
out StringValues apiKey
)
)
return AuthenticateResult.NoResult();
if (!_options.ApiKeys.Contains<string>(apiKey!))
return AuthenticateResult.Fail("Invalid API-Key.");
return AuthenticateResult.Success(
new AuthenticationTicket(
new ClaimsPrincipal(
new[]
{
new ClaimsIdentity(
new[]
{
// TODO: Make permission configurable, for now every APIKEY as all permissions.
new Claim(
Claims.Permissions,
string.Join(',', PermissionOption.Admin)
)
}
)
}
),
"apikey"
)
); );
}
private async Task<AuthenticateResult> _JwtCheck(ActionContext context)
{
AuthenticateResult ret = await context.HttpContext.AuthenticateAsync(
JwtBearerDefaults.AuthenticationScheme
);
// Change the failure message to make the API nice to use.
if (ret.Failure != null)
return AuthenticateResult.Fail(
"Invalid JWT token. The token may have expired."
);
return ret;
}
} }
/// <summary> private AuthenticateResult _ApiKeyCheck(ActionContext context)
/// Create a new action result with the given error message and error code.
/// </summary>
/// <param name="error">The error message.</param>
/// <param name="code">The status code of the error.</param>
/// <returns>The resulting error action.</returns>
private static IActionResult _ErrorResult(string error, int code)
{ {
return new ObjectResult(new RequestError(error)) { StatusCode = code }; if (
!context.HttpContext.Request.Headers.TryGetValue(
"X-API-Key",
out StringValues apiKey
)
)
return AuthenticateResult.NoResult();
if (!_options.ApiKeys.Contains<string>(apiKey!))
return AuthenticateResult.Fail("Invalid API-Key.");
return AuthenticateResult.Success(
new AuthenticationTicket(
new ClaimsPrincipal(
new[]
{
new ClaimsIdentity(
new[]
{
// TODO: Make permission configurable, for now every APIKEY as all permissions.
new Claim(
Claims.Permissions,
string.Join(',', PermissionOption.Admin)
)
}
)
}
),
"apikey"
)
);
}
private async Task<AuthenticateResult> _JwtCheck(ActionContext context)
{
AuthenticateResult ret = await context.HttpContext.AuthenticateAsync(
JwtBearerDefaults.AuthenticationScheme
);
// Change the failure message to make the API nice to use.
if (ret.Failure != null)
return AuthenticateResult.Fail("Invalid JWT token. The token may have expired.");
return ret;
} }
} }
/// <summary>
/// Create a new action result with the given error message and error code.
/// </summary>
/// <param name="error">The error message.</param>
/// <param name="code">The status code of the error.</param>
/// <returns>The resulting error action.</returns>
private static IActionResult _ErrorResult(string error, int code)
{
return new ObjectResult(new RequestError(error)) { StatusCode = code };
}
} }

View File

@ -27,109 +27,108 @@ using Kyoo.Abstractions.Models;
using Kyoo.Authentication.Models; using Kyoo.Authentication.Models;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace Kyoo.Authentication namespace Kyoo.Authentication;
/// <summary>
/// The service that controls jwt creation and validation.
/// </summary>
public class TokenController : ITokenController
{ {
/// <summary> /// <summary>
/// The service that controls jwt creation and validation. /// The options that this controller will use.
/// </summary> /// </summary>
public class TokenController : ITokenController private readonly AuthenticationOption _options;
/// <summary>
/// Create a new <see cref="TokenController"/>.
/// </summary>
/// <param name="options">The options that this controller will use.</param>
public TokenController(AuthenticationOption options)
{ {
/// <summary> _options = options;
/// The options that this controller will use. }
/// </summary>
private readonly AuthenticationOption _options;
/// <summary> /// <inheritdoc />
/// Create a new <see cref="TokenController"/>. public string CreateAccessToken(User user, out TimeSpan expireIn)
/// </summary> {
/// <param name="options">The options that this controller will use.</param> expireIn = new TimeSpan(1, 0, 0);
public TokenController(AuthenticationOption options)
{
_options = options;
}
/// <inheritdoc /> SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
public string CreateAccessToken(User user, out TimeSpan expireIn) SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
{ string permissions =
expireIn = new TimeSpan(1, 0, 0); user.Permissions != null ? string.Join(',', user.Permissions) : string.Empty;
List<Claim> claims =
new()
{
new Claim(Claims.Id, user.Id.ToString()),
new Claim(Claims.Name, user.Username),
new Claim(Claims.Permissions, permissions),
new Claim(Claims.Type, "access")
};
if (user.Email != null)
claims.Add(new Claim(Claims.Email, user.Email));
JwtSecurityToken token =
new(
signingCredentials: credential,
claims: claims,
expires: DateTime.UtcNow.Add(expireIn)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); /// <inheritdoc />
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); public Task<string> CreateRefreshToken(User user)
string permissions = {
user.Permissions != null ? string.Join(',', user.Permissions) : string.Empty; SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
List<Claim> claims = SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
new() JwtSecurityToken token =
new(
signingCredentials: credential,
claims: new[]
{ {
new Claim(Claims.Id, user.Id.ToString()), new Claim(Claims.Id, user.Id.ToString()),
new Claim(Claims.Name, user.Username), new Claim(Claims.Guid, Guid.NewGuid().ToString()),
new Claim(Claims.Permissions, permissions), new Claim(Claims.Type, "refresh")
new Claim(Claims.Type, "access") },
}; expires: DateTime.UtcNow.AddYears(1)
if (user.Email != null) );
claims.Add(new Claim(Claims.Email, user.Email)); // TODO: refresh keys are unique (thanks to the guid) but we could store them in DB to invalidate them if requested by the user.
JwtSecurityToken token = return Task.FromResult(new JwtSecurityTokenHandler().WriteToken(token));
new( }
signingCredentials: credential,
claims: claims,
expires: DateTime.UtcNow.Add(expireIn)
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <inheritdoc /> /// <inheritdoc />
public Task<string> CreateRefreshToken(User user) public Guid GetRefreshTokenUserID(string refreshToken)
{
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
JwtSecurityTokenHandler tokenHandler = new();
ClaimsPrincipal principal;
try
{ {
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); principal = tokenHandler.ValidateToken(
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); refreshToken,
JwtSecurityToken token = new TokenValidationParameters
new( {
signingCredentials: credential, ValidateIssuer = false,
claims: new[] ValidateAudience = false,
{ ValidateIssuerSigningKey = true,
new Claim(Claims.Id, user.Id.ToString()), ValidateLifetime = true,
new Claim(Claims.Guid, Guid.NewGuid().ToString()), IssuerSigningKey = key
new Claim(Claims.Type, "refresh") },
}, out SecurityToken _
expires: DateTime.UtcNow.AddYears(1) );
);
// TODO: refresh keys are unique (thanks to the guid) but we could store them in DB to invalidate them if requested by the user.
return Task.FromResult(new JwtSecurityTokenHandler().WriteToken(token));
} }
catch (Exception)
/// <inheritdoc />
public Guid GetRefreshTokenUserID(string refreshToken)
{ {
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); throw new SecurityTokenException("Invalid refresh token");
JwtSecurityTokenHandler tokenHandler = new();
ClaimsPrincipal principal;
try
{
principal = tokenHandler.ValidateToken(
refreshToken,
new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
IssuerSigningKey = key
},
out SecurityToken _
);
}
catch (Exception)
{
throw new SecurityTokenException("Invalid refresh token");
}
if (principal.Claims.First(x => x.Type == Claims.Type).Value != "refresh")
throw new SecurityTokenException(
"Invalid token type. The token should be a refresh token."
);
Claim identifier = principal.Claims.First(x => x.Type == Claims.Id);
if (Guid.TryParse(identifier.Value, out Guid id))
return id;
throw new SecurityTokenException("Token not associated to any user.");
} }
if (principal.Claims.First(x => x.Type == Claims.Type).Value != "refresh")
throw new SecurityTokenException(
"Invalid token type. The token should be a refresh token."
);
Claim identifier = principal.Claims.First(x => x.Type == Claims.Id);
if (Guid.TryParse(identifier.Value, out Guid id))
return id;
throw new SecurityTokenException("Token not associated to any user.");
} }
} }

View File

@ -16,32 +16,31 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Authentication.Models.DTO namespace Kyoo.Authentication.Models.DTO;
/// <summary>
/// A model only used on login requests.
/// </summary>
public class LoginRequest
{ {
/// <summary> /// <summary>
/// A model only used on login requests. /// The user's username.
/// </summary> /// </summary>
public class LoginRequest public string Username { get; set; }
/// <summary>
/// The user's password.
/// </summary>
public string Password { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="LoginRequest"/> class.
/// </summary>
/// <param name="username">The user's username.</param>
/// <param name="password">The user's password.</param>
public LoginRequest(string username, string password)
{ {
/// <summary> Username = username;
/// The user's username. Password = password;
/// </summary>
public string Username { get; set; }
/// <summary>
/// The user's password.
/// </summary>
public string Password { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="LoginRequest"/> class.
/// </summary>
/// <param name="username">The user's username.</param>
/// <param name="password">The user's password.</param>
public LoginRequest(string username, string password)
{
Username = username;
Password = password;
}
} }
} }

View File

@ -21,57 +21,56 @@ using Kyoo.Abstractions.Models;
using Kyoo.Utils; using Kyoo.Utils;
using BCryptNet = BCrypt.Net.BCrypt; using BCryptNet = BCrypt.Net.BCrypt;
namespace Kyoo.Authentication.Models.DTO namespace Kyoo.Authentication.Models.DTO;
/// <summary>
/// A model only used on register requests.
/// </summary>
public class RegisterRequest
{ {
/// <summary> /// <summary>
/// A model only used on register requests. /// The user email address
/// </summary> /// </summary>
public class RegisterRequest [EmailAddress(ErrorMessage = "The email must be a valid email address")]
public string Email { get; set; }
/// <summary>
/// The user's username.
/// </summary>
[MinLength(4, ErrorMessage = "The username must have at least {1} characters")]
public string Username { get; set; }
/// <summary>
/// The user's password.
/// </summary>
[MinLength(4, ErrorMessage = "The password must have at least {1} characters")]
public string Password { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="RegisterRequest"/> class.
/// </summary>
/// <param name="email">The user email address.</param>
/// <param name="username">The user's username.</param>
/// <param name="password">The user's password.</param>
public RegisterRequest(string email, string username, string password)
{ {
/// <summary> Email = email;
/// The user email address Username = username;
/// </summary> Password = password;
[EmailAddress(ErrorMessage = "The email must be a valid email address")] }
public string Email { get; set; }
/// <summary> /// <summary>
/// The user's username. /// Convert this register request to a new <see cref="User"/> class.
/// </summary> /// </summary>
[MinLength(4, ErrorMessage = "The username must have at least {1} characters")] /// <returns>A user representing this request.</returns>
public string Username { get; set; } public User ToUser()
{
/// <summary> return new User
/// The user's password.
/// </summary>
[MinLength(4, ErrorMessage = "The password must have at least {1} characters")]
public string Password { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="RegisterRequest"/> class.
/// </summary>
/// <param name="email">The user email address.</param>
/// <param name="username">The user's username.</param>
/// <param name="password">The user's password.</param>
public RegisterRequest(string email, string username, string password)
{ {
Email = email; Slug = Utility.ToSlug(Username),
Username = username; Username = Username,
Password = password; Password = BCryptNet.HashPassword(Password),
} Email = Email,
};
/// <summary>
/// Convert this register request to a new <see cref="User"/> class.
/// </summary>
/// <returns>A user representing this request.</returns>
public User ToUser()
{
return new User
{
Slug = Utility.ToSlug(Username),
Username = Username,
Password = BCryptNet.HashPassword(Password),
Email = Email,
};
}
} }
} }

View File

@ -16,31 +16,30 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
namespace Kyoo.Authentication.Models namespace Kyoo.Authentication.Models;
/// <summary>
/// The main authentication options.
/// </summary>
public class AuthenticationOption
{ {
/// <summary> /// <summary>
/// The main authentication options. /// The path to get this option from the root configuration.
/// </summary> /// </summary>
public class AuthenticationOption public const string Path = "authentication";
{
/// <summary>
/// The path to get this option from the root configuration.
/// </summary>
public const string Path = "authentication";
/// <summary> /// <summary>
/// The default jwt secret. /// The default jwt secret.
/// </summary> /// </summary>
public const string DefaultSecret = "4c@mraGB!KRfF@kpS8739y9FcHemKxBsqqxLbdR?"; public const string DefaultSecret = "4c@mraGB!KRfF@kpS8739y9FcHemKxBsqqxLbdR?";
/// <summary> /// <summary>
/// The secret used to encrypt the jwt. /// The secret used to encrypt the jwt.
/// </summary> /// </summary>
public string Secret { get; set; } = DefaultSecret; public string Secret { get; set; } = DefaultSecret;
/// <summary> /// <summary>
/// Options for permissions /// Options for permissions
/// </summary> /// </summary>
public PermissionOption Permissions { get; set; } = new(); public PermissionOption Permissions { get; set; } = new();
}
} }

View File

@ -35,464 +35,458 @@ using Microsoft.IdentityModel.Tokens;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
using BCryptNet = BCrypt.Net.BCrypt; using BCryptNet = BCrypt.Net.BCrypt;
namespace Kyoo.Authentication.Views namespace Kyoo.Authentication.Views;
/// <summary>
/// Sign in, Sign up or refresh tokens.
/// </summary>
[ApiController]
[Route("auth")]
[ApiDefinition("Authentication", Group = UsersGroup)]
public class AuthApi(
IUserRepository users,
OidcController oidc,
ITokenController tokenController,
IThumbnailsManager thumbs,
PermissionOption options
) : ControllerBase
{ {
/// <summary> /// <summary>
/// Sign in, Sign up or refresh tokens. /// Create a new Forbidden result from an object.
/// </summary> /// </summary>
[ApiController] /// <param name="value">The json value to output on the response.</param>
[Route("auth")] /// <returns>A new forbidden result with the given json object.</returns>
[ApiDefinition("Authentication", Group = UsersGroup)] public static ObjectResult Forbid(object value)
public class AuthApi(
IUserRepository users,
OidcController oidc,
ITokenController tokenController,
IThumbnailsManager thumbs,
PermissionOption options
) : ControllerBase
{ {
/// <summary> return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden };
/// Create a new Forbidden result from an object. }
/// </summary>
/// <param name="value">The json value to output on the response.</param>
/// <returns>A new forbidden result with the given json object.</returns>
public static ObjectResult Forbid(object value)
{
return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden };
}
private static string _BuildUrl(string baseUrl, Dictionary<string, string?> queryParams) private static string _BuildUrl(string baseUrl, Dictionary<string, string?> queryParams)
{
char querySep = baseUrl.Contains('?') ? '&' : '?';
foreach ((string key, string? val) in queryParams)
{ {
char querySep = baseUrl.Contains('?') ? '&' : '?'; if (val is null)
foreach ((string key, string? val) in queryParams) continue;
{ baseUrl += $"{querySep}{key}={val}";
if (val is null) querySep = '&';
continue;
baseUrl += $"{querySep}{key}={val}";
querySep = '&';
}
return baseUrl;
} }
return baseUrl;
}
/// <summary> /// <summary>
/// Oauth Login. /// Oauth Login.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Login via a registered oauth provider. /// Login via a registered oauth provider.
/// </remarks> /// </remarks>
/// <param name="provider">The provider code.</param> /// <param name="provider">The provider code.</param>
/// <param name="redirectUrl"> /// <param name="redirectUrl">
/// A url where you will be redirected with the query params provider, code and error. It can be a deep link. /// A url where you will be redirected with the query params provider, code and error. It can be a deep link.
/// </param> /// </param>
/// <returns>A redirect to the provider's login page.</returns> /// <returns>A redirect to the provider's login page.</returns>
/// <response code="404">The provider is not register with this instance of kyoo.</response> /// <response code="404">The provider is not register with this instance of kyoo.</response>
[HttpGet("login/{provider}")] [HttpGet("login/{provider}")]
[ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))]
public ActionResult<JwtToken> LoginVia(string provider, [FromQuery] string redirectUrl) public ActionResult<JwtToken> LoginVia(string provider, [FromQuery] string redirectUrl)
{
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
{ {
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled) return NotFound(
{ new RequestError(
return NotFound( $"Invalid provider. {provider} is not registered no this instance of kyoo."
new RequestError(
$"Invalid provider. {provider} is not registered no this instance of kyoo."
)
);
}
OidcProvider prov = options.OIDC[provider];
return Redirect(
_BuildUrl(
prov.AuthorizationUrl,
new()
{
["response_type"] = "code",
["client_id"] = prov.ClientId,
["redirect_uri"] =
$"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}",
["scope"] = prov.Scope,
["state"] = redirectUrl,
}
) )
); );
} }
OidcProvider prov = options.OIDC[provider];
/// <summary> return Redirect(
/// Oauth Code Redirect. _BuildUrl(
/// </summary> prov.AuthorizationUrl,
/// <remarks> new()
/// This route is not meant to be called manually, the user should be redirected automatically here
/// after a successful login on the /login/{provider} page.
/// </remarks>
/// <returns>A redirect to the provider's login page.</returns>
/// <response code="403">The provider gave an error.</response>
[HttpGet("logged/{provider}")]
[ProducesResponseType(StatusCodes.Status302Found)]
public ActionResult OauthCodeRedirect(
string provider,
string code,
string state,
string? error
)
{
return Redirect(
_BuildUrl(
state,
new()
{
["provider"] = provider,
["code"] = code,
["error"] = error,
}
)
);
}
/// <summary>
/// Oauth callback
/// </summary>
/// <remarks>
/// This route should be manually called by the page that got redirected to after a call to /login/{provider}.
/// </remarks>
/// <returns>A jwt token</returns>
/// <response code="400">Bad provider or code</response>
[HttpPost("callback/{provider}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> OauthCallback(string provider, string code)
{
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
{
return NotFound(
new RequestError(
$"Invalid provider. {provider} is not registered no this instance of kyoo."
)
);
}
if (code == null)
return BadRequest(new RequestError("Invalid code."));
Guid? userId = User.GetId();
User user = userId.HasValue
? await oidc.LinkAccountOrLogin(userId.Value, provider, code)
: await oidc.LoginViaCode(provider, code);
return new JwtToken(
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
await tokenController.CreateRefreshToken(user),
expireIn
);
}
/// <summary>
/// Unlink account
/// </summary>
/// <remarks>
/// Unlink your account from an external account.
/// </remarks>
/// <param name="provider">The provider code.</param>
/// <returns>Your updated user account</returns>
[HttpDelete("login/{provider}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[UserOnly]
public Task<User> UnlinkAccount(string provider)
{
Guid id = User.GetIdOrThrow();
return users.DeleteExternalToken(id, provider);
}
/// <summary>
/// Login.
/// </summary>
/// <remarks>
/// Login as a user and retrieve an access and a refresh token.
/// </remarks>
/// <param name="request">The body of the request.</param>
/// <returns>A new access and a refresh token.</returns>
/// <response code="403">The user and password does not match.</response>
[HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request)
{
User? user = await users.GetOrDefault(
new Filter<User>.Eq(nameof(Abstractions.Models.User.Username), request.Username)
);
if (user == null || !BCryptNet.Verify(request.Password, user.Password))
return Forbid(new RequestError("The user and password does not match."));
return new JwtToken(
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
await tokenController.CreateRefreshToken(user),
expireIn
);
}
/// <summary>
/// Register.
/// </summary>
/// <remarks>
/// Register a new user and get a new access/refresh token for this new user.
/// </remarks>
/// <param name="request">The body of the request.</param>
/// <returns>A new access and a refresh token.</returns>
/// <response code="400">The request is invalid.</response>
/// <response code="409">A user already exists with this username or email address.</response>
[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
{
try
{
User user = await users.Create(request.ToUser());
return new JwtToken(
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
await tokenController.CreateRefreshToken(user),
expireIn
);
}
catch (DuplicatedItemException)
{
return Conflict(new RequestError("A user already exists with this username."));
}
}
/// <summary>
/// Refresh a token.
/// </summary>
/// <remarks>
/// Refresh an access token using the given refresh token. A new access and refresh token are generated.
/// </remarks>
/// <param name="token">A valid refresh token.</param>
/// <returns>A new access and refresh token.</returns>
/// <response code="403">The given refresh token is invalid.</response>
[HttpGet("refresh")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Refresh([FromQuery] string token)
{
try
{
Guid userId = tokenController.GetRefreshTokenUserID(token);
User user = await users.Get(userId);
return new JwtToken(
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
await tokenController.CreateRefreshToken(user),
expireIn
);
}
catch (ItemNotFoundException)
{
return Forbid(new RequestError("Invalid refresh token."));
}
catch (SecurityTokenException ex)
{
return Forbid(new RequestError(ex.Message));
}
}
/// <summary>
/// Reset your password
/// </summary>
/// <remarks>
/// Change your password.
/// </remarks>
/// <param name="request">The old and new password</param>
/// <returns>Your account info.</returns>
/// <response code="403">The old password is invalid.</response>
[HttpPost("password-reset")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<User>> ResetPassword([FromBody] PasswordResetRequest request)
{
User user = await users.Get(User.GetIdOrThrow());
if (user.HasPassword && !BCryptNet.Verify(request.OldPassword, user.Password))
return Forbid(new RequestError("The old password is invalid."));
return await users.Patch(
user.Id,
(user) =>
{ {
user.Password = BCryptNet.HashPassword(request.NewPassword); ["response_type"] = "code",
return user; ["client_id"] = prov.ClientId,
["redirect_uri"] =
$"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}",
["scope"] = prov.Scope,
["state"] = redirectUrl,
} }
)
);
}
/// <summary>
/// Oauth Code Redirect.
/// </summary>
/// <remarks>
/// This route is not meant to be called manually, the user should be redirected automatically here
/// after a successful login on the /login/{provider} page.
/// </remarks>
/// <returns>A redirect to the provider's login page.</returns>
/// <response code="403">The provider gave an error.</response>
[HttpGet("logged/{provider}")]
[ProducesResponseType(StatusCodes.Status302Found)]
public ActionResult OauthCodeRedirect(string provider, string code, string state, string? error)
{
return Redirect(
_BuildUrl(
state,
new()
{
["provider"] = provider,
["code"] = code,
["error"] = error,
}
)
);
}
/// <summary>
/// Oauth callback
/// </summary>
/// <remarks>
/// This route should be manually called by the page that got redirected to after a call to /login/{provider}.
/// </remarks>
/// <returns>A jwt token</returns>
/// <response code="400">Bad provider or code</response>
[HttpPost("callback/{provider}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> OauthCallback(string provider, string code)
{
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
{
return NotFound(
new RequestError(
$"Invalid provider. {provider} is not registered no this instance of kyoo."
)
); );
} }
if (code == null)
return BadRequest(new RequestError("Invalid code."));
/// <summary> Guid? userId = User.GetId();
/// Get authenticated user. User user = userId.HasValue
/// </summary> ? await oidc.LinkAccountOrLogin(userId.Value, provider, code)
/// <remarks> : await oidc.LoginViaCode(provider, code);
/// Get information about the currently authenticated user. This can also be used to ensure that you are return new JwtToken(
/// logged in. tokenController.CreateAccessToken(user, out TimeSpan expireIn),
/// </remarks> await tokenController.CreateRefreshToken(user),
/// <returns>The currently authenticated user.</returns> expireIn
/// <response code="401">The user is not authenticated.</response> );
/// <response code="403">The given access token is invalid.</response> }
[HttpGet("me")]
[UserOnly] /// <summary>
[ProducesResponseType(StatusCodes.Status200OK)] /// Unlink account
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))] /// </summary>
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] /// <remarks>
public async Task<ActionResult<User>> GetMe() /// Unlink your account from an external account.
/// </remarks>
/// <param name="provider">The provider code.</param>
/// <returns>Your updated user account</returns>
[HttpDelete("login/{provider}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[UserOnly]
public Task<User> UnlinkAccount(string provider)
{
Guid id = User.GetIdOrThrow();
return users.DeleteExternalToken(id, provider);
}
/// <summary>
/// Login.
/// </summary>
/// <remarks>
/// Login as a user and retrieve an access and a refresh token.
/// </remarks>
/// <param name="request">The body of the request.</param>
/// <returns>A new access and a refresh token.</returns>
/// <response code="403">The user and password does not match.</response>
[HttpPost("login")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request)
{
User? user = await users.GetOrDefault(
new Filter<User>.Eq(nameof(Abstractions.Models.User.Username), request.Username)
);
if (user == null || !BCryptNet.Verify(request.Password, user.Password))
return Forbid(new RequestError("The user and password does not match."));
return new JwtToken(
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
await tokenController.CreateRefreshToken(user),
expireIn
);
}
/// <summary>
/// Register.
/// </summary>
/// <remarks>
/// Register a new user and get a new access/refresh token for this new user.
/// </remarks>
/// <param name="request">The body of the request.</param>
/// <returns>A new access and a refresh token.</returns>
/// <response code="400">The request is invalid.</response>
/// <response code="409">A user already exists with this username or email address.</response>
[HttpPost("register")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
{
try
{ {
try User user = await users.Create(request.ToUser());
{ return new JwtToken(
return await users.Get(User.GetIdOrThrow()); tokenController.CreateAccessToken(user, out TimeSpan expireIn),
} await tokenController.CreateRefreshToken(user),
catch (ItemNotFoundException) expireIn
{ );
return Forbid(new RequestError("Invalid token"));
}
} }
catch (DuplicatedItemException)
/// <summary>
/// Edit self
/// </summary>
/// <remarks>
/// Edit information about the currently authenticated user.
/// </remarks>
/// <param name="user">The new data for the current user.</param>
/// <returns>The currently authenticated user after modifications.</returns>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpPut("me")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<User>> EditMe(User user)
{ {
try return Conflict(new RequestError("A user already exists with this username."));
{
user.Id = User.GetIdOrThrow();
return await users.Edit(user);
}
catch (ItemNotFoundException)
{
return Forbid(new RequestError("Invalid token"));
}
}
/// <summary>
/// Patch self
/// </summary>
/// <remarks>
/// Edit only provided informations about the currently authenticated user.
/// </remarks>
/// <param name="patch">The new data for the current user.</param>
/// <returns>The currently authenticated user after modifications.</returns>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpPatch("me")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<User>> PatchMe([FromBody] Patch<User> patch)
{
Guid userId = User.GetIdOrThrow();
try
{
if (patch.Id.HasValue && patch.Id != userId)
throw new ArgumentException("Can't edit your user id.");
if (patch.ContainsKey(nameof(Abstractions.Models.User.Password)))
throw new ArgumentException(
"Can't edit your password via a PATCH. Use /auth/password-reset"
);
return await users.Patch(userId, patch.Apply);
}
catch (ItemNotFoundException)
{
return Forbid(new RequestError("Invalid token"));
}
}
/// <summary>
/// Delete account
/// </summary>
/// <remarks>
/// Delete the current account.
/// </remarks>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpDelete("me")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<User>> DeleteMe()
{
try
{
await users.Delete(User.GetIdOrThrow());
return NoContent();
}
catch (ItemNotFoundException)
{
return Forbid(new RequestError("Invalid token"));
}
}
/// <summary>
/// Get profile picture
/// </summary>
/// <remarks>
/// Get your profile picture
/// </remarks>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpGet("me/logo")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult> GetProfilePicture()
{
Stream img = await thumbs.GetUserImage(User.GetIdOrThrow());
// Allow clients to cache the image for 6 month.
Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}");
return File(img, "image/webp", true);
}
/// <summary>
/// Set profile picture
/// </summary>
/// <remarks>
/// Set your profile picture
/// </remarks>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpPost("me/logo")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult> SetProfilePicture(IFormFile picture)
{
if (picture == null || picture.Length == 0)
return BadRequest();
await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream());
return NoContent();
}
/// <summary>
/// Delete profile picture
/// </summary>
/// <remarks>
/// Delete your profile picture
/// </remarks>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpDelete("me/logo")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult> DeleteProfilePicture()
{
await thumbs.SetUserImage(User.GetIdOrThrow(), null);
return NoContent();
} }
} }
/// <summary>
/// Refresh a token.
/// </summary>
/// <remarks>
/// Refresh an access token using the given refresh token. A new access and refresh token are generated.
/// </remarks>
/// <param name="token">A valid refresh token.</param>
/// <returns>A new access and refresh token.</returns>
/// <response code="403">The given refresh token is invalid.</response>
[HttpGet("refresh")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Refresh([FromQuery] string token)
{
try
{
Guid userId = tokenController.GetRefreshTokenUserID(token);
User user = await users.Get(userId);
return new JwtToken(
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
await tokenController.CreateRefreshToken(user),
expireIn
);
}
catch (ItemNotFoundException)
{
return Forbid(new RequestError("Invalid refresh token."));
}
catch (SecurityTokenException ex)
{
return Forbid(new RequestError(ex.Message));
}
}
/// <summary>
/// Reset your password
/// </summary>
/// <remarks>
/// Change your password.
/// </remarks>
/// <param name="request">The old and new password</param>
/// <returns>Your account info.</returns>
/// <response code="403">The old password is invalid.</response>
[HttpPost("password-reset")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<User>> ResetPassword([FromBody] PasswordResetRequest request)
{
User user = await users.Get(User.GetIdOrThrow());
if (user.HasPassword && !BCryptNet.Verify(request.OldPassword, user.Password))
return Forbid(new RequestError("The old password is invalid."));
return await users.Patch(
user.Id,
(user) =>
{
user.Password = BCryptNet.HashPassword(request.NewPassword);
return user;
}
);
}
/// <summary>
/// Get authenticated user.
/// </summary>
/// <remarks>
/// Get information about the currently authenticated user. This can also be used to ensure that you are
/// logged in.
/// </remarks>
/// <returns>The currently authenticated user.</returns>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpGet("me")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<User>> GetMe()
{
try
{
return await users.Get(User.GetIdOrThrow());
}
catch (ItemNotFoundException)
{
return Forbid(new RequestError("Invalid token"));
}
}
/// <summary>
/// Edit self
/// </summary>
/// <remarks>
/// Edit information about the currently authenticated user.
/// </remarks>
/// <param name="user">The new data for the current user.</param>
/// <returns>The currently authenticated user after modifications.</returns>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpPut("me")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<User>> EditMe(User user)
{
try
{
user.Id = User.GetIdOrThrow();
return await users.Edit(user);
}
catch (ItemNotFoundException)
{
return Forbid(new RequestError("Invalid token"));
}
}
/// <summary>
/// Patch self
/// </summary>
/// <remarks>
/// Edit only provided informations about the currently authenticated user.
/// </remarks>
/// <param name="patch">The new data for the current user.</param>
/// <returns>The currently authenticated user after modifications.</returns>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpPatch("me")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<User>> PatchMe([FromBody] Patch<User> patch)
{
Guid userId = User.GetIdOrThrow();
try
{
if (patch.Id.HasValue && patch.Id != userId)
throw new ArgumentException("Can't edit your user id.");
if (patch.ContainsKey(nameof(Abstractions.Models.User.Password)))
throw new ArgumentException(
"Can't edit your password via a PATCH. Use /auth/password-reset"
);
return await users.Patch(userId, patch.Apply);
}
catch (ItemNotFoundException)
{
return Forbid(new RequestError("Invalid token"));
}
}
/// <summary>
/// Delete account
/// </summary>
/// <remarks>
/// Delete the current account.
/// </remarks>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpDelete("me")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<User>> DeleteMe()
{
try
{
await users.Delete(User.GetIdOrThrow());
return NoContent();
}
catch (ItemNotFoundException)
{
return Forbid(new RequestError("Invalid token"));
}
}
/// <summary>
/// Get profile picture
/// </summary>
/// <remarks>
/// Get your profile picture
/// </remarks>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpGet("me/logo")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult> GetProfilePicture()
{
Stream img = await thumbs.GetUserImage(User.GetIdOrThrow());
// Allow clients to cache the image for 6 month.
Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}");
return File(img, "image/webp", true);
}
/// <summary>
/// Set profile picture
/// </summary>
/// <remarks>
/// Set your profile picture
/// </remarks>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpPost("me/logo")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult> SetProfilePicture(IFormFile picture)
{
if (picture == null || picture.Length == 0)
return BadRequest();
await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream());
return NoContent();
}
/// <summary>
/// Delete profile picture
/// </summary>
/// <remarks>
/// Delete your profile picture
/// </remarks>
/// <response code="401">The user is not authenticated.</response>
/// <response code="403">The given access token is invalid.</response>
[HttpDelete("me/logo")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult> DeleteProfilePicture()
{
await thumbs.SetUserImage(User.GetIdOrThrow(), null);
return NoContent();
}
} }

View File

@ -20,23 +20,22 @@ using Kyoo.Abstractions.Models.Utils;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// The route constraint that goes with the <see cref="Identifier"/>.
/// </summary>
public class IdentifierRouteConstraint : IRouteConstraint
{ {
/// <summary> /// <inheritdoc />
/// The route constraint that goes with the <see cref="Identifier"/>. public bool Match(
/// </summary> HttpContext? httpContext,
public class IdentifierRouteConstraint : IRouteConstraint IRouter? route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection
)
{ {
/// <inheritdoc /> return values.ContainsKey(routeKey);
public bool Match(
HttpContext? httpContext,
IRouter? route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection
)
{
return values.ContainsKey(routeKey);
}
} }
} }

View File

@ -20,87 +20,86 @@ using System.Linq;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// An class to interact with the database. Every repository is mapped through here.
/// </summary>
public class LibraryManager : ILibraryManager
{ {
/// <summary> private readonly IBaseRepository[] _repositories;
/// An class to interact with the database. Every repository is mapped through here.
/// </summary> public LibraryManager(
public class LibraryManager : ILibraryManager IRepository<ILibraryItem> libraryItemRepository,
IRepository<INews> newsRepository,
IWatchStatusRepository watchStatusRepository,
IRepository<Collection> collectionRepository,
IRepository<Movie> movieRepository,
IRepository<Show> showRepository,
IRepository<Season> seasonRepository,
IRepository<Episode> episodeRepository,
IRepository<Studio> studioRepository,
IRepository<User> userRepository
)
{ {
private readonly IBaseRepository[] _repositories; LibraryItems = libraryItemRepository;
News = newsRepository;
WatchStatus = watchStatusRepository;
Collections = collectionRepository;
Movies = movieRepository;
Shows = showRepository;
Seasons = seasonRepository;
Episodes = episodeRepository;
Studios = studioRepository;
Users = userRepository;
public LibraryManager( _repositories = new IBaseRepository[]
IRepository<ILibraryItem> libraryItemRepository,
IRepository<INews> newsRepository,
IWatchStatusRepository watchStatusRepository,
IRepository<Collection> collectionRepository,
IRepository<Movie> movieRepository,
IRepository<Show> showRepository,
IRepository<Season> seasonRepository,
IRepository<Episode> episodeRepository,
IRepository<Studio> studioRepository,
IRepository<User> userRepository
)
{ {
LibraryItems = libraryItemRepository; LibraryItems,
News = newsRepository; News,
WatchStatus = watchStatusRepository; Collections,
Collections = collectionRepository; Movies,
Movies = movieRepository; Shows,
Shows = showRepository; Seasons,
Seasons = seasonRepository; Episodes,
Episodes = episodeRepository; Studios,
Studios = studioRepository; Users
Users = userRepository; };
}
_repositories = new IBaseRepository[] /// <inheritdoc />
{ public IRepository<ILibraryItem> LibraryItems { get; }
LibraryItems,
News,
Collections,
Movies,
Shows,
Seasons,
Episodes,
Studios,
Users
};
}
/// <inheritdoc /> /// <inheritdoc />
public IRepository<ILibraryItem> LibraryItems { get; } public IRepository<INews> News { get; }
/// <inheritdoc /> /// <inheritdoc />
public IRepository<INews> News { get; } public IWatchStatusRepository WatchStatus { get; }
/// <inheritdoc /> /// <inheritdoc />
public IWatchStatusRepository WatchStatus { get; } public IRepository<Collection> Collections { get; }
/// <inheritdoc /> /// <inheritdoc />
public IRepository<Collection> Collections { get; } public IRepository<Movie> Movies { get; }
/// <inheritdoc /> /// <inheritdoc />
public IRepository<Movie> Movies { get; } public IRepository<Show> Shows { get; }
/// <inheritdoc /> /// <inheritdoc />
public IRepository<Show> Shows { get; } public IRepository<Season> Seasons { get; }
/// <inheritdoc /> /// <inheritdoc />
public IRepository<Season> Seasons { get; } public IRepository<Episode> Episodes { get; }
/// <inheritdoc /> /// <inheritdoc />
public IRepository<Episode> Episodes { get; } public IRepository<Studio> Studios { get; }
/// <inheritdoc /> /// <inheritdoc />
public IRepository<Studio> Studios { get; } public IRepository<User> Users { get; }
/// <inheritdoc /> public IRepository<T> Repository<T>()
public IRepository<User> Users { get; } where T : IResource, IQuery
{
public IRepository<T> Repository<T>() return (IRepository<T>)_repositories.First(x => x.RepositoryType == typeof(T));
where T : IResource, IQuery
{
return (IRepository<T>)_repositories.First(x => x.RepositoryType == typeof(T));
}
} }
} }

View File

@ -26,78 +26,77 @@ using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql; using Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle collections
/// </summary>
public class CollectionRepository : LocalRepository<Collection>
{ {
/// <summary> /// <summary>
/// A local repository to handle collections /// The database handle
/// </summary> /// </summary>
public class CollectionRepository : LocalRepository<Collection> private readonly DatabaseContext _database;
/// <summary>
/// Create a new <see cref="CollectionRepository"/>.
/// </summary>
/// <param name="database">The database handle to use</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
public CollectionRepository(DatabaseContext database, IThumbnailsManager thumbs)
: base(database, thumbs)
{ {
/// <summary> _database = database;
/// The database handle }
/// </summary>
private readonly DatabaseContext _database;
/// <summary> /// <inheritdoc />
/// Create a new <see cref="CollectionRepository"/>. public override async Task<ICollection<Collection>> Search(
/// </summary> string query,
/// <param name="database">The database handle to use</param> Include<Collection>? include = default
/// <param name="thumbs">The thumbnail manager used to store images.</param> )
public CollectionRepository(DatabaseContext database, IThumbnailsManager thumbs) {
: base(database, thumbs) return await AddIncludes(_database.Collections, include)
{ .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
_database = database; .Take(20)
} .ToListAsync();
}
/// <inheritdoc /> /// <inheritdoc />
public override async Task<ICollection<Collection>> Search( public override async Task<Collection> Create(Collection obj)
string query, {
Include<Collection>? include = default await base.Create(obj);
) _database.Entry(obj).State = EntityState.Added;
{ await _database.SaveChangesAsync(() => Get(obj.Slug));
return await AddIncludes(_database.Collections, include) await IRepository<Collection>.OnResourceCreated(obj);
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) return obj;
.Take(20) }
.ToListAsync();
}
/// <inheritdoc /> /// <inheritdoc />
public override async Task<Collection> Create(Collection obj) protected override async Task Validate(Collection resource)
{ {
await base.Create(obj); await base.Validate(resource);
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<Collection>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc /> if (string.IsNullOrEmpty(resource.Name))
protected override async Task Validate(Collection resource) throw new ArgumentException("The collection's name must be set and not empty");
{ }
await base.Validate(resource);
if (string.IsNullOrEmpty(resource.Name)) public async Task AddMovie(Guid id, Guid movieId)
throw new ArgumentException("The collection's name must be set and not empty"); {
} _database.AddLinks<Collection, Movie>(id, movieId);
await _database.SaveChangesAsync();
}
public async Task AddMovie(Guid id, Guid movieId) public async Task AddShow(Guid id, Guid showId)
{ {
_database.AddLinks<Collection, Movie>(id, movieId); _database.AddLinks<Collection, Show>(id, showId);
await _database.SaveChangesAsync(); await _database.SaveChangesAsync();
} }
public async Task AddShow(Guid id, Guid showId) /// <inheritdoc />
{ public override async Task Delete(Collection obj)
_database.AddLinks<Collection, Show>(id, showId); {
await _database.SaveChangesAsync(); _database.Entry(obj).State = EntityState.Deleted;
} await _database.SaveChangesAsync();
await base.Delete(obj);
/// <inheritdoc />
public override async Task Delete(Collection obj)
{
_database.Entry(obj).State = EntityState.Deleted;
await _database.SaveChangesAsync();
await base.Delete(obj);
}
} }
} }

View File

@ -27,130 +27,128 @@ using Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle episodes.
/// </summary>
public class EpisodeRepository : LocalRepository<Episode>
{ {
/// <summary> /// <summary>
/// A local repository to handle episodes. /// The database handle
/// </summary> /// </summary>
public class EpisodeRepository : LocalRepository<Episode> private readonly DatabaseContext _database;
private readonly IRepository<Show> _shows;
static EpisodeRepository()
{ {
/// <summary> // Edit episode slugs when the show's slug changes.
/// The database handle IRepository<Show>.OnEdited += async (show) =>
/// </summary>
private readonly DatabaseContext _database;
private readonly IRepository<Show> _shows;
static EpisodeRepository()
{ {
// Edit episode slugs when the show's slug changes. await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope();
IRepository<Show>.OnEdited += async (show) => DatabaseContext database = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
{ List<Episode> episodes = await database
await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); .Episodes.AsTracking()
DatabaseContext database = .Where(x => x.ShowId == show.Id)
scope.ServiceProvider.GetRequiredService<DatabaseContext>();
List<Episode> episodes = await database
.Episodes.AsTracking()
.Where(x => x.ShowId == show.Id)
.ToListAsync();
foreach (Episode ep in episodes)
{
ep.ShowSlug = show.Slug;
await database.SaveChangesAsync();
await IRepository<Episode>.OnResourceEdited(ep);
}
};
}
/// <summary>
/// Create a new <see cref="EpisodeRepository"/>.
/// </summary>
/// <param name="database">The database handle to use.</param>
/// <param name="shows">A show repository</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
public EpisodeRepository(
DatabaseContext database,
IRepository<Show> shows,
IThumbnailsManager thumbs
)
: base(database, thumbs)
{
_database = database;
_shows = shows;
}
/// <inheritdoc />
public override async Task<ICollection<Episode>> Search(
string query,
Include<Episode>? include = default
)
{
return await AddIncludes(_database.Episodes, include)
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
.Take(20)
.ToListAsync(); .ToListAsync();
} foreach (Episode ep in episodes)
{
ep.ShowSlug = show.Slug;
await database.SaveChangesAsync();
await IRepository<Episode>.OnResourceEdited(ep);
}
};
}
protected override Task<Episode?> GetDuplicated(Episode item) /// <summary>
{ /// Create a new <see cref="EpisodeRepository"/>.
if (item is { SeasonNumber: not null, EpisodeNumber: not null }) /// </summary>
return _database.Episodes.FirstOrDefaultAsync(x => /// <param name="database">The database handle to use.</param>
x.ShowId == item.ShowId /// <param name="shows">A show repository</param>
&& x.SeasonNumber == item.SeasonNumber /// <param name="thumbs">The thumbnail manager used to store images.</param>
&& x.EpisodeNumber == item.EpisodeNumber public EpisodeRepository(
); DatabaseContext database,
IRepository<Show> shows,
IThumbnailsManager thumbs
)
: base(database, thumbs)
{
_database = database;
_shows = shows;
}
/// <inheritdoc />
public override async Task<ICollection<Episode>> Search(
string query,
Include<Episode>? include = default
)
{
return await AddIncludes(_database.Episodes, include)
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
.Take(20)
.ToListAsync();
}
protected override Task<Episode?> GetDuplicated(Episode item)
{
if (item is { SeasonNumber: not null, EpisodeNumber: not null })
return _database.Episodes.FirstOrDefaultAsync(x => return _database.Episodes.FirstOrDefaultAsync(x =>
x.ShowId == item.ShowId && x.AbsoluteNumber == item.AbsoluteNumber x.ShowId == item.ShowId
&& x.SeasonNumber == item.SeasonNumber
&& x.EpisodeNumber == item.EpisodeNumber
);
return _database.Episodes.FirstOrDefaultAsync(x =>
x.ShowId == item.ShowId && x.AbsoluteNumber == item.AbsoluteNumber
);
}
/// <inheritdoc />
public override async Task<Episode> Create(Episode obj)
{
obj.ShowSlug =
obj.Show?.Slug ?? (await _database.Shows.FirstAsync(x => x.Id == obj.ShowId)).Slug;
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => GetDuplicated(obj));
await IRepository<Episode>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
protected override async Task Validate(Episode resource)
{
await base.Validate(resource);
if (resource.ShowId == Guid.Empty)
{
if (resource.Show == null)
{
throw new ArgumentException(
$"Can't store an episode not related "
+ $"to any show (showID: {resource.ShowId})."
);
}
resource.ShowId = resource.Show.Id;
}
if (resource.SeasonId == null && resource.SeasonNumber != null)
{
resource.Season = await _database.Seasons.FirstOrDefaultAsync(x =>
x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber
); );
} }
}
/// <inheritdoc /> /// <inheritdoc />
public override async Task<Episode> Create(Episode obj) public override async Task Delete(Episode obj)
{ {
obj.ShowSlug = int epCount = await _database
obj.Show?.Slug ?? (await _database.Shows.FirstAsync(x => x.Id == obj.ShowId)).Slug; .Episodes.Where(x => x.ShowId == obj.ShowId)
await base.Create(obj); .Take(2)
_database.Entry(obj).State = EntityState.Added; .CountAsync();
await _database.SaveChangesAsync(() => GetDuplicated(obj)); _database.Entry(obj).State = EntityState.Deleted;
await IRepository<Episode>.OnResourceCreated(obj); await _database.SaveChangesAsync();
return obj; await base.Delete(obj);
} if (epCount == 1)
await _shows.Delete(obj.ShowId);
/// <inheritdoc />
protected override async Task Validate(Episode resource)
{
await base.Validate(resource);
if (resource.ShowId == Guid.Empty)
{
if (resource.Show == null)
{
throw new ArgumentException(
$"Can't store an episode not related "
+ $"to any show (showID: {resource.ShowId})."
);
}
resource.ShowId = resource.Show.Id;
}
if (resource.SeasonId == null && resource.SeasonNumber != null)
{
resource.Season = await _database.Seasons.FirstOrDefaultAsync(x =>
x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber
);
}
}
/// <inheritdoc />
public override async Task Delete(Episode obj)
{
int epCount = await _database
.Episodes.Where(x => x.ShowId == obj.ShowId)
.Take(2)
.CountAsync();
_database.Entry(obj).State = EntityState.Deleted;
await _database.SaveChangesAsync();
await base.Delete(obj);
if (epCount == 1)
await _shows.Delete(obj.ShowId);
}
} }
} }

View File

@ -25,103 +25,102 @@ using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
using Kyoo.Abstractions.Models.Utils; using Kyoo.Abstractions.Models.Utils;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle library items.
/// </summary>
public class LibraryItemRepository : DapperRepository<ILibraryItem>
{ {
/// <summary> // language=PostgreSQL
/// A local repository to handle library items. protected override FormattableString Sql =>
/// </summary> $"""
public class LibraryItemRepository : DapperRepository<ILibraryItem> select
s.*, -- Show as s
m.*,
c.*
/* includes */
from
shows as s
full outer join (
select
* -- Movie
from
movies) as m on false
full outer join(
select
c.* -- Collection as c
from
collections as c
left join link_collection_show as ls on ls.collection_id = c.id
left join link_collection_movie as lm on lm.collection_id = c.id
group by c.id
having count(*) > 1
) as c on false
""";
protected override Dictionary<string, Type> Config =>
new()
{
{ "s", typeof(Show) },
{ "m", typeof(Movie) },
{ "c", typeof(Collection) }
};
protected override ILibraryItem Mapper(List<object?> items)
{
if (items[0] is Show show && show.Id != Guid.Empty)
return show;
if (items[1] is Movie movie && movie.Id != Guid.Empty)
return movie;
if (items[2] is Collection collection && collection.Id != Guid.Empty)
return collection;
throw new InvalidDataException();
}
public LibraryItemRepository(DbConnection database, SqlVariableContext context)
: base(database, context) { }
public async Task<ICollection<ILibraryItem>> GetAllOfCollection(
Guid collectionId,
Filter<ILibraryItem>? filter = default,
Sort<ILibraryItem>? sort = default,
Include<ILibraryItem>? include = default,
Pagination? limit = default
)
{ {
// language=PostgreSQL // language=PostgreSQL
protected override FormattableString Sql => FormattableString sql = $"""
$""" select
s.*,
m.*
/* includes */
from (
select select
s.*, -- Show as s * -- Show
m.*,
c.*
/* includes */
from from
shows as s shows
full outer join ( inner join link_collection_show as ls on ls.show_id = id and ls.collection_id = {collectionId}
select ) as s
* -- Movie full outer join (
from
movies) as m on false
full outer join(
select
c.* -- Collection as c
from
collections as c
left join link_collection_show as ls on ls.collection_id = c.id
left join link_collection_movie as lm on lm.collection_id = c.id
group by c.id
having count(*) > 1
) as c on false
""";
protected override Dictionary<string, Type> Config =>
new()
{
{ "s", typeof(Show) },
{ "m", typeof(Movie) },
{ "c", typeof(Collection) }
};
protected override ILibraryItem Mapper(List<object?> items)
{
if (items[0] is Show show && show.Id != Guid.Empty)
return show;
if (items[1] is Movie movie && movie.Id != Guid.Empty)
return movie;
if (items[2] is Collection collection && collection.Id != Guid.Empty)
return collection;
throw new InvalidDataException();
}
public LibraryItemRepository(DbConnection database, SqlVariableContext context)
: base(database, context) { }
public async Task<ICollection<ILibraryItem>> GetAllOfCollection(
Guid collectionId,
Filter<ILibraryItem>? filter = default,
Sort<ILibraryItem>? sort = default,
Include<ILibraryItem>? include = default,
Pagination? limit = default
)
{
// language=PostgreSQL
FormattableString sql = $"""
select select
s.*, * -- Movie
m.* from
/* includes */ movies
from ( inner join link_collection_movie as lm on lm.movie_id = id and lm.collection_id = {collectionId}
select ) as m on false
* -- Show """;
from
shows
inner join link_collection_show as ls on ls.show_id = id and ls.collection_id = {collectionId}
) as s
full outer join (
select
* -- Movie
from
movies
inner join link_collection_movie as lm on lm.movie_id = id and lm.collection_id = {collectionId}
) as m on false
""";
return await Database.Query<ILibraryItem>( return await Database.Query<ILibraryItem>(
sql, sql,
new() { { "s", typeof(Show) }, { "m", typeof(Movie) }, }, new() { { "s", typeof(Show) }, { "m", typeof(Movie) }, },
Mapper, Mapper,
(id) => Get(id), (id) => Get(id),
Context, Context,
include, include,
filter, filter,
sort ?? new Sort<ILibraryItem>.Default(), sort ?? new Sort<ILibraryItem>.Default(),
limit ?? new() limit ?? new()
); );
}
} }
} }

View File

@ -32,468 +32,456 @@ using Kyoo.Postgresql;
using Kyoo.Utils; using Kyoo.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// A base class to create repositories using Entity Framework.
/// </summary>
/// <typeparam name="T">The type of this repository</typeparam>
public abstract class LocalRepository<T> : IRepository<T>
where T : class, IResource, IQuery
{ {
/// <summary> /// <summary>
/// A base class to create repositories using Entity Framework. /// The Entity Framework's Database handle.
/// </summary> /// </summary>
/// <typeparam name="T">The type of this repository</typeparam> protected DbContext Database { get; }
public abstract class LocalRepository<T> : IRepository<T>
where T : class, IResource, IQuery /// <summary>
/// The thumbnail manager used to store images.
/// </summary>
private readonly IThumbnailsManager _thumbs;
/// <summary>
/// Create a new base <see cref="LocalRepository{T}"/> with the given database handle.
/// </summary>
/// <param name="database">A database connection to load resources of type <typeparamref name="T"/></param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
protected LocalRepository(DbContext database, IThumbnailsManager thumbs)
{ {
/// <summary> Database = database;
/// The Entity Framework's Database handle. _thumbs = thumbs;
/// </summary> }
protected DbContext Database { get; }
/// <summary> /// <inheritdoc/>
/// The thumbnail manager used to store images. public Type RepositoryType => typeof(T);
/// </summary>
private readonly IThumbnailsManager _thumbs;
/// <summary> /// <summary>
/// Create a new base <see cref="LocalRepository{T}"/> with the given database handle. /// Sort the given query.
/// </summary> /// </summary>
/// <param name="database">A database connection to load resources of type <typeparamref name="T"/></param> /// <param name="query">The query to sort.</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param> /// <param name="sortBy">How to sort the query.</param>
protected LocalRepository(DbContext database, IThumbnailsManager thumbs) /// <returns>The newly sorted query.</returns>
protected IOrderedQueryable<T> Sort(IQueryable<T> query, Sort<T>? sortBy)
{
sortBy ??= new Sort<T>.Default();
IOrderedQueryable<T> _SortBy(
IQueryable<T> qr,
Expression<Func<T, object>> sort,
bool desc,
bool then
)
{ {
Database = database; if (then && qr is IOrderedQueryable<T> qro)
_thumbs = thumbs; {
return desc ? qro.ThenByDescending(sort) : qro.ThenBy(sort);
}
return desc ? qr.OrderByDescending(sort) : qr.OrderBy(sort);
} }
/// <inheritdoc/> IOrderedQueryable<T> _Sort(IQueryable<T> query, Sort<T> sortBy, bool then)
public Type RepositoryType => typeof(T);
/// <summary>
/// Sort the given query.
/// </summary>
/// <param name="query">The query to sort.</param>
/// <param name="sortBy">How to sort the query.</param>
/// <returns>The newly sorted query.</returns>
protected IOrderedQueryable<T> Sort(IQueryable<T> query, Sort<T>? sortBy)
{ {
sortBy ??= new Sort<T>.Default(); switch (sortBy)
IOrderedQueryable<T> _SortBy(
IQueryable<T> qr,
Expression<Func<T, object>> sort,
bool desc,
bool then
)
{ {
if (then && qr is IOrderedQueryable<T> qro) case Sort<T>.Default(var value):
{ return _Sort(query, value, then);
return desc ? qro.ThenByDescending(sort) : qro.ThenBy(sort); case Sort<T>.By(var key, var desc):
} return _SortBy(query, x => EF.Property<T>(x, key), desc, then);
return desc ? qr.OrderByDescending(sort) : qr.OrderBy(sort); case Sort<T>.Random(var seed):
// NOTE: To edit this, don't forget to edit the random handiling inside the KeysetPaginate function
return _SortBy(
query,
x => DatabaseContext.MD5(seed + x.Id.ToString()),
false,
then
);
case Sort<T>.Conglomerate(var sorts):
IOrderedQueryable<T> nQuery = _Sort(query, sorts.First(), false);
foreach (Sort<T> sort in sorts.Skip(1))
nQuery = _Sort(nQuery, sort, true);
return nQuery;
default:
// The language should not require me to do this...
throw new SwitchExpressionException();
} }
IOrderedQueryable<T> _Sort(IQueryable<T> query, Sort<T> sortBy, bool then)
{
switch (sortBy)
{
case Sort<T>.Default(var value):
return _Sort(query, value, then);
case Sort<T>.By(var key, var desc):
return _SortBy(query, x => EF.Property<T>(x, key), desc, then);
case Sort<T>.Random(var seed):
// NOTE: To edit this, don't forget to edit the random handiling inside the KeysetPaginate function
return _SortBy(
query,
x => DatabaseContext.MD5(seed + x.Id.ToString()),
false,
then
);
case Sort<T>.Conglomerate(var sorts):
IOrderedQueryable<T> nQuery = _Sort(query, sorts.First(), false);
foreach (Sort<T> sort in sorts.Skip(1))
nQuery = _Sort(nQuery, sort, true);
return nQuery;
default:
// The language should not require me to do this...
throw new SwitchExpressionException();
}
}
return _Sort(query, sortBy, false).ThenBy(x => x.Id);
} }
return _Sort(query, sortBy, false).ThenBy(x => x.Id);
}
protected IQueryable<T> AddIncludes(IQueryable<T> query, Include<T>? include) protected IQueryable<T> AddIncludes(IQueryable<T> query, Include<T>? include)
{ {
if (include == null) if (include == null)
return query;
foreach (string field in include.Fields)
query = query.Include(field);
return query; return query;
} foreach (string field in include.Fields)
query = query.Include(field);
return query;
}
/// <summary> /// <summary>
/// Get a resource from it's ID and make the <see cref="Database"/> instance track it. /// Get a resource from it's ID and make the <see cref="Database"/> instance track it.
/// </summary> /// </summary>
/// <param name="id">The ID of the resource</param> /// <param name="id">The ID of the resource</param>
/// <exception cref="ItemNotFoundException">If the item is not found</exception> /// <exception cref="ItemNotFoundException">If the item is not found</exception>
/// <returns>The tracked resource with the given ID</returns> /// <returns>The tracked resource with the given ID</returns>
protected virtual async Task<T> GetWithTracking(Guid id) protected virtual async Task<T> GetWithTracking(Guid id)
{
T? ret = await Database.Set<T>().AsTracking().FirstOrDefaultAsync(x => x.Id == id);
if (ret == null)
throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}");
return ret;
}
/// <inheritdoc/>
public virtual async Task<T> Get(Guid id, Include<T>? include = default)
{
T? ret = await GetOrDefault(id, include);
if (ret == null)
throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}");
return ret;
}
/// <inheritdoc/>
public virtual async Task<T> Get(string slug, Include<T>? include = default)
{
T? ret = await GetOrDefault(slug, include);
if (ret == null)
throw new ItemNotFoundException($"No {typeof(T).Name} found with the slug {slug}");
return ret;
}
/// <inheritdoc/>
public virtual async Task<T> Get(
Filter<T> filter,
Include<T>? include = default,
Sort<T>? sortBy = default,
bool reverse = false,
Guid? afterId = default
)
{
T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId);
if (ret == null)
throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate.");
return ret;
}
protected virtual Task<T?> GetDuplicated(T item)
{
return GetOrDefault(item.Slug);
}
/// <inheritdoc />
public virtual Task<T?> GetOrDefault(Guid id, Include<T>? include = default)
{
return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Id == id);
}
/// <inheritdoc />
public virtual Task<T?> GetOrDefault(string slug, Include<T>? include = default)
{
if (slug == "random")
{ {
T? ret = await Database.Set<T>().AsTracking().FirstOrDefaultAsync(x => x.Id == id); return AddIncludes(Database.Set<T>(), include)
if (ret == null) .OrderBy(x => EF.Functions.Random())
throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); .FirstOrDefaultAsync();
return ret;
} }
return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Slug == slug);
}
/// <inheritdoc/> /// <inheritdoc />
public virtual async Task<T> Get(Guid id, Include<T>? include = default) public virtual async Task<T?> GetOrDefault(
{ Filter<T>? filter,
T? ret = await GetOrDefault(id, include); Include<T>? include = default,
if (ret == null) Sort<T>? sortBy = default,
throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}"); bool reverse = false,
return ret; Guid? afterId = default
} )
{
IQueryable<T> query = await ApplyFilters(
Database.Set<T>(),
filter,
sortBy,
new Pagination(1, afterId, reverse),
include
);
return await query.FirstOrDefaultAsync();
}
/// <inheritdoc/> /// <inheritdoc/>
public virtual async Task<T> Get(string slug, Include<T>? include = default) public virtual async Task<ICollection<T>> FromIds(
{ IList<Guid> ids,
T? ret = await GetOrDefault(slug, include); Include<T>? include = default
if (ret == null) )
throw new ItemNotFoundException($"No {typeof(T).Name} found with the slug {slug}"); {
return ret; return (
} await AddIncludes(Database.Set<T>(), include)
.Where(x => ids.Contains(x.Id))
/// <inheritdoc/> .ToListAsync()
public virtual async Task<T> Get(
Filter<T> filter,
Include<T>? include = default,
Sort<T>? sortBy = default,
bool reverse = false,
Guid? afterId = default
) )
{ .OrderBy(x => ids.IndexOf(x.Id))
T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId); .ToList();
if (ret == null) }
throw new ItemNotFoundException(
$"No {typeof(T).Name} found with the given predicate."
);
return ret;
}
protected virtual Task<T?> GetDuplicated(T item) /// <inheritdoc/>
{ public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default);
return GetOrDefault(item.Slug);
}
/// <inheritdoc /> /// <inheritdoc/>
public virtual Task<T?> GetOrDefault(Guid id, Include<T>? include = default) public virtual async Task<ICollection<T>> GetAll(
{ Filter<T>? filter = null,
return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Id == id); Sort<T>? sort = default,
} Include<T>? include = default,
Pagination? limit = default
)
{
IQueryable<T> query = await ApplyFilters(Database.Set<T>(), filter, sort, limit, include);
return await query.ToListAsync();
}
/// <inheritdoc /> /// <summary>
public virtual Task<T?> GetOrDefault(string slug, Include<T>? include = default) /// Apply filters to a query to ease sort, pagination and where queries for resources of this repository
{ /// </summary>
if (slug == "random") /// <param name="query">The base query to filter.</param>
{ /// <param name="filter">An expression to filter based on arbitrary conditions</param>
return AddIncludes(Database.Set<T>(), include) /// <param name="sort">The sort settings (sort order and sort by)</param>
.OrderBy(x => EF.Functions.Random()) /// <param name="limit">Pagination information (where to start and how many to get)</param>
.FirstOrDefaultAsync(); /// <param name="include">Related fields to also load with this query.</param>
} /// <returns>The filtered query</returns>
return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Slug == slug); protected async Task<IQueryable<T>> ApplyFilters(
} IQueryable<T> query,
Filter<T>? filter = null,
Sort<T>? sort = default,
Pagination? limit = default,
Include<T>? include = default
)
{
query = AddIncludes(query, include);
query = Sort(query, sort);
limit ??= new();
/// <inheritdoc /> if (limit.AfterID != null)
public virtual async Task<T?> GetOrDefault(
Filter<T>? filter,
Include<T>? include = default,
Sort<T>? sortBy = default,
bool reverse = false,
Guid? afterId = default
)
{ {
IQueryable<T> query = await ApplyFilters( T reference = await Get(limit.AfterID.Value);
Database.Set<T>(), Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate(
filter,
sortBy,
new Pagination(1, afterId, reverse),
include
);
return await query.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public virtual async Task<ICollection<T>> FromIds(
IList<Guid> ids,
Include<T>? include = default
)
{
return (
await AddIncludes(Database.Set<T>(), include)
.Where(x => ids.Contains(x.Id))
.ToListAsync()
)
.OrderBy(x => ids.IndexOf(x.Id))
.ToList();
}
/// <inheritdoc/>
public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default);
/// <inheritdoc/>
public virtual async Task<ICollection<T>> GetAll(
Filter<T>? filter = null,
Sort<T>? sort = default,
Include<T>? include = default,
Pagination? limit = default
)
{
IQueryable<T> query = await ApplyFilters(
Database.Set<T>(),
filter,
sort, sort,
limit, reference,
include !limit.Reverse
); );
return await query.ToListAsync(); filter = Filter.And(filter, keysetFilter);
} }
if (filter != null)
query = query.Where(filter.ToEfLambda());
/// <summary> if (limit.Reverse)
/// Apply filters to a query to ease sort, pagination and where queries for resources of this repository query = query.Reverse();
/// </summary> if (limit.Limit > 0)
/// <param name="query">The base query to filter.</param> query = query.Take(limit.Limit);
/// <param name="filter">An expression to filter based on arbitrary conditions</param> if (limit.Reverse)
/// <param name="sort">The sort settings (sort order and sort by)</param> query = query.Reverse();
/// <param name="limit">Pagination information (where to start and how many to get)</param>
/// <param name="include">Related fields to also load with this query.</param>
/// <returns>The filtered query</returns>
protected async Task<IQueryable<T>> ApplyFilters(
IQueryable<T> query,
Filter<T>? filter = null,
Sort<T>? sort = default,
Pagination? limit = default,
Include<T>? include = default
)
{
query = AddIncludes(query, include);
query = Sort(query, sort);
limit ??= new();
if (limit.AfterID != null) return query;
{ }
T reference = await Get(limit.AfterID.Value);
Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate(
sort,
reference,
!limit.Reverse
);
filter = Filter.And(filter, keysetFilter);
}
if (filter != null)
query = query.Where(filter.ToEfLambda());
if (limit.Reverse) /// <inheritdoc/>
query = query.Reverse(); public virtual Task<int> GetCount(Filter<T>? filter = null)
if (limit.Limit > 0) {
query = query.Take(limit.Limit); IQueryable<T> query = Database.Set<T>();
if (limit.Reverse) if (filter != null)
query = query.Reverse(); query = query.Where(filter.ToEfLambda());
return query.CountAsync();
}
return query; /// <inheritdoc/>
} public virtual async Task<T> Create(T obj)
{
/// <inheritdoc/> await Validate(obj);
public virtual Task<int> GetCount(Filter<T>? filter = null) if (obj is IThumbnails thumbs)
{
IQueryable<T> query = Database.Set<T>();
if (filter != null)
query = query.Where(filter.ToEfLambda());
return query.CountAsync();
}
/// <inheritdoc/>
public virtual async Task<T> Create(T obj)
{
await Validate(obj);
if (obj is IThumbnails thumbs)
{
try
{
await _thumbs.DownloadImages(thumbs);
}
catch (DuplicatedItemException e) when (e.Existing is null)
{
throw new DuplicatedItemException(await GetDuplicated(obj));
}
if (thumbs.Poster != null)
Database.Entry(thumbs).Reference(x => x.Poster).TargetEntry!.State =
EntityState.Added;
if (thumbs.Thumbnail != null)
Database.Entry(thumbs).Reference(x => x.Thumbnail).TargetEntry!.State =
EntityState.Added;
if (thumbs.Logo != null)
Database.Entry(thumbs).Reference(x => x.Logo).TargetEntry!.State =
EntityState.Added;
}
return obj;
}
/// <inheritdoc/>
public virtual async Task<T> CreateIfNotExists(T obj)
{ {
try try
{ {
T? old = await GetOrDefault(obj.Slug); await _thumbs.DownloadImages(thumbs);
if (old != null)
return old;
return await Create(obj);
} }
catch (DuplicatedItemException) catch (DuplicatedItemException e) when (e.Existing is null)
{ {
return await Get(obj.Slug); throw new DuplicatedItemException(await GetDuplicated(obj));
} }
if (thumbs.Poster != null)
Database.Entry(thumbs).Reference(x => x.Poster).TargetEntry!.State =
EntityState.Added;
if (thumbs.Thumbnail != null)
Database.Entry(thumbs).Reference(x => x.Thumbnail).TargetEntry!.State =
EntityState.Added;
if (thumbs.Logo != null)
Database.Entry(thumbs).Reference(x => x.Logo).TargetEntry!.State =
EntityState.Added;
} }
return obj;
}
/// <inheritdoc/> /// <inheritdoc/>
public virtual async Task<T> Edit(T edited) public virtual async Task<T> CreateIfNotExists(T obj)
{
try
{ {
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled; T? old = await GetOrDefault(obj.Slug);
Database.ChangeTracker.LazyLoadingEnabled = false; if (old != null)
try
{
T old = await GetWithTracking(edited.Id);
Merger.Complete(
old,
edited,
x => x.GetCustomAttribute<LoadableRelationAttribute>() == null
);
await EditRelations(old, edited);
await Database.SaveChangesAsync();
await IRepository<T>.OnResourceEdited(old);
return old; return old;
}
finally
{
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
Database.ChangeTracker.Clear();
}
}
/// <inheritdoc/> return await Create(obj);
public virtual async Task<T> Patch(Guid id, Func<T, T> patch) }
catch (DuplicatedItemException)
{
return await Get(obj.Slug);
}
}
/// <inheritdoc/>
public virtual async Task<T> Edit(T edited)
{
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled;
Database.ChangeTracker.LazyLoadingEnabled = false;
try
{
T old = await GetWithTracking(edited.Id);
Merger.Complete(
old,
edited,
x => x.GetCustomAttribute<LoadableRelationAttribute>() == null
);
await EditRelations(old, edited);
await Database.SaveChangesAsync();
await IRepository<T>.OnResourceEdited(old);
return old;
}
finally
{
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
Database.ChangeTracker.Clear();
}
}
/// <inheritdoc/>
public virtual async Task<T> Patch(Guid id, Func<T, T> patch)
{
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled;
Database.ChangeTracker.LazyLoadingEnabled = false;
try
{
T resource = await GetWithTracking(id);
resource = patch(resource);
await Database.SaveChangesAsync();
await IRepository<T>.OnResourceEdited(resource);
return resource;
}
finally
{
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
Database.ChangeTracker.Clear();
}
}
/// <summary>
/// An overridable method to edit relation of a resource.
/// </summary>
/// <param name="resource">
/// The non edited resource
/// </param>
/// <param name="changed">
/// The new version of <paramref name="resource"/>.
/// This item will be saved on the database and replace <paramref name="resource"/>
/// </param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected virtual Task EditRelations(T resource, T changed)
{
if (resource is IThumbnails thumbs && changed is IThumbnails chng)
{
Database.Entry(thumbs).Reference(x => x.Poster).IsModified =
thumbs.Poster != chng.Poster;
Database.Entry(thumbs).Reference(x => x.Thumbnail).IsModified =
thumbs.Thumbnail != chng.Thumbnail;
Database.Entry(thumbs).Reference(x => x.Logo).IsModified = thumbs.Logo != chng.Logo;
}
return Validate(resource);
}
/// <summary>
/// A method called just before saving a new resource to the database.
/// It is also called on the default implementation of <see cref="EditRelations"/>
/// </summary>
/// <param name="resource">The resource that will be saved</param>
/// <exception cref="ArgumentException">
/// You can throw this if the resource is illegal and should not be saved.
/// </exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected virtual Task Validate(T resource)
{
if (
typeof(T).GetProperty(nameof(resource.Slug))!.GetCustomAttribute<ComputedAttribute>()
!= null
)
return Task.CompletedTask;
if (string.IsNullOrEmpty(resource.Slug))
throw new ArgumentException("Resource can't have null as a slug.");
if (int.TryParse(resource.Slug, out int _) || resource.Slug == "random")
{ {
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled;
Database.ChangeTracker.LazyLoadingEnabled = false;
try try
{ {
T resource = await GetWithTracking(id); MethodInfo? setter = typeof(T).GetProperty(nameof(resource.Slug))!.GetSetMethod();
if (setter != null)
resource = patch(resource); setter.Invoke(resource, new object[] { resource.Slug + '!' });
else
await Database.SaveChangesAsync();
await IRepository<T>.OnResourceEdited(resource);
return resource;
}
finally
{
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
Database.ChangeTracker.Clear();
}
}
/// <summary>
/// An overridable method to edit relation of a resource.
/// </summary>
/// <param name="resource">
/// The non edited resource
/// </param>
/// <param name="changed">
/// The new version of <paramref name="resource"/>.
/// This item will be saved on the database and replace <paramref name="resource"/>
/// </param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected virtual Task EditRelations(T resource, T changed)
{
if (resource is IThumbnails thumbs && changed is IThumbnails chng)
{
Database.Entry(thumbs).Reference(x => x.Poster).IsModified =
thumbs.Poster != chng.Poster;
Database.Entry(thumbs).Reference(x => x.Thumbnail).IsModified =
thumbs.Thumbnail != chng.Thumbnail;
Database.Entry(thumbs).Reference(x => x.Logo).IsModified = thumbs.Logo != chng.Logo;
}
return Validate(resource);
}
/// <summary>
/// A method called just before saving a new resource to the database.
/// It is also called on the default implementation of <see cref="EditRelations"/>
/// </summary>
/// <param name="resource">The resource that will be saved</param>
/// <exception cref="ArgumentException">
/// You can throw this if the resource is illegal and should not be saved.
/// </exception>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected virtual Task Validate(T resource)
{
if (
typeof(T)
.GetProperty(nameof(resource.Slug))!
.GetCustomAttribute<ComputedAttribute>() != null
)
return Task.CompletedTask;
if (string.IsNullOrEmpty(resource.Slug))
throw new ArgumentException("Resource can't have null as a slug.");
if (int.TryParse(resource.Slug, out int _) || resource.Slug == "random")
{
try
{
MethodInfo? setter = typeof(T)
.GetProperty(nameof(resource.Slug))!
.GetSetMethod();
if (setter != null)
setter.Invoke(resource, new object[] { resource.Slug + '!' });
else
throw new ArgumentException(
"Resources slug can't be number only or the literal \"random\"."
);
}
catch
{
throw new ArgumentException( throw new ArgumentException(
"Resources slug can't be number only or the literal \"random\"." "Resources slug can't be number only or the literal \"random\"."
); );
}
} }
return Task.CompletedTask; catch
{
throw new ArgumentException(
"Resources slug can't be number only or the literal \"random\"."
);
}
} }
return Task.CompletedTask;
}
/// <inheritdoc/> /// <inheritdoc/>
public virtual async Task Delete(Guid id) public virtual async Task Delete(Guid id)
{ {
T resource = await Get(id); T resource = await Get(id);
await Delete(resource);
}
/// <inheritdoc/>
public virtual async Task Delete(string slug)
{
T resource = await Get(slug);
await Delete(resource);
}
/// <inheritdoc/>
public virtual Task Delete(T obj)
{
IRepository<T>.OnResourceDeleted(obj);
if (obj is IThumbnails thumbs)
return _thumbs.DeleteImages(thumbs);
return Task.CompletedTask;
}
/// <inheritdoc/>
public async Task DeleteAll(Filter<T> filter)
{
foreach (T resource in await GetAll(filter))
await Delete(resource); await Delete(resource);
}
/// <inheritdoc/>
public virtual async Task Delete(string slug)
{
T resource = await Get(slug);
await Delete(resource);
}
/// <inheritdoc/>
public virtual Task Delete(T obj)
{
IRepository<T>.OnResourceDeleted(obj);
if (obj is IThumbnails thumbs)
return _thumbs.DeleteImages(thumbs);
return Task.CompletedTask;
}
/// <inheritdoc/>
public async Task DeleteAll(Filter<T> filter)
{
foreach (T resource in await GetAll(filter))
await Delete(resource);
}
} }
} }

View File

@ -25,85 +25,84 @@ using Kyoo.Abstractions.Models.Utils;
using Kyoo.Postgresql; using Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle shows
/// </summary>
public class MovieRepository : LocalRepository<Movie>
{ {
/// <summary> /// <summary>
/// A local repository to handle shows /// The database handle
/// </summary> /// </summary>
public class MovieRepository : LocalRepository<Movie> private readonly DatabaseContext _database;
/// <summary>
/// A studio repository to handle creation/validation of related studios.
/// </summary>
private readonly IRepository<Studio> _studios;
public MovieRepository(
DatabaseContext database,
IRepository<Studio> studios,
IThumbnailsManager thumbs
)
: base(database, thumbs)
{ {
/// <summary> _database = database;
/// The database handle _studios = studios;
/// </summary> }
private readonly DatabaseContext _database;
/// <summary> /// <inheritdoc />
/// A studio repository to handle creation/validation of related studios. public override async Task<ICollection<Movie>> Search(
/// </summary> string query,
private readonly IRepository<Studio> _studios; Include<Movie>? include = default
)
{
return await AddIncludes(_database.Movies, include)
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
.Take(20)
.ToListAsync();
}
public MovieRepository( /// <inheritdoc />
DatabaseContext database, public override async Task<Movie> Create(Movie obj)
IRepository<Studio> studios, {
IThumbnailsManager thumbs await base.Create(obj);
) _database.Entry(obj).State = EntityState.Added;
: base(database, thumbs) await _database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<Movie>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
protected override async Task Validate(Movie resource)
{
await base.Validate(resource);
if (resource.Studio != null)
{ {
_database = database; resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
_studios = studios; resource.StudioId = resource.Studio.Id;
}
/// <inheritdoc />
public override async Task<ICollection<Movie>> Search(
string query,
Include<Movie>? include = default
)
{
return await AddIncludes(_database.Movies, include)
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
.Take(20)
.ToListAsync();
}
/// <inheritdoc />
public override async Task<Movie> Create(Movie obj)
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<Movie>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
protected override async Task Validate(Movie resource)
{
await base.Validate(resource);
if (resource.Studio != null)
{
resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
resource.StudioId = resource.Studio.Id;
}
}
/// <inheritdoc />
protected override async Task EditRelations(Movie resource, Movie changed)
{
await Validate(changed);
if (changed.Studio != null || changed.StudioId == null)
{
await Database.Entry(resource).Reference(x => x.Studio).LoadAsync();
resource.Studio = changed.Studio;
}
}
/// <inheritdoc />
public override async Task Delete(Movie obj)
{
_database.Remove(obj);
await _database.SaveChangesAsync();
await base.Delete(obj);
} }
} }
/// <inheritdoc />
protected override async Task EditRelations(Movie resource, Movie changed)
{
await Validate(changed);
if (changed.Studio != null || changed.StudioId == null)
{
await Database.Entry(resource).Reference(x => x.Studio).LoadAsync();
resource.Studio = changed.Studio;
}
}
/// <inheritdoc />
public override async Task Delete(Movie obj)
{
_database.Remove(obj);
await _database.SaveChangesAsync();
await base.Delete(obj);
}
} }

View File

@ -22,43 +22,42 @@ using System.Data.Common;
using System.IO; using System.IO;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle shows
/// </summary>
public class NewsRepository : DapperRepository<INews>
{ {
/// <summary> // language=PostgreSQL
/// A local repository to handle shows protected override FormattableString Sql =>
/// </summary> $"""
public class NewsRepository : DapperRepository<INews> select
{ e.*, -- Episode as e
// language=PostgreSQL m.*
protected override FormattableString Sql => /* includes */
$""" from
episodes as e
full outer join (
select select
e.*, -- Episode as e * -- Movie
m.*
/* includes */
from from
episodes as e movies
full outer join ( ) as m on false
select """;
* -- Movie
from
movies
) as m on false
""";
protected override Dictionary<string, Type> Config => protected override Dictionary<string, Type> Config =>
new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, }; new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, };
protected override INews Mapper(List<object?> items) protected override INews Mapper(List<object?> items)
{ {
if (items[0] is Episode episode && episode.Id != Guid.Empty) if (items[0] is Episode episode && episode.Id != Guid.Empty)
return episode; return episode;
if (items[1] is Movie movie && movie.Id != Guid.Empty) if (items[1] is Movie movie && movie.Id != Guid.Empty)
return movie; return movie;
throw new InvalidDataException(); throw new InvalidDataException();
}
public NewsRepository(DbConnection database, SqlVariableContext context)
: base(database, context) { }
} }
public NewsRepository(DbConnection database, SqlVariableContext context)
: base(database, context) { }
} }

View File

@ -29,105 +29,103 @@ using Kyoo.Postgresql;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle seasons.
/// </summary>
public class SeasonRepository : LocalRepository<Season>
{ {
/// <summary> /// <summary>
/// A local repository to handle seasons. /// The database handle
/// </summary> /// </summary>
public class SeasonRepository : LocalRepository<Season> private readonly DatabaseContext _database;
static SeasonRepository()
{ {
/// <summary> // Edit seasons slugs when the show's slug changes.
/// The database handle IRepository<Show>.OnEdited += async (show) =>
/// </summary>
private readonly DatabaseContext _database;
static SeasonRepository()
{ {
// Edit seasons slugs when the show's slug changes. await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope();
IRepository<Show>.OnEdited += async (show) => DatabaseContext database = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
{ List<Season> seasons = await database
await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); .Seasons.AsTracking()
DatabaseContext database = .Where(x => x.ShowId == show.Id)
scope.ServiceProvider.GetRequiredService<DatabaseContext>();
List<Season> seasons = await database
.Seasons.AsTracking()
.Where(x => x.ShowId == show.Id)
.ToListAsync();
foreach (Season season in seasons)
{
season.ShowSlug = show.Slug;
await database.SaveChangesAsync();
await IRepository<Season>.OnResourceEdited(season);
}
};
}
/// <summary>
/// Create a new <see cref="SeasonRepository"/>.
/// </summary>
/// <param name="database">The database handle that will be used</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
public SeasonRepository(DatabaseContext database, IThumbnailsManager thumbs)
: base(database, thumbs)
{
_database = database;
}
protected override Task<Season?> GetDuplicated(Season item)
{
return _database.Seasons.FirstOrDefaultAsync(x =>
x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber
);
}
/// <inheritdoc/>
public override async Task<ICollection<Season>> Search(
string query,
Include<Season>? include = default
)
{
return await AddIncludes(_database.Seasons, include)
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
.Take(20)
.ToListAsync(); .ToListAsync();
} foreach (Season season in seasons)
/// <inheritdoc/>
public override async Task<Season> Create(Season obj)
{
await base.Create(obj);
obj.ShowSlug =
(await _database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug
?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}");
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => GetDuplicated(obj));
await IRepository<Season>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc/>
protected override async Task Validate(Season resource)
{
await base.Validate(resource);
if (resource.ShowId == Guid.Empty)
{ {
if (resource.Show == null) season.ShowSlug = show.Slug;
{ await database.SaveChangesAsync();
throw new ValidationException( await IRepository<Season>.OnResourceEdited(season);
$"Can't store a season not related to any show "
+ $"(showID: {resource.ShowId})."
);
}
resource.ShowId = resource.Show.Id;
} }
} };
}
/// <inheritdoc/> /// <summary>
public override async Task Delete(Season obj) /// Create a new <see cref="SeasonRepository"/>.
/// </summary>
/// <param name="database">The database handle that will be used</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
public SeasonRepository(DatabaseContext database, IThumbnailsManager thumbs)
: base(database, thumbs)
{
_database = database;
}
protected override Task<Season?> GetDuplicated(Season item)
{
return _database.Seasons.FirstOrDefaultAsync(x =>
x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber
);
}
/// <inheritdoc/>
public override async Task<ICollection<Season>> Search(
string query,
Include<Season>? include = default
)
{
return await AddIncludes(_database.Seasons, include)
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
.Take(20)
.ToListAsync();
}
/// <inheritdoc/>
public override async Task<Season> Create(Season obj)
{
await base.Create(obj);
obj.ShowSlug =
(await _database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug
?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}");
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => GetDuplicated(obj));
await IRepository<Season>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc/>
protected override async Task Validate(Season resource)
{
await base.Validate(resource);
if (resource.ShowId == Guid.Empty)
{ {
_database.Remove(obj); if (resource.Show == null)
await _database.SaveChangesAsync(); {
await base.Delete(obj); throw new ValidationException(
$"Can't store a season not related to any show "
+ $"(showID: {resource.ShowId})."
);
}
resource.ShowId = resource.Show.Id;
} }
} }
/// <inheritdoc/>
public override async Task Delete(Season obj)
{
_database.Remove(obj);
await _database.SaveChangesAsync();
await base.Delete(obj);
}
} }

View File

@ -26,85 +26,84 @@ using Kyoo.Postgresql;
using Kyoo.Utils; using Kyoo.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle shows
/// </summary>
public class ShowRepository : LocalRepository<Show>
{ {
/// <summary> /// <summary>
/// A local repository to handle shows /// The database handle
/// </summary> /// </summary>
public class ShowRepository : LocalRepository<Show> private readonly DatabaseContext _database;
/// <summary>
/// A studio repository to handle creation/validation of related studios.
/// </summary>
private readonly IRepository<Studio> _studios;
public ShowRepository(
DatabaseContext database,
IRepository<Studio> studios,
IThumbnailsManager thumbs
)
: base(database, thumbs)
{ {
/// <summary> _database = database;
/// The database handle _studios = studios;
/// </summary> }
private readonly DatabaseContext _database;
/// <summary> /// <inheritdoc />
/// A studio repository to handle creation/validation of related studios. public override async Task<ICollection<Show>> Search(
/// </summary> string query,
private readonly IRepository<Studio> _studios; Include<Show>? include = default
)
{
return await AddIncludes(_database.Shows, include)
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
.Take(20)
.ToListAsync();
}
public ShowRepository( /// <inheritdoc />
DatabaseContext database, public override async Task<Show> Create(Show obj)
IRepository<Studio> studios, {
IThumbnailsManager thumbs await base.Create(obj);
) _database.Entry(obj).State = EntityState.Added;
: base(database, thumbs) await _database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<Show>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
protected override async Task Validate(Show resource)
{
await base.Validate(resource);
if (resource.Studio != null)
{ {
_database = database; resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
_studios = studios; resource.StudioId = resource.Studio.Id;
}
/// <inheritdoc />
public override async Task<ICollection<Show>> Search(
string query,
Include<Show>? include = default
)
{
return await AddIncludes(_database.Shows, include)
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
.Take(20)
.ToListAsync();
}
/// <inheritdoc />
public override async Task<Show> Create(Show obj)
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => Get(obj.Slug));
await IRepository<Show>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
protected override async Task Validate(Show resource)
{
await base.Validate(resource);
if (resource.Studio != null)
{
resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
resource.StudioId = resource.Studio.Id;
}
}
/// <inheritdoc />
protected override async Task EditRelations(Show resource, Show changed)
{
await Validate(changed);
if (changed.Studio != null || changed.StudioId == null)
{
await Database.Entry(resource).Reference(x => x.Studio).LoadAsync();
resource.Studio = changed.Studio;
}
}
/// <inheritdoc />
public override async Task Delete(Show obj)
{
_database.Remove(obj);
await _database.SaveChangesAsync();
await base.Delete(obj);
} }
} }
/// <inheritdoc />
protected override async Task EditRelations(Show resource, Show changed)
{
await Validate(changed);
if (changed.Studio != null || changed.StudioId == null)
{
await Database.Entry(resource).Reference(x => x.Studio).LoadAsync();
resource.Studio = changed.Studio;
}
}
/// <inheritdoc />
public override async Task Delete(Show obj)
{
_database.Remove(obj);
await _database.SaveChangesAsync();
await base.Delete(obj);
}
} }

View File

@ -26,57 +26,56 @@ using Kyoo.Postgresql;
using Kyoo.Utils; using Kyoo.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// A local repository to handle studios
/// </summary>
public class StudioRepository : LocalRepository<Studio>
{ {
/// <summary> /// <summary>
/// A local repository to handle studios /// The database handle
/// </summary> /// </summary>
public class StudioRepository : LocalRepository<Studio> private readonly DatabaseContext _database;
/// <summary>
/// Create a new <see cref="StudioRepository"/>.
/// </summary>
/// <param name="database">The database handle</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param>
public StudioRepository(DatabaseContext database, IThumbnailsManager thumbs)
: base(database, thumbs)
{ {
/// <summary> _database = database;
/// The database handle }
/// </summary>
private readonly DatabaseContext _database;
/// <summary> /// <inheritdoc />
/// Create a new <see cref="StudioRepository"/>. public override async Task<ICollection<Studio>> Search(
/// </summary> string query,
/// <param name="database">The database handle</param> Include<Studio>? include = default
/// <param name="thumbs">The thumbnail manager used to store images.</param> )
public StudioRepository(DatabaseContext database, IThumbnailsManager thumbs) {
: base(database, thumbs) return await AddIncludes(_database.Studios, include)
{ .Where(x => EF.Functions.ILike(x.Name, $"%{query}%"))
_database = database; .Take(20)
} .ToListAsync();
}
/// <inheritdoc /> /// <inheritdoc />
public override async Task<ICollection<Studio>> Search( public override async Task<Studio> Create(Studio obj)
string query, {
Include<Studio>? include = default await base.Create(obj);
) _database.Entry(obj).State = EntityState.Added;
{ await _database.SaveChangesAsync(() => Get(obj.Slug));
return await AddIncludes(_database.Studios, include) await IRepository<Studio>.OnResourceCreated(obj);
.Where(x => EF.Functions.ILike(x.Name, $"%{query}%")) return obj;
.Take(20) }
.ToListAsync();
}
/// <inheritdoc /> /// <inheritdoc />
public override async Task<Studio> Create(Studio obj) public override async Task Delete(Studio obj)
{ {
await base.Create(obj); _database.Entry(obj).State = EntityState.Deleted;
_database.Entry(obj).State = EntityState.Added; await _database.SaveChangesAsync();
await _database.SaveChangesAsync(() => Get(obj.Slug)); await base.Delete(obj);
await IRepository<Studio>.OnResourceCreated(obj);
return obj;
}
/// <inheritdoc />
public override async Task Delete(Studio obj)
{
_database.Entry(obj).State = EntityState.Deleted;
await _database.SaveChangesAsync();
await base.Delete(obj);
}
} }
} }

View File

@ -31,238 +31,236 @@ using Kyoo.Abstractions.Models.Exceptions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SkiaSharp; using SkiaSharp;
namespace Kyoo.Core.Controllers namespace Kyoo.Core.Controllers;
/// <summary>
/// Download images and retrieve the path of those images for a resource.
/// </summary>
public class ThumbnailsManager(
IHttpClientFactory clientFactory,
ILogger<ThumbnailsManager> logger,
Lazy<IRepository<User>> users
) : IThumbnailsManager
{ {
/// <summary> private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading = new();
/// Download images and retrieve the path of those images for a resource.
/// </summary> private static async Task _WriteTo(SKBitmap bitmap, string path, int quality)
public class ThumbnailsManager(
IHttpClientFactory clientFactory,
ILogger<ThumbnailsManager> logger,
Lazy<IRepository<User>> users
) : IThumbnailsManager
{ {
private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading = SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality);
new(); await using Stream reader = data.AsStream();
await using Stream file = File.Create(path);
await reader.CopyToAsync(file);
}
private static async Task _WriteTo(SKBitmap bitmap, string path, int quality) private async Task _DownloadImage(Image? image, string localPath, string what)
{
if (image == null)
return;
try
{ {
SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality); logger.LogInformation("Downloading image {What}", what);
await using Stream reader = data.AsStream();
await using Stream file = File.Create(path);
await reader.CopyToAsync(file);
}
private async Task _DownloadImage(Image? image, string localPath, string what) HttpClient client = clientFactory.CreateClient();
{ HttpResponseMessage response = await client.GetAsync(image.Source);
if (image == null) response.EnsureSuccessStatusCode();
return; await using Stream reader = await response.Content.ReadAsStreamAsync();
try using SKCodec codec = SKCodec.Create(reader);
if (codec == null)
{ {
logger.LogInformation("Downloading image {What}", what); logger.LogError("Unsupported codec for {What}", what);
HttpClient client = clientFactory.CreateClient();
HttpResponseMessage response = await client.GetAsync(image.Source);
response.EnsureSuccessStatusCode();
await using Stream reader = await response.Content.ReadAsStreamAsync();
using SKCodec codec = SKCodec.Create(reader);
if (codec == null)
{
logger.LogError("Unsupported codec for {What}", what);
return;
}
SKImageInfo info = codec.Info;
info.ColorType = SKColorType.Rgba8888;
using SKBitmap original = SKBitmap.Decode(codec, info);
using SKBitmap high = original.Resize(
new SKSizeI(original.Width, original.Height),
SKFilterQuality.High
);
await _WriteTo(
original,
$"{localPath}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp",
90
);
using SKBitmap medium = high.Resize(
new SKSizeI((int)(high.Width / 1.5), (int)(high.Height / 1.5)),
SKFilterQuality.Medium
);
await _WriteTo(
medium,
$"{localPath}.{ImageQuality.Medium.ToString().ToLowerInvariant()}.webp",
75
);
using SKBitmap low = medium.Resize(
new SKSizeI(original.Width / 2, original.Height / 2),
SKFilterQuality.Low
);
await _WriteTo(
low,
$"{localPath}.{ImageQuality.Low.ToString().ToLowerInvariant()}.webp",
50
);
image.Blurhash = Blurhasher.Encode(low, 4, 3);
}
catch (Exception ex)
{
logger.LogError(ex, "{What} could not be downloaded", what);
}
}
/// <inheritdoc />
public async Task DownloadImages<T>(T item)
where T : IThumbnails
{
string name = item is IResource res ? res.Slug : "???";
string posterPath =
$"{_GetBaseImagePath(item, "poster")}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp";
bool duplicated = false;
TaskCompletionSource<object>? sync = null;
try
{
lock (_downloading)
{
if (_downloading.ContainsKey(posterPath))
{
duplicated = true;
sync = _downloading.GetValueOrDefault(posterPath);
}
else
{
sync = new();
_downloading.Add(posterPath, sync);
}
}
if (duplicated)
{
object? dup = sync != null ? await sync.Task : null;
if (dup != null)
throw new DuplicatedItemException(dup);
}
await _DownloadImage(
item.Poster,
_GetBaseImagePath(item, "poster"),
$"The poster of {name}"
);
await _DownloadImage(
item.Thumbnail,
_GetBaseImagePath(item, "thumbnail"),
$"The poster of {name}"
);
await _DownloadImage(
item.Logo,
_GetBaseImagePath(item, "logo"),
$"The poster of {name}"
);
}
finally
{
if (!duplicated)
{
lock (_downloading)
{
_downloading.Remove(posterPath);
sync!.SetResult(item);
}
}
}
}
private static string _GetBaseImagePath<T>(T item, string image)
{
string directory = item switch
{
IResource res
=> Path.Combine("./metadata", item.GetType().Name.ToLowerInvariant(), res.Slug),
_ => Path.Combine("./metadata", typeof(T).Name.ToLowerInvariant())
};
Directory.CreateDirectory(directory);
return Path.Combine(directory, image);
}
/// <inheritdoc />
public string GetImagePath<T>(T item, string image, ImageQuality quality)
where T : IThumbnails
{
return $"{_GetBaseImagePath(item, image)}.{quality.ToString().ToLowerInvariant()}.webp";
}
/// <inheritdoc />
public Task DeleteImages<T>(T item)
where T : IThumbnails
{
IEnumerable<string> images = new[] { "poster", "thumbnail", "logo" }
.SelectMany(x => _GetBaseImagePath(item, x))
.SelectMany(x =>
new[]
{
ImageQuality.High.ToString().ToLowerInvariant(),
ImageQuality.Medium.ToString().ToLowerInvariant(),
ImageQuality.Low.ToString().ToLowerInvariant(),
}.Select(quality => $"{x}.{quality}.webp")
);
foreach (string image in images)
File.Delete(image);
return Task.CompletedTask;
}
public async Task<Stream> GetUserImage(Guid userId)
{
try
{
return File.Open($"/metadata/user/{userId}.webp", FileMode.Open);
}
catch (FileNotFoundException) { }
catch (DirectoryNotFoundException) { }
User user = await users.Value.Get(userId);
if (user.Email == null)
throw new ItemNotFoundException();
using MD5 md5 = MD5.Create();
string hash = Convert
.ToHexString(md5.ComputeHash(Encoding.ASCII.GetBytes(user.Email)))
.ToLower();
try
{
HttpClient client = clientFactory.CreateClient();
HttpResponseMessage response = await client.GetAsync(
$"https://www.gravatar.com/avatar/{hash}.jpg?d=404&s=250"
);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync();
}
catch
{
throw new ItemNotFoundException();
}
}
public async Task SetUserImage(Guid userId, Stream? image)
{
if (image == null)
{
try
{
File.Delete($"/metadata/user/{userId}.webp");
}
catch { }
return; return;
} }
using SKCodec codec = SKCodec.Create(image);
SKImageInfo info = codec.Info; SKImageInfo info = codec.Info;
info.ColorType = SKColorType.Rgba8888; info.ColorType = SKColorType.Rgba8888;
using SKBitmap original = SKBitmap.Decode(codec, info); using SKBitmap original = SKBitmap.Decode(codec, info);
using SKBitmap ret = original.Resize(new SKSizeI(250, 250), SKFilterQuality.High);
Directory.CreateDirectory("/metadata/user"); using SKBitmap high = original.Resize(
await _WriteTo(ret, $"/metadata/user/{userId}.webp", 75); new SKSizeI(original.Width, original.Height),
SKFilterQuality.High
);
await _WriteTo(
original,
$"{localPath}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp",
90
);
using SKBitmap medium = high.Resize(
new SKSizeI((int)(high.Width / 1.5), (int)(high.Height / 1.5)),
SKFilterQuality.Medium
);
await _WriteTo(
medium,
$"{localPath}.{ImageQuality.Medium.ToString().ToLowerInvariant()}.webp",
75
);
using SKBitmap low = medium.Resize(
new SKSizeI(original.Width / 2, original.Height / 2),
SKFilterQuality.Low
);
await _WriteTo(
low,
$"{localPath}.{ImageQuality.Low.ToString().ToLowerInvariant()}.webp",
50
);
image.Blurhash = Blurhasher.Encode(low, 4, 3);
}
catch (Exception ex)
{
logger.LogError(ex, "{What} could not be downloaded", what);
} }
} }
/// <inheritdoc />
public async Task DownloadImages<T>(T item)
where T : IThumbnails
{
string name = item is IResource res ? res.Slug : "???";
string posterPath =
$"{_GetBaseImagePath(item, "poster")}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp";
bool duplicated = false;
TaskCompletionSource<object>? sync = null;
try
{
lock (_downloading)
{
if (_downloading.ContainsKey(posterPath))
{
duplicated = true;
sync = _downloading.GetValueOrDefault(posterPath);
}
else
{
sync = new();
_downloading.Add(posterPath, sync);
}
}
if (duplicated)
{
object? dup = sync != null ? await sync.Task : null;
if (dup != null)
throw new DuplicatedItemException(dup);
}
await _DownloadImage(
item.Poster,
_GetBaseImagePath(item, "poster"),
$"The poster of {name}"
);
await _DownloadImage(
item.Thumbnail,
_GetBaseImagePath(item, "thumbnail"),
$"The poster of {name}"
);
await _DownloadImage(
item.Logo,
_GetBaseImagePath(item, "logo"),
$"The poster of {name}"
);
}
finally
{
if (!duplicated)
{
lock (_downloading)
{
_downloading.Remove(posterPath);
sync!.SetResult(item);
}
}
}
}
private static string _GetBaseImagePath<T>(T item, string image)
{
string directory = item switch
{
IResource res
=> Path.Combine("./metadata", item.GetType().Name.ToLowerInvariant(), res.Slug),
_ => Path.Combine("./metadata", typeof(T).Name.ToLowerInvariant())
};
Directory.CreateDirectory(directory);
return Path.Combine(directory, image);
}
/// <inheritdoc />
public string GetImagePath<T>(T item, string image, ImageQuality quality)
where T : IThumbnails
{
return $"{_GetBaseImagePath(item, image)}.{quality.ToString().ToLowerInvariant()}.webp";
}
/// <inheritdoc />
public Task DeleteImages<T>(T item)
where T : IThumbnails
{
IEnumerable<string> images = new[] { "poster", "thumbnail", "logo" }
.SelectMany(x => _GetBaseImagePath(item, x))
.SelectMany(x =>
new[]
{
ImageQuality.High.ToString().ToLowerInvariant(),
ImageQuality.Medium.ToString().ToLowerInvariant(),
ImageQuality.Low.ToString().ToLowerInvariant(),
}.Select(quality => $"{x}.{quality}.webp")
);
foreach (string image in images)
File.Delete(image);
return Task.CompletedTask;
}
public async Task<Stream> GetUserImage(Guid userId)
{
try
{
return File.Open($"/metadata/user/{userId}.webp", FileMode.Open);
}
catch (FileNotFoundException) { }
catch (DirectoryNotFoundException) { }
User user = await users.Value.Get(userId);
if (user.Email == null)
throw new ItemNotFoundException();
using MD5 md5 = MD5.Create();
string hash = Convert
.ToHexString(md5.ComputeHash(Encoding.ASCII.GetBytes(user.Email)))
.ToLower();
try
{
HttpClient client = clientFactory.CreateClient();
HttpResponseMessage response = await client.GetAsync(
$"https://www.gravatar.com/avatar/{hash}.jpg?d=404&s=250"
);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync();
}
catch
{
throw new ItemNotFoundException();
}
}
public async Task SetUserImage(Guid userId, Stream? image)
{
if (image == null)
{
try
{
File.Delete($"/metadata/user/{userId}.webp");
}
catch { }
return;
}
using SKCodec codec = SKCodec.Create(image);
SKImageInfo info = codec.Info;
info.ColorType = SKColorType.Rgba8888;
using SKBitmap original = SKBitmap.Decode(codec, info);
using SKBitmap ret = original.Resize(new SKSizeI(250, 250), SKFilterQuality.High);
Directory.CreateDirectory("/metadata/user");
await _WriteTo(ret, $"/metadata/user/{userId}.webp", 75);
}
} }

View File

@ -33,116 +33,115 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Core namespace Kyoo.Core;
/// <summary>
/// The core module containing default implementations
/// </summary>
public class CoreModule : IPlugin
{ {
/// <summary> /// <summary>
/// The core module containing default implementations /// A service provider to access services in static context (in events for example).
/// </summary> /// </summary>
public class CoreModule : IPlugin /// <remarks>Don't forget to create a scope.</remarks>
public static IServiceProvider Services { get; set; }
/// <inheritdoc />
public string Name => "Core";
/// <inheritdoc />
public void Configure(ContainerBuilder builder)
{ {
/// <summary> builder
/// A service provider to access services in static context (in events for example). .RegisterType<ThumbnailsManager>()
/// </summary> .As<IThumbnailsManager>()
/// <remarks>Don't forget to create a scope.</remarks> .InstancePerLifetimeScope();
public static IServiceProvider Services { get; set; } builder.RegisterType<LibraryManager>().As<ILibraryManager>().InstancePerLifetimeScope();
/// <inheritdoc /> builder.RegisterRepository<LibraryItemRepository>();
public string Name => "Core"; builder.RegisterRepository<CollectionRepository>();
builder.RegisterRepository<MovieRepository>();
/// <inheritdoc /> builder.RegisterRepository<ShowRepository>();
public void Configure(ContainerBuilder builder) builder.RegisterRepository<SeasonRepository>();
{ builder.RegisterRepository<EpisodeRepository>();
builder builder.RegisterRepository<StudioRepository>();
.RegisterType<ThumbnailsManager>() builder.RegisterRepository<UserRepository>().As<IUserRepository>();
.As<IThumbnailsManager>() builder.RegisterRepository<NewsRepository>();
.InstancePerLifetimeScope(); builder
builder.RegisterType<LibraryManager>().As<ILibraryManager>().InstancePerLifetimeScope(); .RegisterType<WatchStatusRepository>()
.As<IWatchStatusRepository>()
builder.RegisterRepository<LibraryItemRepository>(); .AsSelf()
builder.RegisterRepository<CollectionRepository>(); .InstancePerLifetimeScope();
builder.RegisterRepository<MovieRepository>(); builder
builder.RegisterRepository<ShowRepository>(); .RegisterType<IssueRepository>()
builder.RegisterRepository<SeasonRepository>(); .As<IIssueRepository>()
builder.RegisterRepository<EpisodeRepository>(); .AsSelf()
builder.RegisterRepository<StudioRepository>(); .InstancePerLifetimeScope();
builder.RegisterRepository<UserRepository>().As<IUserRepository>(); builder.RegisterType<SqlVariableContext>().InstancePerLifetimeScope();
builder.RegisterRepository<NewsRepository>();
builder
.RegisterType<WatchStatusRepository>()
.As<IWatchStatusRepository>()
.AsSelf()
.InstancePerLifetimeScope();
builder
.RegisterType<IssueRepository>()
.As<IIssueRepository>()
.AsSelf()
.InstancePerLifetimeScope();
builder.RegisterType<SqlVariableContext>().InstancePerLifetimeScope();
}
/// <inheritdoc />
public void Configure(IServiceCollection services)
{
services.AddHttpContextAccessor();
services
.AddMvcCore(options =>
{
options.Filters.Add<ExceptionFilter>();
options.ModelBinderProviders.Insert(0, new SortBinder.Provider());
options.ModelBinderProviders.Insert(0, new IncludeBinder.Provider());
options.ModelBinderProviders.Insert(0, new FilterBinder.Provider());
})
.AddJsonOptions(x =>
{
x.JsonSerializerOptions.TypeInfoResolver = new WithKindResolver()
{
Modifiers = { WithKindResolver.HandleLoadableFields }
};
x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
x.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
})
.AddDataAnnotations()
.AddControllersAsServices()
.AddApiExplorer()
.ConfigureApiBehaviorOptions(options =>
{
options.SuppressMapClientErrors = true;
options.InvalidModelStateResponseFactory = ctx =>
{
string[] errors = ctx
.ModelState.SelectMany(x => x.Value!.Errors)
.Select(x => x.ErrorMessage)
.ToArray();
return new BadRequestObjectResult(new RequestError(errors));
};
});
services.Configure<RouteOptions>(x =>
{
x.ConstraintMap.Add("id", typeof(IdentifierRouteConstraint));
});
services.AddResponseCompression(x =>
{
x.EnableForHttps = true;
});
services.AddProxies();
services.AddHttpClient();
}
/// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps =>
new IStartupAction[]
{
SA.New<IApplicationBuilder>(app => app.UseHsts(), SA.Before),
SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1),
SA.New<IApplicationBuilder>(app => app.UseRouting(), SA.Routing),
SA.New<IApplicationBuilder>(
app => app.UseEndpoints(x => x.MapControllers()),
SA.Endpoint
)
};
} }
/// <inheritdoc />
public void Configure(IServiceCollection services)
{
services.AddHttpContextAccessor();
services
.AddMvcCore(options =>
{
options.Filters.Add<ExceptionFilter>();
options.ModelBinderProviders.Insert(0, new SortBinder.Provider());
options.ModelBinderProviders.Insert(0, new IncludeBinder.Provider());
options.ModelBinderProviders.Insert(0, new FilterBinder.Provider());
})
.AddJsonOptions(x =>
{
x.JsonSerializerOptions.TypeInfoResolver = new WithKindResolver()
{
Modifiers = { WithKindResolver.HandleLoadableFields }
};
x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
x.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
})
.AddDataAnnotations()
.AddControllersAsServices()
.AddApiExplorer()
.ConfigureApiBehaviorOptions(options =>
{
options.SuppressMapClientErrors = true;
options.InvalidModelStateResponseFactory = ctx =>
{
string[] errors = ctx
.ModelState.SelectMany(x => x.Value!.Errors)
.Select(x => x.ErrorMessage)
.ToArray();
return new BadRequestObjectResult(new RequestError(errors));
};
});
services.Configure<RouteOptions>(x =>
{
x.ConstraintMap.Add("id", typeof(IdentifierRouteConstraint));
});
services.AddResponseCompression(x =>
{
x.EnableForHttps = true;
});
services.AddProxies();
services.AddHttpClient();
}
/// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps =>
new IStartupAction[]
{
SA.New<IApplicationBuilder>(app => app.UseHsts(), SA.Before),
SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1),
SA.New<IApplicationBuilder>(app => app.UseRouting(), SA.Routing),
SA.New<IApplicationBuilder>(
app => app.UseEndpoints(x => x.MapControllers()),
SA.Endpoint
)
};
} }

View File

@ -25,59 +25,58 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Kyoo.Core namespace Kyoo.Core;
{
/// <summary>
/// A middleware to handle errors globally.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="ExceptionFilter"/> class.
/// </remarks>
/// <param name="logger">The logger used to log errors.</param>
public class ExceptionFilter(ILogger<ExceptionFilter> logger) : IExceptionFilter
{
/// <inheritdoc/>
public void OnException(ExceptionContext context)
{
switch (context.Exception)
{
case ValidationException ex:
context.Result = new BadRequestObjectResult(new RequestError(ex.Message));
break;
case ItemNotFoundException ex:
context.Result = new NotFoundObjectResult(new RequestError(ex.Message));
break;
case DuplicatedItemException ex when ex.Existing is not null:
context.Result = new ConflictObjectResult(ex.Existing);
break;
case DuplicatedItemException:
// Should not happen but if it does, it is better than returning a 409 with no body since clients expect json content
context.Result = new ConflictObjectResult(new RequestError("Duplicated item"));
break;
case UnauthorizedException ex:
context.Result = new UnauthorizedObjectResult(new RequestError(ex.Message));
break;
case Exception ex:
logger.LogError(ex, "Unhandled error");
context.Result = new ServerErrorObjectResult(
new RequestError("Internal Server Error")
);
break;
}
}
/// <inheritdoc /> /// <summary>
public class ServerErrorObjectResult : ObjectResult /// A middleware to handle errors globally.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="ExceptionFilter"/> class.
/// </remarks>
/// <param name="logger">The logger used to log errors.</param>
public class ExceptionFilter(ILogger<ExceptionFilter> logger) : IExceptionFilter
{
/// <inheritdoc/>
public void OnException(ExceptionContext context)
{
switch (context.Exception)
{ {
/// <summary> case ValidationException ex:
/// Initializes a new instance of the <see cref="ServerErrorObjectResult"/> class. context.Result = new BadRequestObjectResult(new RequestError(ex.Message));
/// </summary> break;
/// <param name="value">The object to return.</param> case ItemNotFoundException ex:
public ServerErrorObjectResult(object value) context.Result = new NotFoundObjectResult(new RequestError(ex.Message));
: base(value) break;
{ case DuplicatedItemException ex when ex.Existing is not null:
StatusCode = StatusCodes.Status500InternalServerError; context.Result = new ConflictObjectResult(ex.Existing);
} break;
case DuplicatedItemException:
// Should not happen but if it does, it is better than returning a 409 with no body since clients expect json content
context.Result = new ConflictObjectResult(new RequestError("Duplicated item"));
break;
case UnauthorizedException ex:
context.Result = new UnauthorizedObjectResult(new RequestError(ex.Message));
break;
case Exception ex:
logger.LogError(ex, "Unhandled error");
context.Result = new ServerErrorObjectResult(
new RequestError("Internal Server Error")
);
break;
}
}
/// <inheritdoc />
public class ServerErrorObjectResult : ObjectResult
{
/// <summary>
/// Initializes a new instance of the <see cref="ServerErrorObjectResult"/> class.
/// </summary>
/// <param name="value">The object to return.</param>
public ServerErrorObjectResult(object value)
: base(value)
{
StatusCode = StatusCodes.Status500InternalServerError;
} }
} }
} }

View File

@ -22,54 +22,53 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// An API endpoint to check the health.
/// </summary>
[Route("health")]
[ApiController]
[ApiDefinition("Health")]
public class Health : BaseApi
{ {
private readonly HealthCheckService _healthCheckService;
/// <summary> /// <summary>
/// An API endpoint to check the health. /// Create a new <see cref="Health"/>.
/// </summary> /// </summary>
[Route("health")] /// <param name="healthCheckService">The service to check health.</param>
[ApiController] public Health(HealthCheckService healthCheckService)
[ApiDefinition("Health")]
public class Health : BaseApi
{ {
private readonly HealthCheckService _healthCheckService; _healthCheckService = healthCheckService;
/// <summary>
/// Create a new <see cref="Health"/>.
/// </summary>
/// <param name="healthCheckService">The service to check health.</param>
public Health(HealthCheckService healthCheckService)
{
_healthCheckService = healthCheckService;
}
/// <summary>
/// Check if the api is ready to accept requests.
/// </summary>
/// <returns>A status indicating the health of the api.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IActionResult> CheckHealth()
{
IHeaderDictionary headers = HttpContext.Response.Headers;
headers.CacheControl = "no-store, no-cache";
headers.Pragma = "no-cache";
headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT";
HealthReport result = await _healthCheckService.CheckHealthAsync();
return result.Status switch
{
HealthStatus.Healthy => Ok(new HealthResult("Healthy")),
HealthStatus.Unhealthy => Ok(new HealthResult("Unstable")),
HealthStatus.Degraded => StatusCode(StatusCodes.Status503ServiceUnavailable),
_ => StatusCode(StatusCodes.Status500InternalServerError),
};
}
/// <summary>
/// The result of a health operation.
/// </summary>
public record HealthResult(string Status);
} }
/// <summary>
/// Check if the api is ready to accept requests.
/// </summary>
/// <returns>A status indicating the health of the api.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IActionResult> CheckHealth()
{
IHeaderDictionary headers = HttpContext.Response.Headers;
headers.CacheControl = "no-store, no-cache";
headers.Pragma = "no-cache";
headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT";
HealthReport result = await _healthCheckService.CheckHealthAsync();
return result.Status switch
{
HealthStatus.Healthy => Ok(new HealthResult("Healthy")),
HealthStatus.Unhealthy => Ok(new HealthResult("Unstable")),
HealthStatus.Degraded => StatusCode(StatusCodes.Status503ServiceUnavailable),
_ => StatusCode(StatusCodes.Status500InternalServerError),
};
}
/// <summary>
/// The result of a health operation.
/// </summary>
public record HealthResult(string Status);
} }

View File

@ -25,76 +25,75 @@ using Kyoo.Abstractions.Models;
using Kyoo.Utils; using Kyoo.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// A common API containing custom methods to help inheritors.
/// </summary>
public abstract class BaseApi : ControllerBase
{ {
/// <summary> /// <summary>
/// A common API containing custom methods to help inheritors. /// Construct and return a page from an api.
/// </summary> /// </summary>
public abstract class BaseApi : ControllerBase /// <param name="resources">The list of resources that should be included in the current page.</param>
/// <param name="limit">
/// The max number of items that should be present per page. This should be the same as in the request,
/// it is used to calculate if this is the last page and so on.
/// </param>
/// <typeparam name="TResult">The type of items on the page.</typeparam>
/// <returns>A Page representing the response.</returns>
protected Page<TResult> Page<TResult>(ICollection<TResult> resources, int limit)
where TResult : IResource
{ {
/// <summary> Dictionary<string, string> query = Request.Query.ToDictionary(
/// Construct and return a page from an api. x => x.Key,
/// </summary> x => x.Value.ToString(),
/// <param name="resources">The list of resources that should be included in the current page.</param> StringComparer.InvariantCultureIgnoreCase
/// <param name="limit"> );
/// The max number of items that should be present per page. This should be the same as in the request,
/// it is used to calculate if this is the last page and so on. // If the query was sorted randomly, add the seed to the url to get reproducible links (next,prev,first...)
/// </param> if (query.ContainsKey("sortBy"))
/// <typeparam name="TResult">The type of items on the page.</typeparam>
/// <returns>A Page representing the response.</returns>
protected Page<TResult> Page<TResult>(ICollection<TResult> resources, int limit)
where TResult : IResource
{ {
Dictionary<string, string> query = Request.Query.ToDictionary( object seed = HttpContext.Items["seed"]!;
x => x.Key,
x => x.Value.ToString(),
StringComparer.InvariantCultureIgnoreCase
);
// If the query was sorted randomly, add the seed to the url to get reproducible links (next,prev,first...) query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}");
if (query.ContainsKey("sortBy")) }
{ return new Page<TResult>(resources, Request.Path, query, limit);
object seed = HttpContext.Items["seed"]!; }
query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}"); protected SearchPage<TResult> SearchPage<TResult>(SearchPage<TResult>.SearchResult result)
} where TResult : IResource
return new Page<TResult>(resources, Request.Path, query, limit); {
Dictionary<string, string> query = Request.Query.ToDictionary(
x => x.Key,
x => x.Value.ToString(),
StringComparer.InvariantCultureIgnoreCase
);
string self = Request.Path + query.ToQueryString();
string? previous = null;
string? next = null;
string first;
int limit = query.TryGetValue("limit", out string? limitStr)
? int.Parse(limitStr)
: new SearchPagination().Limit;
int? skip = query.TryGetValue("skip", out string? skipStr) ? int.Parse(skipStr) : null;
if (skip != null)
{
query["skip"] = Math.Max(0, skip.Value - limit).ToString();
previous = Request.Path + query.ToQueryString();
}
if (result.Items.Count == limit && limit > 0)
{
int newSkip = skip.HasValue ? skip.Value + limit : limit;
query["skip"] = newSkip.ToString();
next = Request.Path + query.ToQueryString();
} }
protected SearchPage<TResult> SearchPage<TResult>(SearchPage<TResult>.SearchResult result) query.Remove("skip");
where TResult : IResource first = Request.Path + query.ToQueryString();
{
Dictionary<string, string> query = Request.Query.ToDictionary(
x => x.Key,
x => x.Value.ToString(),
StringComparer.InvariantCultureIgnoreCase
);
string self = Request.Path + query.ToQueryString(); return new SearchPage<TResult>(result, self, previous, next, first);
string? previous = null;
string? next = null;
string first;
int limit = query.TryGetValue("limit", out string? limitStr)
? int.Parse(limitStr)
: new SearchPagination().Limit;
int? skip = query.TryGetValue("skip", out string? skipStr) ? int.Parse(skipStr) : null;
if (skip != null)
{
query["skip"] = Math.Max(0, skip.Value - limit).ToString();
previous = Request.Path + query.ToQueryString();
}
if (result.Items.Count == limit && limit > 0)
{
int newSkip = skip.HasValue ? skip.Value + limit : limit;
query["skip"] = newSkip.ToString();
next = Request.Path + query.ToQueryString();
}
query.Remove("skip");
first = Request.Path + query.ToQueryString();
return new SearchPage<TResult>(result, self, previous, next, first);
}
} }
} }

View File

@ -27,250 +27,246 @@ using Kyoo.Models;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// A base class to handle CRUD operations on a specific resource type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of resource to make CRUD apis for.</typeparam>
[ApiController]
public class CrudApi<T> : BaseApi
where T : class, IResource, IQuery
{ {
/// <summary> /// <summary>
/// A base class to handle CRUD operations on a specific resource type <typeparamref name="T"/>. /// The repository of the resource, used to retrieve, save and do operations on the baking store.
/// </summary> /// </summary>
/// <typeparam name="T">The type of resource to make CRUD apis for.</typeparam> protected IRepository<T> Repository { get; }
[ApiController]
public class CrudApi<T> : BaseApi /// <summary>
where T : class, IResource, IQuery /// Create a new <see cref="CrudApi{T}"/> using the given repository and base url.
/// </summary>
/// <param name="repository">
/// The repository to use as a baking store for the type <typeparamref name="T"/>.
/// </param>
public CrudApi(IRepository<T> repository)
{ {
/// <summary> Repository = repository;
/// The repository of the resource, used to retrieve, save and do operations on the baking store. }
/// </summary>
protected IRepository<T> Repository { get; }
/// <summary> /// <summary>
/// Create a new <see cref="CrudApi{T}"/> using the given repository and base url. /// Get item
/// </summary> /// </summary>
/// <param name="repository"> /// <remarks>
/// The repository to use as a baking store for the type <typeparamref name="T"/>. /// Get a specific resource via it's ID or it's slug.
/// </param> /// </remarks>
public CrudApi(IRepository<T> repository) /// <param name="identifier">The ID or slug of the resource to retrieve.</param>
{ /// <param name="fields">The aditional fields to include in the result.</param>
Repository = repository; /// <returns>The retrieved resource.</returns>
} /// <response code="404">A resource with the given ID or slug does not exist.</response>
[HttpGet("{identifier:id}")]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<T>> Get(Identifier identifier, [FromQuery] Include<T>? fields)
{
T? ret = await identifier.Match(
id => Repository.GetOrDefault(id, fields),
slug => Repository.GetOrDefault(slug, fields)
);
if (ret == null)
return NotFound();
return ret;
}
/// <summary> /// <summary>
/// Get item /// Get count
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Get a specific resource via it's ID or it's slug. /// Get the number of resources that match the filters.
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the resource to retrieve.</param> /// <param name="filter">A list of filters to respect.</param>
/// <param name="fields">The aditional fields to include in the result.</param> /// <returns>How many resources matched that filter.</returns>
/// <returns>The retrieved resource.</returns> /// <response code="400">Invalid filters.</response>
/// <response code="404">A resource with the given ID or slug does not exist.</response> [HttpGet("count")]
[HttpGet("{identifier:id}")] [PartialPermission(Kind.Read)]
[PartialPermission(Kind.Read)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)] public async Task<ActionResult<int>> GetCount([FromQuery] Filter<T> filter)
public async Task<ActionResult<T>> Get( {
Identifier identifier, return await Repository.GetCount(filter);
[FromQuery] Include<T>? fields }
)
{
T? ret = await identifier.Match(
id => Repository.GetOrDefault(id, fields),
slug => Repository.GetOrDefault(slug, fields)
);
if (ret == null)
return NotFound();
return ret;
}
/// <summary> /// <summary>
/// Get count /// Get all
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Get the number of resources that match the filters. /// Get all resources that match the given filter.
/// </remarks> /// </remarks>
/// <param name="filter">A list of filters to respect.</param> /// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
/// <returns>How many resources matched that filter.</returns> /// <param name="filter">Filter the returned items.</param>
/// <response code="400">Invalid filters.</response> /// <param name="pagination">How many items per page should be returned, where should the page start...</param>
[HttpGet("count")] /// <param name="fields">The aditional fields to include in the result.</param>
[PartialPermission(Kind.Read)] /// <returns>A list of resources that match every filters.</returns>
[ProducesResponseType(StatusCodes.Status200OK)] /// <response code="400">Invalid filters or sort information.</response>
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [HttpGet]
public async Task<ActionResult<int>> GetCount([FromQuery] Filter<T> filter) [PartialPermission(Kind.Read)]
{ [ProducesResponseType(StatusCodes.Status200OK)]
return await Repository.GetCount(filter); [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
} public async Task<ActionResult<Page<T>>> GetAll(
[FromQuery] Sort<T> sortBy,
[FromQuery] Filter<T>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<T>? fields
)
{
ICollection<T> resources = await Repository.GetAll(filter, sortBy, fields, pagination);
/// <summary> return Page(resources, pagination.Limit);
/// Get all }
/// </summary>
/// <remarks>
/// Get all resources that match the given filter.
/// </remarks>
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
/// <param name="filter">Filter the returned items.</param>
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A list of resources that match every filters.</returns>
/// <response code="400">Invalid filters or sort information.</response>
[HttpGet]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<ActionResult<Page<T>>> GetAll(
[FromQuery] Sort<T> sortBy,
[FromQuery] Filter<T>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<T>? fields
)
{
ICollection<T> resources = await Repository.GetAll(filter, sortBy, fields, pagination);
return Page(resources, pagination.Limit); /// <summary>
} /// Create new
/// </summary>
/// <remarks>
/// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo.
/// </remarks>
/// <param name="resource">The resource to create.</param>
/// <returns>The created resource.</returns>
/// <response code="400">The resource in the request body is invalid.</response>
/// <response code="409">This item already exists (maybe a duplicated slug).</response>
[HttpPost]
[PartialPermission(Kind.Create)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))]
public virtual async Task<ActionResult<T>> Create([FromBody] T resource)
{
return await Repository.Create(resource);
}
/// <summary> /// <summary>
/// Create new /// Edit
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo. /// Edit an item. If the ID is specified it will be used to identify the resource.
/// </remarks> /// If not, the slug will be used to identify it.
/// <param name="resource">The resource to create.</param> /// </remarks>
/// <returns>The created resource.</returns> /// <param name="resource">The resource to edit.</param>
/// <response code="400">The resource in the request body is invalid.</response> /// <returns>The edited resource.</returns>
/// <response code="409">This item already exists (maybe a duplicated slug).</response> /// <response code="400">The resource in the request body is invalid.</response>
[HttpPost] /// <response code="404">No item found with the specified ID (or slug).</response>
[PartialPermission(Kind.Create)] [HttpPut]
[ProducesResponseType(StatusCodes.Status200OK)] [PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public virtual async Task<ActionResult<T>> Create([FromBody] T resource) [ProducesResponseType(StatusCodes.Status404NotFound)]
{ public async Task<ActionResult<T>> Edit([FromBody] T resource)
return await Repository.Create(resource); {
} if (resource.Id != Guid.Empty)
/// <summary>
/// Edit
/// </summary>
/// <remarks>
/// Edit an item. If the ID is specified it will be used to identify the resource.
/// If not, the slug will be used to identify it.
/// </remarks>
/// <param name="resource">The resource to edit.</param>
/// <returns>The edited resource.</returns>
/// <response code="400">The resource in the request body is invalid.</response>
/// <response code="404">No item found with the specified ID (or slug).</response>
[HttpPut]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<T>> Edit([FromBody] T resource)
{
if (resource.Id != Guid.Empty)
return await Repository.Edit(resource);
T old = await Repository.Get(resource.Slug);
resource.Id = old.Id;
return await Repository.Edit(resource); return await Repository.Edit(resource);
}
/// <summary> T old = await Repository.Get(resource.Slug);
/// Patch resource.Id = old.Id;
/// </summary> return await Repository.Edit(resource);
/// <remarks> }
/// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource.
/// If not, the slug will be used to identify it.
/// </remarks>
/// <param name="patch">The resource to patch.</param>
/// <returns>The edited resource.</returns>
/// <response code="400">The resource in the request body is invalid.</response>
/// <response code="404">No item found with the specified ID (or slug).</response>
[HttpPatch]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<T>> Patch([FromBody] Patch<T> patch)
{
if (patch.Id.HasValue)
return await Repository.Patch(patch.Id.Value, patch.Apply);
if (patch.Slug == null)
throw new ArgumentException(
"Either the Id or the slug of the resource has to be defined to edit it."
);
T old = await Repository.Get(patch.Slug); /// <summary>
return await Repository.Patch(old.Id, patch.Apply); /// Patch
} /// </summary>
/// <remarks>
/// <summary> /// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource.
/// Patch /// If not, the slug will be used to identify it.
/// </summary> /// </remarks>
/// <remarks> /// <param name="patch">The resource to patch.</param>
/// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource. /// <returns>The edited resource.</returns>
/// If not, the slug will be used to identify it. /// <response code="400">The resource in the request body is invalid.</response>
/// </remarks> /// <response code="404">No item found with the specified ID (or slug).</response>
/// <param name="identifier">The id or slug of the resource.</param> [HttpPatch]
/// <param name="patch">The resource to patch.</param> [PartialPermission(Kind.Write)]
/// <returns>The edited resource.</returns> [ProducesResponseType(StatusCodes.Status200OK)]
/// <response code="400">The resource in the request body is invalid.</response> [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
/// <response code="404">No item found with the specified ID (or slug).</response> [ProducesResponseType(StatusCodes.Status404NotFound)]
[HttpPatch("{identifier:id}")] public async Task<ActionResult<T>> Patch([FromBody] Patch<T> patch)
[PartialPermission(Kind.Write)] {
[ProducesResponseType(StatusCodes.Status200OK)] if (patch.Id.HasValue)
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] return await Repository.Patch(patch.Id.Value, patch.Apply);
[ProducesResponseType(StatusCodes.Status404NotFound)] if (patch.Slug == null)
public async Task<ActionResult<T>> Patch(Identifier identifier, [FromBody] Patch<T> patch) throw new ArgumentException(
{ "Either the Id or the slug of the resource has to be defined to edit it."
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await Repository.Get(slug)).Id
); );
if (patch.Id.HasValue && patch.Id.Value != id)
throw new ArgumentException("Can not edit id of a resource.");
return await Repository.Patch(id, patch.Apply);
}
/// <summary> T old = await Repository.Get(patch.Slug);
/// Delete an item return await Repository.Patch(old.Id, patch.Apply);
/// </summary> }
/// <remarks>
/// Delete one item via it's ID or it's slug.
/// </remarks>
/// <param name="identifier">The ID or slug of the resource to delete.</param>
/// <returns>The item has successfully been deleted.</returns>
/// <response code="404">No item could be found with the given id or slug.</response>
[HttpDelete("{identifier:id}")]
[PartialPermission(Kind.Delete)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Identifier identifier)
{
await identifier.Match(id => Repository.Delete(id), slug => Repository.Delete(slug));
return NoContent();
}
/// <summary> /// <summary>
/// Delete all where /// Patch
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Delete all items matching the given filters. If no filter is specified, delete all items. /// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource.
/// </remarks> /// If not, the slug will be used to identify it.
/// <param name="filter">The list of filters.</param> /// </remarks>
/// <returns>The item(s) has successfully been deleted.</returns> /// <param name="identifier">The id or slug of the resource.</param>
/// <response code="400">One or multiple filters are invalid.</response> /// <param name="patch">The resource to patch.</param>
[HttpDelete] /// <returns>The edited resource.</returns>
[PartialPermission(Kind.Delete)] /// <response code="400">The resource in the request body is invalid.</response>
[ProducesResponseType(StatusCodes.Status204NoContent)] /// <response code="404">No item found with the specified ID (or slug).</response>
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [HttpPatch("{identifier:id}")]
public async Task<IActionResult> Delete([FromQuery] Filter<T> filter) [PartialPermission(Kind.Write)]
{ [ProducesResponseType(StatusCodes.Status200OK)]
if (filter == null) [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
return BadRequest( [ProducesResponseType(StatusCodes.Status404NotFound)]
new RequestError("Incule a filter to delete items, all items won't be deleted.") public async Task<ActionResult<T>> Patch(Identifier identifier, [FromBody] Patch<T> patch)
); {
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await Repository.Get(slug)).Id
);
if (patch.Id.HasValue && patch.Id.Value != id)
throw new ArgumentException("Can not edit id of a resource.");
return await Repository.Patch(id, patch.Apply);
}
await Repository.DeleteAll(filter); /// <summary>
return NoContent(); /// Delete an item
} /// </summary>
/// <remarks>
/// Delete one item via it's ID or it's slug.
/// </remarks>
/// <param name="identifier">The ID or slug of the resource to delete.</param>
/// <returns>The item has successfully been deleted.</returns>
/// <response code="404">No item could be found with the given id or slug.</response>
[HttpDelete("{identifier:id}")]
[PartialPermission(Kind.Delete)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Identifier identifier)
{
await identifier.Match(id => Repository.Delete(id), slug => Repository.Delete(slug));
return NoContent();
}
/// <summary>
/// Delete all where
/// </summary>
/// <remarks>
/// Delete all items matching the given filters. If no filter is specified, delete all items.
/// </remarks>
/// <param name="filter">The list of filters.</param>
/// <returns>The item(s) has successfully been deleted.</returns>
/// <response code="400">One or multiple filters are invalid.</response>
[HttpDelete]
[PartialPermission(Kind.Delete)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<IActionResult> Delete([FromQuery] Filter<T> filter)
{
if (filter == null)
return BadRequest(
new RequestError("Incule a filter to delete items, all items won't be deleted.")
);
await Repository.DeleteAll(filter);
return NoContent();
} }
} }

View File

@ -26,126 +26,119 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// A base class to handle CRUD operations and services thumbnails for
/// a specific resource type <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type of resource to make CRUD and thumbnails apis for.</typeparam>
[ApiController]
public class CrudThumbsApi<T> : CrudApi<T>
where T : class, IResource, IThumbnails, IQuery
{ {
/// <summary> /// <summary>
/// A base class to handle CRUD operations and services thumbnails for /// The thumbnail manager used to retrieve images paths.
/// a specific resource type <typeparamref name="T"/>.
/// </summary> /// </summary>
/// <typeparam name="T">The type of resource to make CRUD and thumbnails apis for.</typeparam> private readonly IThumbnailsManager _thumbs;
[ApiController]
public class CrudThumbsApi<T> : CrudApi<T> /// <summary>
where T : class, IResource, IThumbnails, IQuery /// Create a new <see cref="CrudThumbsApi{T}"/> that handles crud requests and thumbnails.
/// </summary>
/// <param name="repository">
/// The repository to use as a baking store for the type <typeparamref name="T"/>.
/// </param>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
public CrudThumbsApi(IRepository<T> repository, IThumbnailsManager thumbs)
: base(repository)
{ {
/// <summary> _thumbs = thumbs;
/// The thumbnail manager used to retrieve images paths. }
/// </summary>
private readonly IThumbnailsManager _thumbs;
/// <summary> private async Task<IActionResult> _GetImage(
/// Create a new <see cref="CrudThumbsApi{T}"/> that handles crud requests and thumbnails. Identifier identifier,
/// </summary> string image,
/// <param name="repository"> ImageQuality? quality
/// The repository to use as a baking store for the type <typeparamref name="T"/>. )
/// </param> {
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> T? resource = await identifier.Match(
public CrudThumbsApi(IRepository<T> repository, IThumbnailsManager thumbs) id => Repository.GetOrDefault(id),
: base(repository) slug => Repository.GetOrDefault(slug)
);
if (resource == null)
return NotFound();
string path = _thumbs.GetImagePath(resource, image, quality ?? ImageQuality.High);
if (!System.IO.File.Exists(path))
return NotFound();
if (!identifier.Match(id => false, slug => slug == "random"))
{ {
_thumbs = thumbs; // Allow clients to cache the image for 6 month.
Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}");
} }
else
Response.Headers.Add("Cache-Control", $"public, no-store");
return PhysicalFile(Path.GetFullPath(path), "image/webp", true);
}
private async Task<IActionResult> _GetImage( /// <summary>
Identifier identifier, /// Get Poster
string image, /// </summary>
ImageQuality? quality /// <remarks>
) /// Get the poster for the specified item.
{ /// </remarks>
T? resource = await identifier.Match( /// <param name="identifier">The ID or slug of the resource to get the image for.</param>
id => Repository.GetOrDefault(id), /// <param name="quality">The quality of the image to retrieve.</param>
slug => Repository.GetOrDefault(slug) /// <returns>The image asked.</returns>
); /// <response code="404">
if (resource == null) /// No item exist with the specific identifier or the image does not exists on kyoo.
return NotFound(); /// </response>
string path = _thumbs.GetImagePath(resource, image, quality ?? ImageQuality.High); [HttpGet("{identifier:id}/poster")]
if (!System.IO.File.Exists(path)) [PartialPermission(Kind.Read)]
return NotFound(); [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetPoster(Identifier identifier, [FromQuery] ImageQuality? quality)
{
return _GetImage(identifier, "poster", quality);
}
if (!identifier.Match(id => false, slug => slug == "random")) /// <summary>
{ /// Get Logo
// Allow clients to cache the image for 6 month. /// </summary>
Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}"); /// <remarks>
} /// Get the logo for the specified item.
else /// </remarks>
Response.Headers.Add("Cache-Control", $"public, no-store"); /// <param name="identifier">The ID or slug of the resource to get the image for.</param>
return PhysicalFile(Path.GetFullPath(path), "image/webp", true); /// <param name="quality">The quality of the image to retrieve.</param>
} /// <returns>The image asked.</returns>
/// <response code="404">
/// No item exist with the specific identifier or the image does not exists on kyoo.
/// </response>
[HttpGet("{identifier:id}/logo")]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality)
{
return _GetImage(identifier, "logo", quality);
}
/// <summary> /// <summary>
/// Get Poster /// Get Thumbnail
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Get the poster for the specified item. /// Get the thumbnail for the specified item.
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the resource to get the image for.</param> /// <param name="identifier">The ID or slug of the resource to get the image for.</param>
/// <param name="quality">The quality of the image to retrieve.</param> /// <param name="quality">The quality of the image to retrieve.</param>
/// <returns>The image asked.</returns> /// <returns>The image asked.</returns>
/// <response code="404"> /// <response code="404">
/// No item exist with the specific identifier or the image does not exists on kyoo. /// No item exist with the specific identifier or the image does not exists on kyoo.
/// </response> /// </response>
[HttpGet("{identifier:id}/poster")] [HttpGet("{identifier:id}/thumbnail")]
[PartialPermission(Kind.Read)] [HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)]
[ProducesResponseType(StatusCodes.Status200OK)] public Task<IActionResult> GetBackdrop(Identifier identifier, [FromQuery] ImageQuality? quality)
[ProducesResponseType(StatusCodes.Status404NotFound)] {
public Task<IActionResult> GetPoster( return _GetImage(identifier, "thumbnail", quality);
Identifier identifier,
[FromQuery] ImageQuality? quality
)
{
return _GetImage(identifier, "poster", quality);
}
/// <summary>
/// Get Logo
/// </summary>
/// <remarks>
/// Get the logo for the specified item.
/// </remarks>
/// <param name="identifier">The ID or slug of the resource to get the image for.</param>
/// <param name="quality">The quality of the image to retrieve.</param>
/// <returns>The image asked.</returns>
/// <response code="404">
/// No item exist with the specific identifier or the image does not exists on kyoo.
/// </response>
[HttpGet("{identifier:id}/logo")]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality)
{
return _GetImage(identifier, "logo", quality);
}
/// <summary>
/// Get Thumbnail
/// </summary>
/// <remarks>
/// Get the thumbnail for the specified item.
/// </remarks>
/// <param name="identifier">The ID or slug of the resource to get the image for.</param>
/// <param name="quality">The quality of the image to retrieve.</param>
/// <returns>The image asked.</returns>
/// <response code="404">
/// No item exist with the specific identifier or the image does not exists on kyoo.
/// </response>
[HttpGet("{identifier:id}/thumbnail")]
[HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)]
public Task<IActionResult> GetBackdrop(
Identifier identifier,
[FromQuery] ImageQuality? quality
)
{
return _GetImage(identifier, "thumbnail", quality);
}
} }
} }

View File

@ -28,76 +28,75 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// Information about one or multiple <see cref="Studio"/>.
/// </summary>
[Route("studios")]
[Route("studio", Order = AlternativeRoute)]
[ApiController]
[PartialPermission(nameof(Show))]
[ApiDefinition("Studios", Group = MetadataGroup)]
public class StudioApi : CrudApi<Studio>
{ {
/// <summary> /// <summary>
/// Information about one or multiple <see cref="Studio"/>. /// The library manager used to modify or retrieve information in the data store.
/// </summary> /// </summary>
[Route("studios")] private readonly ILibraryManager _libraryManager;
[Route("studio", Order = AlternativeRoute)]
[ApiController] /// <summary>
[PartialPermission(nameof(Show))] /// Create a new <see cref="StudioApi"/>.
[ApiDefinition("Studios", Group = MetadataGroup)] /// </summary>
public class StudioApi : CrudApi<Studio> /// <param name="libraryManager">
/// The library manager used to modify or retrieve information in the data store.
/// </param>
public StudioApi(ILibraryManager libraryManager)
: base(libraryManager.Studios)
{ {
/// <summary> _libraryManager = libraryManager;
/// The library manager used to modify or retrieve information in the data store. }
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary> /// <summary>
/// Create a new <see cref="StudioApi"/>. /// Get shows
/// </summary> /// </summary>
/// <param name="libraryManager"> /// <remarks>
/// The library manager used to modify or retrieve information in the data store. /// List shows that were made by this specific studio.
/// </param> /// </remarks>
public StudioApi(ILibraryManager libraryManager) /// <param name="identifier">The ID or slug of the <see cref="Studio"/>.</param>
: base(libraryManager.Studios) /// <param name="sortBy">A key to sort shows by.</param>
{ /// <param name="filter">An optional list of filters.</param>
_libraryManager = libraryManager; /// <param name="pagination">The number of shows to return.</param>
} /// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of shows.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No studio with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/shows")]
[HttpGet("{identifier:id}/show", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Show>>> GetShows(
Identifier identifier,
[FromQuery] Sort<Show> sortBy,
[FromQuery] Filter<Show>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Show> fields
)
{
ICollection<Show> resources = await _libraryManager.Shows.GetAll(
Filter.And(filter, identifier.Matcher<Show>(x => x.StudioId, x => x.Studio!.Slug)),
sortBy,
fields,
pagination
);
/// <summary> if (
/// Get shows !resources.Any()
/// </summary> && await _libraryManager.Studios.GetOrDefault(identifier.IsSame<Studio>()) == null
/// <remarks>
/// List shows that were made by this specific studio.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Studio"/>.</param>
/// <param name="sortBy">A key to sort shows by.</param>
/// <param name="filter">An optional list of filters.</param>
/// <param name="pagination">The number of shows to return.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of shows.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No studio with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/shows")]
[HttpGet("{identifier:id}/show", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Show>>> GetShows(
Identifier identifier,
[FromQuery] Sort<Show> sortBy,
[FromQuery] Filter<Show>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Show> fields
) )
{ return NotFound();
ICollection<Show> resources = await _libraryManager.Shows.GetAll( return Page(resources, pagination.Limit);
Filter.And(filter, identifier.Matcher<Show>(x => x.StudioId, x => x.Studio!.Slug)),
sortBy,
fields,
pagination
);
if (
!resources.Any()
&& await _libraryManager.Studios.GetOrDefault(identifier.IsSame<Studio>()) == null
)
return NotFound();
return Page(resources, pagination.Limit);
}
} }
} }

View File

@ -30,234 +30,233 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// Information about one or multiple <see cref="Collection"/>.
/// </summary>
[Route("collections")]
[Route("collection", Order = AlternativeRoute)]
[ApiController]
[PartialPermission(nameof(Collection))]
[ApiDefinition("Collections", Group = ResourcesGroup)]
public class CollectionApi : CrudThumbsApi<Collection>
{ {
/// <summary> private readonly ILibraryManager _libraryManager;
/// Information about one or multiple <see cref="Collection"/>. private readonly CollectionRepository _collections;
/// </summary> private readonly LibraryItemRepository _items;
[Route("collections")]
[Route("collection", Order = AlternativeRoute)] public CollectionApi(
[ApiController] ILibraryManager libraryManager,
[PartialPermission(nameof(Collection))] CollectionRepository collections,
[ApiDefinition("Collections", Group = ResourcesGroup)] LibraryItemRepository items,
public class CollectionApi : CrudThumbsApi<Collection> IThumbnailsManager thumbs
)
: base(libraryManager.Collections, thumbs)
{ {
private readonly ILibraryManager _libraryManager; _libraryManager = libraryManager;
private readonly CollectionRepository _collections; _collections = collections;
private readonly LibraryItemRepository _items; _items = items;
}
public CollectionApi( /// <summary>
ILibraryManager libraryManager, /// Add a movie
CollectionRepository collections, /// </summary>
LibraryItemRepository items, /// <remarks>
IThumbnailsManager thumbs /// Add a movie in the collection.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
/// <param name="movie">The ID or slug of the <see cref="Movie"/> to add.</param>
/// <returns>Nothing if successful.</returns>
/// <response code="404">No collection or movie with the given ID could be found.</response>
/// <response code="409">The specified movie is already in this collection.</response>
[HttpPut("{identifier:id}/movies/{movie:id}")]
[HttpPut("{identifier:id}/movie/{movie:id}", Order = AlternativeRoute)]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> AddMovie(Identifier identifier, Identifier movie)
{
Guid collectionId = await identifier.Match(
async id => (await _libraryManager.Collections.Get(id)).Id,
async slug => (await _libraryManager.Collections.Get(slug)).Id
);
Guid movieId = await movie.Match(
async id => (await _libraryManager.Movies.Get(id)).Id,
async slug => (await _libraryManager.Movies.Get(slug)).Id
);
await _collections.AddMovie(collectionId, movieId);
return NoContent();
}
/// <summary>
/// Add a show
/// </summary>
/// <remarks>
/// Add a show in the collection.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
/// <param name="show">The ID or slug of the <see cref="Show"/> to add.</param>
/// <returns>Nothing if successful.</returns>
/// <response code="404">No collection or show with the given ID could be found.</response>
/// <response code="409">The specified show is already in this collection.</response>
[HttpPut("{identifier:id}/shows/{show:id}")]
[HttpPut("{identifier:id}/show/{show:id}", Order = AlternativeRoute)]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> AddShow(Identifier identifier, Identifier show)
{
Guid collectionId = await identifier.Match(
async id => (await _libraryManager.Collections.Get(id)).Id,
async slug => (await _libraryManager.Collections.Get(slug)).Id
);
Guid showId = await show.Match(
async id => (await _libraryManager.Shows.Get(id)).Id,
async slug => (await _libraryManager.Shows.Get(slug)).Id
);
await _collections.AddShow(collectionId, showId);
return NoContent();
}
/// <summary>
/// Get items in collection
/// </summary>
/// <remarks>
/// Lists the items that are contained in the collection with the given id or slug.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
/// <param name="sortBy">A key to sort items by.</param>
/// <param name="filter">An optional list of filters.</param>
/// <param name="pagination">The number of items to return.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of items.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No collection with the given ID could be found.</response>
[HttpGet("{identifier:id}/items")]
[HttpGet("{identifier:id}/item", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<ILibraryItem>>> GetItems(
Identifier identifier,
[FromQuery] Sort<ILibraryItem> sortBy,
[FromQuery] Filter<ILibraryItem>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<ILibraryItem>? fields
)
{
Guid collectionId = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Collections.Get(slug)).Id
);
ICollection<ILibraryItem> resources = await _items.GetAllOfCollection(
collectionId,
filter,
sortBy == new Sort<ILibraryItem>.Default()
? new Sort<ILibraryItem>.By(nameof(Movie.AirDate))
: sortBy,
fields,
pagination
);
if (
!resources.Any()
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
== null
) )
: base(libraryManager.Collections, thumbs) return NotFound();
{ return Page(resources, pagination.Limit);
_libraryManager = libraryManager; }
_collections = collections;
_items = items;
}
/// <summary> /// <summary>
/// Add a movie /// Get shows in collection
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Add a movie in the collection. /// Lists the shows that are contained in the collection with the given id or slug.
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param> /// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
/// <param name="movie">The ID or slug of the <see cref="Movie"/> to add.</param> /// <param name="sortBy">A key to sort shows by.</param>
/// <returns>Nothing if successful.</returns> /// <param name="filter">An optional list of filters.</param>
/// <response code="404">No collection or movie with the given ID could be found.</response> /// <param name="pagination">The number of shows to return.</param>
/// <response code="409">The specified movie is already in this collection.</response> /// <param name="fields">The additional fields to include in the result.</param>
[HttpPut("{identifier:id}/movies/{movie:id}")] /// <returns>A page of shows.</returns>
[HttpPut("{identifier:id}/movie/{movie:id}", Order = AlternativeRoute)] /// <response code="400">The filters or the sort parameters are invalid.</response>
[PartialPermission(Kind.Write)] /// <response code="404">No collection with the given ID could be found.</response>
[ProducesResponseType(StatusCodes.Status204NoContent)] [HttpGet("{identifier:id}/shows")]
[ProducesResponseType(StatusCodes.Status404NotFound)] [HttpGet("{identifier:id}/show", Order = AlternativeRoute)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [PartialPermission(Kind.Read)]
public async Task<ActionResult> AddMovie(Identifier identifier, Identifier movie) [ProducesResponseType(StatusCodes.Status200OK)]
{ [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
Guid collectionId = await identifier.Match( [ProducesResponseType(StatusCodes.Status404NotFound)]
async id => (await _libraryManager.Collections.Get(id)).Id, public async Task<ActionResult<Page<Show>>> GetShows(
async slug => (await _libraryManager.Collections.Get(slug)).Id Identifier identifier,
); [FromQuery] Sort<Show> sortBy,
Guid movieId = await movie.Match( [FromQuery] Filter<Show>? filter,
async id => (await _libraryManager.Movies.Get(id)).Id, [FromQuery] Pagination pagination,
async slug => (await _libraryManager.Movies.Get(slug)).Id [FromQuery] Include<Show>? fields
); )
await _collections.AddMovie(collectionId, movieId); {
return NoContent(); ICollection<Show> resources = await _libraryManager.Shows.GetAll(
} Filter.And(filter, identifier.IsContainedIn<Show, Collection>(x => x.Collections)),
sortBy == new Sort<Show>.Default() ? new Sort<Show>.By(x => x.AirDate) : sortBy,
fields,
pagination
);
/// <summary> if (
/// Add a show !resources.Any()
/// </summary> && await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
/// <remarks> == null
/// Add a show in the collection.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
/// <param name="show">The ID or slug of the <see cref="Show"/> to add.</param>
/// <returns>Nothing if successful.</returns>
/// <response code="404">No collection or show with the given ID could be found.</response>
/// <response code="409">The specified show is already in this collection.</response>
[HttpPut("{identifier:id}/shows/{show:id}")]
[HttpPut("{identifier:id}/show/{show:id}", Order = AlternativeRoute)]
[PartialPermission(Kind.Write)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult> AddShow(Identifier identifier, Identifier show)
{
Guid collectionId = await identifier.Match(
async id => (await _libraryManager.Collections.Get(id)).Id,
async slug => (await _libraryManager.Collections.Get(slug)).Id
);
Guid showId = await show.Match(
async id => (await _libraryManager.Shows.Get(id)).Id,
async slug => (await _libraryManager.Shows.Get(slug)).Id
);
await _collections.AddShow(collectionId, showId);
return NoContent();
}
/// <summary>
/// Get items in collection
/// </summary>
/// <remarks>
/// Lists the items that are contained in the collection with the given id or slug.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
/// <param name="sortBy">A key to sort items by.</param>
/// <param name="filter">An optional list of filters.</param>
/// <param name="pagination">The number of items to return.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of items.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No collection with the given ID could be found.</response>
[HttpGet("{identifier:id}/items")]
[HttpGet("{identifier:id}/item", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<ILibraryItem>>> GetItems(
Identifier identifier,
[FromQuery] Sort<ILibraryItem> sortBy,
[FromQuery] Filter<ILibraryItem>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<ILibraryItem>? fields
) )
{ return NotFound();
Guid collectionId = await identifier.Match( return Page(resources, pagination.Limit);
id => Task.FromResult(id), }
async slug => (await _libraryManager.Collections.Get(slug)).Id
);
ICollection<ILibraryItem> resources = await _items.GetAllOfCollection(
collectionId,
filter,
sortBy == new Sort<ILibraryItem>.Default()
? new Sort<ILibraryItem>.By(nameof(Movie.AirDate))
: sortBy,
fields,
pagination
);
if ( /// <summary>
!resources.Any() /// Get movies in collection
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>()) /// </summary>
== null /// <remarks>
) /// Lists the movies that are contained in the collection with the given id or slug.
return NotFound(); /// </remarks>
return Page(resources, pagination.Limit); /// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
} /// <param name="sortBy">A key to sort movies by.</param>
/// <param name="filter">An optional list of filters.</param>
/// <param name="pagination">The number of movies to return.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of movies.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No collection with the given ID could be found.</response>
[HttpGet("{identifier:id}/movies")]
[HttpGet("{identifier:id}/movie", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Movie>>> GetMovies(
Identifier identifier,
[FromQuery] Sort<Movie> sortBy,
[FromQuery] Filter<Movie>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Movie>? fields
)
{
ICollection<Movie> resources = await _libraryManager.Movies.GetAll(
Filter.And(filter, identifier.IsContainedIn<Movie, Collection>(x => x.Collections)),
sortBy == new Sort<Movie>.Default() ? new Sort<Movie>.By(x => x.AirDate) : sortBy,
fields,
pagination
);
/// <summary> if (
/// Get shows in collection !resources.Any()
/// </summary> && await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
/// <remarks> == null
/// Lists the shows that are contained in the collection with the given id or slug.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
/// <param name="sortBy">A key to sort shows by.</param>
/// <param name="filter">An optional list of filters.</param>
/// <param name="pagination">The number of shows to return.</param>
/// <param name="fields">The additional fields to include in the result.</param>
/// <returns>A page of shows.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No collection with the given ID could be found.</response>
[HttpGet("{identifier:id}/shows")]
[HttpGet("{identifier:id}/show", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Show>>> GetShows(
Identifier identifier,
[FromQuery] Sort<Show> sortBy,
[FromQuery] Filter<Show>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Show>? fields
) )
{ return NotFound();
ICollection<Show> resources = await _libraryManager.Shows.GetAll( return Page(resources, pagination.Limit);
Filter.And(filter, identifier.IsContainedIn<Show, Collection>(x => x.Collections)),
sortBy == new Sort<Show>.Default() ? new Sort<Show>.By(x => x.AirDate) : sortBy,
fields,
pagination
);
if (
!resources.Any()
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
== null
)
return NotFound();
return Page(resources, pagination.Limit);
}
/// <summary>
/// Get movies in collection
/// </summary>
/// <remarks>
/// Lists the movies that are contained in the collection with the given id or slug.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
/// <param name="sortBy">A key to sort movies by.</param>
/// <param name="filter">An optional list of filters.</param>
/// <param name="pagination">The number of movies to return.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of movies.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No collection with the given ID could be found.</response>
[HttpGet("{identifier:id}/movies")]
[HttpGet("{identifier:id}/movie", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Movie>>> GetMovies(
Identifier identifier,
[FromQuery] Sort<Movie> sortBy,
[FromQuery] Filter<Movie>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Movie>? fields
)
{
ICollection<Movie> resources = await _libraryManager.Movies.GetAll(
Filter.And(filter, identifier.IsContainedIn<Movie, Collection>(x => x.Collections)),
sortBy == new Sort<Movie>.Default() ? new Sort<Movie>.By(x => x.AirDate) : sortBy,
fields,
pagination
);
if (
!resources.Any()
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
== null
)
return NotFound();
return Page(resources, pagination.Limit);
}
} }
} }

View File

@ -28,172 +28,171 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// Information about one or multiple <see cref="Episode"/>.
/// </summary>
[Route("episodes")]
[Route("episode", Order = AlternativeRoute)]
[ApiController]
[PartialPermission(nameof(Episode))]
[ApiDefinition("Episodes", Group = ResourcesGroup)]
public class EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails)
: TranscoderApi<Episode>(libraryManager.Episodes, thumbnails)
{ {
/// <summary> /// <summary>
/// Information about one or multiple <see cref="Episode"/>. /// Get episode's show
/// </summary> /// </summary>
[Route("episodes")] /// <remarks>
[Route("episode", Order = AlternativeRoute)] /// Get the show that this episode is part of.
[ApiController] /// </remarks>
[PartialPermission(nameof(Episode))] /// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
[ApiDefinition("Episodes", Group = ResourcesGroup)] /// <param name="fields">The aditional fields to include in the result.</param>
public class EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails) /// <returns>The show that contains this episode.</returns>
: TranscoderApi<Episode>(libraryManager.Episodes, thumbnails) /// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/show")]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Show>> GetShow(
Identifier identifier,
[FromQuery] Include<Show> fields
)
{ {
/// <summary> return await libraryManager.Shows.Get(
/// Get episode's show identifier.IsContainedIn<Show, Episode>(x => x.Episodes!),
/// </summary> fields
/// <remarks> );
/// Get the show that this episode is part of. }
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>The show that contains this episode.</returns>
/// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/show")]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Show>> GetShow(
Identifier identifier,
[FromQuery] Include<Show> fields
)
{
return await libraryManager.Shows.Get(
identifier.IsContainedIn<Show, Episode>(x => x.Episodes!),
fields
);
}
/// <summary> /// <summary>
/// Get episode's season /// Get episode's season
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Get the season that this episode is part of. /// Get the season that this episode is part of.
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> /// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
/// <param name="fields">The aditional fields to include in the result.</param> /// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>The season that contains this episode.</returns> /// <returns>The season that contains this episode.</returns>
/// <response code="204">The episode is not part of a season.</response> /// <response code="204">The episode is not part of a season.</response>
/// <response code="404">No episode with the given ID or slug could be found.</response> /// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/season")] [HttpGet("{identifier:id}/season")]
[PartialPermission(Kind.Read)] [PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Season>> GetSeason( public async Task<ActionResult<Season>> GetSeason(
Identifier identifier, Identifier identifier,
[FromQuery] Include<Season> fields [FromQuery] Include<Season> fields
) )
{ {
Season? ret = await libraryManager.Seasons.GetOrDefault( Season? ret = await libraryManager.Seasons.GetOrDefault(
identifier.IsContainedIn<Season, Episode>(x => x.Episodes!), identifier.IsContainedIn<Season, Episode>(x => x.Episodes!),
fields fields
); );
if (ret != null) if (ret != null)
return ret; return ret;
Episode? episode = await identifier.Match( Episode? episode = await identifier.Match(
id => libraryManager.Episodes.GetOrDefault(id), id => libraryManager.Episodes.GetOrDefault(id),
slug => libraryManager.Episodes.GetOrDefault(slug) slug => libraryManager.Episodes.GetOrDefault(slug)
); );
return episode == null ? NotFound() : NoContent(); return episode == null ? NotFound() : NoContent();
} }
/// <summary> /// <summary>
/// Get watch status /// Get watch status
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Get when an item has been wathed and if it was watched. /// Get when an item has been wathed and if it was watched.
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> /// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
/// <returns>The status.</returns> /// <returns>The status.</returns>
/// <response code="204">This episode does not have a specific status.</response> /// <response code="204">This episode does not have a specific status.</response>
/// <response code="404">No episode with the given ID or slug could be found.</response> /// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/watchStatus")] [HttpGet("{identifier:id}/watchStatus")]
[UserOnly] [UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<EpisodeWatchStatus?> GetWatchStatus(Identifier identifier) public async Task<EpisodeWatchStatus?> GetWatchStatus(Identifier identifier)
{ {
Guid id = await identifier.Match( Guid id = await identifier.Match(
id => Task.FromResult(id), id => Task.FromResult(id),
async slug => (await libraryManager.Episodes.Get(slug)).Id async slug => (await libraryManager.Episodes.Get(slug)).Id
); );
return await libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow()); return await libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow());
} }
/// <summary> /// <summary>
/// Set watch status /// Set watch status
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Set when an item has been wathed and if it was watched. /// Set when an item has been wathed and if it was watched.
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> /// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
/// <param name="status">The new watch status.</param> /// <param name="status">The new watch status.</param>
/// <param name="watchedTime">Where the user stopped watching (in seconds).</param> /// <param name="watchedTime">Where the user stopped watching (in seconds).</param>
/// <param name="percent">Where the user stopped watching (in percent).</param> /// <param name="percent">Where the user stopped watching (in percent).</param>
/// <returns>The newly set status.</returns> /// <returns>The newly set status.</returns>
/// <response code="200">The status has been set</response> /// <response code="200">The status has been set</response>
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response> /// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
/// <response code="404">No episode with the given ID or slug could be found.</response> /// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpPost("{identifier:id}/watchStatus")] [HttpPost("{identifier:id}/watchStatus")]
[UserOnly] [UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<EpisodeWatchStatus?> SetWatchStatus( public async Task<EpisodeWatchStatus?> SetWatchStatus(
Identifier identifier, Identifier identifier,
WatchStatus status, WatchStatus status,
int? watchedTime, int? watchedTime,
int? percent int? percent
) )
{ {
Guid id = await identifier.Match( Guid id = await identifier.Match(
id => Task.FromResult(id), id => Task.FromResult(id),
async slug => (await libraryManager.Episodes.Get(slug)).Id async slug => (await libraryManager.Episodes.Get(slug)).Id
); );
return await libraryManager.WatchStatus.SetEpisodeStatus( return await libraryManager.WatchStatus.SetEpisodeStatus(
id, id,
User.GetIdOrThrow(), User.GetIdOrThrow(),
status, status,
watchedTime, watchedTime,
percent percent
); );
} }
/// <summary> /// <summary>
/// Delete watch status /// Delete watch status
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Delete watch status (to rewatch for example). /// Delete watch status (to rewatch for example).
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param> /// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
/// <returns>The newly set status.</returns> /// <returns>The newly set status.</returns>
/// <response code="204">The status has been deleted.</response> /// <response code="204">The status has been deleted.</response>
/// <response code="404">No episode with the given ID or slug could be found.</response> /// <response code="404">No episode with the given ID or slug could be found.</response>
[HttpDelete("{identifier:id}/watchStatus")] [HttpDelete("{identifier:id}/watchStatus")]
[UserOnly] [UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task DeleteWatchStatus(Identifier identifier) public async Task DeleteWatchStatus(Identifier identifier)
{ {
Guid id = await identifier.Match( Guid id = await identifier.Match(
id => Task.FromResult(id), id => Task.FromResult(id),
async slug => (await libraryManager.Episodes.Get(slug)).Id async slug => (await libraryManager.Episodes.Get(slug)).Id
); );
await libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow()); await libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow());
} }
protected override async Task<(string path, string route)> GetPath(Identifier identifier) protected override async Task<(string path, string route)> GetPath(Identifier identifier)
{ {
string path = await identifier.Match( string path = await identifier.Match(
async id => (await Repository.Get(id)).Path, async id => (await Repository.Get(id)).Path,
async slug => (await Repository.Get(slug)).Path async slug => (await Repository.Get(slug)).Path
); );
return (path, $"/episodes/{identifier}"); return (path, $"/episodes/{identifier}");
}
} }
} }

View File

@ -23,35 +23,34 @@ using Kyoo.Abstractions.Models.Permissions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// Endpoint for items that are not part of a specific library.
/// An item can ether represent a collection or a show.
/// </summary>
[Route("items")]
[Route("item", Order = AlternativeRoute)]
[ApiController]
[PartialPermission("LibraryItem")]
[ApiDefinition("Items", Group = ResourcesGroup)]
public class LibraryItemApi : CrudThumbsApi<ILibraryItem>
{ {
/// <summary> /// <summary>
/// Endpoint for items that are not part of a specific library. /// The library item repository used to modify or retrieve information in the data store.
/// An item can ether represent a collection or a show.
/// </summary> /// </summary>
[Route("items")] private readonly IRepository<ILibraryItem> _libraryItems;
[Route("item", Order = AlternativeRoute)]
[ApiController]
[PartialPermission("LibraryItem")]
[ApiDefinition("Items", Group = ResourcesGroup)]
public class LibraryItemApi : CrudThumbsApi<ILibraryItem>
{
/// <summary>
/// The library item repository used to modify or retrieve information in the data store.
/// </summary>
private readonly IRepository<ILibraryItem> _libraryItems;
/// <summary> /// <summary>
/// Create a new <see cref="LibraryItemApi"/>. /// Create a new <see cref="LibraryItemApi"/>.
/// </summary> /// </summary>
/// <param name="libraryItems"> /// <param name="libraryItems">
/// The library item repository used to modify or retrieve information in the data store. /// The library item repository used to modify or retrieve information in the data store.
/// </param> /// </param>
/// <param name="thumbs">Thumbnail manager to retrieve images.</param> /// <param name="thumbs">Thumbnail manager to retrieve images.</param>
public LibraryItemApi(IRepository<ILibraryItem> libraryItems, IThumbnailsManager thumbs) public LibraryItemApi(IRepository<ILibraryItem> libraryItems, IThumbnailsManager thumbs)
: base(libraryItems, thumbs) : base(libraryItems, thumbs)
{ {
_libraryItems = libraryItems; _libraryItems = libraryItems;
}
} }
} }

View File

@ -30,182 +30,181 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// Information about one or multiple <see cref="Movie"/>.
/// </summary>
[Route("movies")]
[Route("movie", Order = AlternativeRoute)]
[ApiController]
[PartialPermission(nameof(Show))]
[ApiDefinition("Shows", Group = ResourcesGroup)]
public class MovieApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
: TranscoderApi<Movie>(libraryManager.Movies, thumbs)
{ {
/// <summary> /// <summary>
/// Information about one or multiple <see cref="Movie"/>. /// Get studio that made the show
/// </summary> /// </summary>
[Route("movies")] /// <remarks>
[Route("movie", Order = AlternativeRoute)] /// Get the studio that made the show.
[ApiController] /// </remarks>
[PartialPermission(nameof(Show))] /// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
[ApiDefinition("Shows", Group = ResourcesGroup)] /// <param name="fields">The aditional fields to include in the result.</param>
public class MovieApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) /// <returns>The studio that made the show.</returns>
: TranscoderApi<Movie>(libraryManager.Movies, thumbs) /// <response code="404">No show with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/studio")]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Studio>> GetStudio(
Identifier identifier,
[FromQuery] Include<Studio> fields
)
{ {
/// <summary> return await libraryManager.Studios.Get(
/// Get studio that made the show identifier.IsContainedIn<Studio, Movie>(x => x.Movies!),
/// </summary> fields
/// <remarks> );
/// Get the studio that made the show. }
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> /// <summary>
/// <param name="fields">The aditional fields to include in the result.</param> /// Get collections containing this show
/// <returns>The studio that made the show.</returns> /// </summary>
/// <response code="404">No show with the given ID or slug could be found.</response> /// <remarks>
[HttpGet("{identifier:id}/studio")] /// List the collections that contain this show.
[PartialPermission(Kind.Read)] /// </remarks>
[ProducesResponseType(StatusCodes.Status200OK)] /// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
[ProducesResponseType(StatusCodes.Status404NotFound)] /// <param name="sortBy">A key to sort collections by.</param>
public async Task<ActionResult<Studio>> GetStudio( /// <param name="filter">An optional list of filters.</param>
Identifier identifier, /// <param name="pagination">The number of collections to return.</param>
[FromQuery] Include<Studio> fields /// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of collections.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No show with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/collections")]
[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Collection>>> GetCollections(
Identifier identifier,
[FromQuery] Sort<Collection> sortBy,
[FromQuery] Filter<Collection>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Collection> fields
)
{
ICollection<Collection> resources = await libraryManager.Collections.GetAll(
Filter.And(filter, identifier.IsContainedIn<Collection, Movie>(x => x.Movies)),
sortBy,
fields,
pagination
);
if (
!resources.Any()
&& await libraryManager.Movies.GetOrDefault(identifier.IsSame<Movie>()) == null
) )
{ return NotFound();
return await libraryManager.Studios.Get( return Page(resources, pagination.Limit);
identifier.IsContainedIn<Studio, Movie>(x => x.Movies!), }
fields
);
}
/// <summary> /// <summary>
/// Get collections containing this show /// Get watch status
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// List the collections that contain this show. /// Get when an item has been wathed and if it was watched.
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> /// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
/// <param name="sortBy">A key to sort collections by.</param> /// <returns>The status.</returns>
/// <param name="filter">An optional list of filters.</param> /// <response code="204">This movie does not have a specific status.</response>
/// <param name="pagination">The number of collections to return.</param> /// <response code="404">No movie with the given ID or slug could be found.</response>
/// <param name="fields">The aditional fields to include in the result.</param> [HttpGet("{identifier:id}/watchStatus")]
/// <returns>A page of collections.</returns> [UserOnly]
/// <response code="400">The filters or the sort parameters are invalid.</response> [ProducesResponseType(StatusCodes.Status200OK)]
/// <response code="404">No show with the given ID or slug could be found.</response> [ProducesResponseType(StatusCodes.Status204NoContent)]
[HttpGet("{identifier:id}/collections")] [ProducesResponseType(StatusCodes.Status404NotFound)]
[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)] public async Task<MovieWatchStatus?> GetWatchStatus(Identifier identifier)
[PartialPermission(Kind.Read)] {
[ProducesResponseType(StatusCodes.Status200OK)] Guid id = await identifier.Match(
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] id => Task.FromResult(id),
[ProducesResponseType(StatusCodes.Status404NotFound)] async slug => (await libraryManager.Movies.Get(slug)).Id
public async Task<ActionResult<Page<Collection>>> GetCollections( );
Identifier identifier, return await libraryManager.WatchStatus.GetMovieStatus(id, User.GetIdOrThrow());
[FromQuery] Sort<Collection> sortBy, }
[FromQuery] Filter<Collection>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Collection> fields
)
{
ICollection<Collection> resources = await libraryManager.Collections.GetAll(
Filter.And(filter, identifier.IsContainedIn<Collection, Movie>(x => x.Movies)),
sortBy,
fields,
pagination
);
if ( /// <summary>
!resources.Any() /// Set watch status
&& await libraryManager.Movies.GetOrDefault(identifier.IsSame<Movie>()) == null /// </summary>
) /// <remarks>
return NotFound(); /// Set when an item has been wathed and if it was watched.
return Page(resources, pagination.Limit); /// </remarks>
} /// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
/// <param name="status">The new watch status.</param>
/// <param name="watchedTime">Where the user stopped watching.</param>
/// <param name="percent">Where the user stopped watching (in percent).</param>
/// <returns>The newly set status.</returns>
/// <response code="200">The status has been set</response>
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
/// <response code="400">WatchedTime can't be specified if status is not watching.</response>
/// <response code="404">No movie with the given ID or slug could be found.</response>
[HttpPost("{identifier:id}/watchStatus")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<MovieWatchStatus?> SetWatchStatus(
Identifier identifier,
WatchStatus status,
int? watchedTime,
int? percent
)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await libraryManager.Movies.Get(slug)).Id
);
return await libraryManager.WatchStatus.SetMovieStatus(
id,
User.GetIdOrThrow(),
status,
watchedTime,
percent
);
}
/// <summary> /// <summary>
/// Get watch status /// Delete watch status
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Get when an item has been wathed and if it was watched. /// Delete watch status (to rewatch for example).
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> /// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
/// <returns>The status.</returns> /// <returns>The newly set status.</returns>
/// <response code="204">This movie does not have a specific status.</response> /// <response code="204">The status has been deleted.</response>
/// <response code="404">No movie with the given ID or slug could be found.</response> /// <response code="404">No movie with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/watchStatus")] [HttpDelete("{identifier:id}/watchStatus")]
[UserOnly] [UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status404NotFound)] public async Task DeleteWatchStatus(Identifier identifier)
public async Task<MovieWatchStatus?> GetWatchStatus(Identifier identifier) {
{ Guid id = await identifier.Match(
Guid id = await identifier.Match( id => Task.FromResult(id),
id => Task.FromResult(id), async slug => (await libraryManager.Movies.Get(slug)).Id
async slug => (await libraryManager.Movies.Get(slug)).Id );
); await libraryManager.WatchStatus.DeleteMovieStatus(id, User.GetIdOrThrow());
return await libraryManager.WatchStatus.GetMovieStatus(id, User.GetIdOrThrow()); }
}
/// <summary> protected override async Task<(string path, string route)> GetPath(Identifier identifier)
/// Set watch status {
/// </summary> string path = await identifier.Match(
/// <remarks> async id => (await Repository.Get(id)).Path,
/// Set when an item has been wathed and if it was watched. async slug => (await Repository.Get(slug)).Path
/// </remarks> );
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param> return (path, $"/movies/{identifier}");
/// <param name="status">The new watch status.</param>
/// <param name="watchedTime">Where the user stopped watching.</param>
/// <param name="percent">Where the user stopped watching (in percent).</param>
/// <returns>The newly set status.</returns>
/// <response code="200">The status has been set</response>
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
/// <response code="400">WatchedTime can't be specified if status is not watching.</response>
/// <response code="404">No movie with the given ID or slug could be found.</response>
[HttpPost("{identifier:id}/watchStatus")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<MovieWatchStatus?> SetWatchStatus(
Identifier identifier,
WatchStatus status,
int? watchedTime,
int? percent
)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await libraryManager.Movies.Get(slug)).Id
);
return await libraryManager.WatchStatus.SetMovieStatus(
id,
User.GetIdOrThrow(),
status,
watchedTime,
percent
);
}
/// <summary>
/// Delete watch status
/// </summary>
/// <remarks>
/// Delete watch status (to rewatch for example).
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
/// <returns>The newly set status.</returns>
/// <response code="204">The status has been deleted.</response>
/// <response code="404">No movie with the given ID or slug could be found.</response>
[HttpDelete("{identifier:id}/watchStatus")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task DeleteWatchStatus(Identifier identifier)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await libraryManager.Movies.Get(slug)).Id
);
await libraryManager.WatchStatus.DeleteMovieStatus(id, User.GetIdOrThrow());
}
protected override async Task<(string path, string route)> GetPath(Identifier identifier)
{
string path = await identifier.Match(
async id => (await Repository.Get(id)).Path,
async slug => (await Repository.Get(slug)).Path
);
return (path, $"/movies/{identifier}");
}
} }
} }

View File

@ -23,19 +23,18 @@ using Kyoo.Abstractions.Models.Permissions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// List new items added to kyoo.
/// </summary>
[Route("news")]
[Route("new", Order = AlternativeRoute)]
[ApiController]
[PartialPermission("LibraryItem")]
[ApiDefinition("News", Group = ResourcesGroup)]
public class NewsApi : CrudThumbsApi<INews>
{ {
/// <summary> public NewsApi(IRepository<INews> news, IThumbnailsManager thumbs)
/// List new items added to kyoo. : base(news, thumbs) { }
/// </summary>
[Route("news")]
[Route("new", Order = AlternativeRoute)]
[ApiController]
[PartialPermission("LibraryItem")]
[ApiDefinition("News", Group = ResourcesGroup)]
public class NewsApi : CrudThumbsApi<INews>
{
public NewsApi(IRepository<INews> news, IThumbnailsManager thumbs)
: base(news, thumbs) { }
}
} }

View File

@ -26,182 +26,179 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// An endpoint to search for every resources of kyoo. Searching for only a specific type of resource
/// is available on the said endpoint.
/// </summary>
[Route("search")]
[ApiController]
[ApiDefinition("Search", Group = ResourcesGroup)]
public class SearchApi : BaseApi
{ {
/// <summary> private readonly ISearchManager _searchManager;
/// An endpoint to search for every resources of kyoo. Searching for only a specific type of resource
/// is available on the said endpoint. public SearchApi(ISearchManager searchManager)
/// </summary>
[Route("search")]
[ApiController]
[ApiDefinition("Search", Group = ResourcesGroup)]
public class SearchApi : BaseApi
{ {
private readonly ISearchManager _searchManager; _searchManager = searchManager;
}
public SearchApi(ISearchManager searchManager) // TODO: add filters and facets
{
_searchManager = searchManager;
}
// TODO: add filters and facets /// <summary>
/// Search collections
/// </summary>
/// <remarks>
/// Search for collections
/// </remarks>
/// <param name="q">The query to search for.</param>
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A list of collections found for the specified query.</returns>
[HttpGet("collections")]
[HttpGet("collection", Order = AlternativeRoute)]
[Permission(nameof(Collection), Kind.Read)]
[ApiDefinition("Collections")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<SearchPage<Collection>> SearchCollections(
[FromQuery] string? q,
[FromQuery] Sort<Collection> sortBy,
[FromQuery] SearchPagination pagination,
[FromQuery] Include<Collection> fields
)
{
return SearchPage(await _searchManager.SearchCollections(q, sortBy, pagination, fields));
}
/// <summary> /// <summary>
/// Search collections /// Search shows
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Search for collections /// Search for shows
/// </remarks> /// </remarks>
/// <param name="q">The query to search for.</param> /// <param name="q">The query to search for.</param>
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> /// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
/// <param name="pagination">How many items per page should be returned, where should the page start...</param> /// <param name="pagination">How many items per page should be returned, where should the page start...</param>
/// <param name="fields">The aditional fields to include in the result.</param> /// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A list of collections found for the specified query.</returns> /// <returns>A list of shows found for the specified query.</returns>
[HttpGet("collections")] [HttpGet("shows")]
[HttpGet("collection", Order = AlternativeRoute)] [HttpGet("show", Order = AlternativeRoute)]
[Permission(nameof(Collection), Kind.Read)] [Permission(nameof(Show), Kind.Read)]
[ApiDefinition("Collections")] [ApiDefinition("Show")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<SearchPage<Collection>> SearchCollections( public async Task<SearchPage<Show>> SearchShows(
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<Collection> sortBy, [FromQuery] Sort<Show> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<Collection> fields [FromQuery] Include<Show> fields
) )
{ {
return SearchPage( return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields));
await _searchManager.SearchCollections(q, sortBy, pagination, fields) }
);
}
/// <summary> /// <summary>
/// Search shows /// Search movie
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Search for shows /// Search for movie
/// </remarks> /// </remarks>
/// <param name="q">The query to search for.</param> /// <param name="q">The query to search for.</param>
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> /// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
/// <param name="pagination">How many items per page should be returned, where should the page start...</param> /// <param name="pagination">How many items per page should be returned, where should the page start...</param>
/// <param name="fields">The aditional fields to include in the result.</param> /// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A list of shows found for the specified query.</returns> /// <returns>A list of movies found for the specified query.</returns>
[HttpGet("shows")] [HttpGet("movies")]
[HttpGet("show", Order = AlternativeRoute)] [HttpGet("movie", Order = AlternativeRoute)]
[Permission(nameof(Show), Kind.Read)] [Permission(nameof(Movie), Kind.Read)]
[ApiDefinition("Show")] [ApiDefinition("Movie")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<SearchPage<Show>> SearchShows( public async Task<SearchPage<Movie>> SearchMovies(
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<Show> sortBy, [FromQuery] Sort<Movie> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<Show> fields [FromQuery] Include<Movie> fields
) )
{ {
return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields)); return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields));
} }
/// <summary> /// <summary>
/// Search movie /// Search items
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Search for movie /// Search for items
/// </remarks> /// </remarks>
/// <param name="q">The query to search for.</param> /// <param name="q">The query to search for.</param>
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> /// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
/// <param name="pagination">How many items per page should be returned, where should the page start...</param> /// <param name="pagination">How many items per page should be returned, where should the page start...</param>
/// <param name="fields">The aditional fields to include in the result.</param> /// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A list of movies found for the specified query.</returns> /// <returns>A list of items found for the specified query.</returns>
[HttpGet("movies")] [HttpGet("items")]
[HttpGet("movie", Order = AlternativeRoute)] [HttpGet("item", Order = AlternativeRoute)]
[Permission(nameof(Movie), Kind.Read)] [Permission(nameof(ILibraryItem), Kind.Read)]
[ApiDefinition("Movie")] [ApiDefinition("Item")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<SearchPage<Movie>> SearchMovies( public async Task<SearchPage<ILibraryItem>> SearchItems(
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<Movie> sortBy, [FromQuery] Sort<ILibraryItem> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<Movie> fields [FromQuery] Include<ILibraryItem> fields
) )
{ {
return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields)); return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields));
} }
/// <summary> /// <summary>
/// Search items /// Search episodes
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Search for items /// Search for episodes
/// </remarks> /// </remarks>
/// <param name="q">The query to search for.</param> /// <param name="q">The query to search for.</param>
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> /// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
/// <param name="pagination">How many items per page should be returned, where should the page start...</param> /// <param name="pagination">How many items per page should be returned, where should the page start...</param>
/// <param name="fields">The aditional fields to include in the result.</param> /// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A list of items found for the specified query.</returns> /// <returns>A list of episodes found for the specified query.</returns>
[HttpGet("items")] [HttpGet("episodes")]
[HttpGet("item", Order = AlternativeRoute)] [HttpGet("episode", Order = AlternativeRoute)]
[Permission(nameof(ILibraryItem), Kind.Read)] [Permission(nameof(Episode), Kind.Read)]
[ApiDefinition("Item")] [ApiDefinition("Episodes")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<SearchPage<ILibraryItem>> SearchItems( public async Task<SearchPage<Episode>> SearchEpisodes(
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<ILibraryItem> sortBy, [FromQuery] Sort<Episode> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<ILibraryItem> fields [FromQuery] Include<Episode> fields
) )
{ {
return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields)); return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields));
} }
/// <summary> /// <summary>
/// Search episodes /// Search studios
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Search for episodes /// Search for studios
/// </remarks> /// </remarks>
/// <param name="q">The query to search for.</param> /// <param name="q">The query to search for.</param>
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param> /// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
/// <param name="pagination">How many items per page should be returned, where should the page start...</param> /// <param name="pagination">How many items per page should be returned, where should the page start...</param>
/// <param name="fields">The aditional fields to include in the result.</param> /// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A list of episodes found for the specified query.</returns> /// <returns>A list of studios found for the specified query.</returns>
[HttpGet("episodes")] [HttpGet("studios")]
[HttpGet("episode", Order = AlternativeRoute)] [HttpGet("studio", Order = AlternativeRoute)]
[Permission(nameof(Episode), Kind.Read)] [Permission(nameof(Studio), Kind.Read)]
[ApiDefinition("Episodes")] [ApiDefinition("Studios")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<SearchPage<Episode>> SearchEpisodes( public async Task<SearchPage<Studio>> SearchStudios(
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<Episode> sortBy, [FromQuery] Sort<Studio> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<Episode> fields [FromQuery] Include<Studio> fields
) )
{ {
return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields)); return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields));
}
/// <summary>
/// Search studios
/// </summary>
/// <remarks>
/// Search for studios
/// </remarks>
/// <param name="q">The query to search for.</param>
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A list of studios found for the specified query.</returns>
[HttpGet("studios")]
[HttpGet("studio", Order = AlternativeRoute)]
[Permission(nameof(Studio), Kind.Read)]
[ApiDefinition("Studios")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<SearchPage<Studio>> SearchStudios(
[FromQuery] string? q,
[FromQuery] Sort<Studio> sortBy,
[FromQuery] SearchPagination pagination,
[FromQuery] Include<Studio> fields
)
{
return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields));
}
} }
} }

View File

@ -28,108 +28,104 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// Information about one or multiple <see cref="Season"/>.
/// </summary>
[Route("seasons")]
[Route("season", Order = AlternativeRoute)]
[ApiController]
[PartialPermission(nameof(Season))]
[ApiDefinition("Seasons", Group = ResourcesGroup)]
public class SeasonApi : CrudThumbsApi<Season>
{ {
/// <summary> /// <summary>
/// Information about one or multiple <see cref="Season"/>. /// The library manager used to modify or retrieve information in the data store.
/// </summary> /// </summary>
[Route("seasons")] private readonly ILibraryManager _libraryManager;
[Route("season", Order = AlternativeRoute)]
[ApiController] /// <summary>
[PartialPermission(nameof(Season))] /// Create a new <see cref="SeasonApi"/>.
[ApiDefinition("Seasons", Group = ResourcesGroup)] /// </summary>
public class SeasonApi : CrudThumbsApi<Season> /// <param name="libraryManager">
/// The library manager used to modify or retrieve information in the data store.
/// </param>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
public SeasonApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
: base(libraryManager.Seasons, thumbs)
{ {
/// <summary> _libraryManager = libraryManager;
/// The library manager used to modify or retrieve information in the data store. }
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary> /// <summary>
/// Create a new <see cref="SeasonApi"/>. /// Get episodes in the season
/// </summary> /// </summary>
/// <param name="libraryManager"> /// <remarks>
/// The library manager used to modify or retrieve information in the data store. /// List the episodes that are part of the specified season.
/// </param> /// </remarks>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> /// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param>
public SeasonApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) /// <param name="sortBy">A key to sort episodes by.</param>
: base(libraryManager.Seasons, thumbs) /// <param name="filter">An optional list of filters.</param>
{ /// <param name="pagination">The number of episodes to return.</param>
_libraryManager = libraryManager; /// <param name="fields">The aditional fields to include in the result.</param>
} /// <returns>A page of episodes.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No season with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/episodes")]
[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Episode>>> GetEpisode(
Identifier identifier,
[FromQuery] Sort<Episode> sortBy,
[FromQuery] Filter<Episode>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Episode> fields
)
{
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll(
Filter.And(filter, identifier.Matcher<Episode>(x => x.SeasonId, x => x.Season!.Slug)),
sortBy,
fields,
pagination
);
/// <summary> if (
/// Get episodes in the season !resources.Any()
/// </summary> && await _libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null
/// <remarks>
/// List the episodes that are part of the specified season.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param>
/// <param name="sortBy">A key to sort episodes by.</param>
/// <param name="filter">An optional list of filters.</param>
/// <param name="pagination">The number of episodes to return.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of episodes.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No season with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/episodes")]
[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Episode>>> GetEpisode(
Identifier identifier,
[FromQuery] Sort<Episode> sortBy,
[FromQuery] Filter<Episode>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Episode> fields
) )
{ return NotFound();
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll( return Page(resources, pagination.Limit);
Filter.And( }
filter,
identifier.Matcher<Episode>(x => x.SeasonId, x => x.Season!.Slug)
),
sortBy,
fields,
pagination
);
if ( /// <summary>
!resources.Any() /// Get season's show
&& await _libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null /// </summary>
) /// <remarks>
return NotFound(); /// Get the show that this season is part of.
return Page(resources, pagination.Limit); /// </remarks>
} /// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <summary> /// <returns>The show that contains this season.</returns>
/// Get season's show /// <response code="404">No season with the given ID or slug could be found.</response>
/// </summary> [HttpGet("{identifier:id}/show")]
/// <remarks> [PartialPermission(Kind.Read)]
/// Get the show that this season is part of. [ProducesResponseType(StatusCodes.Status200OK)]
/// </remarks> [ProducesResponseType(StatusCodes.Status404NotFound)]
/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param> public async Task<ActionResult<Show>> GetShow(
/// <param name="fields">The aditional fields to include in the result.</param> Identifier identifier,
/// <returns>The show that contains this season.</returns> [FromQuery] Include<Show> fields
/// <response code="404">No season with the given ID or slug could be found.</response> )
[HttpGet("{identifier:id}/show")] {
[PartialPermission(Kind.Read)] Show? ret = await _libraryManager.Shows.GetOrDefault(
[ProducesResponseType(StatusCodes.Status200OK)] identifier.IsContainedIn<Show, Season>(x => x.Seasons!),
[ProducesResponseType(StatusCodes.Status404NotFound)] fields
public async Task<ActionResult<Show>> GetShow( );
Identifier identifier, if (ret == null)
[FromQuery] Include<Show> fields return NotFound();
) return ret;
{
Show? ret = await _libraryManager.Shows.GetOrDefault(
identifier.IsContainedIn<Show, Season>(x => x.Seasons!),
fields
);
if (ret == null)
return NotFound();
return ret;
}
} }
} }

View File

@ -30,265 +30,261 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// Information about one or multiple <see cref="Show"/>.
/// </summary>
[Route("shows")]
[Route("show", Order = AlternativeRoute)]
[ApiController]
[PartialPermission(nameof(Show))]
[ApiDefinition("Shows", Group = ResourcesGroup)]
public class ShowApi : CrudThumbsApi<Show>
{ {
/// <summary> /// <summary>
/// Information about one or multiple <see cref="Show"/>. /// The library manager used to modify or retrieve information in the data store.
/// </summary> /// </summary>
[Route("shows")] private readonly ILibraryManager _libraryManager;
[Route("show", Order = AlternativeRoute)]
[ApiController] /// <summary>
[PartialPermission(nameof(Show))] /// Create a new <see cref="ShowApi"/>.
[ApiDefinition("Shows", Group = ResourcesGroup)] /// </summary>
public class ShowApi : CrudThumbsApi<Show> /// <param name="libraryManager">
/// The library manager used to modify or retrieve information about the data store.
/// </param>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
public ShowApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
: base(libraryManager.Shows, thumbs)
{ {
/// <summary> _libraryManager = libraryManager;
/// The library manager used to modify or retrieve information in the data store. }
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary> /// <summary>
/// Create a new <see cref="ShowApi"/>. /// Get seasons of this show
/// </summary> /// </summary>
/// <param name="libraryManager"> /// <remarks>
/// The library manager used to modify or retrieve information about the data store. /// List the seasons that are part of the specified show.
/// </param> /// </remarks>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> /// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
public ShowApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) /// <param name="sortBy">A key to sort seasons by.</param>
: base(libraryManager.Shows, thumbs) /// <param name="filter">An optional list of filters.</param>
{ /// <param name="pagination">The number of seasons to return.</param>
_libraryManager = libraryManager; /// <param name="fields">The aditional fields to include in the result.</param>
} /// <returns>A page of seasons.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No show with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/seasons")]
[HttpGet("{identifier:id}/season", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Season>>> GetSeasons(
Identifier identifier,
[FromQuery] Sort<Season> sortBy,
[FromQuery] Filter<Season>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Season> fields
)
{
ICollection<Season> resources = await _libraryManager.Seasons.GetAll(
Filter.And(filter, identifier.Matcher<Season>(x => x.ShowId, x => x.Show!.Slug)),
sortBy,
fields,
pagination
);
/// <summary> if (
/// Get seasons of this show !resources.Any()
/// </summary> && await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
/// <remarks>
/// List the seasons that are part of the specified show.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <param name="sortBy">A key to sort seasons by.</param>
/// <param name="filter">An optional list of filters.</param>
/// <param name="pagination">The number of seasons to return.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of seasons.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No show with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/seasons")]
[HttpGet("{identifier:id}/season", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Season>>> GetSeasons(
Identifier identifier,
[FromQuery] Sort<Season> sortBy,
[FromQuery] Filter<Season>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Season> fields
) )
{ return NotFound();
ICollection<Season> resources = await _libraryManager.Seasons.GetAll( return Page(resources, pagination.Limit);
Filter.And(filter, identifier.Matcher<Season>(x => x.ShowId, x => x.Show!.Slug)), }
sortBy,
fields,
pagination
);
if ( /// <summary>
!resources.Any() /// Get episodes of this show
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null /// </summary>
) /// <remarks>
return NotFound(); /// List the episodes that are part of the specified show.
return Page(resources, pagination.Limit); /// </remarks>
} /// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <param name="sortBy">A key to sort episodes by.</param>
/// <param name="filter">An optional list of filters.</param>
/// <param name="pagination">The number of episodes to return.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of episodes.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No show with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/episodes")]
[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Episode>>> GetEpisodes(
Identifier identifier,
[FromQuery] Sort<Episode> sortBy,
[FromQuery] Filter<Episode>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Episode> fields
)
{
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll(
Filter.And(filter, identifier.Matcher<Episode>(x => x.ShowId, x => x.Show!.Slug)),
sortBy,
fields,
pagination
);
/// <summary> if (
/// Get episodes of this show !resources.Any()
/// </summary> && await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
/// <remarks>
/// List the episodes that are part of the specified show.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <param name="sortBy">A key to sort episodes by.</param>
/// <param name="filter">An optional list of filters.</param>
/// <param name="pagination">The number of episodes to return.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A page of episodes.</returns>
/// <response code="400">The filters or the sort parameters are invalid.</response>
/// <response code="404">No show with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/episodes")]
[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Episode>>> GetEpisodes(
Identifier identifier,
[FromQuery] Sort<Episode> sortBy,
[FromQuery] Filter<Episode>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Episode> fields
) )
{ return NotFound();
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll( return Page(resources, pagination.Limit);
Filter.And(filter, identifier.Matcher<Episode>(x => x.ShowId, x => x.Show!.Slug)), }
sortBy,
fields,
pagination
);
if ( /// <summary>
!resources.Any() /// Get studio that made the show
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null /// </summary>
) /// <remarks>
return NotFound(); /// Get the studio that made the show.
return Page(resources, pagination.Limit); /// </remarks>
} /// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>The studio that made the show.</returns>
/// <response code="404">No show with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/studio")]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Studio>> GetStudio(
Identifier identifier,
[FromQuery] Include<Studio> fields
)
{
return await _libraryManager.Studios.Get(
identifier.IsContainedIn<Studio, Show>(x => x.Shows!),
fields
);
}
/// <summary> /// <summary>
/// Get studio that made the show /// Get collections containing this show
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Get the studio that made the show. /// List the collections that contain this show.
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> /// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <param name="fields">The aditional fields to include in the result.</param> /// <param name="sortBy">A key to sort collections by.</param>
/// <returns>The studio that made the show.</returns> /// <param name="filter">An optional list of filters.</param>
/// <response code="404">No show with the given ID or slug could be found.</response> /// <param name="pagination">The number of collections to return.</param>
[HttpGet("{identifier:id}/studio")] /// <param name="fields">The aditional fields to include in the result.</param>
[PartialPermission(Kind.Read)] /// <returns>A page of collections.</returns>
[ProducesResponseType(StatusCodes.Status200OK)] /// <response code="400">The filters or the sort parameters are invalid.</response>
[ProducesResponseType(StatusCodes.Status404NotFound)] /// <response code="404">No show with the given ID or slug could be found.</response>
public async Task<ActionResult<Studio>> GetStudio( [HttpGet("{identifier:id}/collections")]
Identifier identifier, [HttpGet("{identifier:id}/collection", Order = AlternativeRoute)]
[FromQuery] Include<Studio> fields [PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Collection>>> GetCollections(
Identifier identifier,
[FromQuery] Sort<Collection> sortBy,
[FromQuery] Filter<Collection>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Collection> fields
)
{
ICollection<Collection> resources = await _libraryManager.Collections.GetAll(
Filter.And(filter, identifier.IsContainedIn<Collection, Show>(x => x.Shows!)),
sortBy,
fields,
pagination
);
if (
!resources.Any()
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
) )
{ return NotFound();
return await _libraryManager.Studios.Get( return Page(resources, pagination.Limit);
identifier.IsContainedIn<Studio, Show>(x => x.Shows!), }
fields
);
}
/// <summary> /// <summary>
/// Get collections containing this show /// Get watch status
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// List the collections that contain this show. /// Get when an item has been wathed and if it was watched.
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> /// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <param name="sortBy">A key to sort collections by.</param> /// <returns>The status.</returns>
/// <param name="filter">An optional list of filters.</param> /// <response code="204">This show does not have a specific status.</response>
/// <param name="pagination">The number of collections to return.</param> /// <response code="404">No show with the given ID or slug could be found.</response>
/// <param name="fields">The aditional fields to include in the result.</param> [HttpGet("{identifier:id}/watchStatus")]
/// <returns>A page of collections.</returns> [UserOnly]
/// <response code="400">The filters or the sort parameters are invalid.</response> [ProducesResponseType(StatusCodes.Status200OK)]
/// <response code="404">No show with the given ID or slug could be found.</response> [ProducesResponseType(StatusCodes.Status204NoContent)]
[HttpGet("{identifier:id}/collections")] [ProducesResponseType(StatusCodes.Status404NotFound)]
[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)] public async Task<ShowWatchStatus?> GetWatchStatus(Identifier identifier)
[PartialPermission(Kind.Read)] {
[ProducesResponseType(StatusCodes.Status200OK)] Guid id = await identifier.Match(
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] id => Task.FromResult(id),
[ProducesResponseType(StatusCodes.Status404NotFound)] async slug => (await _libraryManager.Shows.Get(slug)).Id
public async Task<ActionResult<Page<Collection>>> GetCollections( );
Identifier identifier, return await _libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow());
[FromQuery] Sort<Collection> sortBy, }
[FromQuery] Filter<Collection>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<Collection> fields
)
{
ICollection<Collection> resources = await _libraryManager.Collections.GetAll(
Filter.And(filter, identifier.IsContainedIn<Collection, Show>(x => x.Shows!)),
sortBy,
fields,
pagination
);
if ( /// <summary>
!resources.Any() /// Set watch status
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null /// </summary>
) /// <remarks>
return NotFound(); /// Set when an item has been wathed and if it was watched.
return Page(resources, pagination.Limit); /// </remarks>
} /// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <param name="status">The new watch status.</param>
/// <returns>The newly set status.</returns>
/// <response code="200">The status has been set</response>
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
/// <response code="404">No movie with the given ID or slug could be found.</response>
[HttpPost("{identifier:id}/watchStatus")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ShowWatchStatus?> SetWatchStatus(Identifier identifier, WatchStatus status)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Shows.Get(slug)).Id
);
return await _libraryManager.WatchStatus.SetShowStatus(id, User.GetIdOrThrow(), status);
}
/// <summary> /// <summary>
/// Get watch status /// Delete watch status
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Get when an item has been wathed and if it was watched. /// Delete watch status (to rewatch for example).
/// </remarks> /// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param> /// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <returns>The status.</returns> /// <returns>The newly set status.</returns>
/// <response code="204">This show does not have a specific status.</response> /// <response code="204">The status has been deleted.</response>
/// <response code="404">No show with the given ID or slug could be found.</response> /// <response code="404">No show with the given ID or slug could be found.</response>
[HttpGet("{identifier:id}/watchStatus")] [HttpDelete("{identifier:id}/watchStatus")]
[UserOnly] [UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status404NotFound)] public async Task DeleteWatchStatus(Identifier identifier)
public async Task<ShowWatchStatus?> GetWatchStatus(Identifier identifier) {
{ Guid id = await identifier.Match(
Guid id = await identifier.Match( id => Task.FromResult(id),
id => Task.FromResult(id), async slug => (await _libraryManager.Shows.Get(slug)).Id
async slug => (await _libraryManager.Shows.Get(slug)).Id );
); await _libraryManager.WatchStatus.DeleteShowStatus(id, User.GetIdOrThrow());
return await _libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow());
}
/// <summary>
/// Set watch status
/// </summary>
/// <remarks>
/// Set when an item has been wathed and if it was watched.
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <param name="status">The new watch status.</param>
/// <returns>The newly set status.</returns>
/// <response code="200">The status has been set</response>
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
/// <response code="404">No movie with the given ID or slug could be found.</response>
[HttpPost("{identifier:id}/watchStatus")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ShowWatchStatus?> SetWatchStatus(
Identifier identifier,
WatchStatus status
)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Shows.Get(slug)).Id
);
return await _libraryManager.WatchStatus.SetShowStatus(id, User.GetIdOrThrow(), status);
}
/// <summary>
/// Delete watch status
/// </summary>
/// <remarks>
/// Delete watch status (to rewatch for example).
/// </remarks>
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
/// <returns>The newly set status.</returns>
/// <response code="204">The status has been deleted.</response>
/// <response code="404">No show with the given ID or slug could be found.</response>
[HttpDelete("{identifier:id}/watchStatus")]
[UserOnly]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task DeleteWatchStatus(Identifier identifier)
{
Guid id = await identifier.Match(
id => Task.FromResult(id),
async slug => (await _libraryManager.Shows.Get(slug)).Id
);
await _libraryManager.WatchStatus.DeleteShowStatus(id, User.GetIdOrThrow());
}
} }
} }

View File

@ -29,44 +29,43 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
/// <summary>
/// List new items added to kyoo.
/// </summary>
[Route("watchlist")]
[ApiController]
[PartialPermission("LibraryItem")]
[ApiDefinition("News", Group = ResourcesGroup)]
[UserOnly]
public class WatchlistApi(IWatchStatusRepository repository) : BaseApi
{ {
/// <summary> /// <summary>
/// List new items added to kyoo. /// Get all
/// </summary> /// </summary>
[Route("watchlist")] /// <remarks>
[ApiController] /// Get all resources that match the given filter.
[PartialPermission("LibraryItem")] /// </remarks>
[ApiDefinition("News", Group = ResourcesGroup)] /// <param name="filter">Filter the returned items.</param>
[UserOnly] /// <param name="pagination">How many items per page should be returned, where should the page start...</param>
public class WatchlistApi(IWatchStatusRepository repository) : BaseApi /// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A list of resources that match every filters.</returns>
/// <response code="400">Invalid filters or sort information.</response>
[HttpGet]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<ActionResult<Page<IWatchlist>>> GetAll(
[FromQuery] Filter<IWatchlist>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<IWatchlist>? fields
)
{ {
/// <summary> if (User.GetId() == null)
/// Get all throw new UnauthorizedException();
/// </summary> ICollection<IWatchlist> resources = await repository.GetAll(filter, fields, pagination);
/// <remarks>
/// Get all resources that match the given filter.
/// </remarks>
/// <param name="filter">Filter the returned items.</param>
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
/// <param name="fields">The aditional fields to include in the result.</param>
/// <returns>A list of resources that match every filters.</returns>
/// <response code="400">Invalid filters or sort information.</response>
[HttpGet]
[PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
public async Task<ActionResult<Page<IWatchlist>>> GetAll(
[FromQuery] Filter<IWatchlist>? filter,
[FromQuery] Pagination pagination,
[FromQuery] Include<IWatchlist>? fields
)
{
if (User.GetId() == null)
throw new UnauthorizedException();
ICollection<IWatchlist> resources = await repository.GetAll(filter, fields, pagination);
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
}
} }
} }

View File

@ -28,69 +28,68 @@ using Kyoo.Utils;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Kyoo.Core.Api namespace Kyoo.Core.Api;
{
/// <summary>
/// Proxy to other services
/// </summary>
[ApiController]
[Obsolete("Use /episode/id/master.m3u8 or routes like that")]
public class ProxyApi(ILibraryManager library) : Controller
{
private Task _Proxy(string route, (string path, string route) info)
{
HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder
.Instance.WithBeforeSend(
(ctx, req) =>
{
req.Headers.Add("X-Path", info.path);
req.Headers.Add("X-Route", info.route);
return Task.CompletedTask;
}
)
.WithHandleFailure(
async (context, exception) =>
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(
new RequestError("Service unavailable")
);
}
)
.Build();
return this.HttpProxyAsync($"http://transcoder:7666/{route}", proxyOptions);
}
/// <summary> /// <summary>
/// Transcoder proxy /// Proxy to other services
/// </summary> /// </summary>
/// <remarks> [ApiController]
/// Simply proxy requests to the transcoder [Obsolete("Use /episode/id/master.m3u8 or routes like that")]
/// </remarks> public class ProxyApi(ILibraryManager library) : Controller
/// <param name="rest">The path of the transcoder.</param> {
/// <returns>The return value of the transcoder.</returns> private Task _Proxy(string route, (string path, string route) info)
[Route("video/{type}/{id:id}/{**rest}")] {
[Permission("video", Kind.Read)] HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder
[Obsolete("Use /episode/id/master.m3u8 or routes like that")] .Instance.WithBeforeSend(
public async Task Proxy( (ctx, req) =>
string type, {
Identifier id, req.Headers.Add("X-Path", info.path);
string rest, req.Headers.Add("X-Route", info.route);
[FromQuery] Dictionary<string, string> query return Task.CompletedTask;
) }
{ )
string path = await ( .WithHandleFailure(
type is "movie" or "movies" async (context, exception) =>
? id.Match( {
async id => (await library.Movies.Get(id)).Path, context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
async slug => (await library.Movies.Get(slug)).Path await context.Response.WriteAsJsonAsync(
) new RequestError("Service unavailable")
: id.Match( );
async id => (await library.Episodes.Get(id)).Path, }
async slug => (await library.Episodes.Get(slug)).Path )
) .Build();
); return this.HttpProxyAsync($"http://transcoder:7666/{route}", proxyOptions);
await _Proxy(rest + query.ToQueryString(), (path, $"{type}/{id}")); }
}
/// <summary>
/// Transcoder proxy
/// </summary>
/// <remarks>
/// Simply proxy requests to the transcoder
/// </remarks>
/// <param name="rest">The path of the transcoder.</param>
/// <returns>The return value of the transcoder.</returns>
[Route("video/{type}/{id:id}/{**rest}")]
[Permission("video", Kind.Read)]
[Obsolete("Use /episode/id/master.m3u8 or routes like that")]
public async Task Proxy(
string type,
Identifier id,
string rest,
[FromQuery] Dictionary<string, string> query
)
{
string path = await (
type is "movie" or "movies"
? id.Match(
async id => (await library.Movies.Get(id)).Path,
async slug => (await library.Movies.Get(slug)).Path
)
: id.Match(
async id => (await library.Episodes.Get(id)).Path,
async slug => (await library.Episodes.Get(slug)).Path
)
);
await _Proxy(rest + query.ToQueryString(), (path, $"{type}/{id}"));
} }
} }

View File

@ -37,166 +37,160 @@ using Serilog.Templates;
using Serilog.Templates.Themes; using Serilog.Templates.Themes;
using ILogger = Serilog.ILogger; using ILogger = Serilog.ILogger;
namespace Kyoo.Host namespace Kyoo.Host;
/// <summary>
/// Hosts of kyoo (main functions) generally only create a new <see cref="Application"/>
/// and return <see cref="Start(string[])"/>.
/// </summary>
public class Application
{ {
/// <summary> /// <summary>
/// Hosts of kyoo (main functions) generally only create a new <see cref="Application"/> /// The environment in witch Kyoo will run (ether "Production" or "Development").
/// and return <see cref="Start(string[])"/>.
/// </summary> /// </summary>
public class Application private readonly string _environment;
/// <summary>
/// The logger used for startup and error messages.
/// </summary>
private ILogger _logger;
/// <summary>
/// Create a new <see cref="Application"/> that will use the specified environment.
/// </summary>
/// <param name="environment">The environment to run in.</param>
public Application(string environment)
{ {
/// <summary> _environment = environment;
/// The environment in witch Kyoo will run (ether "Production" or "Development"). }
/// </summary>
private readonly string _environment;
/// <summary> /// <summary>
/// The logger used for startup and error messages. /// Start the application with the given console args.
/// </summary> /// This is generally called from the Main entrypoint of Kyoo.
private ILogger _logger; /// </summary>
/// <param name="args">The console arguments to use for kyoo.</param>
/// <returns>A task representing the whole process</returns>
public Task Start(string[] args)
{
return Start(args, _ => { });
}
/// <summary> /// <summary>
/// Create a new <see cref="Application"/> that will use the specified environment. /// Start the application with the given console args.
/// </summary> /// This is generally called from the Main entrypoint of Kyoo.
/// <param name="environment">The environment to run in.</param> /// </summary>
public Application(string environment) /// <param name="args">The console arguments to use for kyoo.</param>
/// <param name="configure">A custom action to configure the container before the start</param>
/// <returns>A task representing the whole process</returns>
public async Task Start(string[] args, Action<ContainerBuilder> configure)
{
IConfiguration parsed = _SetupConfig(new ConfigurationBuilder(), args).Build();
string path = Path.GetFullPath(parsed.GetValue("DATADIR", "/kyoo"));
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
Environment.CurrentDirectory = path;
LoggerConfiguration config = new();
_ConfigureLogging(config);
Log.Logger = config.CreateBootstrapLogger();
_logger = Log.Logger.ForContext<Application>();
AppDomain.CurrentDomain.ProcessExit += (_, _) => Log.CloseAndFlush();
AppDomain.CurrentDomain.UnhandledException += (_, ex) =>
Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception");
IHost host = _CreateWebHostBuilder(args).ConfigureContainer(configure).Build();
await using (AsyncServiceScope scope = host.Services.CreateAsyncScope())
{ {
_environment = environment; PostgresModule.Initialize(scope.ServiceProvider);
await MeilisearchModule.Initialize(scope.ServiceProvider);
} }
/// <summary> await _StartWithHost(host);
/// Start the application with the given console args. }
/// This is generally called from the Main entrypoint of Kyoo.
/// </summary> /// <summary>
/// <param name="args">The console arguments to use for kyoo.</param> /// Start the given host and log failing exceptions.
/// <returns>A task representing the whole process</returns> /// </summary>
public Task Start(string[] args) /// <param name="host">The host to start.</param>
/// <param name="cancellationToken">A token to allow one to stop the host.</param>
private async Task _StartWithHost(IHost host, CancellationToken cancellationToken = default)
{
try
{ {
return Start(args, _ => { }); CoreModule.Services = host.Services;
_logger.Information(
"Version: {Version}",
Assembly.GetExecutingAssembly().GetName().Version.ToString(3)
);
_logger.Information("Data directory: {DataDirectory}", Environment.CurrentDirectory);
await host.RunAsync(cancellationToken);
} }
catch (Exception ex)
/// <summary>
/// Start the application with the given console args.
/// This is generally called from the Main entrypoint of Kyoo.
/// </summary>
/// <param name="args">The console arguments to use for kyoo.</param>
/// <param name="configure">A custom action to configure the container before the start</param>
/// <returns>A task representing the whole process</returns>
public async Task Start(string[] args, Action<ContainerBuilder> configure)
{ {
IConfiguration parsed = _SetupConfig(new ConfigurationBuilder(), args).Build(); _logger.Fatal(ex, "Unhandled exception");
string path = Path.GetFullPath(parsed.GetValue("DATADIR", "/kyoo"));
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
Environment.CurrentDirectory = path;
LoggerConfiguration config = new();
_ConfigureLogging(config);
Log.Logger = config.CreateBootstrapLogger();
_logger = Log.Logger.ForContext<Application>();
AppDomain.CurrentDomain.ProcessExit += (_, _) => Log.CloseAndFlush();
AppDomain.CurrentDomain.UnhandledException += (_, ex) =>
Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception");
IHost host = _CreateWebHostBuilder(args).ConfigureContainer(configure).Build();
await using (AsyncServiceScope scope = host.Services.CreateAsyncScope())
{
PostgresModule.Initialize(scope.ServiceProvider);
await MeilisearchModule.Initialize(scope.ServiceProvider);
}
await _StartWithHost(host);
}
/// <summary>
/// Start the given host and log failing exceptions.
/// </summary>
/// <param name="host">The host to start.</param>
/// <param name="cancellationToken">A token to allow one to stop the host.</param>
private async Task _StartWithHost(IHost host, CancellationToken cancellationToken = default)
{
try
{
CoreModule.Services = host.Services;
_logger.Information(
"Version: {Version}",
Assembly.GetExecutingAssembly().GetName().Version.ToString(3)
);
_logger.Information(
"Data directory: {DataDirectory}",
Environment.CurrentDirectory
);
await host.RunAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.Fatal(ex, "Unhandled exception");
}
}
/// <summary>
/// Create a a web host
/// </summary>
/// <param name="args">Command line parameters that can be handled by kestrel</param>
/// <returns>A new web host instance</returns>
private IHostBuilder _CreateWebHostBuilder(string[] args)
{
return new HostBuilder()
.UseServiceProviderFactory(new AutofacServiceProviderFactory())
.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
.UseEnvironment(_environment)
.ConfigureAppConfiguration(x => _SetupConfig(x, args))
.UseSerilog((host, services, builder) => _ConfigureLogging(builder))
.ConfigureServices(x => x.AddRouting())
.ConfigureWebHost(x =>
x.UseKestrel(options =>
{
options.AddServerHeader = false;
})
.UseIIS()
.UseIISIntegration()
.UseUrls(
Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000"
)
.UseStartup(host =>
PluginsStartup.FromWebHost(host, new LoggerFactory().AddSerilog())
)
);
}
/// <summary>
/// Register settings.json, environment variables and command lines arguments as configuration.
/// </summary>
/// <param name="builder">The configuration builder to use</param>
/// <param name="args">The command line arguments</param>
/// <returns>The modified configuration builder</returns>
private IConfigurationBuilder _SetupConfig(IConfigurationBuilder builder, string[] args)
{
return builder
.AddEnvironmentVariables()
.AddEnvironmentVariables("KYOO_")
.AddCommandLine(args);
}
/// <summary>
/// Configure the logging.
/// </summary>
/// <param name="builder">The logger builder to configure.</param>
private void _ConfigureLogging(LoggerConfiguration builder)
{
const string template =
"[{@t:HH:mm:ss} {@l:u3} {Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), 25} "
+ "({@i:D10})] {@m}{#if not EndsWith(@m, '\n')}\n{#end}{@x}";
builder
.MinimumLevel.Warning()
.MinimumLevel.Override("Kyoo", LogEventLevel.Verbose)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Verbose)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Fatal)
.WriteTo.Console(new ExpressionTemplate(template, theme: TemplateTheme.Code))
.Enrich.WithThreadId()
.Enrich.FromLogContext();
} }
} }
/// <summary>
/// Create a a web host
/// </summary>
/// <param name="args">Command line parameters that can be handled by kestrel</param>
/// <returns>A new web host instance</returns>
private IHostBuilder _CreateWebHostBuilder(string[] args)
{
return new HostBuilder()
.UseServiceProviderFactory(new AutofacServiceProviderFactory())
.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
.UseEnvironment(_environment)
.ConfigureAppConfiguration(x => _SetupConfig(x, args))
.UseSerilog((host, services, builder) => _ConfigureLogging(builder))
.ConfigureServices(x => x.AddRouting())
.ConfigureWebHost(x =>
x.UseKestrel(options =>
{
options.AddServerHeader = false;
})
.UseIIS()
.UseIISIntegration()
.UseUrls(Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000")
.UseStartup(host =>
PluginsStartup.FromWebHost(host, new LoggerFactory().AddSerilog())
)
);
}
/// <summary>
/// Register settings.json, environment variables and command lines arguments as configuration.
/// </summary>
/// <param name="builder">The configuration builder to use</param>
/// <param name="args">The command line arguments</param>
/// <returns>The modified configuration builder</returns>
private IConfigurationBuilder _SetupConfig(IConfigurationBuilder builder, string[] args)
{
return builder
.AddEnvironmentVariables()
.AddEnvironmentVariables("KYOO_")
.AddCommandLine(args);
}
/// <summary>
/// Configure the logging.
/// </summary>
/// <param name="builder">The logger builder to configure.</param>
private void _ConfigureLogging(LoggerConfiguration builder)
{
const string template =
"[{@t:HH:mm:ss} {@l:u3} {Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), 25} "
+ "({@i:D10})] {@m}{#if not EndsWith(@m, '\n')}\n{#end}{@x}";
builder
.MinimumLevel.Warning()
.MinimumLevel.Override("Kyoo", LogEventLevel.Verbose)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Verbose)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Fatal)
.WriteTo.Console(new ExpressionTemplate(template, theme: TemplateTheme.Code))
.Enrich.WithThreadId()
.Enrich.FromLogContext();
}
} }

View File

@ -23,73 +23,70 @@ using Kyoo.Abstractions.Controllers;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Kyoo.Host.Controllers namespace Kyoo.Host.Controllers;
/// <summary>
/// An implementation of <see cref="IPluginManager"/>.
/// This is used to load plugins and retrieve information from them.
/// </summary>
public class PluginManager : IPluginManager
{ {
/// <summary> /// <summary>
/// An implementation of <see cref="IPluginManager"/>. /// The service provider. It allow plugin's activation.
/// This is used to load plugins and retrieve information from them.
/// </summary> /// </summary>
public class PluginManager : IPluginManager private readonly IServiceProvider _provider;
/// <summary>
/// The logger used by this class.
/// </summary>
private readonly ILogger<PluginManager> _logger;
/// <summary>
/// The list of plugins that are currently loaded.
/// </summary>
private readonly List<IPlugin> _plugins = new();
/// <summary>
/// Create a new <see cref="PluginManager"/> instance.
/// </summary>
/// <param name="provider">A service container to allow initialization of plugins</param>
/// <param name="logger">The logger used by this class.</param>
public PluginManager(IServiceProvider provider, ILogger<PluginManager> logger)
{ {
/// <summary> _provider = provider;
/// The service provider. It allow plugin's activation. _logger = logger;
/// </summary> }
private readonly IServiceProvider _provider;
/// <summary> /// <inheritdoc />
/// The logger used by this class. public T GetPlugin<T>(string name)
/// </summary> {
private readonly ILogger<PluginManager> _logger; return (T)_plugins?.FirstOrDefault(x => x.Name == name && x is T);
}
/// <summary> /// <inheritdoc />
/// The list of plugins that are currently loaded. public ICollection<T> GetPlugins<T>()
/// </summary> {
private readonly List<IPlugin> _plugins = new(); return _plugins?.OfType<T>().ToArray();
}
/// <summary> /// <inheritdoc />
/// Create a new <see cref="PluginManager"/> instance. public ICollection<IPlugin> GetAllPlugins()
/// </summary> {
/// <param name="provider">A service container to allow initialization of plugins</param> return _plugins;
/// <param name="logger">The logger used by this class.</param> }
public PluginManager(IServiceProvider provider, ILogger<PluginManager> logger)
{
_provider = provider;
_logger = logger;
}
/// <inheritdoc /> /// <inheritdoc />
public T GetPlugin<T>(string name) public void LoadPlugins(ICollection<IPlugin> plugins)
{ {
return (T)_plugins?.FirstOrDefault(x => x.Name == name && x is T); _plugins.AddRange(plugins);
} _logger.LogInformation("Modules enabled: {Plugins}", _plugins.Select(x => x.Name));
}
/// <inheritdoc /> /// <inheritdoc />
public ICollection<T> GetPlugins<T>() public void LoadPlugins(params Type[] plugins)
{ {
return _plugins?.OfType<T>().ToArray(); LoadPlugins(
} plugins.Select(x => (IPlugin)ActivatorUtilities.CreateInstance(_provider, x)).ToArray()
);
/// <inheritdoc />
public ICollection<IPlugin> GetAllPlugins()
{
return _plugins;
}
/// <inheritdoc />
public void LoadPlugins(ICollection<IPlugin> plugins)
{
_plugins.AddRange(plugins);
_logger.LogInformation("Modules enabled: {Plugins}", _plugins.Select(x => x.Name));
}
/// <inheritdoc />
public void LoadPlugins(params Type[] plugins)
{
LoadPlugins(
plugins
.Select(x => (IPlugin)ActivatorUtilities.CreateInstance(_provider, x))
.ToArray()
);
}
} }
} }

View File

@ -23,39 +23,38 @@ using Kyoo.Abstractions.Controllers;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Serilog; using Serilog;
namespace Kyoo.Host namespace Kyoo.Host;
/// <summary>
/// A module that registers host controllers and other needed things.
/// </summary>
public class HostModule : IPlugin
{ {
/// <inheritdoc />
public string Name => "Host";
/// <summary> /// <summary>
/// A module that registers host controllers and other needed things. /// The plugin manager that loaded all plugins.
/// </summary> /// </summary>
public class HostModule : IPlugin private readonly IPluginManager _plugins;
/// <summary>
/// Create a new <see cref="HostModule"/>.
/// </summary>
/// <param name="plugins">The plugin manager that loaded all plugins.</param>
public HostModule(IPluginManager plugins)
{ {
/// <inheritdoc /> _plugins = plugins;
public string Name => "Host";
/// <summary>
/// The plugin manager that loaded all plugins.
/// </summary>
private readonly IPluginManager _plugins;
/// <summary>
/// Create a new <see cref="HostModule"/>.
/// </summary>
/// <param name="plugins">The plugin manager that loaded all plugins.</param>
public HostModule(IPluginManager plugins)
{
_plugins = plugins;
}
/// <inheritdoc />
public void Configure(ContainerBuilder builder)
{
builder.RegisterModule<AttributedMetadataModule>();
builder.RegisterInstance(_plugins).As<IPluginManager>().ExternallyOwned();
}
/// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps =>
new[] { SA.New<IApplicationBuilder>(app => app.UseSerilogRequestLogging(), SA.Before) };
} }
/// <inheritdoc />
public void Configure(ContainerBuilder builder)
{
builder.RegisterModule<AttributedMetadataModule>();
builder.RegisterInstance(_plugins).As<IPluginManager>().ExternallyOwned();
}
/// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps =>
new[] { SA.New<IApplicationBuilder>(app => app.UseSerilogRequestLogging(), SA.Before) };
} }

View File

@ -36,167 +36,163 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Kyoo.Host namespace Kyoo.Host;
/// <summary>
/// The Startup class is used to configure the AspNet's webhost.
/// </summary>
public class PluginsStartup
{ {
/// <summary> /// <summary>
/// The Startup class is used to configure the AspNet's webhost. /// A plugin manager used to load plugins and allow them to configure services / asp net.
/// </summary> /// </summary>
public class PluginsStartup private readonly IPluginManager _plugins;
/// <summary>
/// The plugin that adds controllers and tasks specific to this host.
/// </summary>
private readonly IPlugin _hostModule;
/// <summary>
/// Created from the DI container, those services are needed to load information and instantiate plugins.s
/// </summary>
/// <param name="plugins">The plugin manager to use to load new plugins and configure the host.</param>
public PluginsStartup(IPluginManager plugins)
{
_plugins = plugins;
_hostModule = new HostModule(_plugins);
_plugins.LoadPlugins(
typeof(CoreModule),
typeof(AuthenticationModule),
typeof(PostgresModule),
typeof(MeilisearchModule),
typeof(SwaggerModule)
);
}
/// <summary>
/// Create a new <see cref="PluginsStartup"/> from a webhost.
/// This is meant to be used from <see cref="WebHostBuilderExtensions.UseStartup"/>.
/// </summary>
/// <param name="host">The context of the web host.</param>
/// <param name="logger">
/// The logger factory used to log while the application is setting itself up.
/// </param>
/// <returns>A new <see cref="PluginsStartup"/>.</returns>
public static PluginsStartup FromWebHost(WebHostBuilderContext host, ILoggerFactory logger)
{
HostServiceProvider hostProvider = new(host.HostingEnvironment, host.Configuration, logger);
PluginManager plugins = new(hostProvider, logger.CreateLogger<PluginManager>());
return new PluginsStartup(plugins);
}
/// <summary>
/// Configure the services context via the <see cref="PluginManager"/>.
/// </summary>
/// <param name="services">The service collection to fill.</param>
public void ConfigureServices(IServiceCollection services)
{
foreach (Assembly assembly in _plugins.GetAllPlugins().Select(x => x.GetType().Assembly))
services.AddMvcCore().AddApplicationPart(assembly);
_hostModule.Configure(services);
foreach (IPlugin plugin in _plugins.GetAllPlugins())
plugin.Configure(services);
}
/// <summary>
/// Configure the autofac container via the <see cref="PluginManager"/>.
/// </summary>
/// <param name="builder">The builder to configure.</param>
public void ConfigureContainer(ContainerBuilder builder)
{
_hostModule.Configure(builder);
foreach (IPlugin plugin in _plugins.GetAllPlugins())
plugin.Configure(builder);
}
/// <summary>
/// Configure the asp net host.
/// </summary>
/// <param name="app">The asp net host to configure</param>
/// <param name="container">An autofac container used to create a new scope to configure asp-net.</param>
public void Configure(IApplicationBuilder app, ILifetimeScope container)
{
IEnumerable<IStartupAction> steps = _plugins
.GetAllPlugins()
.Append(_hostModule)
.SelectMany(x => x.ConfigureSteps)
.OrderByDescending(x => x.Priority);
using ILifetimeScope scope = container.BeginLifetimeScope(x =>
x.RegisterInstance(app).SingleInstance().ExternallyOwned()
);
IServiceProvider provider = scope.Resolve<IServiceProvider>();
foreach (IStartupAction step in steps)
step.Run(provider);
}
/// <summary>
/// A simple host service provider used to activate plugins instance.
/// The same services as a generic host are available and an <see cref="ILoggerFactory"/> has been added.
/// </summary>
private class HostServiceProvider : IServiceProvider
{ {
/// <summary> /// <summary>
/// A plugin manager used to load plugins and allow them to configure services / asp net. /// The host environment that could be used by plugins to configure themself.
/// </summary> /// </summary>
private readonly IPluginManager _plugins; private readonly IWebHostEnvironment _hostEnvironment;
/// <summary> /// <summary>
/// The plugin that adds controllers and tasks specific to this host. /// The configuration context.
/// </summary> /// </summary>
private readonly IPlugin _hostModule; private readonly IConfiguration _configuration;
/// <summary> /// <summary>
/// Created from the DI container, those services are needed to load information and instantiate plugins.s /// A logger factory used to create a logger for the plugin manager.
/// </summary> /// </summary>
/// <param name="plugins">The plugin manager to use to load new plugins and configure the host.</param> private readonly ILoggerFactory _loggerFactory;
public PluginsStartup(IPluginManager plugins)
{
_plugins = plugins;
_hostModule = new HostModule(_plugins);
_plugins.LoadPlugins(
typeof(CoreModule),
typeof(AuthenticationModule),
typeof(PostgresModule),
typeof(MeilisearchModule),
typeof(SwaggerModule)
);
}
/// <summary> /// <summary>
/// Create a new <see cref="PluginsStartup"/> from a webhost. /// Create a new <see cref="HostServiceProvider"/> that will return given services when asked.
/// This is meant to be used from <see cref="WebHostBuilderExtensions.UseStartup"/>.
/// </summary> /// </summary>
/// <param name="host">The context of the web host.</param> /// <param name="hostEnvironment">
/// <param name="logger"> /// The host environment that could be used by plugins to configure themself.
/// The logger factory used to log while the application is setting itself up.
/// </param> /// </param>
/// <returns>A new <see cref="PluginsStartup"/>.</returns> /// <param name="configuration">The configuration context</param>
public static PluginsStartup FromWebHost(WebHostBuilderContext host, ILoggerFactory logger) /// <param name="loggerFactory">A logger factory used to create a logger for the plugin manager.</param>
public HostServiceProvider(
IWebHostEnvironment hostEnvironment,
IConfiguration configuration,
ILoggerFactory loggerFactory
)
{ {
HostServiceProvider hostProvider = _hostEnvironment = hostEnvironment;
new(host.HostingEnvironment, host.Configuration, logger); _configuration = configuration;
PluginManager plugins = new(hostProvider, logger.CreateLogger<PluginManager>()); _loggerFactory = loggerFactory;
return new PluginsStartup(plugins);
} }
/// <summary> /// <inheritdoc />
/// Configure the services context via the <see cref="PluginManager"/>. public object GetService(Type serviceType)
/// </summary>
/// <param name="services">The service collection to fill.</param>
public void ConfigureServices(IServiceCollection services)
{ {
foreach ( if (
Assembly assembly in _plugins.GetAllPlugins().Select(x => x.GetType().Assembly) serviceType == typeof(IWebHostEnvironment)
) || serviceType == typeof(IHostEnvironment)
services.AddMvcCore().AddApplicationPart(assembly);
_hostModule.Configure(services);
foreach (IPlugin plugin in _plugins.GetAllPlugins())
plugin.Configure(services);
}
/// <summary>
/// Configure the autofac container via the <see cref="PluginManager"/>.
/// </summary>
/// <param name="builder">The builder to configure.</param>
public void ConfigureContainer(ContainerBuilder builder)
{
_hostModule.Configure(builder);
foreach (IPlugin plugin in _plugins.GetAllPlugins())
plugin.Configure(builder);
}
/// <summary>
/// Configure the asp net host.
/// </summary>
/// <param name="app">The asp net host to configure</param>
/// <param name="container">An autofac container used to create a new scope to configure asp-net.</param>
public void Configure(IApplicationBuilder app, ILifetimeScope container)
{
IEnumerable<IStartupAction> steps = _plugins
.GetAllPlugins()
.Append(_hostModule)
.SelectMany(x => x.ConfigureSteps)
.OrderByDescending(x => x.Priority);
using ILifetimeScope scope = container.BeginLifetimeScope(x =>
x.RegisterInstance(app).SingleInstance().ExternallyOwned()
);
IServiceProvider provider = scope.Resolve<IServiceProvider>();
foreach (IStartupAction step in steps)
step.Run(provider);
}
/// <summary>
/// A simple host service provider used to activate plugins instance.
/// The same services as a generic host are available and an <see cref="ILoggerFactory"/> has been added.
/// </summary>
private class HostServiceProvider : IServiceProvider
{
/// <summary>
/// The host environment that could be used by plugins to configure themself.
/// </summary>
private readonly IWebHostEnvironment _hostEnvironment;
/// <summary>
/// The configuration context.
/// </summary>
private readonly IConfiguration _configuration;
/// <summary>
/// A logger factory used to create a logger for the plugin manager.
/// </summary>
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Create a new <see cref="HostServiceProvider"/> that will return given services when asked.
/// </summary>
/// <param name="hostEnvironment">
/// The host environment that could be used by plugins to configure themself.
/// </param>
/// <param name="configuration">The configuration context</param>
/// <param name="loggerFactory">A logger factory used to create a logger for the plugin manager.</param>
public HostServiceProvider(
IWebHostEnvironment hostEnvironment,
IConfiguration configuration,
ILoggerFactory loggerFactory
) )
return _hostEnvironment;
if (serviceType == typeof(IConfiguration))
return _configuration;
if (serviceType.GetGenericTypeDefinition() == typeof(ILogger<>))
{ {
_hostEnvironment = hostEnvironment; return Utility.RunGenericMethod<object>(
_configuration = configuration; typeof(LoggerFactoryExtensions),
_loggerFactory = loggerFactory; nameof(LoggerFactoryExtensions.CreateLogger),
serviceType.GetGenericArguments().First(),
_loggerFactory
);
} }
/// <inheritdoc /> return null;
public object GetService(Type serviceType)
{
if (
serviceType == typeof(IWebHostEnvironment)
|| serviceType == typeof(IHostEnvironment)
)
return _hostEnvironment;
if (serviceType == typeof(IConfiguration))
return _configuration;
if (serviceType.GetGenericTypeDefinition() == typeof(ILogger<>))
{
return Utility.RunGenericMethod<object>(
typeof(LoggerFactoryExtensions),
nameof(LoggerFactoryExtensions.CreateLogger),
serviceType.GetGenericArguments().First(),
_loggerFactory
);
}
return null;
}
} }
} }
} }

View File

@ -19,31 +19,30 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
namespace Kyoo.Host namespace Kyoo.Host;
/// <summary>
/// Program entrypoint.
/// </summary>
public static class Program
{ {
/// <summary> /// <summary>
/// Program entrypoint. /// The string representation of the environment used in <see cref="IWebHostEnvironment"/>.
/// </summary> /// </summary>
public static class Program
{
/// <summary>
/// The string representation of the environment used in <see cref="IWebHostEnvironment"/>.
/// </summary>
#if DEBUG #if DEBUG
private const string Environment = "Development"; private const string Environment = "Development";
#else #else
private const string Environment = "Production"; private const string Environment = "Production";
#endif #endif
/// <summary> /// <summary>
/// Main function of the program /// Main function of the program
/// </summary> /// </summary>
/// <param name="args">Command line arguments</param> /// <param name="args">Command line arguments</param>
/// <returns>A <see cref="Task"/> representing the lifetime of the program.</returns> /// <returns>A <see cref="Task"/> representing the lifetime of the program.</returns>
public static Task Main(string[] args) public static Task Main(string[] args)
{ {
Application application = new(Environment); Application application = new(Environment);
return application.Start(args); return application.Start(args);
}
} }
} }

View File

@ -24,171 +24,166 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using static System.Text.Json.JsonNamingPolicy; using static System.Text.Json.JsonNamingPolicy;
namespace Kyoo.Meiliseach namespace Kyoo.Meiliseach;
public class MeilisearchModule : IPlugin
{ {
public class MeilisearchModule : IPlugin /// <inheritdoc />
public string Name => "Meilisearch";
private readonly IConfiguration _configuration;
public static Dictionary<string, Settings> IndexSettings =>
new()
{
{
"items",
new Settings()
{
SearchableAttributes = new[]
{
CamelCase.ConvertName(nameof(Movie.Name)),
CamelCase.ConvertName(nameof(Movie.Slug)),
CamelCase.ConvertName(nameof(Movie.Aliases)),
CamelCase.ConvertName(nameof(Movie.Path)),
CamelCase.ConvertName(nameof(Movie.Tags)),
CamelCase.ConvertName(nameof(Movie.Overview)),
},
FilterableAttributes = new[]
{
CamelCase.ConvertName(nameof(Movie.Genres)),
CamelCase.ConvertName(nameof(Movie.Status)),
CamelCase.ConvertName(nameof(Movie.AirDate)),
CamelCase.ConvertName(nameof(Movie.StudioId)),
"kind"
},
SortableAttributes = new[]
{
CamelCase.ConvertName(nameof(Movie.AirDate)),
CamelCase.ConvertName(nameof(Movie.AddedDate)),
CamelCase.ConvertName(nameof(Movie.Rating)),
CamelCase.ConvertName(nameof(Movie.Runtime)),
},
DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Movie.Id)), "kind" },
RankingRules = new[]
{
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
$"{CamelCase.ConvertName(nameof(Movie.Rating))}:desc",
}
// TODO: Add stopwords
}
},
{
nameof(Episode),
new Settings()
{
SearchableAttributes = new[]
{
CamelCase.ConvertName(nameof(Episode.Name)),
CamelCase.ConvertName(nameof(Episode.Overview)),
CamelCase.ConvertName(nameof(Episode.Slug)),
CamelCase.ConvertName(nameof(Episode.Path)),
},
FilterableAttributes = new[]
{
CamelCase.ConvertName(nameof(Episode.SeasonNumber)),
},
SortableAttributes = new[]
{
CamelCase.ConvertName(nameof(Episode.ReleaseDate)),
CamelCase.ConvertName(nameof(Episode.AddedDate)),
CamelCase.ConvertName(nameof(Episode.SeasonNumber)),
CamelCase.ConvertName(nameof(Episode.EpisodeNumber)),
CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)),
},
DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Episode.Id)), },
// TODO: Add stopwords
}
},
{
nameof(Studio),
new Settings()
{
SearchableAttributes = new[]
{
CamelCase.ConvertName(nameof(Studio.Name)),
CamelCase.ConvertName(nameof(Studio.Slug)),
},
FilterableAttributes = Array.Empty<string>(),
SortableAttributes = Array.Empty<string>(),
DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Studio.Id)), },
// TODO: Add stopwords
}
},
};
public MeilisearchModule(IConfiguration configuration)
{ {
/// <inheritdoc /> _configuration = configuration;
public string Name => "Meilisearch"; }
private readonly IConfiguration _configuration; /// <summary>
/// Init meilisearch indexes.
/// </summary>
/// <param name="provider">The service list to retrieve the meilisearch client</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public static async Task Initialize(IServiceProvider provider)
{
MeilisearchClient client = provider.GetRequiredService<MeilisearchClient>();
public static Dictionary<string, Settings> IndexSettings => await _CreateIndex(client, "items", true);
new() await _CreateIndex(client, nameof(Episode), false);
{ await _CreateIndex(client, nameof(Studio), false);
{
"items",
new Settings()
{
SearchableAttributes = new[]
{
CamelCase.ConvertName(nameof(Movie.Name)),
CamelCase.ConvertName(nameof(Movie.Slug)),
CamelCase.ConvertName(nameof(Movie.Aliases)),
CamelCase.ConvertName(nameof(Movie.Path)),
CamelCase.ConvertName(nameof(Movie.Tags)),
CamelCase.ConvertName(nameof(Movie.Overview)),
},
FilterableAttributes = new[]
{
CamelCase.ConvertName(nameof(Movie.Genres)),
CamelCase.ConvertName(nameof(Movie.Status)),
CamelCase.ConvertName(nameof(Movie.AirDate)),
CamelCase.ConvertName(nameof(Movie.StudioId)),
"kind"
},
SortableAttributes = new[]
{
CamelCase.ConvertName(nameof(Movie.AirDate)),
CamelCase.ConvertName(nameof(Movie.AddedDate)),
CamelCase.ConvertName(nameof(Movie.Rating)),
CamelCase.ConvertName(nameof(Movie.Runtime)),
},
DisplayedAttributes = new[]
{
CamelCase.ConvertName(nameof(Movie.Id)),
"kind"
},
RankingRules = new[]
{
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
$"{CamelCase.ConvertName(nameof(Movie.Rating))}:desc",
}
// TODO: Add stopwords
}
},
{
nameof(Episode),
new Settings()
{
SearchableAttributes = new[]
{
CamelCase.ConvertName(nameof(Episode.Name)),
CamelCase.ConvertName(nameof(Episode.Overview)),
CamelCase.ConvertName(nameof(Episode.Slug)),
CamelCase.ConvertName(nameof(Episode.Path)),
},
FilterableAttributes = new[]
{
CamelCase.ConvertName(nameof(Episode.SeasonNumber)),
},
SortableAttributes = new[]
{
CamelCase.ConvertName(nameof(Episode.ReleaseDate)),
CamelCase.ConvertName(nameof(Episode.AddedDate)),
CamelCase.ConvertName(nameof(Episode.SeasonNumber)),
CamelCase.ConvertName(nameof(Episode.EpisodeNumber)),
CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)),
},
DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Episode.Id)), },
// TODO: Add stopwords
}
},
{
nameof(Studio),
new Settings()
{
SearchableAttributes = new[]
{
CamelCase.ConvertName(nameof(Studio.Name)),
CamelCase.ConvertName(nameof(Studio.Slug)),
},
FilterableAttributes = Array.Empty<string>(),
SortableAttributes = Array.Empty<string>(),
DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Studio.Id)), },
// TODO: Add stopwords
}
},
};
public MeilisearchModule(IConfiguration configuration) IndexStats info = await client.Index("items").GetStatsAsync();
// If there is no documents in meilisearch, if a db exist and is not empty, add items to meilisearch.
if (info.NumberOfDocuments == 0)
{ {
_configuration = configuration; ILibraryManager database = provider.GetRequiredService<ILibraryManager>();
} MeiliSync search = provider.GetRequiredService<MeiliSync>();
/// <summary> // This is a naive implementation that absolutly does not care about performances.
/// Init meilisearch indexes. // This will run only once on users that already had a database when they upgrade.
/// </summary> foreach (Movie movie in await database.Movies.GetAll(limit: 0))
/// <param name="provider">The service list to retrieve the meilisearch client</param> await search.CreateOrUpdate("items", movie, nameof(Movie));
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> foreach (Show show in await database.Shows.GetAll(limit: 0))
public static async Task Initialize(IServiceProvider provider) await search.CreateOrUpdate("items", show, nameof(Show));
{ foreach (Collection collection in await database.Collections.GetAll(limit: 0))
MeilisearchClient client = provider.GetRequiredService<MeilisearchClient>(); await search.CreateOrUpdate("items", collection, nameof(Collection));
foreach (Episode episode in await database.Episodes.GetAll(limit: 0))
await _CreateIndex(client, "items", true); await search.CreateOrUpdate(nameof(Episode), episode);
await _CreateIndex(client, nameof(Episode), false); foreach (Studio studio in await database.Studios.GetAll(limit: 0))
await _CreateIndex(client, nameof(Studio), false); await search.CreateOrUpdate(nameof(Studio), studio);
IndexStats info = await client.Index("items").GetStatsAsync();
// If there is no documents in meilisearch, if a db exist and is not empty, add items to meilisearch.
if (info.NumberOfDocuments == 0)
{
ILibraryManager database = provider.GetRequiredService<ILibraryManager>();
MeiliSync search = provider.GetRequiredService<MeiliSync>();
// This is a naive implementation that absolutly does not care about performances.
// This will run only once on users that already had a database when they upgrade.
foreach (Movie movie in await database.Movies.GetAll(limit: 0))
await search.CreateOrUpdate("items", movie, nameof(Movie));
foreach (Show show in await database.Shows.GetAll(limit: 0))
await search.CreateOrUpdate("items", show, nameof(Show));
foreach (Collection collection in await database.Collections.GetAll(limit: 0))
await search.CreateOrUpdate("items", collection, nameof(Collection));
foreach (Episode episode in await database.Episodes.GetAll(limit: 0))
await search.CreateOrUpdate(nameof(Episode), episode);
foreach (Studio studio in await database.Studios.GetAll(limit: 0))
await search.CreateOrUpdate(nameof(Studio), studio);
}
}
private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind)
{
TaskInfo task = await client.CreateIndexAsync(
index,
hasKind ? "ref" : CamelCase.ConvertName(nameof(IResource.Id))
);
await client.WaitForTaskAsync(task.TaskUid);
await client.Index(index).UpdateSettingsAsync(IndexSettings[index]);
}
/// <inheritdoc />
public void Configure(ContainerBuilder builder)
{
builder
.RegisterInstance(
new MeilisearchClient(
_configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"),
_configuration.GetValue<string?>("MEILI_MASTER_KEY")
)
)
.SingleInstance();
builder.RegisterType<MeiliSync>().AsSelf().SingleInstance().AutoActivate();
builder.RegisterType<SearchManager>().As<ISearchManager>().InstancePerLifetimeScope();
} }
} }
private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind)
{
TaskInfo task = await client.CreateIndexAsync(
index,
hasKind ? "ref" : CamelCase.ConvertName(nameof(IResource.Id))
);
await client.WaitForTaskAsync(task.TaskUid);
await client.Index(index).UpdateSettingsAsync(IndexSettings[index]);
}
/// <inheritdoc />
public void Configure(ContainerBuilder builder)
{
builder
.RegisterInstance(
new MeilisearchClient(
_configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"),
_configuration.GetValue<string?>("MEILI_MASTER_KEY")
)
)
.SingleInstance();
builder.RegisterType<MeiliSync>().AsSelf().SingleInstance().AutoActivate();
builder.RegisterType<SearchManager>().As<ISearchManager>().InstancePerLifetimeScope();
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -25,114 +25,113 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
using Npgsql; using Npgsql;
namespace Kyoo.Postgresql namespace Kyoo.Postgresql;
/// <summary>
/// A postgresql implementation of <see cref="DatabaseContext"/>.
/// </summary>
public class PostgresContext : DatabaseContext
{ {
/// <summary> /// <summary>
/// A postgresql implementation of <see cref="DatabaseContext"/>. /// Is this instance in debug mode?
/// </summary> /// </summary>
public class PostgresContext : DatabaseContext private readonly bool _debugMode;
/// <summary>
/// Should the configure step be skipped? This is used when the database is created via DbContextOptions.
/// </summary>
private readonly bool _skipConfigure;
// TODO: This needs ot be updated but ef-core still does not offer a way to use this.
[Obsolete]
static PostgresContext()
{ {
/// <summary> NpgsqlConnection.GlobalTypeMapper.MapEnum<Status>();
/// Is this instance in debug mode? NpgsqlConnection.GlobalTypeMapper.MapEnum<Genre>();
/// </summary> NpgsqlConnection.GlobalTypeMapper.MapEnum<WatchStatus>();
private readonly bool _debugMode; }
/// <summary> /// <summary>
/// Should the configure step be skipped? This is used when the database is created via DbContextOptions. /// Design time constructor (dotnet ef migrations add). Do not use
/// </summary> /// </summary>
private readonly bool _skipConfigure; public PostgresContext()
: base(null!) { }
// TODO: This needs ot be updated but ef-core still does not offer a way to use this. public PostgresContext(DbContextOptions options, IHttpContextAccessor accessor)
[Obsolete] : base(options, accessor)
static PostgresContext() {
_skipConfigure = true;
}
public PostgresContext(string connection, bool debugMode, IHttpContextAccessor accessor)
: base(accessor)
{
_debugMode = debugMode;
}
/// <summary>
/// Set connection information for this database context
/// </summary>
/// <param name="optionsBuilder">An option builder to fill.</param>
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!_skipConfigure)
{ {
NpgsqlConnection.GlobalTypeMapper.MapEnum<Status>(); optionsBuilder.UseNpgsql();
NpgsqlConnection.GlobalTypeMapper.MapEnum<Genre>(); if (_debugMode)
NpgsqlConnection.GlobalTypeMapper.MapEnum<WatchStatus>(); optionsBuilder.EnableDetailedErrors().EnableSensitiveDataLogging();
} }
/// <summary> optionsBuilder.UseSnakeCaseNamingConvention();
/// Design time constructor (dotnet ef migrations add). Do not use base.OnConfiguring(optionsBuilder);
/// </summary> }
public PostgresContext()
: base(null!) { }
public PostgresContext(DbContextOptions options, IHttpContextAccessor accessor) /// <summary>
: base(options, accessor) /// Set database parameters to support every types of Kyoo.
{ /// </summary>
_skipConfigure = true; /// <param name="modelBuilder">The database's model builder.</param>
} protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresEnum<Status>();
modelBuilder.HasPostgresEnum<Genre>();
modelBuilder.HasPostgresEnum<WatchStatus>();
public PostgresContext(string connection, bool debugMode, IHttpContextAccessor accessor) modelBuilder
: base(accessor) .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!)
{ .HasTranslation(args => new SqlFunctionExpression(
_debugMode = debugMode; "md5",
} args,
nullable: true,
argumentsPropagateNullability: new[] { false },
type: args[0].Type,
typeMapping: args[0].TypeMapping
));
/// <summary> base.OnModelCreating(modelBuilder);
/// Set connection information for this database context }
/// </summary>
/// <param name="optionsBuilder">An option builder to fill.</param> /// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override string LinkName<T, T2>()
{ {
if (!_skipConfigure) SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
return rewriter.RewriteName("Link" + typeof(T).Name + typeof(T2).Name);
}
/// <inheritdoc />
protected override string LinkNameFk<T>()
{
SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
return rewriter.RewriteName(typeof(T).Name + "ID");
}
/// <inheritdoc />
protected override bool IsDuplicateException(Exception ex)
{
return ex.InnerException
is PostgresException
{ {
optionsBuilder.UseNpgsql(); SqlState: PostgresErrorCodes.UniqueViolation
if (_debugMode) or PostgresErrorCodes.ForeignKeyViolation
optionsBuilder.EnableDetailedErrors().EnableSensitiveDataLogging(); };
}
optionsBuilder.UseSnakeCaseNamingConvention();
base.OnConfiguring(optionsBuilder);
}
/// <summary>
/// Set database parameters to support every types of Kyoo.
/// </summary>
/// <param name="modelBuilder">The database's model builder.</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresEnum<Status>();
modelBuilder.HasPostgresEnum<Genre>();
modelBuilder.HasPostgresEnum<WatchStatus>();
modelBuilder
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!)
.HasTranslation(args => new SqlFunctionExpression(
"md5",
args,
nullable: true,
argumentsPropagateNullability: new[] { false },
type: args[0].Type,
typeMapping: args[0].TypeMapping
));
base.OnModelCreating(modelBuilder);
}
/// <inheritdoc />
protected override string LinkName<T, T2>()
{
SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
return rewriter.RewriteName("Link" + typeof(T).Name + typeof(T2).Name);
}
/// <inheritdoc />
protected override string LinkNameFk<T>()
{
SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
return rewriter.RewriteName(typeof(T).Name + "ID");
}
/// <inheritdoc />
protected override bool IsDuplicateException(Exception ex)
{
return ex.InnerException
is PostgresException
{
SqlState: PostgresErrorCodes.UniqueViolation
or PostgresErrorCodes.ForeignKeyViolation
};
}
} }
} }

View File

@ -33,115 +33,112 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Npgsql; using Npgsql;
namespace Kyoo.Postgresql namespace Kyoo.Postgresql;
/// <summary>
/// A module to add postgresql capacity to the app.
/// </summary>
public class PostgresModule : IPlugin
{ {
/// <inheritdoc />
public string Name => "Postgresql";
/// <summary> /// <summary>
/// A module to add postgresql capacity to the app. /// The configuration to use. The database connection string is pulled from it.
/// </summary> /// </summary>
public class PostgresModule : IPlugin private readonly IConfiguration _configuration;
/// <summary>
/// The host environment to check if the app is in debug mode.
/// </summary>
private readonly IWebHostEnvironment _environment;
/// <summary>
/// Create a new postgres module instance and use the given configuration and environment.
/// </summary>
/// <param name="configuration">The configuration to use</param>
/// <param name="env">The environment that will be used (if the env is in development mode, more information will be displayed on errors.</param>
public PostgresModule(IConfiguration configuration, IWebHostEnvironment env)
{ {
/// <inheritdoc /> _configuration = configuration;
public string Name => "Postgresql"; _environment = env;
}
/// <summary> /// <summary>
/// The configuration to use. The database connection string is pulled from it. /// Migrate the database.
/// </summary> /// </summary>
private readonly IConfiguration _configuration; /// <param name="provider">The service list to retrieve the database context</param>
public static void Initialize(IServiceProvider provider)
{
DatabaseContext context = provider.GetRequiredService<DatabaseContext>();
context.Database.Migrate();
/// <summary> using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection();
/// The host environment to check if the app is in debug mode. conn.Open();
/// </summary> conn.ReloadTypes();
private readonly IWebHostEnvironment _environment;
/// <summary> SqlMapper.TypeMapProvider = (type) =>
/// Create a new postgres module instance and use the given configuration and environment.
/// </summary>
/// <param name="configuration">The configuration to use</param>
/// <param name="env">The environment that will be used (if the env is in development mode, more information will be displayed on errors.</param>
public PostgresModule(IConfiguration configuration, IWebHostEnvironment env)
{ {
_configuration = configuration; return new CustomPropertyTypeMap(
_environment = env; type,
} (type, name) =>
{
string newName = Regex.Replace(
name,
"(^|_)([a-z])",
(match) => match.Groups[2].Value.ToUpperInvariant()
);
// TODO: Add images handling here (name: poster_source, newName: PosterSource) should set Poster.Source
return type.GetProperty(newName)!;
}
);
};
SqlMapper.AddTypeHandler(
typeof(Dictionary<string, MetadataId>),
new JsonTypeHandler<Dictionary<string, MetadataId>>()
);
SqlMapper.AddTypeHandler(
typeof(Dictionary<string, string>),
new JsonTypeHandler<Dictionary<string, string>>()
);
SqlMapper.AddTypeHandler(
typeof(Dictionary<string, ExternalToken>),
new JsonTypeHandler<Dictionary<string, ExternalToken>>()
);
SqlMapper.AddTypeHandler(typeof(List<string>), new ListTypeHandler<string>());
SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>());
SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler());
InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true;
InterpolatedSqlBuilderOptions.DefaultOptions.AutoFixSingleQuotes = false;
}
/// <summary> /// <inheritdoc />
/// Migrate the database. public void Configure(IServiceCollection services)
/// </summary> {
/// <param name="provider">The service list to retrieve the database context</param> DbConnectionStringBuilder builder =
public static void Initialize(IServiceProvider provider) new()
{
DatabaseContext context = provider.GetRequiredService<DatabaseContext>();
context.Database.Migrate();
using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection();
conn.Open();
conn.ReloadTypes();
SqlMapper.TypeMapProvider = (type) =>
{ {
return new CustomPropertyTypeMap( ["USER ID"] = _configuration.GetValue("POSTGRES_USER", "KyooUser"),
type, ["PASSWORD"] = _configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"),
(type, name) => ["SERVER"] = _configuration.GetValue("POSTGRES_SERVER", "db"),
{ ["PORT"] = _configuration.GetValue("POSTGRES_PORT", "5432"),
string newName = Regex.Replace( ["DATABASE"] = _configuration.GetValue("POSTGRES_DB", "kyooDB"),
name, ["POOLING"] = "true",
"(^|_)([a-z])", ["MAXPOOLSIZE"] = "95",
(match) => match.Groups[2].Value.ToUpperInvariant() ["TIMEOUT"] = "30"
);
// TODO: Add images handling here (name: poster_source, newName: PosterSource) should set Poster.Source
return type.GetProperty(newName)!;
}
);
}; };
SqlMapper.AddTypeHandler(
typeof(Dictionary<string, MetadataId>),
new JsonTypeHandler<Dictionary<string, MetadataId>>()
);
SqlMapper.AddTypeHandler(
typeof(Dictionary<string, string>),
new JsonTypeHandler<Dictionary<string, string>>()
);
SqlMapper.AddTypeHandler(
typeof(Dictionary<string, ExternalToken>),
new JsonTypeHandler<Dictionary<string, ExternalToken>>()
);
SqlMapper.AddTypeHandler(typeof(List<string>), new ListTypeHandler<string>());
SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>());
SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler());
InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true;
InterpolatedSqlBuilderOptions.DefaultOptions.AutoFixSingleQuotes = false;
}
/// <inheritdoc /> services.AddDbContext<DatabaseContext, PostgresContext>(
public void Configure(IServiceCollection services) x =>
{ {
DbConnectionStringBuilder builder = x.UseNpgsql(builder.ConnectionString).UseProjectables();
new() if (_environment.IsDevelopment())
{ x.EnableDetailedErrors().EnableSensitiveDataLogging();
["USER ID"] = _configuration.GetValue("POSTGRES_USER", "KyooUser"), },
["PASSWORD"] = _configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"), ServiceLifetime.Transient
["SERVER"] = _configuration.GetValue("POSTGRES_SERVER", "db"), );
["PORT"] = _configuration.GetValue("POSTGRES_PORT", "5432"), services.AddTransient<DbConnection>((_) => new NpgsqlConnection(builder.ConnectionString));
["DATABASE"] = _configuration.GetValue("POSTGRES_DB", "kyooDB"),
["POOLING"] = "true",
["MAXPOOLSIZE"] = "95",
["TIMEOUT"] = "30"
};
services.AddDbContext<DatabaseContext, PostgresContext>( services.AddHealthChecks().AddDbContextCheck<DatabaseContext>();
x =>
{
x.UseNpgsql(builder.ConnectionString).UseProjectables();
if (_environment.IsDevelopment())
x.EnableDetailedErrors().EnableSensitiveDataLogging();
},
ServiceLifetime.Transient
);
services.AddTransient<DbConnection>(
(_) => new NpgsqlConnection(builder.ConnectionString)
);
services.AddHealthChecks().AddDbContextCheck<DatabaseContext>();
}
} }
} }

View File

@ -22,45 +22,44 @@ using Kyoo.Swagger.Models;
using NSwag; using NSwag;
using NSwag.Generation.AspNetCore; using NSwag.Generation.AspNetCore;
namespace Kyoo.Swagger namespace Kyoo.Swagger;
/// <summary>
/// A class to sort apis.
/// </summary>
public static class ApiSorter
{ {
/// <summary> /// <summary>
/// A class to sort apis. /// Sort apis by alphabetical orders.
/// </summary> /// </summary>
public static class ApiSorter /// <param name="options">The swagger settings to update.</param>
public static void SortApis(this AspNetCoreOpenApiDocumentGeneratorSettings options)
{ {
/// <summary> options.PostProcess += postProcess =>
/// Sort apis by alphabetical orders.
/// </summary>
/// <param name="options">The swagger settings to update.</param>
public static void SortApis(this AspNetCoreOpenApiDocumentGeneratorSettings options)
{ {
options.PostProcess += postProcess => // We can't reorder items by assigning the sorted value to the Paths variable since it has no setter.
{ List<KeyValuePair<string, OpenApiPathItem>> sorted = postProcess
// We can't reorder items by assigning the sorted value to the Paths variable since it has no setter. .Paths.OrderBy(x => x.Key)
List<KeyValuePair<string, OpenApiPathItem>> sorted = postProcess .ToList();
.Paths.OrderBy(x => x.Key) postProcess.Paths.Clear();
.ToList(); foreach ((string key, OpenApiPathItem value) in sorted)
postProcess.Paths.Clear(); postProcess.Paths.Add(key, value);
foreach ((string key, OpenApiPathItem value) in sorted) };
postProcess.Paths.Add(key, value);
};
options.PostProcess += postProcess => options.PostProcess += postProcess =>
{ {
if (!postProcess.ExtensionData.TryGetValue("x-tagGroups", out object list)) if (!postProcess.ExtensionData.TryGetValue("x-tagGroups", out object list))
return; return;
List<TagGroups> tagGroups = (List<TagGroups>)list; List<TagGroups> tagGroups = (List<TagGroups>)list;
postProcess.ExtensionData["x-tagGroups"] = tagGroups postProcess.ExtensionData["x-tagGroups"] = tagGroups
.OrderBy(x => x.Name) .OrderBy(x => x.Name)
.Select(x => .Select(x =>
{ {
x.Name = x.Name[(x.Name.IndexOf(':') + 1)..]; x.Name = x.Name[(x.Name.IndexOf(':') + 1)..];
x.Tags = x.Tags.OrderBy(y => y).ToList(); x.Tags = x.Tags.OrderBy(y => y).ToList();
return x; return x;
}) })
.ToList(); .ToList();
}; };
}
} }
} }

View File

@ -26,99 +26,98 @@ using NSwag;
using NSwag.Generation.AspNetCore; using NSwag.Generation.AspNetCore;
using NSwag.Generation.Processors.Contexts; using NSwag.Generation.Processors.Contexts;
namespace Kyoo.Swagger namespace Kyoo.Swagger;
/// <summary>
/// A class to handle Api Groups (OpenApi tags and x-tagGroups).
/// Tags should be specified via <see cref="ApiDefinitionAttribute"/> and this filter will map this to the
/// <see cref="OpenApiDocument"/>.
/// </summary>
public static class ApiTagsFilter
{ {
/// <summary> /// <summary>
/// A class to handle Api Groups (OpenApi tags and x-tagGroups). /// The main operation filter that will map every <see cref="ApiDefinitionAttribute"/>.
/// Tags should be specified via <see cref="ApiDefinitionAttribute"/> and this filter will map this to the
/// <see cref="OpenApiDocument"/>.
/// </summary> /// </summary>
public static class ApiTagsFilter /// <param name="context">The processor context, this is given by the AddOperationFilter method.</param>
/// <returns>This always return <c>true</c> since it should not remove operations.</returns>
public static bool OperationFilter(OperationProcessorContext context)
{ {
/// <summary> ApiDefinitionAttribute def =
/// The main operation filter that will map every <see cref="ApiDefinitionAttribute"/>. context.ControllerType.GetCustomAttribute<ApiDefinitionAttribute>();
/// </summary> string name = def?.Name ?? context.ControllerType.Name;
/// <param name="context">The processor context, this is given by the AddOperationFilter method.</param>
/// <returns>This always return <c>true</c> since it should not remove operations.</returns> ApiDefinitionAttribute methodOverride =
public static bool OperationFilter(OperationProcessorContext context) context.MethodInfo.GetCustomAttribute<ApiDefinitionAttribute>();
if (methodOverride != null)
name = methodOverride.Name;
context.OperationDescription.Operation.Tags.Add(name);
if (context.Document.Tags.All(x => x.Name != name))
{ {
ApiDefinitionAttribute def = context.Document.Tags.Add(
context.ControllerType.GetCustomAttribute<ApiDefinitionAttribute>(); new OpenApiTag
string name = def?.Name ?? context.ControllerType.Name; {
Name = name,
ApiDefinitionAttribute methodOverride = Description = context.ControllerType.GetXmlDocsSummary()
context.MethodInfo.GetCustomAttribute<ApiDefinitionAttribute>(); }
if (methodOverride != null) );
name = methodOverride.Name; }
context.OperationDescription.Operation.Tags.Add(name);
if (context.Document.Tags.All(x => x.Name != name))
{
context.Document.Tags.Add(
new OpenApiTag
{
Name = name,
Description = context.ControllerType.GetXmlDocsSummary()
}
);
}
if (def?.Group == null)
return true;
context.Document.ExtensionData ??= new Dictionary<string, object>();
context.Document.ExtensionData.TryAdd("x-tagGroups", new List<TagGroups>());
List<TagGroups> obj = (List<TagGroups>)context.Document.ExtensionData["x-tagGroups"];
TagGroups existing = obj.FirstOrDefault(x => x.Name == def.Group);
if (existing != null)
{
if (!existing.Tags.Contains(def.Name))
existing.Tags.Add(def.Name);
}
else
{
obj.Add(
new TagGroups
{
Name = def.Group,
Tags = new List<string> { def.Name }
}
);
}
if (def?.Group == null)
return true; return true;
context.Document.ExtensionData ??= new Dictionary<string, object>();
context.Document.ExtensionData.TryAdd("x-tagGroups", new List<TagGroups>());
List<TagGroups> obj = (List<TagGroups>)context.Document.ExtensionData["x-tagGroups"];
TagGroups existing = obj.FirstOrDefault(x => x.Name == def.Group);
if (existing != null)
{
if (!existing.Tags.Contains(def.Name))
existing.Tags.Add(def.Name);
}
else
{
obj.Add(
new TagGroups
{
Name = def.Group,
Tags = new List<string> { def.Name }
}
);
} }
/// <summary> return true;
/// This add every tags that are not in a x-tagGroups to a new tagGroups named "Other". }
/// Since tags that are not in a tagGroups are not shown, this is necessary if you want them displayed.
/// </summary>
/// <param name="postProcess">
/// The document to do this for. This should be done in the PostProcess part of the document or after
/// the main operation filter (see <see cref="OperationFilter"/>) has finished.
/// </param>
public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess)
{
List<TagGroups> tagGroups = (List<TagGroups>)postProcess.ExtensionData["x-tagGroups"];
List<string> tagsWithoutGroup = postProcess
.Tags.Select(x => x.Name)
.Where(x => tagGroups.SelectMany(y => y.Tags).All(y => y != x))
.ToList();
if (tagsWithoutGroup.Any())
{
tagGroups.Add(new TagGroups { Name = "Others", Tags = tagsWithoutGroup });
}
}
/// <summary> /// <summary>
/// Use <see cref="ApiDefinitionAttribute"/> to create tags and groups of tags on the resulting swagger /// This add every tags that are not in a x-tagGroups to a new tagGroups named "Other".
/// document. /// Since tags that are not in a tagGroups are not shown, this is necessary if you want them displayed.
/// </summary> /// </summary>
/// <param name="options">The settings of the swagger document.</param> /// <param name="postProcess">
public static void UseApiTags(this AspNetCoreOpenApiDocumentGeneratorSettings options) /// The document to do this for. This should be done in the PostProcess part of the document or after
/// the main operation filter (see <see cref="OperationFilter"/>) has finished.
/// </param>
public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess)
{
List<TagGroups> tagGroups = (List<TagGroups>)postProcess.ExtensionData["x-tagGroups"];
List<string> tagsWithoutGroup = postProcess
.Tags.Select(x => x.Name)
.Where(x => tagGroups.SelectMany(y => y.Tags).All(y => y != x))
.ToList();
if (tagsWithoutGroup.Any())
{ {
options.AddOperationFilter(OperationFilter); tagGroups.Add(new TagGroups { Name = "Others", Tags = tagsWithoutGroup });
options.PostProcess += x => x.AddLeftoversToOthersGroup();
} }
} }
/// <summary>
/// Use <see cref="ApiDefinitionAttribute"/> to create tags and groups of tags on the resulting swagger
/// document.
/// </summary>
/// <param name="options">The settings of the swagger document.</param>
public static void UseApiTags(this AspNetCoreOpenApiDocumentGeneratorSettings options)
{
options.AddOperationFilter(OperationFilter);
options.PostProcess += x => x.AddLeftoversToOthersGroup();
}
} }

View File

@ -24,45 +24,44 @@ using Kyoo.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ApplicationModels;
namespace Kyoo.Swagger namespace Kyoo.Swagger;
/// <summary>
/// A filter that change <see cref="ProducesResponseTypeAttribute"/>'s
/// <see cref="ProducesResponseTypeAttribute.Type"/> that where set to <see cref="ActionResult{T}"/> to the
/// return type of the method.
/// </summary>
/// <remarks>
/// This is only useful when the return type of the method is a generics type and that can't be specified in the
/// attribute directly (since attributes don't support generics). This should not be used otherwise.
/// </remarks>
public class GenericResponseProvider : IApplicationModelProvider
{ {
/// <summary> /// <inheritdoc />
/// A filter that change <see cref="ProducesResponseTypeAttribute"/>'s public int Order => -1;
/// <see cref="ProducesResponseTypeAttribute.Type"/> that where set to <see cref="ActionResult{T}"/> to the
/// return type of the method. /// <inheritdoc />
/// </summary> public void OnProvidersExecuted(ApplicationModelProviderContext context) { }
/// <remarks>
/// This is only useful when the return type of the method is a generics type and that can't be specified in the /// <inheritdoc />
/// attribute directly (since attributes don't support generics). This should not be used otherwise. public void OnProvidersExecuting(ApplicationModelProviderContext context)
/// </remarks>
public class GenericResponseProvider : IApplicationModelProvider
{ {
/// <inheritdoc /> foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions))
public int Order => -1;
/// <inheritdoc />
public void OnProvidersExecuted(ApplicationModelProviderContext context) { }
/// <inheritdoc />
public void OnProvidersExecuting(ApplicationModelProviderContext context)
{ {
foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions)) IEnumerable<ProducesResponseTypeAttribute> responses = action
.Filters.OfType<ProducesResponseTypeAttribute>()
.Where(x => x.Type == typeof(ActionResult<>));
foreach (ProducesResponseTypeAttribute response in responses)
{ {
IEnumerable<ProducesResponseTypeAttribute> responses = action Type type = action.ActionMethod.ReturnType;
.Filters.OfType<ProducesResponseTypeAttribute>() type =
.Where(x => x.Type == typeof(ActionResult<>)); Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0]
foreach (ProducesResponseTypeAttribute response in responses) ?? type;
{ type =
Type type = action.ActionMethod.ReturnType; Utility
type = .GetGenericDefinition(type, typeof(ActionResult<>))
Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0] ?.GetGenericArguments()[0] ?? type;
?? type; response.Type = type;
type =
Utility
.GetGenericDefinition(type, typeof(ActionResult<>))
?.GetGenericArguments()[0] ?? type;
response.Type = type;
}
} }
} }
} }

View File

@ -20,23 +20,22 @@ using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
using NSwag; using NSwag;
namespace Kyoo.Swagger.Models namespace Kyoo.Swagger.Models;
/// <summary>
/// A class representing a group of tags in the <see cref="OpenApiDocument"/>
/// </summary>
public class TagGroups
{ {
/// <summary> /// <summary>
/// A class representing a group of tags in the <see cref="OpenApiDocument"/> /// The name of the tag group.
/// </summary> /// </summary>
public class TagGroups [JsonProperty(PropertyName = "name")]
{ public string Name { get; set; }
/// <summary>
/// The name of the tag group.
/// </summary>
[JsonProperty(PropertyName = "name")]
public string Name { get; set; }
/// <summary> /// <summary>
/// The list of tags in this group. /// The list of tags in this group.
/// </summary> /// </summary>
[JsonProperty(PropertyName = "tags")] [JsonProperty(PropertyName = "tags")]
public List<string> Tags { get; set; } public List<string> Tags { get; set; }
}
} }

View File

@ -25,80 +25,78 @@ using NSwag;
using NSwag.Generation.Processors; using NSwag.Generation.Processors;
using NSwag.Generation.Processors.Contexts; using NSwag.Generation.Processors.Contexts;
namespace Kyoo.Swagger namespace Kyoo.Swagger;
{
/// <summary>
/// An operation processor that adds permissions information from the <see cref="PermissionAttribute"/> and the
/// <see cref="PartialPermissionAttribute"/>.
/// </summary>
public class OperationPermissionProcessor : IOperationProcessor
{
/// <inheritdoc />
public bool Process(OperationProcessorContext context)
{
context.OperationDescription.Operation.Security ??=
new List<OpenApiSecurityRequirement>();
OpenApiSecurityRequirement perms = context
.MethodInfo.GetCustomAttributes<UserOnlyAttribute>()
.Aggregate(
new OpenApiSecurityRequirement(),
(agg, _) =>
{
agg[nameof(Kyoo)] = Array.Empty<string>();
return agg;
}
);
/// <summary>
/// An operation processor that adds permissions information from the <see cref="PermissionAttribute"/> and the
/// <see cref="PartialPermissionAttribute"/>.
/// </summary>
public class OperationPermissionProcessor : IOperationProcessor
{
/// <inheritdoc />
public bool Process(OperationProcessorContext context)
{
context.OperationDescription.Operation.Security ??= new List<OpenApiSecurityRequirement>();
OpenApiSecurityRequirement perms = context
.MethodInfo.GetCustomAttributes<UserOnlyAttribute>()
.Aggregate(
new OpenApiSecurityRequirement(),
(agg, _) =>
{
agg[nameof(Kyoo)] = Array.Empty<string>();
return agg;
}
);
perms = context
.MethodInfo.GetCustomAttributes<PermissionAttribute>()
.Aggregate(
perms,
(agg, cur) =>
{
ICollection<string> permissions = _GetPermissionsList(agg, cur.Group);
permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}");
agg[nameof(Kyoo)] = permissions;
return agg;
}
);
PartialPermissionAttribute controller =
context.ControllerType.GetCustomAttribute<PartialPermissionAttribute>();
if (controller != null)
{
perms = context perms = context
.MethodInfo.GetCustomAttributes<PermissionAttribute>() .MethodInfo.GetCustomAttributes<PartialPermissionAttribute>()
.Aggregate( .Aggregate(
perms, perms,
(agg, cur) => (agg, cur) =>
{ {
ICollection<string> permissions = _GetPermissionsList(agg, cur.Group); Group? group =
permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}"); controller.Group != Group.Overall ? controller.Group : cur.Group;
string type = controller.Type ?? cur.Type;
Kind? kind = controller.Type == null ? controller.Kind : cur.Kind;
ICollection<string> permissions = _GetPermissionsList(
agg,
group ?? Group.Overall
);
permissions.Add($"{type}.{kind!.Value.ToString().ToLower()}");
agg[nameof(Kyoo)] = permissions; agg[nameof(Kyoo)] = permissions;
return agg; return agg;
} }
); );
PartialPermissionAttribute controller =
context.ControllerType.GetCustomAttribute<PartialPermissionAttribute>();
if (controller != null)
{
perms = context
.MethodInfo.GetCustomAttributes<PartialPermissionAttribute>()
.Aggregate(
perms,
(agg, cur) =>
{
Group? group =
controller.Group != Group.Overall ? controller.Group : cur.Group;
string type = controller.Type ?? cur.Type;
Kind? kind = controller.Type == null ? controller.Kind : cur.Kind;
ICollection<string> permissions = _GetPermissionsList(
agg,
group ?? Group.Overall
);
permissions.Add($"{type}.{kind!.Value.ToString().ToLower()}");
agg[nameof(Kyoo)] = permissions;
return agg;
}
);
}
context.OperationDescription.Operation.Security.Add(perms);
return true;
} }
private static ICollection<string> _GetPermissionsList( context.OperationDescription.Operation.Security.Add(perms);
OpenApiSecurityRequirement security, return true;
Group group }
)
{ private static ICollection<string> _GetPermissionsList(
return security.TryGetValue(group.ToString(), out IEnumerable<string> perms) OpenApiSecurityRequirement security,
? perms.ToList() Group group
: new List<string>(); )
} {
return security.TryGetValue(group.ToString(), out IEnumerable<string> perms)
? perms.ToList()
: new List<string>();
} }
} }

View File

@ -29,103 +29,102 @@ using NSwag;
using NSwag.Generation.AspNetCore; using NSwag.Generation.AspNetCore;
using static Kyoo.Abstractions.Models.Utils.Constants; using static Kyoo.Abstractions.Models.Utils.Constants;
namespace Kyoo.Swagger namespace Kyoo.Swagger;
/// <summary>
/// A module to enable a swagger interface and an OpenAPI endpoint to document Kyoo.
/// </summary>
public class SwaggerModule : IPlugin
{ {
/// <summary> /// <inheritdoc />
/// A module to enable a swagger interface and an OpenAPI endpoint to document Kyoo. public string Name => "Swagger";
/// </summary>
public class SwaggerModule : IPlugin /// <inheritdoc />
public void Configure(IServiceCollection services)
{ {
/// <inheritdoc /> services.AddTransient<IApplicationModelProvider, GenericResponseProvider>();
public string Name => "Swagger"; services.AddOpenApiDocument(document =>
/// <inheritdoc />
public void Configure(IServiceCollection services)
{ {
services.AddTransient<IApplicationModelProvider, GenericResponseProvider>(); document.Title = "Kyoo API";
services.AddOpenApiDocument(document => // TODO use a real multi-line description in markdown.
document.Description = "The Kyoo's public API";
document.Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString(3);
document.DocumentName = "v1";
document.UseControllerSummaryAsTagDescription = true;
document.GenerateExamples = true;
document.PostProcess = options =>
{ {
document.Title = "Kyoo API"; options.Info.Contact = new OpenApiContact
// TODO use a real multi-line description in markdown.
document.Description = "The Kyoo's public API";
document.Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString(3);
document.DocumentName = "v1";
document.UseControllerSummaryAsTagDescription = true;
document.GenerateExamples = true;
document.PostProcess = options =>
{ {
options.Info.Contact = new OpenApiContact Name = "Kyoo's github",
{ Url = "https://github.com/zoriya/Kyoo"
Name = "Kyoo's github",
Url = "https://github.com/zoriya/Kyoo"
};
options.Info.License = new OpenApiLicense
{
Name = "GPL-3.0-or-later",
Url = "https://github.com/zoriya/Kyoo/blob/master/LICENSE"
};
options.Info.ExtensionData ??= new Dictionary<string, object>();
options.Info.ExtensionData["x-logo"] = new
{
url = "/banner.png",
backgroundColor = "#FFFFFF",
altText = "Kyoo's logo"
};
}; };
document.UseApiTags(); options.Info.License = new OpenApiLicense
document.SortApis();
document.AddOperationFilter(x =>
{ {
if (x is AspNetCoreOperationProcessorContext ctx) Name = "GPL-3.0-or-later",
return ctx.ApiDescription.ActionDescriptor.AttributeRouteInfo?.Order Url = "https://github.com/zoriya/Kyoo/blob/master/LICENSE"
!= AlternativeRoute; };
return true;
});
document.SchemaGenerator.Settings.TypeMappers.Add(
new PrimitiveTypeMapper(
typeof(Identifier),
x =>
{
x.IsNullableRaw = false;
x.Type = JsonObjectType.String | JsonObjectType.Integer;
}
)
);
document.AddSecurity( options.Info.ExtensionData ??= new Dictionary<string, object>();
nameof(Kyoo), options.Info.ExtensionData["x-logo"] = new
new OpenApiSecurityScheme {
{ url = "/banner.png",
Type = OpenApiSecuritySchemeType.Http, backgroundColor = "#FFFFFF",
Scheme = "Bearer", altText = "Kyoo's logo"
BearerFormat = "JWT", };
Description = "The user's bearer"
}
);
document.OperationProcessors.Add(new OperationPermissionProcessor());
});
}
/// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps =>
new IStartupAction[]
{
SA.New<IApplicationBuilder>(app => app.UseOpenApi(), SA.Before + 1),
SA.New<IApplicationBuilder>(
app =>
app.UseReDoc(x =>
{
x.Path = "/doc";
x.TransformToExternalPath = (internalUiRoute, _) =>
"/api" + internalUiRoute;
x.AdditionalSettings["theme"] = new
{
colors = new { primary = new { main = "#e13e13" } }
};
}),
SA.Before
)
}; };
document.UseApiTags();
document.SortApis();
document.AddOperationFilter(x =>
{
if (x is AspNetCoreOperationProcessorContext ctx)
return ctx.ApiDescription.ActionDescriptor.AttributeRouteInfo?.Order
!= AlternativeRoute;
return true;
});
document.SchemaGenerator.Settings.TypeMappers.Add(
new PrimitiveTypeMapper(
typeof(Identifier),
x =>
{
x.IsNullableRaw = false;
x.Type = JsonObjectType.String | JsonObjectType.Integer;
}
)
);
document.AddSecurity(
nameof(Kyoo),
new OpenApiSecurityScheme
{
Type = OpenApiSecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
Description = "The user's bearer"
}
);
document.OperationProcessors.Add(new OperationPermissionProcessor());
});
} }
/// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps =>
new IStartupAction[]
{
SA.New<IApplicationBuilder>(app => app.UseOpenApi(), SA.Before + 1),
SA.New<IApplicationBuilder>(
app =>
app.UseReDoc(x =>
{
x.Path = "/doc";
x.TransformToExternalPath = (internalUiRoute, _) =>
"/api" + internalUiRoute;
x.AdditionalSettings["theme"] = new
{
colors = new { primary = new { main = "#e13e13" } }
};
}),
SA.Before
)
};
} }