mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-24 02:02:36 -04:00
Switch to file scopped namespaces
This commit is contained in:
parent
35e37bbe76
commit
18e301f26a
@ -16,6 +16,8 @@ dotnet_diagnostic.IDE0055.severity = none
|
|||||||
dotnet_diagnostic.IDE0058.severity = none
|
dotnet_diagnostic.IDE0058.severity = none
|
||||||
dotnet_diagnostic.IDE0130.severity = none
|
dotnet_diagnostic.IDE0130.severity = none
|
||||||
|
|
||||||
|
# Convert to file-scoped namespace
|
||||||
|
csharp_style_namespace_declarations = file_scoped:warning
|
||||||
# Sort using and Import directives with System.* appearing first
|
# Sort using and Import directives with System.* appearing first
|
||||||
dotnet_sort_system_directives_first = true
|
dotnet_sort_system_directives_first = true
|
||||||
csharp_using_directive_placement = outside_namespace:warning
|
csharp_using_directive_placement = outside_namespace:warning
|
||||||
|
@ -18,64 +18,63 @@
|
|||||||
|
|
||||||
using Kyoo.Abstractions.Models;
|
using Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Controllers
|
namespace Kyoo.Abstractions.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An interface to interact with the database. Every repository is mapped through here.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILibraryManager
|
||||||
{
|
{
|
||||||
|
IRepository<T> Repository<T>()
|
||||||
|
where T : IResource, IQuery;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An interface to interact with the database. Every repository is mapped through here.
|
/// The repository that handle libraries items (a wrapper around shows and collections).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ILibraryManager
|
IRepository<ILibraryItem> LibraryItems { get; }
|
||||||
{
|
|
||||||
IRepository<T> Repository<T>()
|
|
||||||
where T : IResource, IQuery;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The repository that handle libraries items (a wrapper around shows and collections).
|
/// The repository that handle new items.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IRepository<ILibraryItem> LibraryItems { get; }
|
IRepository<INews> News { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The repository that handle new items.
|
/// The repository that handle watched items.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IRepository<INews> News { get; }
|
IWatchStatusRepository WatchStatus { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The repository that handle watched items.
|
/// The repository that handle collections.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IWatchStatusRepository WatchStatus { get; }
|
IRepository<Collection> Collections { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The repository that handle collections.
|
/// The repository that handle shows.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IRepository<Collection> Collections { get; }
|
IRepository<Movie> Movies { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The repository that handle shows.
|
/// The repository that handle shows.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IRepository<Movie> Movies { get; }
|
IRepository<Show> Shows { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The repository that handle shows.
|
/// The repository that handle seasons.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IRepository<Show> Shows { get; }
|
IRepository<Season> Seasons { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The repository that handle seasons.
|
/// The repository that handle episodes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IRepository<Season> Seasons { get; }
|
IRepository<Episode> Episodes { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The repository that handle episodes.
|
/// The repository that handle studios.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IRepository<Episode> Episodes { get; }
|
IRepository<Studio> Studios { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The repository that handle studios.
|
/// The repository that handle users.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IRepository<Studio> Studios { get; }
|
IRepository<User> Users { get; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The repository that handle users.
|
|
||||||
/// </summary>
|
|
||||||
IRepository<User> Users { get; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -19,29 +19,28 @@
|
|||||||
using Kyoo.Abstractions.Models.Permissions;
|
using Kyoo.Abstractions.Models.Permissions;
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Controllers
|
namespace Kyoo.Abstractions.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A service to validate permissions.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPermissionValidator
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A service to validate permissions.
|
/// Create an IAuthorizationFilter that will be used to validate permissions.
|
||||||
|
/// This can registered with any lifetime.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IPermissionValidator
|
/// <param name="attribute">The permission attribute to validate.</param>
|
||||||
{
|
/// <returns>An authorization filter used to validate the permission.</returns>
|
||||||
/// <summary>
|
IFilterMetadata Create(PermissionAttribute attribute);
|
||||||
/// Create an IAuthorizationFilter that will be used to validate permissions.
|
|
||||||
/// This can registered with any lifetime.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="attribute">The permission attribute to validate.</param>
|
|
||||||
/// <returns>An authorization filter used to validate the permission.</returns>
|
|
||||||
IFilterMetadata Create(PermissionAttribute attribute);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create an IAuthorizationFilter that will be used to validate permissions.
|
/// Create an IAuthorizationFilter that will be used to validate permissions.
|
||||||
/// This can registered with any lifetime.
|
/// This can registered with any lifetime.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="attribute">
|
/// <param name="attribute">
|
||||||
/// A partial attribute to validate. See <see cref="PartialPermissionAttribute"/>.
|
/// A partial attribute to validate. See <see cref="PartialPermissionAttribute"/>.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>An authorization filter used to validate the permission.</returns>
|
/// <returns>An authorization filter used to validate the permission.</returns>
|
||||||
IFilterMetadata Create(PartialPermissionAttribute attribute);
|
IFilterMetadata Create(PartialPermissionAttribute attribute);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -21,46 +21,45 @@ using System.Collections.Generic;
|
|||||||
using Autofac;
|
using Autofac;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Controllers
|
namespace Kyoo.Abstractions.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A common interface used to discord plugins
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// You can inject services in the IPlugin constructor.
|
||||||
|
/// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IPlugin
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A common interface used to discord plugins
|
/// The name of the plugin
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
string Name { get; }
|
||||||
/// You can inject services in the IPlugin constructor.
|
|
||||||
/// You should only inject well known services like an ILogger, IConfiguration or IWebHostEnvironment.
|
/// <summary>
|
||||||
/// </remarks>
|
/// An optional configuration step to allow a plugin to change asp net configurations.
|
||||||
public interface IPlugin
|
/// </summary>
|
||||||
|
/// <seealso cref="SA"/>
|
||||||
|
IEnumerable<IStartupAction> ConfigureSteps => ArraySegment<IStartupAction>.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A configure method that will be run on plugin's startup.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The autofac service container to register services.</param>
|
||||||
|
void Configure(ContainerBuilder builder)
|
||||||
{
|
{
|
||||||
/// <summary>
|
// Skipped
|
||||||
/// The name of the plugin
|
}
|
||||||
/// </summary>
|
|
||||||
string Name { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An optional configuration step to allow a plugin to change asp net configurations.
|
/// A configure method that will be run on plugin's startup.
|
||||||
/// </summary>
|
/// This is available for libraries that build upon a <see cref="IServiceCollection"/>, for more precise
|
||||||
/// <seealso cref="SA"/>
|
/// configuration use <see cref="Configure(Autofac.ContainerBuilder)"/>.
|
||||||
IEnumerable<IStartupAction> ConfigureSteps => ArraySegment<IStartupAction>.Empty;
|
/// </summary>
|
||||||
|
/// <param name="services">A service container to register new services.</param>
|
||||||
/// <summary>
|
void Configure(IServiceCollection services)
|
||||||
/// A configure method that will be run on plugin's startup.
|
{
|
||||||
/// </summary>
|
// Skipped
|
||||||
/// <param name="builder">The autofac service container to register services.</param>
|
|
||||||
void Configure(ContainerBuilder builder)
|
|
||||||
{
|
|
||||||
// Skipped
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A configure method that will be run on plugin's startup.
|
|
||||||
/// This is available for libraries that build upon a <see cref="IServiceCollection"/>, for more precise
|
|
||||||
/// configuration use <see cref="Configure(Autofac.ContainerBuilder)"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="services">A service container to register new services.</param>
|
|
||||||
void Configure(IServiceCollection services)
|
|
||||||
{
|
|
||||||
// Skipped
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,51 +20,50 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Kyoo.Abstractions.Models.Exceptions;
|
using Kyoo.Abstractions.Models.Exceptions;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Controllers
|
namespace Kyoo.Abstractions.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A manager to load plugins and retrieve information from them.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPluginManager
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A manager to load plugins and retrieve information from them.
|
/// Get a single plugin that match the type and name given.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IPluginManager
|
/// <param name="name">The name of the plugin</param>
|
||||||
{
|
/// <typeparam name="T">The type of the plugin</typeparam>
|
||||||
/// <summary>
|
/// <exception cref="ItemNotFoundException">If no plugins match the query</exception>
|
||||||
/// Get a single plugin that match the type and name given.
|
/// <returns>A plugin that match the queries</returns>
|
||||||
/// </summary>
|
public T GetPlugin<T>(string name);
|
||||||
/// <param name="name">The name of the plugin</param>
|
|
||||||
/// <typeparam name="T">The type of the plugin</typeparam>
|
|
||||||
/// <exception cref="ItemNotFoundException">If no plugins match the query</exception>
|
|
||||||
/// <returns>A plugin that match the queries</returns>
|
|
||||||
public T GetPlugin<T>(string name);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get all plugins of the given type.
|
/// Get all plugins of the given type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The type of plugins to get</typeparam>
|
/// <typeparam name="T">The type of plugins to get</typeparam>
|
||||||
/// <returns>A list of plugins matching the given type or an empty list of none match.</returns>
|
/// <returns>A list of plugins matching the given type or an empty list of none match.</returns>
|
||||||
public ICollection<T> GetPlugins<T>();
|
public ICollection<T> GetPlugins<T>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get all plugins currently running on Kyoo. This also includes deleted plugins if the app as not been restarted.
|
/// Get all plugins currently running on Kyoo. This also includes deleted plugins if the app as not been restarted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>All plugins currently loaded.</returns>
|
/// <returns>All plugins currently loaded.</returns>
|
||||||
public ICollection<IPlugin> GetAllPlugins();
|
public ICollection<IPlugin> GetAllPlugins();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load plugins and their dependencies from the plugin directory.
|
/// Load plugins and their dependencies from the plugin directory.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="plugins">
|
/// <param name="plugins">
|
||||||
/// An initial plugin list to use.
|
/// An initial plugin list to use.
|
||||||
/// You should not try to put plugins from the plugins directory here as they will get automatically loaded.
|
/// You should not try to put plugins from the plugins directory here as they will get automatically loaded.
|
||||||
/// </param>
|
/// </param>
|
||||||
public void LoadPlugins(ICollection<IPlugin> plugins);
|
public void LoadPlugins(ICollection<IPlugin> plugins);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load plugins and their dependencies from the plugin directory.
|
/// Load plugins and their dependencies from the plugin directory.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="plugins">
|
/// <param name="plugins">
|
||||||
/// An initial plugin list to use.
|
/// An initial plugin list to use.
|
||||||
/// You should not try to put plugins from the plugins directory here as they will get automatically loaded.
|
/// You should not try to put plugins from the plugins directory here as they will get automatically loaded.
|
||||||
/// </param>
|
/// </param>
|
||||||
public void LoadPlugins(params Type[] plugins);
|
public void LoadPlugins(params Type[] plugins);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -23,249 +23,245 @@ using Kyoo.Abstractions.Models;
|
|||||||
using Kyoo.Abstractions.Models.Exceptions;
|
using Kyoo.Abstractions.Models.Exceptions;
|
||||||
using Kyoo.Abstractions.Models.Utils;
|
using Kyoo.Abstractions.Models.Utils;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Controllers
|
namespace Kyoo.Abstractions.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A common repository for every resources.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The resource's type that this repository manage.</typeparam>
|
||||||
|
public interface IRepository<T> : IBaseRepository
|
||||||
|
where T : IResource, IQuery
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A common repository for every resources.
|
/// The event handler type for all events of this repository.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The resource's type that this repository manage.</typeparam>
|
/// <param name="resource">The resource created/modified/deleted</param>
|
||||||
public interface IRepository<T> : IBaseRepository
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
where T : IResource, IQuery
|
public delegate Task ResourceEventHandler(T resource);
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The event handler type for all events of this repository.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="resource">The resource created/modified/deleted</param>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
public delegate Task ResourceEventHandler(T resource);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get a resource from it's ID.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The id of the resource</param>
|
|
||||||
/// <param name="include">The related fields to include.</param>
|
|
||||||
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
|
|
||||||
/// <returns>The resource found</returns>
|
|
||||||
Task<T> Get(Guid id, Include<T>? include = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get a resource from it's slug.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="slug">The slug of the resource</param>
|
|
||||||
/// <param name="include">The related fields to include.</param>
|
|
||||||
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
|
|
||||||
/// <returns>The resource found</returns>
|
|
||||||
Task<T> Get(string slug, Include<T>? include = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the first resource that match the predicate.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="filter">A predicate to filter the resource.</param>
|
|
||||||
/// <param name="include">The related fields to include.</param>
|
|
||||||
/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param>
|
|
||||||
/// <param name="reverse">Reverse the sort.</param>
|
|
||||||
/// <param name="afterId">Select the first element after this id if it was in a list.</param>
|
|
||||||
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
|
|
||||||
/// <returns>The resource found</returns>
|
|
||||||
Task<T> Get(
|
|
||||||
Filter<T> filter,
|
|
||||||
Include<T>? include = default,
|
|
||||||
Sort<T>? sortBy = default,
|
|
||||||
bool reverse = false,
|
|
||||||
Guid? afterId = default
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get a resource from it's ID or null if it is not found.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The id of the resource</param>
|
|
||||||
/// <param name="include">The related fields to include.</param>
|
|
||||||
/// <returns>The resource found</returns>
|
|
||||||
Task<T?> GetOrDefault(Guid id, Include<T>? include = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get a resource from it's slug or null if it is not found.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="slug">The slug of the resource</param>
|
|
||||||
/// <param name="include">The related fields to include.</param>
|
|
||||||
/// <returns>The resource found</returns>
|
|
||||||
Task<T?> GetOrDefault(string slug, Include<T>? include = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the first resource that match the predicate or null if it is not found.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="filter">A predicate to filter the resource.</param>
|
|
||||||
/// <param name="include">The related fields to include.</param>
|
|
||||||
/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param>
|
|
||||||
/// <param name="reverse">Reverse the sort.</param>
|
|
||||||
/// <param name="afterId">Select the first element after this id if it was in a list.</param>
|
|
||||||
/// <returns>The resource found</returns>
|
|
||||||
Task<T?> GetOrDefault(
|
|
||||||
Filter<T>? filter,
|
|
||||||
Include<T>? include = default,
|
|
||||||
Sort<T>? sortBy = default,
|
|
||||||
bool reverse = false,
|
|
||||||
Guid? afterId = default
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Search for resources with the database.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">The query string.</param>
|
|
||||||
/// <param name="include">The related fields to include.</param>
|
|
||||||
/// <returns>A list of resources found</returns>
|
|
||||||
Task<ICollection<T>> Search(string query, Include<T>? include = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get every resources that match all filters
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="filter">A filter predicate</param>
|
|
||||||
/// <param name="sort">Sort information about the query (sort by, sort order)</param>
|
|
||||||
/// <param name="include">The related fields to include.</param>
|
|
||||||
/// <param name="limit">How pagination should be done (where to start and how many to return)</param>
|
|
||||||
/// <returns>A list of resources that match every filters</returns>
|
|
||||||
Task<ICollection<T>> GetAll(
|
|
||||||
Filter<T>? filter = null,
|
|
||||||
Sort<T>? sort = default,
|
|
||||||
Include<T>? include = default,
|
|
||||||
Pagination? limit = default
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the number of resources that match the filter's predicate.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="filter">A filter predicate</param>
|
|
||||||
/// <returns>How many resources matched that filter</returns>
|
|
||||||
Task<int> GetCount(Filter<T>? filter = null);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Map a list of ids to a list of items (keep the order).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ids">The list of items id.</param>
|
|
||||||
/// <param name="include">The related fields to include.</param>
|
|
||||||
/// <returns>A list of resources mapped from ids.</returns>
|
|
||||||
Task<ICollection<T>> FromIds(IList<Guid> ids, Include<T>? include = default);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new resource.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="obj">The item to register</param>
|
|
||||||
/// <returns>The resource registers and completed by database's information (related items and so on)</returns>
|
|
||||||
Task<T> Create(T obj);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new resource if it does not exist already. If it does, the existing value is returned instead.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="obj">The object to create</param>
|
|
||||||
/// <returns>The newly created item or the existing value if it existed.</returns>
|
|
||||||
Task<T> CreateIfNotExists(T obj);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when a resource has been created.
|
|
||||||
/// </summary>
|
|
||||||
static event ResourceEventHandler OnCreated;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Callback that should be called after a resource has been created.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="obj">The resource newly created.</param>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
protected static Task OnResourceCreated(T obj) =>
|
|
||||||
OnCreated?.Invoke(obj) ?? Task.CompletedTask;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Edit a resource and replace every property
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="edited">The resource to edit, it's ID can't change.</param>
|
|
||||||
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
|
||||||
/// <returns>The resource edited and completed by database's information (related items and so on)</returns>
|
|
||||||
Task<T> Edit(T edited);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Edit only specific properties of a resource
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The id of the resource to edit</param>
|
|
||||||
/// <param name="patch">
|
|
||||||
/// A method that will be called when you need to update every properties that you want to
|
|
||||||
/// persist.
|
|
||||||
/// </param>
|
|
||||||
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
|
||||||
/// <returns>The resource edited and completed by database's information (related items and so on)</returns>
|
|
||||||
Task<T> Patch(Guid id, Func<T, T> patch);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when a resource has been edited.
|
|
||||||
/// </summary>
|
|
||||||
static event ResourceEventHandler OnEdited;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Callback that should be called after a resource has been edited.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="obj">The resource newly edited.</param>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
protected static Task OnResourceEdited(T obj) =>
|
|
||||||
OnEdited?.Invoke(obj) ?? Task.CompletedTask;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete a resource by it's ID
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="id">The ID of the resource</param>
|
|
||||||
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
Task Delete(Guid id);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete a resource by it's slug
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="slug">The slug of the resource</param>
|
|
||||||
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
Task Delete(string slug);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete a resource
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="obj">The resource to delete</param>
|
|
||||||
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
Task Delete(T obj);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete all resources that match the predicate.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="filter">A predicate to filter resources to delete. Every resource that match this will be deleted.</param>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
Task DeleteAll(Filter<T> filter);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when a resource has been edited.
|
|
||||||
/// </summary>
|
|
||||||
static event ResourceEventHandler OnDeleted;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Callback that should be called after a resource has been deleted.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="obj">The resource newly deleted.</param>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
protected static Task OnResourceDeleted(T obj) =>
|
|
||||||
OnDeleted?.Invoke(obj) ?? Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A base class for repositories. Every service implementing this will be handled by the <see cref="ILibraryManager"/>.
|
/// Get a resource from it's ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IBaseRepository
|
/// <param name="id">The id of the resource</param>
|
||||||
{
|
/// <param name="include">The related fields to include.</param>
|
||||||
/// <summary>
|
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
|
||||||
/// The type for witch this repository is responsible or null if non applicable.
|
/// <returns>The resource found</returns>
|
||||||
/// </summary>
|
Task<T> Get(Guid id, Include<T>? include = default);
|
||||||
Type RepositoryType { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IUserRepository : IRepository<User>
|
/// <summary>
|
||||||
{
|
/// Get a resource from it's slug.
|
||||||
Task<User?> GetByExternalId(string provider, string id);
|
/// </summary>
|
||||||
Task<User> AddExternalToken(Guid userId, string provider, ExternalToken token);
|
/// <param name="slug">The slug of the resource</param>
|
||||||
Task<User> DeleteExternalToken(Guid userId, string provider);
|
/// <param name="include">The related fields to include.</param>
|
||||||
}
|
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
|
||||||
|
/// <returns>The resource found</returns>
|
||||||
|
Task<T> Get(string slug, Include<T>? include = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the first resource that match the predicate.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filter">A predicate to filter the resource.</param>
|
||||||
|
/// <param name="include">The related fields to include.</param>
|
||||||
|
/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param>
|
||||||
|
/// <param name="reverse">Reverse the sort.</param>
|
||||||
|
/// <param name="afterId">Select the first element after this id if it was in a list.</param>
|
||||||
|
/// <exception cref="ItemNotFoundException">If the item could not be found.</exception>
|
||||||
|
/// <returns>The resource found</returns>
|
||||||
|
Task<T> Get(
|
||||||
|
Filter<T> filter,
|
||||||
|
Include<T>? include = default,
|
||||||
|
Sort<T>? sortBy = default,
|
||||||
|
bool reverse = false,
|
||||||
|
Guid? afterId = default
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a resource from it's ID or null if it is not found.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The id of the resource</param>
|
||||||
|
/// <param name="include">The related fields to include.</param>
|
||||||
|
/// <returns>The resource found</returns>
|
||||||
|
Task<T?> GetOrDefault(Guid id, Include<T>? include = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a resource from it's slug or null if it is not found.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="slug">The slug of the resource</param>
|
||||||
|
/// <param name="include">The related fields to include.</param>
|
||||||
|
/// <returns>The resource found</returns>
|
||||||
|
Task<T?> GetOrDefault(string slug, Include<T>? include = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the first resource that match the predicate or null if it is not found.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filter">A predicate to filter the resource.</param>
|
||||||
|
/// <param name="include">The related fields to include.</param>
|
||||||
|
/// <param name="sortBy">A custom sort method to handle cases where multiples items match the filters.</param>
|
||||||
|
/// <param name="reverse">Reverse the sort.</param>
|
||||||
|
/// <param name="afterId">Select the first element after this id if it was in a list.</param>
|
||||||
|
/// <returns>The resource found</returns>
|
||||||
|
Task<T?> GetOrDefault(
|
||||||
|
Filter<T>? filter,
|
||||||
|
Include<T>? include = default,
|
||||||
|
Sort<T>? sortBy = default,
|
||||||
|
bool reverse = false,
|
||||||
|
Guid? afterId = default
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Search for resources with the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The query string.</param>
|
||||||
|
/// <param name="include">The related fields to include.</param>
|
||||||
|
/// <returns>A list of resources found</returns>
|
||||||
|
Task<ICollection<T>> Search(string query, Include<T>? include = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get every resources that match all filters
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filter">A filter predicate</param>
|
||||||
|
/// <param name="sort">Sort information about the query (sort by, sort order)</param>
|
||||||
|
/// <param name="include">The related fields to include.</param>
|
||||||
|
/// <param name="limit">How pagination should be done (where to start and how many to return)</param>
|
||||||
|
/// <returns>A list of resources that match every filters</returns>
|
||||||
|
Task<ICollection<T>> GetAll(
|
||||||
|
Filter<T>? filter = null,
|
||||||
|
Sort<T>? sort = default,
|
||||||
|
Include<T>? include = default,
|
||||||
|
Pagination? limit = default
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the number of resources that match the filter's predicate.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filter">A filter predicate</param>
|
||||||
|
/// <returns>How many resources matched that filter</returns>
|
||||||
|
Task<int> GetCount(Filter<T>? filter = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map a list of ids to a list of items (keep the order).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ids">The list of items id.</param>
|
||||||
|
/// <param name="include">The related fields to include.</param>
|
||||||
|
/// <returns>A list of resources mapped from ids.</returns>
|
||||||
|
Task<ICollection<T>> FromIds(IList<Guid> ids, Include<T>? include = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new resource.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The item to register</param>
|
||||||
|
/// <returns>The resource registers and completed by database's information (related items and so on)</returns>
|
||||||
|
Task<T> Create(T obj);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new resource if it does not exist already. If it does, the existing value is returned instead.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The object to create</param>
|
||||||
|
/// <returns>The newly created item or the existing value if it existed.</returns>
|
||||||
|
Task<T> CreateIfNotExists(T obj);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when a resource has been created.
|
||||||
|
/// </summary>
|
||||||
|
static event ResourceEventHandler OnCreated;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback that should be called after a resource has been created.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The resource newly created.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
protected static Task OnResourceCreated(T obj) => OnCreated?.Invoke(obj) ?? Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edit a resource and replace every property
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="edited">The resource to edit, it's ID can't change.</param>
|
||||||
|
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
||||||
|
/// <returns>The resource edited and completed by database's information (related items and so on)</returns>
|
||||||
|
Task<T> Edit(T edited);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edit only specific properties of a resource
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The id of the resource to edit</param>
|
||||||
|
/// <param name="patch">
|
||||||
|
/// A method that will be called when you need to update every properties that you want to
|
||||||
|
/// persist.
|
||||||
|
/// </param>
|
||||||
|
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
||||||
|
/// <returns>The resource edited and completed by database's information (related items and so on)</returns>
|
||||||
|
Task<T> Patch(Guid id, Func<T, T> patch);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when a resource has been edited.
|
||||||
|
/// </summary>
|
||||||
|
static event ResourceEventHandler OnEdited;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback that should be called after a resource has been edited.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The resource newly edited.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
protected static Task OnResourceEdited(T obj) => OnEdited?.Invoke(obj) ?? Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete a resource by it's ID
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The ID of the resource</param>
|
||||||
|
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task Delete(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete a resource by it's slug
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="slug">The slug of the resource</param>
|
||||||
|
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task Delete(string slug);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete a resource
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The resource to delete</param>
|
||||||
|
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task Delete(T obj);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete all resources that match the predicate.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filter">A predicate to filter resources to delete. Every resource that match this will be deleted.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task DeleteAll(Filter<T> filter);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when a resource has been edited.
|
||||||
|
/// </summary>
|
||||||
|
static event ResourceEventHandler OnDeleted;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback that should be called after a resource has been deleted.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="obj">The resource newly deleted.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
protected static Task OnResourceDeleted(T obj) => OnDeleted?.Invoke(obj) ?? Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A base class for repositories. Every service implementing this will be handled by the <see cref="ILibraryManager"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBaseRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The type for witch this repository is responsible or null if non applicable.
|
||||||
|
/// </summary>
|
||||||
|
Type RepositoryType { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IUserRepository : IRepository<User>
|
||||||
|
{
|
||||||
|
Task<User?> GetByExternalId(string provider, string id);
|
||||||
|
Task<User> AddExternalToken(Guid userId, string provider, ExternalToken token);
|
||||||
|
Task<User> DeleteExternalToken(Guid userId, string provider);
|
||||||
}
|
}
|
||||||
|
@ -21,59 +21,58 @@ using System.IO;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Kyoo.Abstractions.Models;
|
using Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Controllers
|
namespace Kyoo.Abstractions.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download images and retrieve the path of those images for a resource.
|
||||||
|
/// </summary>
|
||||||
|
public interface IThumbnailsManager
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Download images and retrieve the path of those images for a resource.
|
/// Download images of a specified item.
|
||||||
|
/// If no images is available to download, do nothing and silently return.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IThumbnailsManager
|
/// <param name="item">
|
||||||
{
|
/// The item to cache images.
|
||||||
/// <summary>
|
/// </param>
|
||||||
/// Download images of a specified item.
|
/// <typeparam name="T">The type of the item</typeparam>
|
||||||
/// If no images is available to download, do nothing and silently return.
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
/// </summary>
|
Task DownloadImages<T>(T item)
|
||||||
/// <param name="item">
|
where T : IThumbnails;
|
||||||
/// The item to cache images.
|
|
||||||
/// </param>
|
|
||||||
/// <typeparam name="T">The type of the item</typeparam>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
Task DownloadImages<T>(T item)
|
|
||||||
where T : IThumbnails;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieve the local path of an image of the given item.
|
/// Retrieve the local path of an image of the given item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="item">The item to retrieve the poster from.</param>
|
/// <param name="item">The item to retrieve the poster from.</param>
|
||||||
/// <param name="image">The ID of the image.</param>
|
/// <param name="image">The ID of the image.</param>
|
||||||
/// <param name="quality">The quality of the image</param>
|
/// <param name="quality">The quality of the image</param>
|
||||||
/// <typeparam name="T">The type of the item</typeparam>
|
/// <typeparam name="T">The type of the item</typeparam>
|
||||||
/// <returns>The path of the image for the given resource or null if it does not exists.</returns>
|
/// <returns>The path of the image for the given resource or null if it does not exists.</returns>
|
||||||
string GetImagePath<T>(T item, string image, ImageQuality quality)
|
string GetImagePath<T>(T item, string image, ImageQuality quality)
|
||||||
where T : IThumbnails;
|
where T : IThumbnails;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delete images associated with the item.
|
/// Delete images associated with the item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="item">
|
/// <param name="item">
|
||||||
/// The item with cached images.
|
/// The item with cached images.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <typeparam name="T">The type of the item</typeparam>
|
/// <typeparam name="T">The type of the item</typeparam>
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
Task DeleteImages<T>(T item)
|
Task DeleteImages<T>(T item)
|
||||||
where T : IThumbnails;
|
where T : IThumbnails;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set the user's profile picture
|
/// Set the user's profile picture
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">The id of the user. </param>
|
/// <param name="userId">The id of the user. </param>
|
||||||
/// <returns>The byte stream of the image. Null if no image exist.</returns>
|
/// <returns>The byte stream of the image. Null if no image exist.</returns>
|
||||||
Task<Stream> GetUserImage(Guid userId);
|
Task<Stream> GetUserImage(Guid userId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set the user's profile picture
|
/// Set the user's profile picture
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">The id of the user. </param>
|
/// <param name="userId">The id of the user. </param>
|
||||||
/// <param name="image">The byte stream of the image. Null to delete the image.</param>
|
/// <param name="image">The byte stream of the image. Null to delete the image.</param>
|
||||||
Task SetUserImage(Guid userId, Stream? image);
|
Task SetUserImage(Guid userId, Stream? image);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -19,256 +19,252 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Controllers
|
namespace Kyoo.Abstractions.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A list of constant priorities used for <see cref="IStartupAction"/>'s <see cref="IStartupAction.Priority"/>.
|
||||||
|
/// It also contains helper methods for creating new <see cref="StartupAction"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class SA
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A list of constant priorities used for <see cref="IStartupAction"/>'s <see cref="IStartupAction.Priority"/>.
|
/// The highest predefined priority existing for <see cref="StartupAction"/>.
|
||||||
/// It also contains helper methods for creating new <see cref="StartupAction"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SA
|
public const int Before = 5000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Items defining routing (see IApplicationBuilder.UseRouting use this priority.
|
||||||
|
/// </summary>
|
||||||
|
public const int Routing = 4000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Actions defining new static files router use this priority.
|
||||||
|
/// </summary>
|
||||||
|
public const int StaticFiles = 3000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Actions calling IApplicationBuilder.UseAuthentication use this priority.
|
||||||
|
/// </summary>
|
||||||
|
public const int Authentication = 2000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Actions calling IApplicationBuilder.UseAuthorization use this priority.
|
||||||
|
/// </summary>
|
||||||
|
public const int Authorization = 1000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Action adding endpoint should use this priority (with a negative modificator if there is a catchall).
|
||||||
|
/// </summary>
|
||||||
|
public const int Endpoint = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The lowest predefined priority existing for <see cref="StartupAction"/>.
|
||||||
|
/// It should run after all other actions.
|
||||||
|
/// </summary>
|
||||||
|
public const int After = -1000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="StartupAction"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="action">The action to run</param>
|
||||||
|
/// <param name="priority">The priority of the new action</param>
|
||||||
|
/// <returns>A new <see cref="StartupAction"/></returns>
|
||||||
|
public static StartupAction New(Action action, int priority) => new(action, priority);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="StartupAction"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="action">The action to run</param>
|
||||||
|
/// <param name="priority">The priority of the new action</param>
|
||||||
|
/// <typeparam name="T">A dependency that this action will use.</typeparam>
|
||||||
|
/// <returns>A new <see cref="StartupAction"/></returns>
|
||||||
|
public static StartupAction<T> New<T>(Action<T> action, int priority)
|
||||||
|
where T : notnull => new(action, priority);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="StartupAction"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="action">The action to run</param>
|
||||||
|
/// <param name="priority">The priority of the new action</param>
|
||||||
|
/// <typeparam name="T">A dependency that this action will use.</typeparam>
|
||||||
|
/// <typeparam name="T2">A second dependency that this action will use.</typeparam>
|
||||||
|
/// <returns>A new <see cref="StartupAction"/></returns>
|
||||||
|
public static StartupAction<T, T2> New<T, T2>(Action<T, T2> action, int priority)
|
||||||
|
where T : notnull
|
||||||
|
where T2 : notnull => new(action, priority);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="StartupAction"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="action">The action to run</param>
|
||||||
|
/// <param name="priority">The priority of the new action</param>
|
||||||
|
/// <typeparam name="T">A dependency that this action will use.</typeparam>
|
||||||
|
/// <typeparam name="T2">A second dependency that this action will use.</typeparam>
|
||||||
|
/// <typeparam name="T3">A third dependency that this action will use.</typeparam>
|
||||||
|
/// <returns>A new <see cref="StartupAction"/></returns>
|
||||||
|
public static StartupAction<T, T2, T3> New<T, T2, T3>(Action<T, T2, T3> action, int priority)
|
||||||
|
where T : notnull
|
||||||
|
where T2 : notnull
|
||||||
|
where T3 : notnull => new(action, priority);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="IStartupAction"/> with no dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public class StartupAction : IStartupAction
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The highest predefined priority existing for <see cref="StartupAction"/>.
|
/// The action to execute at startup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int Before = 5000;
|
private readonly Action _action;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Items defining routing (see IApplicationBuilder.UseRouting use this priority.
|
public int Priority { get; }
|
||||||
/// </summary>
|
|
||||||
public const int Routing = 4000;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Actions defining new static files router use this priority.
|
|
||||||
/// </summary>
|
|
||||||
public const int StaticFiles = 3000;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Actions calling IApplicationBuilder.UseAuthentication use this priority.
|
|
||||||
/// </summary>
|
|
||||||
public const int Authentication = 2000;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Actions calling IApplicationBuilder.UseAuthorization use this priority.
|
|
||||||
/// </summary>
|
|
||||||
public const int Authorization = 1000;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Action adding endpoint should use this priority (with a negative modificator if there is a catchall).
|
|
||||||
/// </summary>
|
|
||||||
public const int Endpoint = 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The lowest predefined priority existing for <see cref="StartupAction"/>.
|
|
||||||
/// It should run after all other actions.
|
|
||||||
/// </summary>
|
|
||||||
public const int After = -1000;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="StartupAction"/>.
|
/// Create a new <see cref="StartupAction"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="action">The action to run</param>
|
/// <param name="action">The action to execute on startup.</param>
|
||||||
/// <param name="priority">The priority of the new action</param>
|
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
|
||||||
/// <returns>A new <see cref="StartupAction"/></returns>
|
public StartupAction(Action action, int priority)
|
||||||
public static StartupAction New(Action action, int priority) => new(action, priority);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="StartupAction"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="action">The action to run</param>
|
|
||||||
/// <param name="priority">The priority of the new action</param>
|
|
||||||
/// <typeparam name="T">A dependency that this action will use.</typeparam>
|
|
||||||
/// <returns>A new <see cref="StartupAction"/></returns>
|
|
||||||
public static StartupAction<T> New<T>(Action<T> action, int priority)
|
|
||||||
where T : notnull => new(action, priority);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="StartupAction"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="action">The action to run</param>
|
|
||||||
/// <param name="priority">The priority of the new action</param>
|
|
||||||
/// <typeparam name="T">A dependency that this action will use.</typeparam>
|
|
||||||
/// <typeparam name="T2">A second dependency that this action will use.</typeparam>
|
|
||||||
/// <returns>A new <see cref="StartupAction"/></returns>
|
|
||||||
public static StartupAction<T, T2> New<T, T2>(Action<T, T2> action, int priority)
|
|
||||||
where T : notnull
|
|
||||||
where T2 : notnull => new(action, priority);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="StartupAction"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="action">The action to run</param>
|
|
||||||
/// <param name="priority">The priority of the new action</param>
|
|
||||||
/// <typeparam name="T">A dependency that this action will use.</typeparam>
|
|
||||||
/// <typeparam name="T2">A second dependency that this action will use.</typeparam>
|
|
||||||
/// <typeparam name="T3">A third dependency that this action will use.</typeparam>
|
|
||||||
/// <returns>A new <see cref="StartupAction"/></returns>
|
|
||||||
public static StartupAction<T, T2, T3> New<T, T2, T3>(
|
|
||||||
Action<T, T2, T3> action,
|
|
||||||
int priority
|
|
||||||
)
|
|
||||||
where T : notnull
|
|
||||||
where T2 : notnull
|
|
||||||
where T3 : notnull => new(action, priority);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A <see cref="IStartupAction"/> with no dependencies.
|
|
||||||
/// </summary>
|
|
||||||
public class StartupAction : IStartupAction
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
_action = action;
|
||||||
/// The action to execute at startup.
|
Priority = priority;
|
||||||
/// </summary>
|
|
||||||
private readonly Action _action;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public int Priority { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="StartupAction"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="action">The action to execute on startup.</param>
|
|
||||||
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
|
|
||||||
public StartupAction(Action action, int priority)
|
|
||||||
{
|
|
||||||
_action = action;
|
|
||||||
Priority = priority;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Run(IServiceProvider provider)
|
|
||||||
{
|
|
||||||
_action.Invoke();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// A <see cref="IStartupAction"/> with one dependencies.
|
public void Run(IServiceProvider provider)
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">The dependency to use.</typeparam>
|
|
||||||
public class StartupAction<T> : IStartupAction
|
|
||||||
where T : notnull
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
_action.Invoke();
|
||||||
/// The action to execute at startup.
|
|
||||||
/// </summary>
|
|
||||||
private readonly Action<T> _action;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public int Priority { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="StartupAction{T}"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="action">The action to execute on startup.</param>
|
|
||||||
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
|
|
||||||
public StartupAction(Action<T> action, int priority)
|
|
||||||
{
|
|
||||||
_action = action;
|
|
||||||
Priority = priority;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Run(IServiceProvider provider)
|
|
||||||
{
|
|
||||||
_action.Invoke(provider.GetRequiredService<T>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A <see cref="IStartupAction"/> with two dependencies.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">The dependency to use.</typeparam>
|
|
||||||
/// <typeparam name="T2">The second dependency to use.</typeparam>
|
|
||||||
public class StartupAction<T, T2> : IStartupAction
|
|
||||||
where T : notnull
|
|
||||||
where T2 : notnull
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The action to execute at startup.
|
|
||||||
/// </summary>
|
|
||||||
private readonly Action<T, T2> _action;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public int Priority { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="StartupAction{T, T2}"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="action">The action to execute on startup.</param>
|
|
||||||
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
|
|
||||||
public StartupAction(Action<T, T2> action, int priority)
|
|
||||||
{
|
|
||||||
_action = action;
|
|
||||||
Priority = priority;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Run(IServiceProvider provider)
|
|
||||||
{
|
|
||||||
_action.Invoke(provider.GetRequiredService<T>(), provider.GetRequiredService<T2>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A <see cref="IStartupAction"/> with three dependencies.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">The dependency to use.</typeparam>
|
|
||||||
/// <typeparam name="T2">The second dependency to use.</typeparam>
|
|
||||||
/// <typeparam name="T3">The third dependency to use.</typeparam>
|
|
||||||
public class StartupAction<T, T2, T3> : IStartupAction
|
|
||||||
where T : notnull
|
|
||||||
where T2 : notnull
|
|
||||||
where T3 : notnull
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The action to execute at startup.
|
|
||||||
/// </summary>
|
|
||||||
private readonly Action<T, T2, T3> _action;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public int Priority { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="StartupAction{T, T2, T3}"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="action">The action to execute on startup.</param>
|
|
||||||
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
|
|
||||||
public StartupAction(Action<T, T2, T3> action, int priority)
|
|
||||||
{
|
|
||||||
_action = action;
|
|
||||||
Priority = priority;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Run(IServiceProvider provider)
|
|
||||||
{
|
|
||||||
_action.Invoke(
|
|
||||||
provider.GetRequiredService<T>(),
|
|
||||||
provider.GetRequiredService<T2>(),
|
|
||||||
provider.GetRequiredService<T3>()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An action executed on kyoo's startup to initialize the asp-net container.
|
/// A <see cref="IStartupAction"/> with one dependencies.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <typeparam name="T">The dependency to use.</typeparam>
|
||||||
/// This is the base interface, see <see cref="SA.StartupAction"/> for a simpler use of this.
|
public class StartupAction<T> : IStartupAction
|
||||||
/// </remarks>
|
where T : notnull
|
||||||
public interface IStartupAction
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The priority of this action. The actions will be executed on descending priority order.
|
/// The action to execute at startup.
|
||||||
/// If two actions have the same priority, their order is undefined.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
int Priority { get; }
|
private readonly Action<T> _action;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int Priority { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Run this action to configure the container, a service provider containing all services can be used.
|
/// Create a new <see cref="StartupAction{T}"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="provider">The service provider containing all services can be used.</param>
|
/// <param name="action">The action to execute on startup.</param>
|
||||||
void Run(IServiceProvider provider);
|
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
|
||||||
|
public StartupAction(Action<T> action, int priority)
|
||||||
|
{
|
||||||
|
_action = action;
|
||||||
|
Priority = priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Run(IServiceProvider provider)
|
||||||
|
{
|
||||||
|
_action.Invoke(provider.GetRequiredService<T>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="IStartupAction"/> with two dependencies.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The dependency to use.</typeparam>
|
||||||
|
/// <typeparam name="T2">The second dependency to use.</typeparam>
|
||||||
|
public class StartupAction<T, T2> : IStartupAction
|
||||||
|
where T : notnull
|
||||||
|
where T2 : notnull
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The action to execute at startup.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Action<T, T2> _action;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int Priority { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="StartupAction{T, T2}"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="action">The action to execute on startup.</param>
|
||||||
|
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
|
||||||
|
public StartupAction(Action<T, T2> action, int priority)
|
||||||
|
{
|
||||||
|
_action = action;
|
||||||
|
Priority = priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Run(IServiceProvider provider)
|
||||||
|
{
|
||||||
|
_action.Invoke(provider.GetRequiredService<T>(), provider.GetRequiredService<T2>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="IStartupAction"/> with three dependencies.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The dependency to use.</typeparam>
|
||||||
|
/// <typeparam name="T2">The second dependency to use.</typeparam>
|
||||||
|
/// <typeparam name="T3">The third dependency to use.</typeparam>
|
||||||
|
public class StartupAction<T, T2, T3> : IStartupAction
|
||||||
|
where T : notnull
|
||||||
|
where T2 : notnull
|
||||||
|
where T3 : notnull
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The action to execute at startup.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Action<T, T2, T3> _action;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int Priority { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="StartupAction{T, T2, T3}"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="action">The action to execute on startup.</param>
|
||||||
|
/// <param name="priority">The priority of this action (see <see cref="Priority"/>).</param>
|
||||||
|
public StartupAction(Action<T, T2, T3> action, int priority)
|
||||||
|
{
|
||||||
|
_action = action;
|
||||||
|
Priority = priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Run(IServiceProvider provider)
|
||||||
|
{
|
||||||
|
_action.Invoke(
|
||||||
|
provider.GetRequiredService<T>(),
|
||||||
|
provider.GetRequiredService<T2>(),
|
||||||
|
provider.GetRequiredService<T3>()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An action executed on kyoo's startup to initialize the asp-net container.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the base interface, see <see cref="SA.StartupAction"/> for a simpler use of this.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IStartupAction
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The priority of this action. The actions will be executed on descending priority order.
|
||||||
|
/// If two actions have the same priority, their order is undefined.
|
||||||
|
/// </summary>
|
||||||
|
int Priority { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Run this action to configure the container, a service provider containing all services can be used.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The service provider containing all services can be used.</param>
|
||||||
|
void Run(IServiceProvider provider);
|
||||||
|
}
|
||||||
|
@ -23,43 +23,42 @@ using System.Security.Claims;
|
|||||||
using Kyoo.Abstractions.Models.Exceptions;
|
using Kyoo.Abstractions.Models.Exceptions;
|
||||||
using Kyoo.Authentication.Models;
|
using Kyoo.Authentication.Models;
|
||||||
|
|
||||||
namespace Kyoo.Authentication
|
namespace Kyoo.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods.
|
||||||
|
/// </summary>
|
||||||
|
public static class Extensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extension methods.
|
/// Get the permissions of an user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Extensions
|
/// <param name="user">The user</param>
|
||||||
|
/// <returns>The list of permissions</returns>
|
||||||
|
public static ICollection<string> GetPermissions(this ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
/// <summary>
|
return user.Claims.FirstOrDefault(x => x.Type == Claims.Permissions)?.Value.Split(',')
|
||||||
/// Get the permissions of an user.
|
?? Array.Empty<string>();
|
||||||
/// </summary>
|
}
|
||||||
/// <param name="user">The user</param>
|
|
||||||
/// <returns>The list of permissions</returns>
|
|
||||||
public static ICollection<string> GetPermissions(this ClaimsPrincipal user)
|
|
||||||
{
|
|
||||||
return user.Claims.FirstOrDefault(x => x.Type == Claims.Permissions)?.Value.Split(',')
|
|
||||||
?? Array.Empty<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the id of the current user or null if unlogged or invalid.
|
/// Get the id of the current user or null if unlogged or invalid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user.</param>
|
/// <param name="user">The user.</param>
|
||||||
/// <returns>The id of the user or null.</returns>
|
/// <returns>The id of the user or null.</returns>
|
||||||
public static Guid? GetId(this ClaimsPrincipal user)
|
public static Guid? GetId(this ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
Claim? value = user.FindFirst(Claims.Id);
|
Claim? value = user.FindFirst(Claims.Id);
|
||||||
if (Guid.TryParse(value?.Value, out Guid id))
|
if (Guid.TryParse(value?.Value, out Guid id))
|
||||||
return id;
|
return id;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Guid GetIdOrThrow(this ClaimsPrincipal user)
|
public static Guid GetIdOrThrow(this ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
Guid? ret = user.GetId();
|
Guid? ret = user.GetId();
|
||||||
if (ret == null)
|
if (ret == null)
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
return ret.Value;
|
return ret.Value;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,35 +18,34 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Attributes
|
namespace Kyoo.Abstractions.Models.Attributes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An attribute to specify on apis to specify it's documentation's name and category.
|
||||||
|
/// If this is applied on a method, the specified method will be exploded from the controller's page and be
|
||||||
|
/// included on the specified tag page.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
|
public class ApiDefinitionAttribute : Attribute
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An attribute to specify on apis to specify it's documentation's name and category.
|
/// The public name of this api.
|
||||||
/// If this is applied on a method, the specified method will be exploded from the controller's page and be
|
|
||||||
/// included on the specified tag page.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
public string Name { get; }
|
||||||
public class ApiDefinitionAttribute : Attribute
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the group in witch this API is. You can also specify a custom sort order using the following
|
||||||
|
/// format: <code>order:name</code>. Everything before the first <c>:</c> will be removed but kept for
|
||||||
|
/// th alphabetical ordering.
|
||||||
|
/// </summary>
|
||||||
|
public string? Group { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="ApiDefinitionAttribute"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The name of the api that will be used on the documentation page.</param>
|
||||||
|
public ApiDefinitionAttribute(string name)
|
||||||
{
|
{
|
||||||
/// <summary>
|
Name = name;
|
||||||
/// The public name of this api.
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The name of the group in witch this API is. You can also specify a custom sort order using the following
|
|
||||||
/// format: <code>order:name</code>. Everything before the first <c>:</c> will be removed but kept for
|
|
||||||
/// th alphabetical ordering.
|
|
||||||
/// </summary>
|
|
||||||
public string? Group { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="ApiDefinitionAttribute"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="name">The name of the api that will be used on the documentation page.</param>
|
|
||||||
public ApiDefinitionAttribute(string name)
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,11 +18,10 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Attributes
|
namespace Kyoo.Abstractions.Models.Attributes;
|
||||||
{
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An attribute to inform that the property is computed automatically and can't be assigned manually.
|
/// An attribute to inform that the property is computed automatically and can't be assigned manually.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AttributeUsage(AttributeTargets.Property)]
|
[AttributeUsage(AttributeTargets.Property)]
|
||||||
public class ComputedAttribute : NotMergeableAttribute { }
|
public class ComputedAttribute : NotMergeableAttribute { }
|
||||||
}
|
|
||||||
|
@ -18,37 +18,36 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Attributes
|
namespace Kyoo.Abstractions.Models.Attributes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The targeted relation can be loaded.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Property)]
|
||||||
|
public class LoadableRelationAttribute : Attribute
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The targeted relation can be loaded.
|
/// The name of the field containing the related resource's ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AttributeUsage(AttributeTargets.Property)]
|
public string? RelationID { get; }
|
||||||
public class LoadableRelationAttribute : Attribute
|
|
||||||
|
public string? Sql { get; set; }
|
||||||
|
|
||||||
|
public string? On { get; set; }
|
||||||
|
|
||||||
|
public string? Projected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="LoadableRelationAttribute"/>.
|
||||||
|
/// </summary>
|
||||||
|
public LoadableRelationAttribute() { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="LoadableRelationAttribute"/> with a baking relationID field.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="relationID">The name of the RelationID field.</param>
|
||||||
|
public LoadableRelationAttribute(string relationID)
|
||||||
{
|
{
|
||||||
/// <summary>
|
RelationID = relationID;
|
||||||
/// The name of the field containing the related resource's ID.
|
|
||||||
/// </summary>
|
|
||||||
public string? RelationID { get; }
|
|
||||||
|
|
||||||
public string? Sql { get; set; }
|
|
||||||
|
|
||||||
public string? On { get; set; }
|
|
||||||
|
|
||||||
public string? Projected { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="LoadableRelationAttribute"/>.
|
|
||||||
/// </summary>
|
|
||||||
public LoadableRelationAttribute() { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="LoadableRelationAttribute"/> with a baking relationID field.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="relationID">The name of the RelationID field.</param>
|
|
||||||
public LoadableRelationAttribute(string relationID)
|
|
||||||
{
|
|
||||||
RelationID = relationID;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,23 +18,22 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Attributes
|
namespace Kyoo.Abstractions.Models.Attributes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specify that a property can't be merged.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Property)]
|
||||||
|
public class NotMergeableAttribute : Attribute { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An interface with a method called when this object is merged.
|
||||||
|
/// </summary>
|
||||||
|
public interface IOnMerge
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Specify that a property can't be merged.
|
/// This function is called after the object has been merged.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AttributeUsage(AttributeTargets.Property)]
|
/// <param name="merged">The object that has been merged with this.</param>
|
||||||
public class NotMergeableAttribute : Attribute { }
|
void OnMerge(object merged);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An interface with a method called when this object is merged.
|
|
||||||
/// </summary>
|
|
||||||
public interface IOnMerge
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This function is called after the object has been merged.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="merged">The object that has been merged with this.</param>
|
|
||||||
void OnMerge(object merged);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -21,68 +21,67 @@ using Kyoo.Abstractions.Controllers;
|
|||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Permissions
|
namespace Kyoo.Abstractions.Models.Permissions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specify one part of a permissions needed for the API (the kind or the type).
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
||||||
|
public class PartialPermissionAttribute : Attribute, IFilterFactory
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Specify one part of a permissions needed for the API (the kind or the type).
|
/// The needed permission type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
public string? Type { get; }
|
||||||
public class PartialPermissionAttribute : Attribute, IFilterFactory
|
|
||||||
|
/// <summary>
|
||||||
|
/// The needed permission kind.
|
||||||
|
/// </summary>
|
||||||
|
public Kind? Kind { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The group of this permission.
|
||||||
|
/// </summary>
|
||||||
|
public Group Group { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ask a permission to run an action.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// With this attribute, you can only specify a type or a kind.
|
||||||
|
/// To have a valid permission attribute, you must specify the kind and the permission using two attributes.
|
||||||
|
/// Those attributes can be dispatched at different places (one on the class, one on the method for example).
|
||||||
|
/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will
|
||||||
|
/// lead to unspecified behaviors.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="type">The type of the action</param>
|
||||||
|
public PartialPermissionAttribute(string type)
|
||||||
{
|
{
|
||||||
/// <summary>
|
Type = type.ToLower();
|
||||||
/// The needed permission type.
|
|
||||||
/// </summary>
|
|
||||||
public string? Type { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The needed permission kind.
|
|
||||||
/// </summary>
|
|
||||||
public Kind? Kind { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The group of this permission.
|
|
||||||
/// </summary>
|
|
||||||
public Group Group { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ask a permission to run an action.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// With this attribute, you can only specify a type or a kind.
|
|
||||||
/// To have a valid permission attribute, you must specify the kind and the permission using two attributes.
|
|
||||||
/// Those attributes can be dispatched at different places (one on the class, one on the method for example).
|
|
||||||
/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will
|
|
||||||
/// lead to unspecified behaviors.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="type">The type of the action</param>
|
|
||||||
public PartialPermissionAttribute(string type)
|
|
||||||
{
|
|
||||||
Type = type.ToLower();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ask a permission to run an action.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// With this attribute, you can only specify a type or a kind.
|
|
||||||
/// To have a valid permission attribute, you must specify the kind and the permission using two attributes.
|
|
||||||
/// Those attributes can be dispatched at different places (one on the class, one on the method for example).
|
|
||||||
/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will
|
|
||||||
/// lead to unspecified behaviors.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="permission">The kind of permission needed.</param>
|
|
||||||
public PartialPermissionAttribute(Kind permission)
|
|
||||||
{
|
|
||||||
Kind = permission;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
|
|
||||||
{
|
|
||||||
return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool IsReusable => true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ask a permission to run an action.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// With this attribute, you can only specify a type or a kind.
|
||||||
|
/// To have a valid permission attribute, you must specify the kind and the permission using two attributes.
|
||||||
|
/// Those attributes can be dispatched at different places (one on the class, one on the method for example).
|
||||||
|
/// If you don't put exactly two of those attributes, the permission attribute will be ill-formed and will
|
||||||
|
/// lead to unspecified behaviors.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="permission">The kind of permission needed.</param>
|
||||||
|
public PartialPermissionAttribute(Kind permission)
|
||||||
|
{
|
||||||
|
Kind = permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsReusable => true;
|
||||||
}
|
}
|
||||||
|
@ -21,117 +21,116 @@ using Kyoo.Abstractions.Controllers;
|
|||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Permissions
|
namespace Kyoo.Abstractions.Models.Permissions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The kind of permission needed.
|
||||||
|
/// </summary>
|
||||||
|
public enum Kind
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Allow the user to read for this kind of data.
|
||||||
|
/// </summary>
|
||||||
|
Read,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allow the user to write for this kind of data.
|
||||||
|
/// </summary>
|
||||||
|
Write,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allow the user to create this kind of data.
|
||||||
|
/// </summary>
|
||||||
|
Create,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allow the user to delete this kind of data.
|
||||||
|
/// </summary>
|
||||||
|
Delete,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allow the user to play this file.
|
||||||
|
/// </summary>
|
||||||
|
Play,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The group of the permission.
|
||||||
|
/// </summary>
|
||||||
|
public enum Group
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default group indicating no value.
|
||||||
|
/// </summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allow all operations on basic items types.
|
||||||
|
/// </summary>
|
||||||
|
Overall,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allow operation on sensitive items like libraries path, configurations and so on.
|
||||||
|
/// </summary>
|
||||||
|
Admin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specify permissions needed for the API.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
||||||
|
public class PermissionAttribute : Attribute, IFilterFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The needed permission as string.
|
||||||
|
/// </summary>
|
||||||
|
public string Type { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The needed permission kind.
|
||||||
|
/// </summary>
|
||||||
|
public Kind Kind { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The group of this permission.
|
||||||
|
/// </summary>
|
||||||
|
public Group Group { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ask a permission to run an action.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">
|
||||||
|
/// The type of the action
|
||||||
|
/// </param>
|
||||||
|
/// <param name="permission">
|
||||||
/// The kind of permission needed.
|
/// The kind of permission needed.
|
||||||
/// </summary>
|
/// </param>
|
||||||
public enum Kind
|
/// <param name="group">
|
||||||
|
/// The group of this permission (allow grouped permission like overall.read
|
||||||
|
/// for all read permissions of this group).
|
||||||
|
/// </param>
|
||||||
|
public PermissionAttribute(string type, Kind permission, Group group = Group.Overall)
|
||||||
{
|
{
|
||||||
/// <summary>
|
Type = type.ToLower();
|
||||||
/// Allow the user to read for this kind of data.
|
Kind = permission;
|
||||||
/// </summary>
|
Group = group;
|
||||||
Read,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Allow the user to write for this kind of data.
|
|
||||||
/// </summary>
|
|
||||||
Write,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Allow the user to create this kind of data.
|
|
||||||
/// </summary>
|
|
||||||
Create,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Allow the user to delete this kind of data.
|
|
||||||
/// </summary>
|
|
||||||
Delete,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Allow the user to play this file.
|
|
||||||
/// </summary>
|
|
||||||
Play,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// The group of the permission.
|
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
|
||||||
/// </summary>
|
|
||||||
public enum Group
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this);
|
||||||
/// Default group indicating no value.
|
|
||||||
/// </summary>
|
|
||||||
None,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Allow all operations on basic items types.
|
|
||||||
/// </summary>
|
|
||||||
Overall,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Allow operation on sensitive items like libraries path, configurations and so on.
|
|
||||||
/// </summary>
|
|
||||||
Admin
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsReusable => true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Specify permissions needed for the API.
|
/// Return this permission attribute as a string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
/// <returns>The string representation.</returns>
|
||||||
public class PermissionAttribute : Attribute, IFilterFactory
|
public string AsPermissionString()
|
||||||
{
|
{
|
||||||
/// <summary>
|
return Type;
|
||||||
/// The needed permission as string.
|
|
||||||
/// </summary>
|
|
||||||
public string Type { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The needed permission kind.
|
|
||||||
/// </summary>
|
|
||||||
public Kind Kind { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The group of this permission.
|
|
||||||
/// </summary>
|
|
||||||
public Group Group { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ask a permission to run an action.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="type">
|
|
||||||
/// The type of the action
|
|
||||||
/// </param>
|
|
||||||
/// <param name="permission">
|
|
||||||
/// The kind of permission needed.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="group">
|
|
||||||
/// The group of this permission (allow grouped permission like overall.read
|
|
||||||
/// for all read permissions of this group).
|
|
||||||
/// </param>
|
|
||||||
public PermissionAttribute(string type, Kind permission, Group group = Group.Overall)
|
|
||||||
{
|
|
||||||
Type = type.ToLower();
|
|
||||||
Kind = permission;
|
|
||||||
Group = group;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
|
|
||||||
{
|
|
||||||
return serviceProvider.GetRequiredService<IPermissionValidator>().Create(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool IsReusable => true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return this permission attribute as a string.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>The string representation.</returns>
|
|
||||||
public string AsPermissionString()
|
|
||||||
{
|
|
||||||
return Type;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,14 +18,13 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Permissions
|
namespace Kyoo.Abstractions.Models.Permissions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The annotated route can only be accessed by a logged in user.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
||||||
|
public class UserOnlyAttribute : Attribute
|
||||||
{
|
{
|
||||||
/// <summary>
|
// TODO: Implement a Filter Attribute to make this work. For now, this attribute is only useful as documentation.
|
||||||
/// The annotated route can only be accessed by a logged in user.
|
|
||||||
/// </summary>
|
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
|
||||||
public class UserOnlyAttribute : Attribute
|
|
||||||
{
|
|
||||||
// TODO: Implement a Filter Attribute to make this work. For now, this attribute is only useful as documentation.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -19,35 +19,34 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Runtime.Serialization;
|
using System.Runtime.Serialization;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Exceptions
|
namespace Kyoo.Abstractions.Models.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An exception raised when an item already exists in the database.
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class DuplicatedItemException : Exception
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An exception raised when an item already exists in the database.
|
/// The existing object.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Serializable]
|
public object? Existing { get; }
|
||||||
public class DuplicatedItemException : Exception
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="DuplicatedItemException"/> with the default message.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="existing">The existing object.</param>
|
||||||
|
public DuplicatedItemException(object? existing = null)
|
||||||
|
: base("Already exists in the database.")
|
||||||
{
|
{
|
||||||
/// <summary>
|
Existing = existing;
|
||||||
/// The existing object.
|
|
||||||
/// </summary>
|
|
||||||
public object? Existing { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="DuplicatedItemException"/> with the default message.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="existing">The existing object.</param>
|
|
||||||
public DuplicatedItemException(object? existing = null)
|
|
||||||
: base("Already exists in the database.")
|
|
||||||
{
|
|
||||||
Existing = existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The serialization constructor.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="info">Serialization infos</param>
|
|
||||||
/// <param name="context">The serialization context</param>
|
|
||||||
protected DuplicatedItemException(SerializationInfo info, StreamingContext context)
|
|
||||||
: base(info, context) { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The serialization constructor.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="info">Serialization infos</param>
|
||||||
|
/// <param name="context">The serialization context</param>
|
||||||
|
protected DuplicatedItemException(SerializationInfo info, StreamingContext context)
|
||||||
|
: base(info, context) { }
|
||||||
}
|
}
|
||||||
|
@ -19,33 +19,32 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Runtime.Serialization;
|
using System.Runtime.Serialization;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Exceptions
|
namespace Kyoo.Abstractions.Models.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An exception raised when an item could not be found.
|
||||||
|
/// </summary>
|
||||||
|
[Serializable]
|
||||||
|
public class ItemNotFoundException : Exception
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An exception raised when an item could not be found.
|
/// Create a default <see cref="ItemNotFoundException"/> with no message.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Serializable]
|
public ItemNotFoundException()
|
||||||
public class ItemNotFoundException : Exception
|
: base("Item not found") { }
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Create a default <see cref="ItemNotFoundException"/> with no message.
|
|
||||||
/// </summary>
|
|
||||||
public ItemNotFoundException()
|
|
||||||
: base("Item not found") { }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="ItemNotFoundException"/> with a message
|
/// Create a new <see cref="ItemNotFoundException"/> with a message
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">The message of the exception</param>
|
/// <param name="message">The message of the exception</param>
|
||||||
public ItemNotFoundException(string message)
|
public ItemNotFoundException(string message)
|
||||||
: base(message) { }
|
: base(message) { }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The serialization constructor
|
/// The serialization constructor
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="info">Serialization infos</param>
|
/// <param name="info">Serialization infos</param>
|
||||||
/// <param name="context">The serialization context</param>
|
/// <param name="context">The serialization context</param>
|
||||||
protected ItemNotFoundException(SerializationInfo info, StreamingContext context)
|
protected ItemNotFoundException(SerializationInfo info, StreamingContext context)
|
||||||
: base(info, context) { }
|
: base(info, context) { }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -19,18 +19,17 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Runtime.Serialization;
|
using System.Runtime.Serialization;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Exceptions
|
namespace Kyoo.Abstractions.Models.Exceptions;
|
||||||
|
|
||||||
|
[Serializable]
|
||||||
|
public class UnauthorizedException : Exception
|
||||||
{
|
{
|
||||||
[Serializable]
|
public UnauthorizedException()
|
||||||
public class UnauthorizedException : Exception
|
: base("User not authenticated or token invalid.") { }
|
||||||
{
|
|
||||||
public UnauthorizedException()
|
|
||||||
: base("User not authenticated or token invalid.") { }
|
|
||||||
|
|
||||||
public UnauthorizedException(string message)
|
public UnauthorizedException(string message)
|
||||||
: base(message) { }
|
: base(message) { }
|
||||||
|
|
||||||
protected UnauthorizedException(SerializationInfo info, StreamingContext context)
|
protected UnauthorizedException(SerializationInfo info, StreamingContext context)
|
||||||
: base(info, context) { }
|
: base(info, context) { }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -16,30 +16,29 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A genre that allow one to specify categories for shows.
|
||||||
|
/// </summary>
|
||||||
|
public enum Genre
|
||||||
{
|
{
|
||||||
/// <summary>
|
Action,
|
||||||
/// A genre that allow one to specify categories for shows.
|
Adventure,
|
||||||
/// </summary>
|
Animation,
|
||||||
public enum Genre
|
Comedy,
|
||||||
{
|
Crime,
|
||||||
Action,
|
Documentary,
|
||||||
Adventure,
|
Drama,
|
||||||
Animation,
|
Family,
|
||||||
Comedy,
|
Fantasy,
|
||||||
Crime,
|
History,
|
||||||
Documentary,
|
Horror,
|
||||||
Drama,
|
Music,
|
||||||
Family,
|
Mystery,
|
||||||
Fantasy,
|
Romance,
|
||||||
History,
|
ScienceFiction,
|
||||||
Horror,
|
Thriller,
|
||||||
Music,
|
War,
|
||||||
Mystery,
|
Western,
|
||||||
Romance,
|
|
||||||
ScienceFiction,
|
|
||||||
Thriller,
|
|
||||||
War,
|
|
||||||
Western,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -16,21 +16,20 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID and link of an item on an external provider.
|
||||||
|
/// </summary>
|
||||||
|
public class MetadataId
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ID and link of an item on an external provider.
|
/// The ID of the resource on the external provider.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MetadataId
|
public string DataId { get; set; }
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of the resource on the external provider.
|
|
||||||
/// </summary>
|
|
||||||
public string DataId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The URL of the resource on the external provider.
|
/// The URL of the resource on the external provider.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Link { get; set; }
|
public string? Link { get; set; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -20,93 +20,86 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A page of resource that contains information about the pagination of resources.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of resource contained in this page.</typeparam>
|
||||||
|
public class Page<T>
|
||||||
|
where T : IResource
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A page of resource that contains information about the pagination of resources.
|
/// The link of the current page.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The type of resource contained in this page.</typeparam>
|
public string This { get; }
|
||||||
public class Page<T>
|
|
||||||
where T : IResource
|
/// <summary>
|
||||||
|
/// The link of the first page.
|
||||||
|
/// </summary>
|
||||||
|
public string First { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The link of the previous page.
|
||||||
|
/// </summary>
|
||||||
|
public string? Previous { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The link of the next page.
|
||||||
|
/// </summary>
|
||||||
|
public string? Next { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of items in the current page.
|
||||||
|
/// </summary>
|
||||||
|
public int Count => Items.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of items in the page.
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<T> Items { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="Page{T}"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="items">The list of items in the page.</param>
|
||||||
|
/// <param name="this">The link of the current page.</param>
|
||||||
|
/// <param name="previous">The link of the previous page.</param>
|
||||||
|
/// <param name="next">The link of the next page.</param>
|
||||||
|
/// <param name="first">The link of the first page.</param>
|
||||||
|
public Page(ICollection<T> items, string @this, string? previous, string? next, string first)
|
||||||
{
|
{
|
||||||
/// <summary>
|
Items = items;
|
||||||
/// The link of the current page.
|
This = @this;
|
||||||
/// </summary>
|
Previous = previous;
|
||||||
public string This { get; }
|
Next = next;
|
||||||
|
First = first;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The link of the first page.
|
/// Create a new <see cref="Page{T}"/> and compute the urls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string First { get; }
|
/// <param name="items">The list of items in the page.</param>
|
||||||
|
/// <param name="url">The base url of the resources available from this page.</param>
|
||||||
/// <summary>
|
/// <param name="query">The list of query strings of the current page</param>
|
||||||
/// The link of the previous page.
|
/// <param name="limit">The number of items requested for the current page.</param>
|
||||||
/// </summary>
|
public Page(ICollection<T> items, string url, Dictionary<string, string> query, int limit)
|
||||||
public string? Previous { get; }
|
{
|
||||||
|
Items = items;
|
||||||
/// <summary>
|
This = url + query.ToQueryString();
|
||||||
/// The link of the next page.
|
if (items.Count > 0 && query.ContainsKey("afterID"))
|
||||||
/// </summary>
|
|
||||||
public string? Next { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The number of items in the current page.
|
|
||||||
/// </summary>
|
|
||||||
public int Count => Items.Count;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of items in the page.
|
|
||||||
/// </summary>
|
|
||||||
public ICollection<T> Items { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="Page{T}"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="items">The list of items in the page.</param>
|
|
||||||
/// <param name="this">The link of the current page.</param>
|
|
||||||
/// <param name="previous">The link of the previous page.</param>
|
|
||||||
/// <param name="next">The link of the next page.</param>
|
|
||||||
/// <param name="first">The link of the first page.</param>
|
|
||||||
public Page(
|
|
||||||
ICollection<T> items,
|
|
||||||
string @this,
|
|
||||||
string? previous,
|
|
||||||
string? next,
|
|
||||||
string first
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Items = items;
|
query["afterID"] = items.First().Id.ToString();
|
||||||
This = @this;
|
query["reverse"] = "true";
|
||||||
Previous = previous;
|
Previous = url + query.ToQueryString();
|
||||||
Next = next;
|
|
||||||
First = first;
|
|
||||||
}
|
}
|
||||||
|
query.Remove("reverse");
|
||||||
/// <summary>
|
if (items.Count == limit && limit > 0)
|
||||||
/// Create a new <see cref="Page{T}"/> and compute the urls.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="items">The list of items in the page.</param>
|
|
||||||
/// <param name="url">The base url of the resources available from this page.</param>
|
|
||||||
/// <param name="query">The list of query strings of the current page</param>
|
|
||||||
/// <param name="limit">The number of items requested for the current page.</param>
|
|
||||||
public Page(ICollection<T> items, string url, Dictionary<string, string> query, int limit)
|
|
||||||
{
|
{
|
||||||
Items = items;
|
query["afterID"] = items.Last().Id.ToString();
|
||||||
This = url + query.ToQueryString();
|
Next = url + query.ToQueryString();
|
||||||
if (items.Count > 0 && query.ContainsKey("afterID"))
|
|
||||||
{
|
|
||||||
query["afterID"] = items.First().Id.ToString();
|
|
||||||
query["reverse"] = "true";
|
|
||||||
Previous = url + query.ToQueryString();
|
|
||||||
}
|
|
||||||
query.Remove("reverse");
|
|
||||||
if (items.Count == limit && limit > 0)
|
|
||||||
{
|
|
||||||
query["afterID"] = items.Last().Id.ToString();
|
|
||||||
Next = url + query.ToQueryString();
|
|
||||||
}
|
|
||||||
query.Remove("afterID");
|
|
||||||
First = url + query.ToQueryString();
|
|
||||||
}
|
}
|
||||||
|
query.Remove("afterID");
|
||||||
|
First = url + query.ToQueryString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,69 +23,68 @@ using System.Text.Json.Serialization;
|
|||||||
using Kyoo.Abstractions.Controllers;
|
using Kyoo.Abstractions.Controllers;
|
||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A class representing collections of <see cref="Show"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, ILibraryItem
|
||||||
{
|
{
|
||||||
|
public static Sort DefaultSort => new Sort<Collection>.By(nameof(Collection.Name));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string Slug { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A class representing collections of <see cref="Show"/>.
|
/// The name of this collection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Collection : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, ILibraryItem
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The description of this collection.
|
||||||
|
/// </summary>
|
||||||
|
public string? Overview { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime AddedDate { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Image? Poster { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Image? Thumbnail { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Image? Logo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of movies contained in this collection.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public ICollection<Movie>? Movies { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of shows contained in this collection.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public ICollection<Show>? Shows { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
|
public Collection() { }
|
||||||
|
|
||||||
|
[JsonConstructor]
|
||||||
|
public Collection(string name)
|
||||||
{
|
{
|
||||||
public static Sort DefaultSort => new Sort<Collection>.By(nameof(Collection.Name));
|
if (name != null)
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
[MaxLength(256)]
|
|
||||||
public string Slug { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The name of this collection.
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The description of this collection.
|
|
||||||
/// </summary>
|
|
||||||
public string? Overview { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public DateTime AddedDate { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Image? Poster { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Image? Thumbnail { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Image? Logo { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of movies contained in this collection.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public ICollection<Movie>? Movies { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of shows contained in this collection.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public ICollection<Show>? Shows { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
|
||||||
|
|
||||||
public Collection() { }
|
|
||||||
|
|
||||||
[JsonConstructor]
|
|
||||||
public Collection(string name)
|
|
||||||
{
|
{
|
||||||
if (name != null)
|
Slug = Utility.ToSlug(name);
|
||||||
{
|
Name = name;
|
||||||
Slug = Utility.ToSlug(name);
|
|
||||||
Name = name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,280 +26,274 @@ using EntityFrameworkCore.Projectables;
|
|||||||
using Kyoo.Abstractions.Controllers;
|
using Kyoo.Abstractions.Controllers;
|
||||||
using Kyoo.Abstractions.Models.Attributes;
|
using Kyoo.Abstractions.Models.Attributes;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A class to represent a single show's episode.
|
||||||
|
/// </summary>
|
||||||
|
public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews
|
||||||
{
|
{
|
||||||
/// <summary>
|
// Use absolute numbers by default and fallback to season/episodes if it does not exists.
|
||||||
/// A class to represent a single show's episode.
|
public static Sort DefaultSort =>
|
||||||
/// </summary>
|
new Sort<Episode>.Conglomerate(
|
||||||
public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews
|
new Sort<Episode>.By(x => x.AbsoluteNumber),
|
||||||
|
new Sort<Episode>.By(x => x.SeasonNumber),
|
||||||
|
new Sort<Episode>.By(x => x.EpisodeNumber)
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[Computed]
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string Slug
|
||||||
{
|
{
|
||||||
// Use absolute numbers by default and fallback to season/episodes if it does not exists.
|
get
|
||||||
public static Sort DefaultSort =>
|
|
||||||
new Sort<Episode>.Conglomerate(
|
|
||||||
new Sort<Episode>.By(x => x.AbsoluteNumber),
|
|
||||||
new Sort<Episode>.By(x => x.SeasonNumber),
|
|
||||||
new Sort<Episode>.By(x => x.EpisodeNumber)
|
|
||||||
);
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
[Computed]
|
|
||||||
[MaxLength(256)]
|
|
||||||
public string Slug
|
|
||||||
{
|
{
|
||||||
get
|
if (ShowSlug != null || Show?.Slug != null)
|
||||||
{
|
return GetSlug(ShowSlug ?? Show!.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber);
|
||||||
if (ShowSlug != null || Show?.Slug != null)
|
return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber);
|
||||||
return GetSlug(
|
}
|
||||||
ShowSlug ?? Show!.Slug,
|
private set
|
||||||
SeasonNumber,
|
{
|
||||||
EpisodeNumber,
|
Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)e(?<episode>\d+)");
|
||||||
AbsoluteNumber
|
|
||||||
);
|
|
||||||
return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber);
|
|
||||||
}
|
|
||||||
private set
|
|
||||||
{
|
|
||||||
Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)e(?<episode>\d+)");
|
|
||||||
|
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
ShowSlug = match.Groups["show"].Value;
|
||||||
|
SeasonNumber = int.Parse(match.Groups["season"].Value);
|
||||||
|
EpisodeNumber = int.Parse(match.Groups["episode"].Value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
match = Regex.Match(value, @"(?<show>.+)-(?<absolute>\d+)");
|
||||||
if (match.Success)
|
if (match.Success)
|
||||||
{
|
{
|
||||||
ShowSlug = match.Groups["show"].Value;
|
ShowSlug = match.Groups["show"].Value;
|
||||||
SeasonNumber = int.Parse(match.Groups["season"].Value);
|
AbsoluteNumber = int.Parse(match.Groups["absolute"].Value);
|
||||||
EpisodeNumber = int.Parse(match.Groups["episode"].Value);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
ShowSlug = value;
|
||||||
match = Regex.Match(value, @"(?<show>.+)-(?<absolute>\d+)");
|
SeasonNumber = null;
|
||||||
if (match.Success)
|
EpisodeNumber = null;
|
||||||
{
|
|
||||||
ShowSlug = match.Groups["show"].Value;
|
|
||||||
AbsoluteNumber = int.Parse(match.Groups["absolute"].Value);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
ShowSlug = value;
|
|
||||||
SeasonNumber = null;
|
|
||||||
EpisodeNumber = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The slug of the Show that contain this episode. If this is not set, this episode is ill-formed.
|
/// The slug of the Show that contain this episode. If this is not set, this episode is ill-formed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string? ShowSlug { private get; set; }
|
public string? ShowSlug { private get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The ID of the Show containing this episode.
|
/// The ID of the Show containing this episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid ShowId { get; set; }
|
public Guid ShowId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The show that contains this episode.
|
/// The show that contains this episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[LoadableRelation(nameof(ShowId))]
|
[LoadableRelation(nameof(ShowId))]
|
||||||
public Show? Show { get; set; }
|
public Show? Show { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The ID of the Season containing this episode.
|
/// The ID of the Season containing this episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? SeasonId { get; set; }
|
public Guid? SeasonId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The season that contains this episode.
|
/// The season that contains this episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This can be null if the season is unknown and the episode is only identified
|
/// This can be null if the season is unknown and the episode is only identified
|
||||||
/// by it's <see cref="AbsoluteNumber"/>.
|
/// by it's <see cref="AbsoluteNumber"/>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[LoadableRelation(nameof(SeasonId))]
|
[LoadableRelation(nameof(SeasonId))]
|
||||||
public Season? Season { get; set; }
|
public Season? Season { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The season in witch this episode is in.
|
/// The season in witch this episode is in.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? SeasonNumber { get; set; }
|
public int? SeasonNumber { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The number of this episode in it's season.
|
/// The number of this episode in it's season.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? EpisodeNumber { get; set; }
|
public int? EpisodeNumber { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The absolute number of this episode. It's an episode number that is not reset to 1 after a new season.
|
/// The absolute number of this episode. It's an episode number that is not reset to 1 after a new season.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? AbsoluteNumber { get; set; }
|
public int? AbsoluteNumber { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The path of the video file for this episode.
|
/// The path of the video file for this episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Path { get; set; }
|
public string Path { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The title of this episode.
|
/// The title of this episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The overview of this episode.
|
/// The overview of this episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Overview { get; set; }
|
public string? Overview { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How long is this episode? (in minutes)
|
/// How long is this episode? (in minutes)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? Runtime { get; set; }
|
public int? Runtime { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The release date of this episode. It can be null if unknown.
|
/// The release date of this episode. It can be null if unknown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? ReleaseDate { get; set; }
|
public DateTime? ReleaseDate { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public DateTime AddedDate { get; set; }
|
public DateTime AddedDate { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Image? Poster { get; set; }
|
public Image? Poster { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Image? Thumbnail { get; set; }
|
public Image? Thumbnail { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Image? Logo { get; set; }
|
public Image? Logo { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The previous episode that should be seen before viewing this one.
|
/// The previous episode that should be seen before viewing this one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Projectable(UseMemberBody = nameof(_PreviousEpisode), OnlyOnInclude = true)]
|
[Projectable(UseMemberBody = nameof(_PreviousEpisode), OnlyOnInclude = true)]
|
||||||
[LoadableRelation(
|
[LoadableRelation(
|
||||||
// language=PostgreSQL
|
// language=PostgreSQL
|
||||||
Sql = """
|
Sql = """
|
||||||
select
|
select
|
||||||
pe.* -- Episode as pe
|
pe.* -- Episode as pe
|
||||||
from
|
from
|
||||||
episodes as "pe"
|
episodes as "pe"
|
||||||
where
|
where
|
||||||
pe.show_id = "this".show_id
|
pe.show_id = "this".show_id
|
||||||
and (pe.absolute_number < "this".absolute_number
|
and (pe.absolute_number < "this".absolute_number
|
||||||
or pe.season_number < "this".season_number
|
or pe.season_number < "this".season_number
|
||||||
or (pe.season_number = "this".season_number
|
or (pe.season_number = "this".season_number
|
||||||
and e.episode_number < "this".episode_number))
|
and e.episode_number < "this".episode_number))
|
||||||
order by
|
order by
|
||||||
pe.absolute_number desc nulls last,
|
pe.absolute_number desc nulls last,
|
||||||
pe.season_number desc,
|
pe.season_number desc,
|
||||||
pe.episode_number desc
|
pe.episode_number desc
|
||||||
limit 1
|
limit 1
|
||||||
"""
|
"""
|
||||||
)]
|
)]
|
||||||
public Episode? PreviousEpisode { get; set; }
|
public Episode? PreviousEpisode { get; set; }
|
||||||
|
|
||||||
private Episode? _PreviousEpisode =>
|
private Episode? _PreviousEpisode =>
|
||||||
Show!
|
Show!
|
||||||
.Episodes!.OrderBy(x => x.AbsoluteNumber == null)
|
.Episodes!.OrderBy(x => x.AbsoluteNumber == null)
|
||||||
.ThenByDescending(x => x.AbsoluteNumber)
|
.ThenByDescending(x => x.AbsoluteNumber)
|
||||||
.ThenByDescending(x => x.SeasonNumber)
|
.ThenByDescending(x => x.SeasonNumber)
|
||||||
.ThenByDescending(x => x.EpisodeNumber)
|
.ThenByDescending(x => x.EpisodeNumber)
|
||||||
.FirstOrDefault(x =>
|
.FirstOrDefault(x =>
|
||||||
x.AbsoluteNumber < AbsoluteNumber
|
x.AbsoluteNumber < AbsoluteNumber
|
||||||
|| x.SeasonNumber < SeasonNumber
|
|| x.SeasonNumber < SeasonNumber
|
||||||
|| (x.SeasonNumber == SeasonNumber && x.EpisodeNumber < EpisodeNumber)
|
|| (x.SeasonNumber == SeasonNumber && x.EpisodeNumber < EpisodeNumber)
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The next episode to watch after this one.
|
/// The next episode to watch after this one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Projectable(UseMemberBody = nameof(_NextEpisode), OnlyOnInclude = true)]
|
[Projectable(UseMemberBody = nameof(_NextEpisode), OnlyOnInclude = true)]
|
||||||
[LoadableRelation(
|
[LoadableRelation(
|
||||||
// language=PostgreSQL
|
// language=PostgreSQL
|
||||||
Sql = """
|
Sql = """
|
||||||
select
|
select
|
||||||
ne.* -- Episode as ne
|
ne.* -- Episode as ne
|
||||||
from
|
from
|
||||||
episodes as "ne"
|
episodes as "ne"
|
||||||
where
|
where
|
||||||
ne.show_id = "this".show_id
|
ne.show_id = "this".show_id
|
||||||
and (ne.absolute_number > "this".absolute_number
|
and (ne.absolute_number > "this".absolute_number
|
||||||
or ne.season_number > "this".season_number
|
or ne.season_number > "this".season_number
|
||||||
or (ne.season_number = "this".season_number
|
or (ne.season_number = "this".season_number
|
||||||
and e.episode_number > "this".episode_number))
|
and e.episode_number > "this".episode_number))
|
||||||
order by
|
order by
|
||||||
ne.absolute_number,
|
ne.absolute_number,
|
||||||
ne.season_number,
|
ne.season_number,
|
||||||
ne.episode_number
|
ne.episode_number
|
||||||
limit 1
|
limit 1
|
||||||
"""
|
"""
|
||||||
)]
|
)]
|
||||||
public Episode? NextEpisode { get; set; }
|
public Episode? NextEpisode { get; set; }
|
||||||
|
|
||||||
private Episode? _NextEpisode =>
|
private Episode? _NextEpisode =>
|
||||||
Show!
|
Show!
|
||||||
.Episodes!.OrderBy(x => x.AbsoluteNumber)
|
.Episodes!.OrderBy(x => x.AbsoluteNumber)
|
||||||
.ThenBy(x => x.SeasonNumber)
|
.ThenBy(x => x.SeasonNumber)
|
||||||
.ThenBy(x => x.EpisodeNumber)
|
.ThenBy(x => x.EpisodeNumber)
|
||||||
.FirstOrDefault(x =>
|
.FirstOrDefault(x =>
|
||||||
x.AbsoluteNumber > AbsoluteNumber
|
x.AbsoluteNumber > AbsoluteNumber
|
||||||
|| x.SeasonNumber > SeasonNumber
|
|| x.SeasonNumber > SeasonNumber
|
||||||
|| (x.SeasonNumber == SeasonNumber && x.EpisodeNumber > EpisodeNumber)
|
|| (x.SeasonNumber == SeasonNumber && x.EpisodeNumber > EpisodeNumber)
|
||||||
);
|
);
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public ICollection<EpisodeWatchStatus>? Watched { get; set; }
|
public ICollection<EpisodeWatchStatus>? Watched { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Metadata of what an user as started/planned to watch.
|
/// Metadata of what an user as started/planned to watch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
|
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
|
||||||
[LoadableRelation(
|
[LoadableRelation(
|
||||||
Sql = "episode_watch_status",
|
Sql = "episode_watch_status",
|
||||||
On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]"
|
On = "episode_id = \"this\".id and \"relation\".user_id = [current_user]"
|
||||||
)]
|
)]
|
||||||
public EpisodeWatchStatus? WatchStatus { get; set; }
|
public EpisodeWatchStatus? WatchStatus { get; set; }
|
||||||
|
|
||||||
// There is a global query filter to filter by user so we just need to do single.
|
// There is a global query filter to filter by user so we just need to do single.
|
||||||
private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
|
private EpisodeWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Links to watch this episode.
|
/// Links to watch this episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public VideoLinks Links =>
|
public VideoLinks Links =>
|
||||||
new() { Direct = $"/episode/{Slug}/direct", Hls = $"/episode/{Slug}/master.m3u8", };
|
new() { Direct = $"/episode/{Slug}/direct", Hls = $"/episode/{Slug}/master.m3u8", };
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the slug of an episode.
|
/// Get the slug of an episode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="showSlug">The slug of the show. It can't be null.</param>
|
/// <param name="showSlug">The slug of the show. It can't be null.</param>
|
||||||
/// <param name="seasonNumber">
|
/// <param name="seasonNumber">
|
||||||
/// The season in which the episode is.
|
/// The season in which the episode is.
|
||||||
/// If this is a movie or if the episode should be referred by it's absolute number, set this to null.
|
/// If this is a movie or if the episode should be referred by it's absolute number, set this to null.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="episodeNumber">
|
/// <param name="episodeNumber">
|
||||||
/// The number of the episode in it's season.
|
/// The number of the episode in it's season.
|
||||||
/// If this is a movie or if the episode should be referred by it's absolute number, set this to null.
|
/// If this is a movie or if the episode should be referred by it's absolute number, set this to null.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="absoluteNumber">
|
/// <param name="absoluteNumber">
|
||||||
/// The absolute number of this show.
|
/// The absolute number of this show.
|
||||||
/// If you don't know it or this is a movie, use null
|
/// If you don't know it or this is a movie, use null
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>The slug corresponding to the given arguments</returns>
|
/// <returns>The slug corresponding to the given arguments</returns>
|
||||||
public static string GetSlug(
|
public static string GetSlug(
|
||||||
string showSlug,
|
string showSlug,
|
||||||
int? seasonNumber,
|
int? seasonNumber,
|
||||||
int? episodeNumber,
|
int? episodeNumber,
|
||||||
int? absoluteNumber = null
|
int? absoluteNumber = null
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
return seasonNumber switch
|
||||||
{
|
{
|
||||||
return seasonNumber switch
|
null when absoluteNumber == null => showSlug,
|
||||||
{
|
null => $"{showSlug}-{absoluteNumber}",
|
||||||
null when absoluteNumber == null => showSlug,
|
_ => $"{showSlug}-s{seasonNumber}e{episodeNumber}"
|
||||||
null => $"{showSlug}-{absoluteNumber}",
|
};
|
||||||
_ => $"{showSlug}-s{seasonNumber}e{episodeNumber}"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,16 +18,15 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An interface applied to resources.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAddedDate
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An interface applied to resources.
|
/// The date at which this resource was added to kyoo.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAddedDate
|
public DateTime AddedDate { get; set; }
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The date at which this resource was added to kyoo.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime AddedDate { get; set; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -18,16 +18,15 @@
|
|||||||
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An interface applied to resources containing external metadata.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMetadata
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An interface applied to resources containing external metadata.
|
/// The link to metadata providers that this show has. See <see cref="MetadataId"/> for more information.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMetadata
|
public Dictionary<string, MetadataId> ExternalId { get; set; }
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The link to metadata providers that this show has. See <see cref="MetadataId"/> for more information.
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -20,31 +20,30 @@ using System;
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Kyoo.Abstractions.Controllers;
|
using Kyoo.Abstractions.Controllers;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An interface to represent a resource that can be retrieved from the database.
|
||||||
|
/// </summary>
|
||||||
|
public interface IResource : IQuery
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An interface to represent a resource that can be retrieved from the database.
|
/// A unique ID for this type of resource. This can't be changed and duplicates are not allowed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IResource : IQuery
|
/// <remarks>
|
||||||
{
|
/// You don't need to specify an ID manually when creating a new resource,
|
||||||
/// <summary>
|
/// this field is automatically assigned by the <see cref="IRepository{T}"/>.
|
||||||
/// A unique ID for this type of resource. This can't be changed and duplicates are not allowed.
|
/// </remarks>
|
||||||
/// </summary>
|
public Guid Id { get; set; }
|
||||||
/// <remarks>
|
|
||||||
/// You don't need to specify an ID manually when creating a new resource,
|
|
||||||
/// this field is automatically assigned by the <see cref="IRepository{T}"/>.
|
|
||||||
/// </remarks>
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A human-readable identifier that can be used instead of an ID.
|
/// A human-readable identifier that can be used instead of an ID.
|
||||||
/// A slug must be unique for a type of resource but it can be changed.
|
/// A slug must be unique for a type of resource but it can be changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// There is no setter for a slug since it can be computed from other fields.
|
/// There is no setter for a slug since it can be computed from other fields.
|
||||||
/// For example, a season slug is {ShowSlug}-s{SeasonNumber}.
|
/// For example, a season slug is {ShowSlug}-s{SeasonNumber}.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[MaxLength(256)]
|
[MaxLength(256)]
|
||||||
public string Slug { get; }
|
public string Slug { get; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -23,105 +23,101 @@ using System.Globalization;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Kyoo.Abstractions.Models.Attributes;
|
using Kyoo.Abstractions.Models.Attributes;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An interface representing items that contains images (like posters, thumbnails, logo, banners...)
|
||||||
|
/// </summary>
|
||||||
|
public interface IThumbnails
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An interface representing items that contains images (like posters, thumbnails, logo, banners...)
|
/// A poster is a 2/3 format image with the cover of the resource.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IThumbnails
|
public Image? Poster { get; set; }
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A poster is a 2/3 format image with the cover of the resource.
|
|
||||||
/// </summary>
|
|
||||||
public Image? Poster { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A thumbnail is a 16/9 format image, it could ether be used as a background or as a preview but it usually
|
|
||||||
/// is not an official image.
|
|
||||||
/// </summary>
|
|
||||||
public Image? Thumbnail { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A logo is a small image representing the resource.
|
|
||||||
/// </summary>
|
|
||||||
public Image? Logo { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[TypeConverter(typeof(ImageConvertor))]
|
|
||||||
[SqlFirstColumn(nameof(Source))]
|
|
||||||
public class Image
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The original image from another server.
|
|
||||||
/// </summary>
|
|
||||||
public string Source { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A hash to display as placeholder while the image is loading.
|
|
||||||
/// </summary>
|
|
||||||
[MaxLength(32)]
|
|
||||||
public string Blurhash { get; set; }
|
|
||||||
|
|
||||||
public Image() { }
|
|
||||||
|
|
||||||
[JsonConstructor]
|
|
||||||
public Image(string source, string? blurhash = null)
|
|
||||||
{
|
|
||||||
Source = source;
|
|
||||||
Blurhash = blurhash ?? "000000";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ImageConvertor : TypeConverter
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
|
|
||||||
{
|
|
||||||
if (sourceType == typeof(string))
|
|
||||||
return true;
|
|
||||||
return base.CanConvertFrom(context, sourceType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override object ConvertFrom(
|
|
||||||
ITypeDescriptorContext? context,
|
|
||||||
CultureInfo? culture,
|
|
||||||
object value
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (value is not string source)
|
|
||||||
return base.ConvertFrom(context, culture, value)!;
|
|
||||||
return new Image(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override bool CanConvertTo(
|
|
||||||
ITypeDescriptorContext? context,
|
|
||||||
Type? destinationType
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The quality of an image
|
/// A thumbnail is a 16/9 format image, it could ether be used as a background or as a preview but it usually
|
||||||
|
/// is not an official image.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum ImageQuality
|
public Image? Thumbnail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A logo is a small image representing the resource.
|
||||||
|
/// </summary>
|
||||||
|
public Image? Logo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[TypeConverter(typeof(ImageConvertor))]
|
||||||
|
[SqlFirstColumn(nameof(Source))]
|
||||||
|
public class Image
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The original image from another server.
|
||||||
|
/// </summary>
|
||||||
|
public string Source { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A hash to display as placeholder while the image is loading.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string Blurhash { get; set; }
|
||||||
|
|
||||||
|
public Image() { }
|
||||||
|
|
||||||
|
[JsonConstructor]
|
||||||
|
public Image(string source, string? blurhash = null)
|
||||||
{
|
{
|
||||||
/// <summary>
|
Source = source;
|
||||||
/// Small
|
Blurhash = blurhash ?? "000000";
|
||||||
/// </summary>
|
}
|
||||||
Low,
|
|
||||||
|
|
||||||
/// <summary>
|
public class ImageConvertor : TypeConverter
|
||||||
/// Medium
|
{
|
||||||
/// </summary>
|
/// <inheritdoc />
|
||||||
Medium,
|
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
|
||||||
|
{
|
||||||
|
if (sourceType == typeof(string))
|
||||||
|
return true;
|
||||||
|
return base.CanConvertFrom(context, sourceType);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Large
|
public override object ConvertFrom(
|
||||||
/// </summary>
|
ITypeDescriptorContext? context,
|
||||||
High,
|
CultureInfo? culture,
|
||||||
|
object value
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (value is not string source)
|
||||||
|
return base.ConvertFrom(context, culture, value)!;
|
||||||
|
return new Image(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The quality of an image
|
||||||
|
/// </summary>
|
||||||
|
public enum ImageQuality
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Small
|
||||||
|
/// </summary>
|
||||||
|
Low,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Medium
|
||||||
|
/// </summary>
|
||||||
|
Medium,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Large
|
||||||
|
/// </summary>
|
||||||
|
High,
|
||||||
|
}
|
||||||
|
@ -27,163 +27,162 @@ using Kyoo.Abstractions.Controllers;
|
|||||||
using Kyoo.Abstractions.Models.Attributes;
|
using Kyoo.Abstractions.Models.Attributes;
|
||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A series or a movie.
|
||||||
|
/// </summary>
|
||||||
|
public class Movie
|
||||||
|
: IQuery,
|
||||||
|
IResource,
|
||||||
|
IMetadata,
|
||||||
|
IThumbnails,
|
||||||
|
IAddedDate,
|
||||||
|
ILibraryItem,
|
||||||
|
INews,
|
||||||
|
IWatchlist
|
||||||
{
|
{
|
||||||
|
public static Sort DefaultSort => new Sort<Movie>.By(x => x.Name);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string Slug { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A series or a movie.
|
/// The title of this show.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Movie
|
public string Name { get; set; }
|
||||||
: IQuery,
|
|
||||||
IResource,
|
/// <summary>
|
||||||
IMetadata,
|
/// A catchphrase for this movie.
|
||||||
IThumbnails,
|
/// </summary>
|
||||||
IAddedDate,
|
public string? Tagline { get; set; }
|
||||||
ILibraryItem,
|
|
||||||
INews,
|
/// <summary>
|
||||||
IWatchlist
|
/// The list of alternative titles of this show.
|
||||||
|
/// </summary>
|
||||||
|
public string[] Aliases { get; set; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The path of the movie video file.
|
||||||
|
/// </summary>
|
||||||
|
public string Path { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The summary of this show.
|
||||||
|
/// </summary>
|
||||||
|
public string? Overview { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A list of tags that match this movie.
|
||||||
|
/// </summary>
|
||||||
|
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of genres (themes) this show has.
|
||||||
|
/// </summary>
|
||||||
|
public Genre[] Genres { get; set; } = Array.Empty<Genre>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is this show airing, not aired yet or finished?
|
||||||
|
/// </summary>
|
||||||
|
public Status Status { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How well this item is rated? (from 0 to 100).
|
||||||
|
/// </summary>
|
||||||
|
public int Rating { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long is this movie? (in minutes)
|
||||||
|
/// </summary>
|
||||||
|
public int? Runtime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date this movie aired.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? AirDate { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime AddedDate { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Image? Poster { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Image? Thumbnail { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Image? Logo { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
[Column("air_date")]
|
||||||
|
public DateTime? StartAir => AirDate;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
[Column("air_date")]
|
||||||
|
public DateTime? EndAir => AirDate;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A video of a few minutes that tease the content.
|
||||||
|
/// </summary>
|
||||||
|
public string? Trailer { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the Studio that made this show.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public Guid? StudioId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Studio that made this show.
|
||||||
|
/// </summary>
|
||||||
|
[LoadableRelation(nameof(StudioId))]
|
||||||
|
public Studio? Studio { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of collections that contains this show.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public ICollection<Collection>? Collections { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Links to watch this movie.
|
||||||
|
/// </summary>
|
||||||
|
public VideoLinks Links =>
|
||||||
|
new() { Direct = $"/movie/{Slug}/direct", Hls = $"/movie/{Slug}/master.m3u8", };
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public ICollection<MovieWatchStatus>? Watched { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata of what an user as started/planned to watch.
|
||||||
|
/// </summary>
|
||||||
|
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
|
||||||
|
[LoadableRelation(
|
||||||
|
Sql = "movie_watch_status",
|
||||||
|
On = "movie_id = \"this\".id and \"relation\".user_id = [current_user]"
|
||||||
|
)]
|
||||||
|
public MovieWatchStatus? WatchStatus { get; set; }
|
||||||
|
|
||||||
|
// There is a global query filter to filter by user so we just need to do single.
|
||||||
|
private MovieWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
|
||||||
|
|
||||||
|
public Movie() { }
|
||||||
|
|
||||||
|
[JsonConstructor]
|
||||||
|
public Movie(string name)
|
||||||
{
|
{
|
||||||
public static Sort DefaultSort => new Sort<Movie>.By(x => x.Name);
|
if (name != null)
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
[MaxLength(256)]
|
|
||||||
public string Slug { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The title of this show.
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A catchphrase for this movie.
|
|
||||||
/// </summary>
|
|
||||||
public string? Tagline { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of alternative titles of this show.
|
|
||||||
/// </summary>
|
|
||||||
public string[] Aliases { get; set; } = Array.Empty<string>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The path of the movie video file.
|
|
||||||
/// </summary>
|
|
||||||
public string Path { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The summary of this show.
|
|
||||||
/// </summary>
|
|
||||||
public string? Overview { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A list of tags that match this movie.
|
|
||||||
/// </summary>
|
|
||||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of genres (themes) this show has.
|
|
||||||
/// </summary>
|
|
||||||
public Genre[] Genres { get; set; } = Array.Empty<Genre>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Is this show airing, not aired yet or finished?
|
|
||||||
/// </summary>
|
|
||||||
public Status Status { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How well this item is rated? (from 0 to 100).
|
|
||||||
/// </summary>
|
|
||||||
public int Rating { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How long is this movie? (in minutes)
|
|
||||||
/// </summary>
|
|
||||||
public int? Runtime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The date this movie aired.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? AirDate { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public DateTime AddedDate { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Image? Poster { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Image? Thumbnail { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Image? Logo { get; set; }
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
[Column("air_date")]
|
|
||||||
public DateTime? StartAir => AirDate;
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
[Column("air_date")]
|
|
||||||
public DateTime? EndAir => AirDate;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A video of a few minutes that tease the content.
|
|
||||||
/// </summary>
|
|
||||||
public string? Trailer { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of the Studio that made this show.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public Guid? StudioId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The Studio that made this show.
|
|
||||||
/// </summary>
|
|
||||||
[LoadableRelation(nameof(StudioId))]
|
|
||||||
public Studio? Studio { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of collections that contains this show.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public ICollection<Collection>? Collections { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Links to watch this movie.
|
|
||||||
/// </summary>
|
|
||||||
public VideoLinks Links =>
|
|
||||||
new() { Direct = $"/movie/{Slug}/direct", Hls = $"/movie/{Slug}/master.m3u8", };
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
public ICollection<MovieWatchStatus>? Watched { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Metadata of what an user as started/planned to watch.
|
|
||||||
/// </summary>
|
|
||||||
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
|
|
||||||
[LoadableRelation(
|
|
||||||
Sql = "movie_watch_status",
|
|
||||||
On = "movie_id = \"this\".id and \"relation\".user_id = [current_user]"
|
|
||||||
)]
|
|
||||||
public MovieWatchStatus? WatchStatus { get; set; }
|
|
||||||
|
|
||||||
// There is a global query filter to filter by user so we just need to do single.
|
|
||||||
private MovieWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
|
|
||||||
|
|
||||||
public Movie() { }
|
|
||||||
|
|
||||||
[JsonConstructor]
|
|
||||||
public Movie(string name)
|
|
||||||
{
|
{
|
||||||
if (name != null)
|
Slug = Utility.ToSlug(name);
|
||||||
{
|
Name = name;
|
||||||
Slug = Utility.ToSlug(name);
|
|
||||||
Name = name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,124 +26,123 @@ using EntityFrameworkCore.Projectables;
|
|||||||
using Kyoo.Abstractions.Controllers;
|
using Kyoo.Abstractions.Controllers;
|
||||||
using Kyoo.Abstractions.Models.Attributes;
|
using Kyoo.Abstractions.Models.Attributes;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A season of a <see cref="Show"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate
|
||||||
{
|
{
|
||||||
/// <summary>
|
public static Sort DefaultSort => new Sort<Season>.By(x => x.SeasonNumber);
|
||||||
/// A season of a <see cref="Show"/>.
|
|
||||||
/// </summary>
|
/// <inheritdoc />
|
||||||
public class Season : IQuery, IResource, IMetadata, IThumbnails, IAddedDate
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[Computed]
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string Slug
|
||||||
{
|
{
|
||||||
public static Sort DefaultSort => new Sort<Season>.By(x => x.SeasonNumber);
|
get
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
[Computed]
|
|
||||||
[MaxLength(256)]
|
|
||||||
public string Slug
|
|
||||||
{
|
{
|
||||||
get
|
if (ShowSlug == null && Show == null)
|
||||||
{
|
return $"{ShowId}-s{SeasonNumber}";
|
||||||
if (ShowSlug == null && Show == null)
|
return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}";
|
||||||
return $"{ShowId}-s{SeasonNumber}";
|
|
||||||
return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}";
|
|
||||||
}
|
|
||||||
private set
|
|
||||||
{
|
|
||||||
Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)");
|
|
||||||
|
|
||||||
if (!match.Success)
|
|
||||||
throw new ArgumentException(
|
|
||||||
"Invalid season slug. Format: {showSlug}-s{seasonNumber}"
|
|
||||||
);
|
|
||||||
ShowSlug = match.Groups["show"].Value;
|
|
||||||
SeasonNumber = int.Parse(match.Groups["season"].Value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)");
|
||||||
|
|
||||||
/// <summary>
|
if (!match.Success)
|
||||||
/// The slug of the Show that contain this episode. If this is not set, this season is ill-formed.
|
throw new ArgumentException(
|
||||||
/// </summary>
|
"Invalid season slug. Format: {showSlug}-s{seasonNumber}"
|
||||||
[JsonIgnore]
|
);
|
||||||
public string? ShowSlug { private get; set; }
|
ShowSlug = match.Groups["show"].Value;
|
||||||
|
SeasonNumber = int.Parse(match.Groups["season"].Value);
|
||||||
/// <summary>
|
}
|
||||||
/// The ID of the Show containing this season.
|
|
||||||
/// </summary>
|
|
||||||
public Guid ShowId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The show that contains this season.
|
|
||||||
/// </summary>
|
|
||||||
[LoadableRelation(nameof(ShowId))]
|
|
||||||
public Show? Show { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The number of this season. This can be set to 0 to indicate specials.
|
|
||||||
/// </summary>
|
|
||||||
public int SeasonNumber { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The title of this season.
|
|
||||||
/// </summary>
|
|
||||||
public string? Name { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A quick overview of this season.
|
|
||||||
/// </summary>
|
|
||||||
public string? Overview { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The starting air date of this season.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? StartDate { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public DateTime AddedDate { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ending date of this season.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? EndDate { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Image? Poster { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Image? Thumbnail { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Image? Logo { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of episodes that this season contains.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public ICollection<Episode>? Episodes { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The number of episodes in this season.
|
|
||||||
/// </summary>
|
|
||||||
[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)]
|
|
||||||
[NotMapped]
|
|
||||||
[LoadableRelation(
|
|
||||||
// language=PostgreSQL
|
|
||||||
Projected = """
|
|
||||||
(
|
|
||||||
select
|
|
||||||
count(*)::int
|
|
||||||
from
|
|
||||||
episodes as e
|
|
||||||
where
|
|
||||||
e.season_id = id) as episodes_count
|
|
||||||
"""
|
|
||||||
)]
|
|
||||||
public int EpisodesCount { get; set; }
|
|
||||||
|
|
||||||
private int _EpisodesCount => Episodes!.Count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The slug of the Show that contain this episode. If this is not set, this season is ill-formed.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? ShowSlug { private get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the Show containing this season.
|
||||||
|
/// </summary>
|
||||||
|
public Guid ShowId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The show that contains this season.
|
||||||
|
/// </summary>
|
||||||
|
[LoadableRelation(nameof(ShowId))]
|
||||||
|
public Show? Show { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of this season. This can be set to 0 to indicate specials.
|
||||||
|
/// </summary>
|
||||||
|
public int SeasonNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The title of this season.
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A quick overview of this season.
|
||||||
|
/// </summary>
|
||||||
|
public string? Overview { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The starting air date of this season.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? StartDate { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime AddedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ending date of this season.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? EndDate { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Image? Poster { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Image? Thumbnail { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Image? Logo { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of episodes that this season contains.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public ICollection<Episode>? Episodes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of episodes in this season.
|
||||||
|
/// </summary>
|
||||||
|
[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)]
|
||||||
|
[NotMapped]
|
||||||
|
[LoadableRelation(
|
||||||
|
// language=PostgreSQL
|
||||||
|
Projected = """
|
||||||
|
(
|
||||||
|
select
|
||||||
|
count(*)::int
|
||||||
|
from
|
||||||
|
episodes as e
|
||||||
|
where
|
||||||
|
e.season_id = id) as episodes_count
|
||||||
|
"""
|
||||||
|
)]
|
||||||
|
public int EpisodesCount { get; set; }
|
||||||
|
|
||||||
|
private int _EpisodesCount => Episodes!.Count;
|
||||||
}
|
}
|
||||||
|
@ -27,254 +27,253 @@ using Kyoo.Abstractions.Controllers;
|
|||||||
using Kyoo.Abstractions.Models.Attributes;
|
using Kyoo.Abstractions.Models.Attributes;
|
||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A series or a movie.
|
||||||
|
/// </summary>
|
||||||
|
public class Show
|
||||||
|
: IQuery,
|
||||||
|
IResource,
|
||||||
|
IMetadata,
|
||||||
|
IOnMerge,
|
||||||
|
IThumbnails,
|
||||||
|
IAddedDate,
|
||||||
|
ILibraryItem,
|
||||||
|
IWatchlist
|
||||||
{
|
{
|
||||||
|
public static Sort DefaultSort => new Sort<Show>.By(x => x.Name);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string Slug { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A series or a movie.
|
/// The title of this show.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Show
|
public string Name { get; set; }
|
||||||
: IQuery,
|
|
||||||
IResource,
|
|
||||||
IMetadata,
|
|
||||||
IOnMerge,
|
|
||||||
IThumbnails,
|
|
||||||
IAddedDate,
|
|
||||||
ILibraryItem,
|
|
||||||
IWatchlist
|
|
||||||
{
|
|
||||||
public static Sort DefaultSort => new Sort<Show>.By(x => x.Name);
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public Guid Id { get; set; }
|
/// A catchphrase for this show.
|
||||||
|
/// </summary>
|
||||||
|
public string? Tagline { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
[MaxLength(256)]
|
/// The list of alternative titles of this show.
|
||||||
public string Slug { get; set; }
|
/// </summary>
|
||||||
|
public List<string> Aliases { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The title of this show.
|
/// The summary of this show.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name { get; set; }
|
public string? Overview { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A catchphrase for this show.
|
/// A list of tags that match this movie.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Tagline { get; set; }
|
public List<string> Tags { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of alternative titles of this show.
|
/// The list of genres (themes) this show has.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> Aliases { get; set; } = new();
|
public List<Genre> Genres { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The summary of this show.
|
/// Is this show airing, not aired yet or finished?
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Overview { get; set; }
|
public Status Status { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A list of tags that match this movie.
|
/// How well this item is rated? (from 0 to 100).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> Tags { get; set; } = new();
|
public int Rating { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of genres (themes) this show has.
|
/// The date this show started airing. It can be null if this is unknown.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Genre> Genres { get; set; } = new();
|
public DateTime? StartAir { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Is this show airing, not aired yet or finished?
|
/// The date this show finished airing.
|
||||||
/// </summary>
|
/// It can also be null if this is unknown.
|
||||||
public Status Status { get; set; }
|
/// </summary>
|
||||||
|
public DateTime? EndAir { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// How well this item is rated? (from 0 to 100).
|
public DateTime AddedDate { get; set; }
|
||||||
/// </summary>
|
|
||||||
public int Rating { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// The date this show started airing. It can be null if this is unknown.
|
public Image? Poster { get; set; }
|
||||||
/// </summary>
|
|
||||||
public DateTime? StartAir { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// The date this show finished airing.
|
public Image? Thumbnail { get; set; }
|
||||||
/// It can also be null if this is unknown.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? EndAir { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public DateTime AddedDate { get; set; }
|
public Image? Logo { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public Image? Poster { get; set; }
|
/// A video of a few minutes that tease the content.
|
||||||
|
/// </summary>
|
||||||
|
public string? Trailer { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
[JsonIgnore]
|
||||||
public Image? Thumbnail { get; set; }
|
[Column("start_air")]
|
||||||
|
public DateTime? AirDate => StartAir;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Image? Logo { get; set; }
|
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A video of a few minutes that tease the content.
|
/// The ID of the Studio that made this show.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Trailer { get; set; }
|
public Guid? StudioId { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
/// <summary>
|
||||||
[Column("start_air")]
|
/// The Studio that made this show.
|
||||||
public DateTime? AirDate => StartAir;
|
/// </summary>
|
||||||
|
[LoadableRelation(nameof(StudioId))]
|
||||||
|
public Studio? Studio { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
/// The different seasons in this show. If this is a movie, this list is always null or empty.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public ICollection<Season>? Seasons { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The ID of the Studio that made this show.
|
/// The list of episodes in this show.
|
||||||
/// </summary>
|
/// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null).
|
||||||
public Guid? StudioId { get; set; }
|
/// Having an episode is necessary to store metadata and tracks.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public ICollection<Episode>? Episodes { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Studio that made this show.
|
/// The list of collections that contains this show.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[LoadableRelation(nameof(StudioId))]
|
[JsonIgnore]
|
||||||
public Studio? Studio { get; set; }
|
public ICollection<Collection>? Collections { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The different seasons in this show. If this is a movie, this list is always null or empty.
|
/// The first episode of this show.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonIgnore]
|
[Projectable(UseMemberBody = nameof(_FirstEpisode), OnlyOnInclude = true)]
|
||||||
public ICollection<Season>? Seasons { get; set; }
|
[LoadableRelation(
|
||||||
|
// language=PostgreSQL
|
||||||
/// <summary>
|
Sql = """
|
||||||
/// The list of episodes in this show.
|
select
|
||||||
/// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null).
|
fe.* -- Episode as fe
|
||||||
/// Having an episode is necessary to store metadata and tracks.
|
from (
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public ICollection<Episode>? Episodes { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of collections that contains this show.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public ICollection<Collection>? Collections { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The first episode of this show.
|
|
||||||
/// </summary>
|
|
||||||
[Projectable(UseMemberBody = nameof(_FirstEpisode), OnlyOnInclude = true)]
|
|
||||||
[LoadableRelation(
|
|
||||||
// language=PostgreSQL
|
|
||||||
Sql = """
|
|
||||||
select
|
select
|
||||||
fe.* -- Episode as fe
|
e.*,
|
||||||
from (
|
row_number() over (partition by e.show_id order by e.absolute_number, e.season_number, e.episode_number) as number
|
||||||
select
|
from
|
||||||
e.*,
|
episodes as e) as "fe"
|
||||||
row_number() over (partition by e.show_id order by e.absolute_number, e.season_number, e.episode_number) as number
|
where
|
||||||
from
|
fe.number <= 1
|
||||||
episodes as e) as "fe"
|
""",
|
||||||
|
On = "show_id = \"this\".id"
|
||||||
|
)]
|
||||||
|
public Episode? FirstEpisode { get; set; }
|
||||||
|
|
||||||
|
private Episode? _FirstEpisode =>
|
||||||
|
Episodes!
|
||||||
|
.OrderBy(x => x.AbsoluteNumber)
|
||||||
|
.ThenBy(x => x.SeasonNumber)
|
||||||
|
.ThenBy(x => x.EpisodeNumber)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The number of episodes in this show.
|
||||||
|
/// </summary>
|
||||||
|
[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)]
|
||||||
|
[NotMapped]
|
||||||
|
[LoadableRelation(
|
||||||
|
// language=PostgreSQL
|
||||||
|
Projected = """
|
||||||
|
(
|
||||||
|
select
|
||||||
|
count(*)::int
|
||||||
|
from
|
||||||
|
episodes as e
|
||||||
where
|
where
|
||||||
fe.number <= 1
|
e.show_id = "this".id) as episodes_count
|
||||||
""",
|
"""
|
||||||
On = "show_id = \"this\".id"
|
)]
|
||||||
)]
|
public int EpisodesCount { get; set; }
|
||||||
public Episode? FirstEpisode { get; set; }
|
|
||||||
|
|
||||||
private Episode? _FirstEpisode =>
|
private int _EpisodesCount => Episodes!.Count;
|
||||||
Episodes!
|
|
||||||
.OrderBy(x => x.AbsoluteNumber)
|
|
||||||
.ThenBy(x => x.SeasonNumber)
|
|
||||||
.ThenBy(x => x.EpisodeNumber)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
/// <summary>
|
[JsonIgnore]
|
||||||
/// The number of episodes in this show.
|
public ICollection<ShowWatchStatus>? Watched { get; set; }
|
||||||
/// </summary>
|
|
||||||
[Projectable(UseMemberBody = nameof(_EpisodesCount), OnlyOnInclude = true)]
|
|
||||||
[NotMapped]
|
|
||||||
[LoadableRelation(
|
|
||||||
// language=PostgreSQL
|
|
||||||
Projected = """
|
|
||||||
(
|
|
||||||
select
|
|
||||||
count(*)::int
|
|
||||||
from
|
|
||||||
episodes as e
|
|
||||||
where
|
|
||||||
e.show_id = "this".id) as episodes_count
|
|
||||||
"""
|
|
||||||
)]
|
|
||||||
public int EpisodesCount { get; set; }
|
|
||||||
|
|
||||||
private int _EpisodesCount => Episodes!.Count;
|
/// <summary>
|
||||||
|
/// Metadata of what an user as started/planned to watch.
|
||||||
|
/// </summary>
|
||||||
|
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
|
||||||
|
[LoadableRelation(
|
||||||
|
Sql = "show_watch_status",
|
||||||
|
On = "show_id = \"this\".id and \"relation\".user_id = [current_user]"
|
||||||
|
)]
|
||||||
|
public ShowWatchStatus? WatchStatus { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
// There is a global query filter to filter by user so we just need to do single.
|
||||||
public ICollection<ShowWatchStatus>? Watched { get; set; }
|
private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Metadata of what an user as started/planned to watch.
|
public void OnMerge(object merged)
|
||||||
/// </summary>
|
{
|
||||||
[Projectable(UseMemberBody = nameof(_WatchStatus), OnlyOnInclude = true)]
|
if (Seasons != null)
|
||||||
[LoadableRelation(
|
|
||||||
Sql = "show_watch_status",
|
|
||||||
On = "show_id = \"this\".id and \"relation\".user_id = [current_user]"
|
|
||||||
)]
|
|
||||||
public ShowWatchStatus? WatchStatus { get; set; }
|
|
||||||
|
|
||||||
// There is a global query filter to filter by user so we just need to do single.
|
|
||||||
private ShowWatchStatus? _WatchStatus => Watched!.FirstOrDefault();
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void OnMerge(object merged)
|
|
||||||
{
|
{
|
||||||
if (Seasons != null)
|
foreach (Season season in Seasons)
|
||||||
{
|
season.Show = this;
|
||||||
foreach (Season season in Seasons)
|
|
||||||
season.Show = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Episodes != null)
|
|
||||||
{
|
|
||||||
foreach (Episode episode in Episodes)
|
|
||||||
episode.Show = this;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Show() { }
|
if (Episodes != null)
|
||||||
|
|
||||||
[JsonConstructor]
|
|
||||||
public Show(string name)
|
|
||||||
{
|
{
|
||||||
if (name != null)
|
foreach (Episode episode in Episodes)
|
||||||
{
|
episode.Show = this;
|
||||||
Slug = Utility.ToSlug(name);
|
|
||||||
Name = name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Show() { }
|
||||||
/// The enum containing show's status.
|
|
||||||
/// </summary>
|
[JsonConstructor]
|
||||||
public enum Status
|
public Show(string name)
|
||||||
{
|
{
|
||||||
/// <summary>
|
if (name != null)
|
||||||
/// The status of the show is not known.
|
{
|
||||||
/// </summary>
|
Slug = Utility.ToSlug(name);
|
||||||
Unknown,
|
Name = name;
|
||||||
|
}
|
||||||
/// <summary>
|
|
||||||
/// The show has finished airing.
|
|
||||||
/// </summary>
|
|
||||||
Finished,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The show is still actively airing.
|
|
||||||
/// </summary>
|
|
||||||
Airing,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This show has not aired yet but has been announced.
|
|
||||||
/// </summary>
|
|
||||||
Planned
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The enum containing show's status.
|
||||||
|
/// </summary>
|
||||||
|
public enum Status
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The status of the show is not known.
|
||||||
|
/// </summary>
|
||||||
|
Unknown,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The show has finished airing.
|
||||||
|
/// </summary>
|
||||||
|
Finished,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The show is still actively airing.
|
||||||
|
/// </summary>
|
||||||
|
Airing,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This show has not aired yet but has been announced.
|
||||||
|
/// </summary>
|
||||||
|
Planned
|
||||||
|
}
|
||||||
|
@ -23,59 +23,58 @@ using System.Text.Json.Serialization;
|
|||||||
using Kyoo.Abstractions.Controllers;
|
using Kyoo.Abstractions.Controllers;
|
||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A studio that make shows.
|
||||||
|
/// </summary>
|
||||||
|
public class Studio : IQuery, IResource, IMetadata
|
||||||
{
|
{
|
||||||
|
public static Sort DefaultSort => new Sort<Studio>.By(x => x.Name);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string Slug { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A studio that make shows.
|
/// The name of this studio.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Studio : IQuery, IResource, IMetadata
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of shows that are made by this studio.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public ICollection<Show>? Shows { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of movies that are made by this studio.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public ICollection<Movie>? Movies { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new, empty, <see cref="Studio"/>.
|
||||||
|
/// </summary>
|
||||||
|
public Studio() { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="Studio"/> with a specific name, the slug is calculated automatically.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The name of the studio.</param>
|
||||||
|
[JsonConstructor]
|
||||||
|
public Studio(string name)
|
||||||
{
|
{
|
||||||
public static Sort DefaultSort => new Sort<Studio>.By(x => x.Name);
|
if (name != null)
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
[MaxLength(256)]
|
|
||||||
public string Slug { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The name of this studio.
|
|
||||||
/// </summary>
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of shows that are made by this studio.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public ICollection<Show>? Shows { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of movies that are made by this studio.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public ICollection<Movie>? Movies { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Dictionary<string, MetadataId> ExternalId { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new, empty, <see cref="Studio"/>.
|
|
||||||
/// </summary>
|
|
||||||
public Studio() { }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="Studio"/> with a specific name, the slug is calculated automatically.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="name">The name of the studio.</param>
|
|
||||||
[JsonConstructor]
|
|
||||||
public Studio(string name)
|
|
||||||
{
|
{
|
||||||
if (name != null)
|
Slug = Utility.ToSlug(name);
|
||||||
{
|
Name = name;
|
||||||
Slug = Utility.ToSlug(name);
|
|
||||||
Name = name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,95 +23,94 @@ using System.Text.Json.Serialization;
|
|||||||
using Kyoo.Abstractions.Controllers;
|
using Kyoo.Abstractions.Controllers;
|
||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single user of the app.
|
||||||
|
/// </summary>
|
||||||
|
public class User : IQuery, IResource, IAddedDate
|
||||||
{
|
{
|
||||||
|
public static Sort DefaultSort => new Sort<User>.By(x => x.Username);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string Slug { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single user of the app.
|
/// A username displayed to the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class User : IQuery, IResource, IAddedDate
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user email address.
|
||||||
|
/// </summary>
|
||||||
|
public string Email { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user password (hashed, it can't be read like that). The hashing format is implementation defined.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? Password { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Does the user can sign-in with a password or only via oidc?
|
||||||
|
/// </summary>
|
||||||
|
public bool HasPassword => Password != null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of permissions of the user. The format of this is implementation dependent.
|
||||||
|
/// </summary>
|
||||||
|
public string[] Permissions { get; set; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime AddedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User settings
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> Settings { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User accounts on other services.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, ExternalToken> ExternalId { get; set; } = new();
|
||||||
|
|
||||||
|
public User() { }
|
||||||
|
|
||||||
|
[JsonConstructor]
|
||||||
|
public User(string username)
|
||||||
{
|
{
|
||||||
public static Sort DefaultSort => new Sort<User>.By(x => x.Username);
|
if (username != null)
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
[MaxLength(256)]
|
|
||||||
public string Slug { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A username displayed to the user.
|
|
||||||
/// </summary>
|
|
||||||
public string Username { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user email address.
|
|
||||||
/// </summary>
|
|
||||||
public string Email { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user password (hashed, it can't be read like that). The hashing format is implementation defined.
|
|
||||||
/// </summary>
|
|
||||||
[JsonIgnore]
|
|
||||||
public string? Password { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Does the user can sign-in with a password or only via oidc?
|
|
||||||
/// </summary>
|
|
||||||
public bool HasPassword => Password != null;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The list of permissions of the user. The format of this is implementation dependent.
|
|
||||||
/// </summary>
|
|
||||||
public string[] Permissions { get; set; } = Array.Empty<string>();
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public DateTime AddedDate { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// User settings
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, string> Settings { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// User accounts on other services.
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<string, ExternalToken> ExternalId { get; set; } = new();
|
|
||||||
|
|
||||||
public User() { }
|
|
||||||
|
|
||||||
[JsonConstructor]
|
|
||||||
public User(string username)
|
|
||||||
{
|
{
|
||||||
if (username != null)
|
Slug = Utility.ToSlug(username);
|
||||||
{
|
Username = username;
|
||||||
Slug = Utility.ToSlug(username);
|
|
||||||
Username = username;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public class ExternalToken
|
|
||||||
{
|
public class ExternalToken
|
||||||
/// <summary>
|
{
|
||||||
/// The id of this user on the external service.
|
/// <summary>
|
||||||
/// </summary>
|
/// The id of this user on the external service.
|
||||||
public string Id { get; set; }
|
/// </summary>
|
||||||
|
public string Id { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// The username on the external service.
|
/// <summary>
|
||||||
/// </summary>
|
/// The username on the external service.
|
||||||
public string Username { get; set; }
|
/// </summary>
|
||||||
|
public string Username { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// The link to the user profile on this website. Null if it does not exist.
|
/// <summary>
|
||||||
/// </summary>
|
/// The link to the user profile on this website. Null if it does not exist.
|
||||||
public string? ProfileUrl { get; set; }
|
/// </summary>
|
||||||
|
public string? ProfileUrl { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// A jwt token used to interact with the service.
|
/// <summary>
|
||||||
/// Do not forget to refresh it when using it if necessary.
|
/// A jwt token used to interact with the service.
|
||||||
/// </summary>
|
/// Do not forget to refresh it when using it if necessary.
|
||||||
public JwtToken Token { get; set; }
|
/// </summary>
|
||||||
}
|
public JwtToken Token { get; set; }
|
||||||
}
|
}
|
||||||
|
@ -20,214 +20,213 @@ using System;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Kyoo.Abstractions.Models.Attributes;
|
using Kyoo.Abstractions.Models.Attributes;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Has the user started watching, is it planned?
|
||||||
|
/// </summary>
|
||||||
|
public enum WatchStatus
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The user has already watched this.
|
||||||
|
/// </summary>
|
||||||
|
Completed,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user started watching this but has not finished.
|
||||||
|
/// </summary>
|
||||||
|
Watching,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user does not plan to continue watching.
|
||||||
|
/// </summary>
|
||||||
|
Droped,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user has not started watching this but plans to.
|
||||||
|
/// </summary>
|
||||||
|
Planned,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata of what an user as started/planned to watch.
|
||||||
|
/// </summary>
|
||||||
|
[SqlFirstColumn(nameof(UserId))]
|
||||||
|
public class MovieWatchStatus : IAddedDate
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the user that started watching this episode.
|
||||||
|
/// </summary>
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user that started watching this episode.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public User User { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the movie started.
|
||||||
|
/// </summary>
|
||||||
|
public Guid MovieId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="Movie"/> started.
|
||||||
|
/// </summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public Movie Movie { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public DateTime AddedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date at which this item was played.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? PlayedDate { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Has the user started watching, is it planned?
|
/// Has the user started watching, is it planned?
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum WatchStatus
|
public WatchStatus Status { get; set; }
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The user has already watched this.
|
|
||||||
/// </summary>
|
|
||||||
Completed,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user started watching this but has not finished.
|
|
||||||
/// </summary>
|
|
||||||
Watching,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user does not plan to continue watching.
|
|
||||||
/// </summary>
|
|
||||||
Droped,
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user has not started watching this but plans to.
|
|
||||||
/// </summary>
|
|
||||||
Planned,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Metadata of what an user as started/planned to watch.
|
/// Where the player has stopped watching the movie (in seconds).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[SqlFirstColumn(nameof(UserId))]
|
/// <remarks>
|
||||||
public class MovieWatchStatus : IAddedDate
|
/// Null if the status is not Watching.
|
||||||
{
|
/// </remarks>
|
||||||
/// <summary>
|
public int? WatchedTime { get; set; }
|
||||||
/// The ID of the user that started watching this episode.
|
|
||||||
/// </summary>
|
|
||||||
public Guid UserId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The user that started watching this episode.
|
/// Where the player has stopped watching the movie (in percentage between 0 and 100).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonIgnore]
|
/// <remarks>
|
||||||
public User User { get; set; }
|
/// Null if the status is not Watching.
|
||||||
|
/// </remarks>
|
||||||
/// <summary>
|
public int? WatchedPercent { get; set; }
|
||||||
/// The ID of the movie started.
|
}
|
||||||
/// </summary>
|
|
||||||
public Guid MovieId { get; set; }
|
[SqlFirstColumn(nameof(UserId))]
|
||||||
|
public class EpisodeWatchStatus : IAddedDate
|
||||||
/// <summary>
|
{
|
||||||
/// The <see cref="Movie"/> started.
|
/// <summary>
|
||||||
/// </summary>
|
/// The ID of the user that started watching this episode.
|
||||||
[JsonIgnore]
|
/// </summary>
|
||||||
public Movie Movie { get; set; }
|
public Guid UserId { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <summary>
|
||||||
public DateTime AddedDate { get; set; }
|
/// The user that started watching this episode.
|
||||||
|
/// </summary>
|
||||||
/// <summary>
|
[JsonIgnore]
|
||||||
/// The date at which this item was played.
|
public User User { get; set; }
|
||||||
/// </summary>
|
|
||||||
public DateTime? PlayedDate { get; set; }
|
/// <summary>
|
||||||
|
/// The ID of the episode started.
|
||||||
/// <summary>
|
/// </summary>
|
||||||
/// Has the user started watching, is it planned?
|
public Guid? EpisodeId { get; set; }
|
||||||
/// </summary>
|
|
||||||
public WatchStatus Status { get; set; }
|
/// <summary>
|
||||||
|
/// The <see cref="Episode"/> started.
|
||||||
/// <summary>
|
/// </summary>
|
||||||
/// Where the player has stopped watching the movie (in seconds).
|
[JsonIgnore]
|
||||||
/// </summary>
|
public Episode Episode { get; set; }
|
||||||
/// <remarks>
|
|
||||||
/// Null if the status is not Watching.
|
/// <inheritdoc/>
|
||||||
/// </remarks>
|
public DateTime AddedDate { get; set; }
|
||||||
public int? WatchedTime { get; set; }
|
|
||||||
|
/// <summary>
|
||||||
/// <summary>
|
/// The date at which this item was played.
|
||||||
/// Where the player has stopped watching the movie (in percentage between 0 and 100).
|
/// </summary>
|
||||||
/// </summary>
|
public DateTime? PlayedDate { get; set; }
|
||||||
/// <remarks>
|
|
||||||
/// Null if the status is not Watching.
|
/// <summary>
|
||||||
/// </remarks>
|
/// Has the user started watching, is it planned?
|
||||||
public int? WatchedPercent { get; set; }
|
/// </summary>
|
||||||
}
|
public WatchStatus Status { get; set; }
|
||||||
|
|
||||||
[SqlFirstColumn(nameof(UserId))]
|
/// <summary>
|
||||||
public class EpisodeWatchStatus : IAddedDate
|
/// Where the player has stopped watching the episode (in seconds).
|
||||||
{
|
/// </summary>
|
||||||
/// <summary>
|
/// <remarks>
|
||||||
/// The ID of the user that started watching this episode.
|
/// Null if the status is not Watching.
|
||||||
/// </summary>
|
/// </remarks>
|
||||||
public Guid UserId { get; set; }
|
public int? WatchedTime { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The user that started watching this episode.
|
/// Where the player has stopped watching the episode (in percentage between 0 and 100).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonIgnore]
|
/// <remarks>
|
||||||
public User User { get; set; }
|
/// Null if the status is not Watching or if the next episode is not started.
|
||||||
|
/// </remarks>
|
||||||
/// <summary>
|
public int? WatchedPercent { get; set; }
|
||||||
/// The ID of the episode started.
|
}
|
||||||
/// </summary>
|
|
||||||
public Guid? EpisodeId { get; set; }
|
[SqlFirstColumn(nameof(UserId))]
|
||||||
|
public class ShowWatchStatus : IAddedDate
|
||||||
/// <summary>
|
{
|
||||||
/// The <see cref="Episode"/> started.
|
/// <summary>
|
||||||
/// </summary>
|
/// The ID of the user that started watching this episode.
|
||||||
[JsonIgnore]
|
/// </summary>
|
||||||
public Episode Episode { get; set; }
|
public Guid UserId { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <summary>
|
||||||
public DateTime AddedDate { get; set; }
|
/// The user that started watching this episode.
|
||||||
|
/// </summary>
|
||||||
/// <summary>
|
[JsonIgnore]
|
||||||
/// The date at which this item was played.
|
public User User { get; set; }
|
||||||
/// </summary>
|
|
||||||
public DateTime? PlayedDate { get; set; }
|
/// <summary>
|
||||||
|
/// The ID of the show started.
|
||||||
/// <summary>
|
/// </summary>
|
||||||
/// Has the user started watching, is it planned?
|
public Guid ShowId { get; set; }
|
||||||
/// </summary>
|
|
||||||
public WatchStatus Status { get; set; }
|
/// <summary>
|
||||||
|
/// The <see cref="Show"/> started.
|
||||||
/// <summary>
|
/// </summary>
|
||||||
/// Where the player has stopped watching the episode (in seconds).
|
[JsonIgnore]
|
||||||
/// </summary>
|
public Show Show { get; set; }
|
||||||
/// <remarks>
|
|
||||||
/// Null if the status is not Watching.
|
/// <inheritdoc/>
|
||||||
/// </remarks>
|
public DateTime AddedDate { get; set; }
|
||||||
public int? WatchedTime { get; set; }
|
|
||||||
|
/// <summary>
|
||||||
/// <summary>
|
/// The date at which this item was played.
|
||||||
/// Where the player has stopped watching the episode (in percentage between 0 and 100).
|
/// </summary>
|
||||||
/// </summary>
|
public DateTime? PlayedDate { get; set; }
|
||||||
/// <remarks>
|
|
||||||
/// Null if the status is not Watching or if the next episode is not started.
|
/// <summary>
|
||||||
/// </remarks>
|
/// Has the user started watching, is it planned?
|
||||||
public int? WatchedPercent { get; set; }
|
/// </summary>
|
||||||
}
|
public WatchStatus Status { get; set; }
|
||||||
|
|
||||||
[SqlFirstColumn(nameof(UserId))]
|
/// <summary>
|
||||||
public class ShowWatchStatus : IAddedDate
|
/// The number of episodes the user has not seen.
|
||||||
{
|
/// </summary>
|
||||||
/// <summary>
|
public int UnseenEpisodesCount { get; set; }
|
||||||
/// The ID of the user that started watching this episode.
|
|
||||||
/// </summary>
|
/// <summary>
|
||||||
public Guid UserId { get; set; }
|
/// The ID of the episode started.
|
||||||
|
/// </summary>
|
||||||
/// <summary>
|
public Guid? NextEpisodeId { get; set; }
|
||||||
/// The user that started watching this episode.
|
|
||||||
/// </summary>
|
/// <summary>
|
||||||
[JsonIgnore]
|
/// The next <see cref="Episode"/> to watch.
|
||||||
public User User { get; set; }
|
/// </summary>
|
||||||
|
public Episode? NextEpisode { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// The ID of the show started.
|
/// <summary>
|
||||||
/// </summary>
|
/// Where the player has stopped watching the episode (in seconds).
|
||||||
public Guid ShowId { get; set; }
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
/// <summary>
|
/// Null if the status is not Watching or if the next episode is not started.
|
||||||
/// The <see cref="Show"/> started.
|
/// </remarks>
|
||||||
/// </summary>
|
public int? WatchedTime { get; set; }
|
||||||
[JsonIgnore]
|
|
||||||
public Show Show { get; set; }
|
/// <summary>
|
||||||
|
/// Where the player has stopped watching the episode (in percentage between 0 and 100).
|
||||||
/// <inheritdoc/>
|
/// </summary>
|
||||||
public DateTime AddedDate { get; set; }
|
/// <remarks>
|
||||||
|
/// Null if the status is not Watching or if the next episode is not started.
|
||||||
/// <summary>
|
/// </remarks>
|
||||||
/// The date at which this item was played.
|
public int? WatchedPercent { get; set; }
|
||||||
/// </summary>
|
|
||||||
public DateTime? PlayedDate { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Has the user started watching, is it planned?
|
|
||||||
/// </summary>
|
|
||||||
public WatchStatus Status { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The number of episodes the user has not seen.
|
|
||||||
/// </summary>
|
|
||||||
public int UnseenEpisodesCount { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The ID of the episode started.
|
|
||||||
/// </summary>
|
|
||||||
public Guid? NextEpisodeId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The next <see cref="Episode"/> to watch.
|
|
||||||
/// </summary>
|
|
||||||
public Episode? NextEpisode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Where the player has stopped watching the episode (in seconds).
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Null if the status is not Watching or if the next episode is not started.
|
|
||||||
/// </remarks>
|
|
||||||
public int? WatchedTime { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Where the player has stopped watching the episode (in percentage between 0 and 100).
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Null if the status is not Watching or if the next episode is not started.
|
|
||||||
/// </remarks>
|
|
||||||
public int? WatchedPercent { get; set; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -18,37 +18,36 @@
|
|||||||
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Results of a search request.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The search item's type.</typeparam>
|
||||||
|
public class SearchPage<T> : Page<T>
|
||||||
|
where T : IResource
|
||||||
{
|
{
|
||||||
/// <summary>
|
public SearchPage(
|
||||||
/// Results of a search request.
|
SearchResult result,
|
||||||
/// </summary>
|
string @this,
|
||||||
/// <typeparam name="T">The search item's type.</typeparam>
|
string? previous,
|
||||||
public class SearchPage<T> : Page<T>
|
string? next,
|
||||||
where T : IResource
|
string first
|
||||||
|
)
|
||||||
|
: base(result.Items, @this, previous, next, first)
|
||||||
{
|
{
|
||||||
public SearchPage(
|
Query = result.Query;
|
||||||
SearchResult result,
|
}
|
||||||
string @this,
|
|
||||||
string? previous,
|
|
||||||
string? next,
|
|
||||||
string first
|
|
||||||
)
|
|
||||||
: base(result.Items, @this, previous, next, first)
|
|
||||||
{
|
|
||||||
Query = result.Query;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The query of the search request.
|
/// The query of the search request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Query { get; init; }
|
public string? Query { get; init; }
|
||||||
|
|
||||||
public class SearchResult
|
public class SearchResult
|
||||||
{
|
{
|
||||||
public string? Query { get; set; }
|
public string? Query { get; set; }
|
||||||
|
|
||||||
public ICollection<T> Items { get; set; }
|
public ICollection<T> Items { get; set; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,41 +16,40 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
namespace Kyoo.Authentication.Models
|
namespace Kyoo.Authentication.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of well known claims of kyoo
|
||||||
|
/// </summary>
|
||||||
|
public static class Claims
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// List of well known claims of kyoo
|
/// The id of the user
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Claims
|
public static string Id => "id";
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The id of the user
|
|
||||||
/// </summary>
|
|
||||||
public static string Id => "id";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The name of the user
|
/// The name of the user
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string Name => "name";
|
public static string Name => "name";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The email of the user.
|
/// The email of the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string Email => "email";
|
public static string Email => "email";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of permissions that the user has.
|
/// The list of permissions that the user has.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string Permissions => "permissions";
|
public static string Permissions => "permissions";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The type of the token (either "access" or "refresh").
|
/// The type of the token (either "access" or "refresh").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string Type => "type";
|
public static string Type => "type";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A guid used to identify a specific refresh token. This is only useful for the server to revokate tokens.
|
/// A guid used to identify a specific refresh token. This is only useful for the server to revokate tokens.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string Guid => "guid";
|
public static string Guid => "guid";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -18,43 +18,42 @@
|
|||||||
|
|
||||||
using Kyoo.Abstractions.Models.Attributes;
|
using Kyoo.Abstractions.Models.Attributes;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Utils
|
namespace Kyoo.Abstractions.Models.Utils;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A class containing constant numbers.
|
||||||
|
/// </summary>
|
||||||
|
public static class Constants
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A class containing constant numbers.
|
/// A property to use on a Microsoft.AspNet.MVC.Route.Order property to mark it as an alternative route
|
||||||
|
/// that won't be included on the swagger.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Constants
|
public const int AlternativeRoute = 1;
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A property to use on a Microsoft.AspNet.MVC.Route.Order property to mark it as an alternative route
|
|
||||||
/// that won't be included on the swagger.
|
|
||||||
/// </summary>
|
|
||||||
public const int AlternativeRoute = 1;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by users.
|
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by users.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string UsersGroup = "0:Users";
|
public const string UsersGroup = "0:Users";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for main resources of kyoo.
|
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for main resources of kyoo.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string ResourcesGroup = "1:Resources";
|
public const string ResourcesGroup = "1:Resources";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A group name for <see cref="ApiDefinitionAttribute"/>.
|
/// A group name for <see cref="ApiDefinitionAttribute"/>.
|
||||||
/// It should be used for sub resources of kyoo that help define the main resources.
|
/// It should be used for sub resources of kyoo that help define the main resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string MetadataGroup = "2:Metadata";
|
public const string MetadataGroup = "2:Metadata";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints useful for playback.
|
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints useful for playback.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string WatchGroup = "3:Watch";
|
public const string WatchGroup = "3:Watch";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins.
|
/// A group name for <see cref="ApiDefinitionAttribute"/>. It should be used for endpoints used by admins.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string AdminGroup = "4:Admin";
|
public const string AdminGroup = "4:Admin";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -24,225 +24,222 @@ using System.Linq;
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Utils
|
namespace Kyoo.Abstractions.Models.Utils;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A class that represent a resource. It is made to be used as a parameter in a query and not used somewhere else
|
||||||
|
/// on the application.
|
||||||
|
/// This class allow routes to be used via ether IDs or Slugs, this is suitable for every <see cref="IResource"/>.
|
||||||
|
/// </summary>
|
||||||
|
[TypeConverter(typeof(IdentifierConvertor))]
|
||||||
|
public class Identifier
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A class that represent a resource. It is made to be used as a parameter in a query and not used somewhere else
|
/// The ID of the resource or null if the slug is specified.
|
||||||
/// on the application.
|
|
||||||
/// This class allow routes to be used via ether IDs or Slugs, this is suitable for every <see cref="IResource"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[TypeConverter(typeof(IdentifierConvertor))]
|
private readonly Guid? _id;
|
||||||
public class Identifier
|
|
||||||
|
/// <summary>
|
||||||
|
/// The slug of the resource or null if the id is specified.
|
||||||
|
/// </summary>
|
||||||
|
private readonly string? _slug;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="Identifier"/> for the given id.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The id of the resource.</param>
|
||||||
|
public Identifier(Guid id)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_id = id;
|
||||||
/// The ID of the resource or null if the slug is specified.
|
}
|
||||||
/// </summary>
|
|
||||||
private readonly Guid? _id;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The slug of the resource or null if the id is specified.
|
/// Create a new <see cref="Identifier"/> for the given slug.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly string? _slug;
|
/// <param name="slug">The slug of the resource.</param>
|
||||||
|
public Identifier(string slug)
|
||||||
|
{
|
||||||
|
_slug = slug;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="Identifier"/> for the given id.
|
/// Pattern match out of the identifier to a resource.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">The id of the resource.</param>
|
/// <param name="idFunc">The function to match the ID to a type <typeparamref name="T"/>.</param>
|
||||||
public Identifier(Guid id)
|
/// <param name="slugFunc">The function to match the slug to a type <typeparamref name="T"/>.</param>
|
||||||
|
/// <typeparam name="T">The return type that will be converted to from an ID or a slug.</typeparam>
|
||||||
|
/// <returns>
|
||||||
|
/// The result of the <paramref name="idFunc"/> or <paramref name="slugFunc"/> depending on the pattern.
|
||||||
|
/// </returns>
|
||||||
|
/// <example>
|
||||||
|
/// Example usage:
|
||||||
|
/// <code lang="csharp">
|
||||||
|
/// T ret = await identifier.Match(
|
||||||
|
/// id => _repository.GetOrDefault(id),
|
||||||
|
/// slug => _repository.GetOrDefault(slug)
|
||||||
|
/// );
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public T Match<T>(Func<Guid, T> idFunc, Func<string, T> slugFunc)
|
||||||
|
{
|
||||||
|
return _id.HasValue ? idFunc(_id.Value) : slugFunc(_slug!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Match a custom type to an identifier. This can be used for wrapped resources (see example for more details).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param>
|
||||||
|
/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param>
|
||||||
|
/// <typeparam name="T">The type to match against this identifier.</typeparam>
|
||||||
|
/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns>
|
||||||
|
/// <example>
|
||||||
|
/// <code lang="csharp">
|
||||||
|
/// identifier.Matcher<Season>(x => x.ShowID, x => x.Show.Slug)
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public Filter<T> Matcher<T>(
|
||||||
|
Expression<Func<T, Guid>> idGetter,
|
||||||
|
Expression<Func<T, string>> slugGetter
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug);
|
||||||
|
BinaryExpression equal = Expression.Equal(
|
||||||
|
_id.HasValue ? idGetter.Body : slugGetter.Body,
|
||||||
|
self
|
||||||
|
);
|
||||||
|
ICollection<ParameterExpression> parameters = _id.HasValue
|
||||||
|
? idGetter.Parameters
|
||||||
|
: slugGetter.Parameters;
|
||||||
|
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters);
|
||||||
|
return new Filter<T>.Lambda(lambda);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A matcher overload for nullable IDs. See
|
||||||
|
/// <see cref="Matcher{T}(Expression{Func{T,Guid}},Expression{Func{T,string}})"/>
|
||||||
|
/// for more details.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param>
|
||||||
|
/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param>
|
||||||
|
/// <typeparam name="T">The type to match against this identifier.</typeparam>
|
||||||
|
/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns>
|
||||||
|
public Filter<T> Matcher<T>(
|
||||||
|
Expression<Func<T, Guid?>> idGetter,
|
||||||
|
Expression<Func<T, string>> slugGetter
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug);
|
||||||
|
BinaryExpression equal = Expression.Equal(
|
||||||
|
_id.HasValue ? idGetter.Body : slugGetter.Body,
|
||||||
|
self
|
||||||
|
);
|
||||||
|
ICollection<ParameterExpression> parameters = _id.HasValue
|
||||||
|
? idGetter.Parameters
|
||||||
|
: slugGetter.Parameters;
|
||||||
|
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters);
|
||||||
|
return new Filter<T>.Lambda(lambda);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return true if this <see cref="Identifier"/> match a resource.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resource">The resource to match</param>
|
||||||
|
/// <returns>
|
||||||
|
/// <c>true</c> if the <paramref name="resource"/> match this identifier, <c>false</c> otherwise.
|
||||||
|
/// </returns>
|
||||||
|
public bool IsSame(IResource resource)
|
||||||
|
{
|
||||||
|
return Match(id => resource.Id == id, slug => resource.Slug == slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return a filter to get this <see cref="Identifier"/> match a given resource.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of resource to match against.</typeparam>
|
||||||
|
/// <returns>
|
||||||
|
/// <c>true</c> if the given resource match this identifier, <c>false</c> otherwise.
|
||||||
|
/// </returns>
|
||||||
|
public Filter<T> IsSame<T>()
|
||||||
|
where T : IResource
|
||||||
|
{
|
||||||
|
return _id.HasValue ? new Filter<T>.Eq("Id", _id.Value) : new Filter<T>.Eq("Slug", _slug!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Is(Guid uid)
|
||||||
|
{
|
||||||
|
return _id.HasValue && _id.Value == uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Is(string slug)
|
||||||
|
{
|
||||||
|
return !_id.HasValue && _slug == slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression<Func<T, bool>> _IsSameExpression<T>()
|
||||||
|
where T : IResource
|
||||||
|
{
|
||||||
|
return _id.HasValue ? x => x.Id == _id.Value : x => x.Slug == _slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return an expression that return true if this <see cref="Identifier"/> is containing in a collection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="listGetter">An expression to retrieve the list to check.</param>
|
||||||
|
/// <typeparam name="T">The type that contain the list to check.</typeparam>
|
||||||
|
/// <typeparam name="T2">The type of resource to check this identifier against.</typeparam>
|
||||||
|
/// <returns>An expression to check if this <see cref="Identifier"/> is contained.</returns>
|
||||||
|
public Filter<T> IsContainedIn<T, T2>(Expression<Func<T, IEnumerable<T2>?>> listGetter)
|
||||||
|
where T2 : IResource
|
||||||
|
{
|
||||||
|
MethodInfo method = typeof(Enumerable)
|
||||||
|
.GetMethods()
|
||||||
|
.Where(x => x.Name == nameof(Enumerable.Any))
|
||||||
|
.FirstOrDefault(x => x.GetParameters().Length == 2)!
|
||||||
|
.MakeGenericMethod(typeof(T2));
|
||||||
|
MethodCallExpression call = Expression.Call(
|
||||||
|
null,
|
||||||
|
method,
|
||||||
|
listGetter.Body,
|
||||||
|
_IsSameExpression<T2>()
|
||||||
|
);
|
||||||
|
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(
|
||||||
|
call,
|
||||||
|
listGetter.Parameters
|
||||||
|
);
|
||||||
|
return new Filter<T>.Lambda(lambda);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return _id.HasValue ? _id.Value.ToString() : _slug!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A custom <see cref="TypeConverter"/> used to convert int or strings to an <see cref="Identifier"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class IdentifierConvertor : TypeConverter
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
|
||||||
{
|
{
|
||||||
_id = id;
|
if (sourceType == typeof(Guid) || sourceType == typeof(string))
|
||||||
}
|
return true;
|
||||||
|
return base.CanConvertFrom(context, sourceType);
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="Identifier"/> for the given slug.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="slug">The slug of the resource.</param>
|
|
||||||
public Identifier(string slug)
|
|
||||||
{
|
|
||||||
_slug = slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pattern match out of the identifier to a resource.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="idFunc">The function to match the ID to a type <typeparamref name="T"/>.</param>
|
|
||||||
/// <param name="slugFunc">The function to match the slug to a type <typeparamref name="T"/>.</param>
|
|
||||||
/// <typeparam name="T">The return type that will be converted to from an ID or a slug.</typeparam>
|
|
||||||
/// <returns>
|
|
||||||
/// The result of the <paramref name="idFunc"/> or <paramref name="slugFunc"/> depending on the pattern.
|
|
||||||
/// </returns>
|
|
||||||
/// <example>
|
|
||||||
/// Example usage:
|
|
||||||
/// <code lang="csharp">
|
|
||||||
/// T ret = await identifier.Match(
|
|
||||||
/// id => _repository.GetOrDefault(id),
|
|
||||||
/// slug => _repository.GetOrDefault(slug)
|
|
||||||
/// );
|
|
||||||
/// </code>
|
|
||||||
/// </example>
|
|
||||||
public T Match<T>(Func<Guid, T> idFunc, Func<string, T> slugFunc)
|
|
||||||
{
|
|
||||||
return _id.HasValue ? idFunc(_id.Value) : slugFunc(_slug!);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Match a custom type to an identifier. This can be used for wrapped resources (see example for more details).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param>
|
|
||||||
/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param>
|
|
||||||
/// <typeparam name="T">The type to match against this identifier.</typeparam>
|
|
||||||
/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns>
|
|
||||||
/// <example>
|
|
||||||
/// <code lang="csharp">
|
|
||||||
/// identifier.Matcher<Season>(x => x.ShowID, x => x.Show.Slug)
|
|
||||||
/// </code>
|
|
||||||
/// </example>
|
|
||||||
public Filter<T> Matcher<T>(
|
|
||||||
Expression<Func<T, Guid>> idGetter,
|
|
||||||
Expression<Func<T, string>> slugGetter
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug);
|
|
||||||
BinaryExpression equal = Expression.Equal(
|
|
||||||
_id.HasValue ? idGetter.Body : slugGetter.Body,
|
|
||||||
self
|
|
||||||
);
|
|
||||||
ICollection<ParameterExpression> parameters = _id.HasValue
|
|
||||||
? idGetter.Parameters
|
|
||||||
: slugGetter.Parameters;
|
|
||||||
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters);
|
|
||||||
return new Filter<T>.Lambda(lambda);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A matcher overload for nullable IDs. See
|
|
||||||
/// <see cref="Matcher{T}(Expression{Func{T,Guid}},Expression{Func{T,string}})"/>
|
|
||||||
/// for more details.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="idGetter">An expression to retrieve an ID from the type <typeparamref name="T"/>.</param>
|
|
||||||
/// <param name="slugGetter">An expression to retrieve a slug from the type <typeparamref name="T"/>.</param>
|
|
||||||
/// <typeparam name="T">The type to match against this identifier.</typeparam>
|
|
||||||
/// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns>
|
|
||||||
public Filter<T> Matcher<T>(
|
|
||||||
Expression<Func<T, Guid?>> idGetter,
|
|
||||||
Expression<Func<T, string>> slugGetter
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug);
|
|
||||||
BinaryExpression equal = Expression.Equal(
|
|
||||||
_id.HasValue ? idGetter.Body : slugGetter.Body,
|
|
||||||
self
|
|
||||||
);
|
|
||||||
ICollection<ParameterExpression> parameters = _id.HasValue
|
|
||||||
? idGetter.Parameters
|
|
||||||
: slugGetter.Parameters;
|
|
||||||
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters);
|
|
||||||
return new Filter<T>.Lambda(lambda);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return true if this <see cref="Identifier"/> match a resource.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="resource">The resource to match</param>
|
|
||||||
/// <returns>
|
|
||||||
/// <c>true</c> if the <paramref name="resource"/> match this identifier, <c>false</c> otherwise.
|
|
||||||
/// </returns>
|
|
||||||
public bool IsSame(IResource resource)
|
|
||||||
{
|
|
||||||
return Match(id => resource.Id == id, slug => resource.Slug == slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return a filter to get this <see cref="Identifier"/> match a given resource.
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">The type of resource to match against.</typeparam>
|
|
||||||
/// <returns>
|
|
||||||
/// <c>true</c> if the given resource match this identifier, <c>false</c> otherwise.
|
|
||||||
/// </returns>
|
|
||||||
public Filter<T> IsSame<T>()
|
|
||||||
where T : IResource
|
|
||||||
{
|
|
||||||
return _id.HasValue
|
|
||||||
? new Filter<T>.Eq("Id", _id.Value)
|
|
||||||
: new Filter<T>.Eq("Slug", _slug!);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Is(Guid uid)
|
|
||||||
{
|
|
||||||
return _id.HasValue && _id.Value == uid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Is(string slug)
|
|
||||||
{
|
|
||||||
return !_id.HasValue && _slug == slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Expression<Func<T, bool>> _IsSameExpression<T>()
|
|
||||||
where T : IResource
|
|
||||||
{
|
|
||||||
return _id.HasValue ? x => x.Id == _id.Value : x => x.Slug == _slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return an expression that return true if this <see cref="Identifier"/> is containing in a collection.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="listGetter">An expression to retrieve the list to check.</param>
|
|
||||||
/// <typeparam name="T">The type that contain the list to check.</typeparam>
|
|
||||||
/// <typeparam name="T2">The type of resource to check this identifier against.</typeparam>
|
|
||||||
/// <returns>An expression to check if this <see cref="Identifier"/> is contained.</returns>
|
|
||||||
public Filter<T> IsContainedIn<T, T2>(Expression<Func<T, IEnumerable<T2>?>> listGetter)
|
|
||||||
where T2 : IResource
|
|
||||||
{
|
|
||||||
MethodInfo method = typeof(Enumerable)
|
|
||||||
.GetMethods()
|
|
||||||
.Where(x => x.Name == nameof(Enumerable.Any))
|
|
||||||
.FirstOrDefault(x => x.GetParameters().Length == 2)!
|
|
||||||
.MakeGenericMethod(typeof(T2));
|
|
||||||
MethodCallExpression call = Expression.Call(
|
|
||||||
null,
|
|
||||||
method,
|
|
||||||
listGetter.Body,
|
|
||||||
_IsSameExpression<T2>()
|
|
||||||
);
|
|
||||||
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(
|
|
||||||
call,
|
|
||||||
listGetter.Parameters
|
|
||||||
);
|
|
||||||
return new Filter<T>.Lambda(lambda);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override string ToString()
|
public override object ConvertFrom(
|
||||||
|
ITypeDescriptorContext? context,
|
||||||
|
CultureInfo? culture,
|
||||||
|
object value
|
||||||
|
)
|
||||||
{
|
{
|
||||||
return _id.HasValue ? _id.Value.ToString() : _slug!;
|
if (value is Guid id)
|
||||||
}
|
return new Identifier(id);
|
||||||
|
if (value is not string slug)
|
||||||
/// <summary>
|
return base.ConvertFrom(context, culture, value)!;
|
||||||
/// A custom <see cref="TypeConverter"/> used to convert int or strings to an <see cref="Identifier"/>.
|
return Guid.TryParse(slug, out id) ? new Identifier(id) : new Identifier(slug);
|
||||||
/// </summary>
|
|
||||||
public class IdentifierConvertor : TypeConverter
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
|
|
||||||
{
|
|
||||||
if (sourceType == typeof(Guid) || sourceType == typeof(string))
|
|
||||||
return true;
|
|
||||||
return base.CanConvertFrom(context, sourceType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override object ConvertFrom(
|
|
||||||
ITypeDescriptorContext? context,
|
|
||||||
CultureInfo? culture,
|
|
||||||
object value
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (value is Guid id)
|
|
||||||
return new Identifier(id);
|
|
||||||
if (value is not string slug)
|
|
||||||
return base.ConvertFrom(context, culture, value)!;
|
|
||||||
return Guid.TryParse(slug, out id) ? new Identifier(id) : new Identifier(slug);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,56 +18,55 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Controllers
|
namespace Kyoo.Abstractions.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about the pagination. How many items should be displayed and where to start.
|
||||||
|
/// </summary>
|
||||||
|
public class Pagination
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Information about the pagination. How many items should be displayed and where to start.
|
/// The count of items to return.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Pagination
|
public int Limit { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where to start? Using the given sort.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? AfterID { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Should the previous page be returned instead of the next?
|
||||||
|
/// </summary>
|
||||||
|
public bool Reverse { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="Pagination"/> with default values.
|
||||||
|
/// </summary>
|
||||||
|
public Pagination()
|
||||||
{
|
{
|
||||||
/// <summary>
|
Limit = 50;
|
||||||
/// The count of items to return.
|
AfterID = null;
|
||||||
/// </summary>
|
Reverse = false;
|
||||||
public int Limit { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Where to start? Using the given sort.
|
|
||||||
/// </summary>
|
|
||||||
public Guid? AfterID { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Should the previous page be returned instead of the next?
|
|
||||||
/// </summary>
|
|
||||||
public bool Reverse { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="Pagination"/> with default values.
|
|
||||||
/// </summary>
|
|
||||||
public Pagination()
|
|
||||||
{
|
|
||||||
Limit = 50;
|
|
||||||
AfterID = null;
|
|
||||||
Reverse = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="Pagination"/> instance.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="count">Set the <see cref="Limit"/> value</param>
|
|
||||||
/// <param name="afterID">Set the <see cref="AfterID"/> value. If not specified, it will start from the start</param>
|
|
||||||
/// <param name="reverse">Should the previous page be returned instead of the next?</param>
|
|
||||||
public Pagination(int count, Guid? afterID = null, bool reverse = false)
|
|
||||||
{
|
|
||||||
Limit = count;
|
|
||||||
AfterID = afterID;
|
|
||||||
Reverse = reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Implicitly create a new pagination from a limit number.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="limit">Set the <see cref="Limit"/> value</param>
|
|
||||||
/// <returns>A new <see cref="Pagination"/> instance</returns>
|
|
||||||
public static implicit operator Pagination(int limit) => new(limit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="Pagination"/> instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="count">Set the <see cref="Limit"/> value</param>
|
||||||
|
/// <param name="afterID">Set the <see cref="AfterID"/> value. If not specified, it will start from the start</param>
|
||||||
|
/// <param name="reverse">Should the previous page be returned instead of the next?</param>
|
||||||
|
public Pagination(int count, Guid? afterID = null, bool reverse = false)
|
||||||
|
{
|
||||||
|
Limit = count;
|
||||||
|
AfterID = afterID;
|
||||||
|
Reverse = reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implicitly create a new pagination from a limit number.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="limit">Set the <see cref="Limit"/> value</param>
|
||||||
|
/// <returns>A new <see cref="Pagination"/> instance</returns>
|
||||||
|
public static implicit operator Pagination(int limit) => new(limit);
|
||||||
}
|
}
|
||||||
|
@ -19,42 +19,38 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models.Utils
|
namespace Kyoo.Abstractions.Models.Utils;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of errors that where made in the request.
|
||||||
|
/// </summary>
|
||||||
|
public class RequestError
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of errors that where made in the request.
|
/// The list of errors that where made in the request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RequestError
|
/// <example><c>["InvalidFilter: no field 'startYear' on a collection"]</c></example>
|
||||||
|
public string[] Errors { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="RequestError"/> with one error.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="error">The error to specify in the response.</param>
|
||||||
|
public RequestError(string error)
|
||||||
{
|
{
|
||||||
/// <summary>
|
if (error == null)
|
||||||
/// The list of errors that where made in the request.
|
throw new ArgumentNullException(nameof(error));
|
||||||
/// </summary>
|
Errors = new[] { error };
|
||||||
/// <example><c>["InvalidFilter: no field 'startYear' on a collection"]</c></example>
|
}
|
||||||
public string[] Errors { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="RequestError"/> with one error.
|
/// Create a new <see cref="RequestError"/> with multiple errors.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="error">The error to specify in the response.</param>
|
/// <param name="errors">The errors to specify in the response.</param>
|
||||||
public RequestError(string error)
|
public RequestError(string[] errors)
|
||||||
{
|
{
|
||||||
if (error == null)
|
if (errors == null || !errors.Any())
|
||||||
throw new ArgumentNullException(nameof(error));
|
throw new ArgumentException("Errors must be non null and not empty", nameof(errors));
|
||||||
Errors = new[] { error };
|
Errors = errors;
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="RequestError"/> with multiple errors.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="errors">The errors to specify in the response.</param>
|
|
||||||
public RequestError(string[] errors)
|
|
||||||
{
|
|
||||||
if (errors == null || !errors.Any())
|
|
||||||
throw new ArgumentException(
|
|
||||||
"Errors must be non null and not empty",
|
|
||||||
nameof(errors)
|
|
||||||
);
|
|
||||||
Errors = errors;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,21 +16,20 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Controllers
|
namespace Kyoo.Abstractions.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about the pagination. How many items should be displayed and where to start.
|
||||||
|
/// </summary>
|
||||||
|
public class SearchPagination
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Information about the pagination. How many items should be displayed and where to start.
|
/// The count of items to return.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SearchPagination
|
public int Limit { get; set; } = 50;
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The count of items to return.
|
|
||||||
/// </summary>
|
|
||||||
public int Limit { get; set; } = 50;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Where to start? How many items to skip?
|
/// Where to start? How many items to skip?
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? Skip { get; set; }
|
public int? Skip { get; set; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -25,111 +25,109 @@ using Kyoo.Abstractions.Models;
|
|||||||
using Kyoo.Abstractions.Models.Attributes;
|
using Kyoo.Abstractions.Models.Attributes;
|
||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Controllers
|
namespace Kyoo.Abstractions.Controllers;
|
||||||
{
|
|
||||||
public record Sort;
|
|
||||||
|
|
||||||
|
public record Sort;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about how a query should be sorted. What factor should decide the sort and in which order.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">For witch type this sort applies</typeparam>
|
||||||
|
public record Sort<T> : Sort
|
||||||
|
where T : IQuery
|
||||||
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Information about how a query should be sorted. What factor should decide the sort and in which order.
|
/// Sort by a specific key
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">For witch type this sort applies</typeparam>
|
/// <param name="Key">The sort keys. This members will be used to sort the results.</param>
|
||||||
public record Sort<T> : Sort
|
/// <param name="Desendant">
|
||||||
where T : IQuery
|
/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order.
|
||||||
|
/// </param>
|
||||||
|
public record By(string Key, bool Desendant = false) : Sort<T>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sort by a specific key
|
/// Sort by a specific key
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Key">The sort keys. This members will be used to sort the results.</param>
|
/// <param name="key">The sort keys. This members will be used to sort the results.</param>
|
||||||
/// <param name="Desendant">
|
/// <param name="desendant">
|
||||||
/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order.
|
/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order.
|
||||||
/// </param>
|
/// </param>
|
||||||
public record By(string Key, bool Desendant = false) : Sort<T>
|
public By(Expression<Func<T, object?>> key, bool desendant = false)
|
||||||
|
: this(Utility.GetPropertyName(key), desendant) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sort by multiple keys.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="List">The list of keys to sort by.</param>
|
||||||
|
public record Conglomerate(params Sort<T>[] List) : Sort<T>;
|
||||||
|
|
||||||
|
/// <summary>Sort randomly items</summary>
|
||||||
|
public record Random(uint Seed) : Sort<T>
|
||||||
|
{
|
||||||
|
public Random()
|
||||||
|
: this(0)
|
||||||
{
|
{
|
||||||
/// <summary>
|
uint seed = BitConverter.ToUInt32(
|
||||||
/// Sort by a specific key
|
BitConverter.GetBytes(new System.Random().Next(int.MinValue, int.MaxValue)),
|
||||||
/// </summary>
|
0
|
||||||
/// <param name="key">The sort keys. This members will be used to sort the results.</param>
|
);
|
||||||
/// <param name="desendant">
|
Seed = seed;
|
||||||
/// If this is set to true, items will be sorted in descend order else, they will be sorted in ascendant order.
|
|
||||||
/// </param>
|
|
||||||
public By(Expression<Func<T, object?>> key, bool desendant = false)
|
|
||||||
: this(Utility.GetPropertyName(key), desendant) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sort by multiple keys.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="List">The list of keys to sort by.</param>
|
|
||||||
public record Conglomerate(params Sort<T>[] List) : Sort<T>;
|
|
||||||
|
|
||||||
/// <summary>Sort randomly items</summary>
|
|
||||||
public record Random(uint Seed) : Sort<T>
|
|
||||||
{
|
|
||||||
public Random()
|
|
||||||
: this(0)
|
|
||||||
{
|
|
||||||
uint seed = BitConverter.ToUInt32(
|
|
||||||
BitConverter.GetBytes(new System.Random().Next(int.MinValue, int.MaxValue)),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
Seed = seed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The default sort method for the given type.</summary>
|
|
||||||
public record Default : Sort<T>
|
|
||||||
{
|
|
||||||
public void Deconstruct(out Sort<T> value)
|
|
||||||
{
|
|
||||||
value = (Sort<T>)T.DefaultSort;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="Sort{T}"/> instance from a key's name (case insensitive).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="sortBy">A key name with an optional order specifier. Format: "key:asc", "key:desc" or "key".</param>
|
|
||||||
/// <param name="seed">The random seed.</param>
|
|
||||||
/// <exception cref="ArgumentException">An invalid key or sort specifier as been given.</exception>
|
|
||||||
/// <returns>A <see cref="Sort{T}"/> for the given string</returns>
|
|
||||||
public static Sort<T> From(string? sortBy, uint seed)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(sortBy) || sortBy == "default")
|
|
||||||
return new Default();
|
|
||||||
if (sortBy == "random")
|
|
||||||
return new Random(seed);
|
|
||||||
if (sortBy.Contains(','))
|
|
||||||
return new Conglomerate(sortBy.Split(',').Select(x => From(x, seed)).ToArray());
|
|
||||||
|
|
||||||
if (sortBy.StartsWith("random:"))
|
|
||||||
return new Random(uint.Parse(sortBy["random:".Length..]));
|
|
||||||
|
|
||||||
string key = sortBy.Contains(':') ? sortBy[..sortBy.IndexOf(':')] : sortBy;
|
|
||||||
string? order = sortBy.Contains(':') ? sortBy[(sortBy.IndexOf(':') + 1)..] : null;
|
|
||||||
bool desendant = order switch
|
|
||||||
{
|
|
||||||
"desc" => true,
|
|
||||||
"asc" => false,
|
|
||||||
null => false,
|
|
||||||
_
|
|
||||||
=> throw new ValidationException(
|
|
||||||
$"The sort order, if set, should be :asc or :desc but it was :{order}."
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
Type[] types =
|
|
||||||
typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) };
|
|
||||||
PropertyInfo? property = types
|
|
||||||
.Select(x =>
|
|
||||||
x.GetProperty(
|
|
||||||
key,
|
|
||||||
BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.FirstOrDefault(x => x != null);
|
|
||||||
if (property == null)
|
|
||||||
throw new ValidationException("The given sort key is not valid.");
|
|
||||||
return new By(property.Name, desendant);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The default sort method for the given type.</summary>
|
||||||
|
public record Default : Sort<T>
|
||||||
|
{
|
||||||
|
public void Deconstruct(out Sort<T> value)
|
||||||
|
{
|
||||||
|
value = (Sort<T>)T.DefaultSort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="Sort{T}"/> instance from a key's name (case insensitive).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sortBy">A key name with an optional order specifier. Format: "key:asc", "key:desc" or "key".</param>
|
||||||
|
/// <param name="seed">The random seed.</param>
|
||||||
|
/// <exception cref="ArgumentException">An invalid key or sort specifier as been given.</exception>
|
||||||
|
/// <returns>A <see cref="Sort{T}"/> for the given string</returns>
|
||||||
|
public static Sort<T> From(string? sortBy, uint seed)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(sortBy) || sortBy == "default")
|
||||||
|
return new Default();
|
||||||
|
if (sortBy == "random")
|
||||||
|
return new Random(seed);
|
||||||
|
if (sortBy.Contains(','))
|
||||||
|
return new Conglomerate(sortBy.Split(',').Select(x => From(x, seed)).ToArray());
|
||||||
|
|
||||||
|
if (sortBy.StartsWith("random:"))
|
||||||
|
return new Random(uint.Parse(sortBy["random:".Length..]));
|
||||||
|
|
||||||
|
string key = sortBy.Contains(':') ? sortBy[..sortBy.IndexOf(':')] : sortBy;
|
||||||
|
string? order = sortBy.Contains(':') ? sortBy[(sortBy.IndexOf(':') + 1)..] : null;
|
||||||
|
bool desendant = order switch
|
||||||
|
{
|
||||||
|
"desc" => true,
|
||||||
|
"asc" => false,
|
||||||
|
null => false,
|
||||||
|
_
|
||||||
|
=> throw new ValidationException(
|
||||||
|
$"The sort order, if set, should be :asc or :desc but it was :{order}."
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) };
|
||||||
|
PropertyInfo? property = types
|
||||||
|
.Select(x =>
|
||||||
|
x.GetProperty(
|
||||||
|
key,
|
||||||
|
BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.FirstOrDefault(x => x != null);
|
||||||
|
if (property == null)
|
||||||
|
throw new ValidationException("The given sort key is not valid.");
|
||||||
|
return new By(property.Name, desendant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,21 +16,20 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
namespace Kyoo.Abstractions.Models
|
namespace Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The links to see a movie or an episode.
|
||||||
|
/// </summary>
|
||||||
|
public class VideoLinks
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The links to see a movie or an episode.
|
/// The direct link to the unprocessed video (pristine quality).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class VideoLinks
|
public string Direct { get; set; }
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The direct link to the unprocessed video (pristine quality).
|
|
||||||
/// </summary>
|
|
||||||
public string Direct { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The link to an HLS master playlist containing all qualities available for this video.
|
/// The link to an HLS master playlist containing all qualities available for this video.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Hls { get; set; }
|
public string Hls { get; set; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -21,56 +21,55 @@ using Autofac.Builder;
|
|||||||
using Kyoo.Abstractions.Controllers;
|
using Kyoo.Abstractions.Controllers;
|
||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
|
|
||||||
namespace Kyoo.Abstractions
|
namespace Kyoo.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A static class with helper functions to setup external modules
|
||||||
|
/// </summary>
|
||||||
|
public static class Module
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A static class with helper functions to setup external modules
|
/// Register a new repository to the container.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Module
|
/// <param name="builder">The container</param>
|
||||||
|
/// <typeparam name="T">The type of the repository.</typeparam>
|
||||||
|
/// <remarks>
|
||||||
|
/// If your repository implements a special interface, please use <see cref="RegisterRepository{T,T2}"/>
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns>The initial container.</returns>
|
||||||
|
public static IRegistrationBuilder<
|
||||||
|
T,
|
||||||
|
ConcreteReflectionActivatorData,
|
||||||
|
SingleRegistrationStyle
|
||||||
|
> RegisterRepository<T>(this ContainerBuilder builder)
|
||||||
|
where T : IBaseRepository
|
||||||
{
|
{
|
||||||
/// <summary>
|
return builder
|
||||||
/// Register a new repository to the container.
|
.RegisterType<T>()
|
||||||
/// </summary>
|
.AsSelf()
|
||||||
/// <param name="builder">The container</param>
|
.As<IBaseRepository>()
|
||||||
/// <typeparam name="T">The type of the repository.</typeparam>
|
.As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))!)
|
||||||
/// <remarks>
|
.InstancePerLifetimeScope();
|
||||||
/// If your repository implements a special interface, please use <see cref="RegisterRepository{T,T2}"/>
|
}
|
||||||
/// </remarks>
|
|
||||||
/// <returns>The initial container.</returns>
|
|
||||||
public static IRegistrationBuilder<
|
|
||||||
T,
|
|
||||||
ConcreteReflectionActivatorData,
|
|
||||||
SingleRegistrationStyle
|
|
||||||
> RegisterRepository<T>(this ContainerBuilder builder)
|
|
||||||
where T : IBaseRepository
|
|
||||||
{
|
|
||||||
return builder
|
|
||||||
.RegisterType<T>()
|
|
||||||
.AsSelf()
|
|
||||||
.As<IBaseRepository>()
|
|
||||||
.As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))!)
|
|
||||||
.InstancePerLifetimeScope();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Register a new repository with a custom mapping to the container.
|
/// Register a new repository with a custom mapping to the container.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="builder">The container</param>
|
/// <param name="builder">The container</param>
|
||||||
/// <typeparam name="T">The custom mapping you have for your repository.</typeparam>
|
/// <typeparam name="T">The custom mapping you have for your repository.</typeparam>
|
||||||
/// <typeparam name="T2">The type of the repository.</typeparam>
|
/// <typeparam name="T2">The type of the repository.</typeparam>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// If your repository does not implements a special interface, please use <see cref="RegisterRepository{T}"/>
|
/// If your repository does not implements a special interface, please use <see cref="RegisterRepository{T}"/>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <returns>The initial container.</returns>
|
/// <returns>The initial container.</returns>
|
||||||
public static IRegistrationBuilder<
|
public static IRegistrationBuilder<
|
||||||
T2,
|
T2,
|
||||||
ConcreteReflectionActivatorData,
|
ConcreteReflectionActivatorData,
|
||||||
SingleRegistrationStyle
|
SingleRegistrationStyle
|
||||||
> RegisterRepository<T, T2>(this ContainerBuilder builder)
|
> RegisterRepository<T, T2>(this ContainerBuilder builder)
|
||||||
where T : notnull
|
where T : notnull
|
||||||
where T2 : IBaseRepository, T
|
where T2 : IBaseRepository, T
|
||||||
{
|
{
|
||||||
return builder.RegisterRepository<T2>().AsSelf().As<T>();
|
return builder.RegisterRepository<T2>().AsSelf().As<T>();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,53 +19,52 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Kyoo.Utils
|
namespace Kyoo.Utils;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A set of extensions class for enumerable.
|
||||||
|
/// </summary>
|
||||||
|
public static class EnumerableExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A set of extensions class for enumerable.
|
/// If the enumerable is empty, execute an action.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class EnumerableExtensions
|
/// <param name="self">The enumerable to check</param>
|
||||||
|
/// <param name="action">The action to execute is the list is empty</param>
|
||||||
|
/// <typeparam name="T">The type of items inside the list</typeparam>
|
||||||
|
/// <returns>The iterator proxied, there is no dual iterations.</returns>
|
||||||
|
public static IEnumerable<T> IfEmpty<T>(this IEnumerable<T> self, Action action)
|
||||||
{
|
{
|
||||||
/// <summary>
|
static IEnumerable<T> Generator(IEnumerable<T> self, Action action)
|
||||||
/// If the enumerable is empty, execute an action.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="self">The enumerable to check</param>
|
|
||||||
/// <param name="action">The action to execute is the list is empty</param>
|
|
||||||
/// <typeparam name="T">The type of items inside the list</typeparam>
|
|
||||||
/// <returns>The iterator proxied, there is no dual iterations.</returns>
|
|
||||||
public static IEnumerable<T> IfEmpty<T>(this IEnumerable<T> self, Action action)
|
|
||||||
{
|
{
|
||||||
static IEnumerable<T> Generator(IEnumerable<T> self, Action action)
|
using IEnumerator<T> enumerator = self.GetEnumerator();
|
||||||
|
|
||||||
|
if (!enumerator.MoveNext())
|
||||||
{
|
{
|
||||||
using IEnumerator<T> enumerator = self.GetEnumerator();
|
action();
|
||||||
|
yield break;
|
||||||
if (!enumerator.MoveNext())
|
|
||||||
{
|
|
||||||
action();
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
yield return enumerator.Current;
|
|
||||||
} while (enumerator.MoveNext());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Generator(self, action);
|
do
|
||||||
|
{
|
||||||
|
yield return enumerator.Current;
|
||||||
|
} while (enumerator.MoveNext());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
return Generator(self, action);
|
||||||
/// A foreach used as a function with a little specificity: the list can be null.
|
}
|
||||||
/// </summary>
|
|
||||||
/// <param name="self">The list to enumerate. If this is null, the function result in a no-op</param>
|
/// <summary>
|
||||||
/// <param name="action">The action to execute for each arguments</param>
|
/// A foreach used as a function with a little specificity: the list can be null.
|
||||||
/// <typeparam name="T">The type of items in the list</typeparam>
|
/// </summary>
|
||||||
public static void ForEach<T>(this IEnumerable<T>? self, Action<T> action)
|
/// <param name="self">The list to enumerate. If this is null, the function result in a no-op</param>
|
||||||
{
|
/// <param name="action">The action to execute for each arguments</param>
|
||||||
if (self == null)
|
/// <typeparam name="T">The type of items in the list</typeparam>
|
||||||
return;
|
public static void ForEach<T>(this IEnumerable<T>? self, Action<T> action)
|
||||||
foreach (T i in self)
|
{
|
||||||
action(i);
|
if (self == null)
|
||||||
}
|
return;
|
||||||
|
foreach (T i in self)
|
||||||
|
action(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,113 +22,112 @@ using System.Linq;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Kyoo.Abstractions.Models.Attributes;
|
using Kyoo.Abstractions.Models.Attributes;
|
||||||
|
|
||||||
namespace Kyoo.Utils
|
namespace Kyoo.Utils;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A class containing helper methods to merge objects.
|
||||||
|
/// </summary>
|
||||||
|
public static class Merger
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A class containing helper methods to merge objects.
|
/// Merge two dictionary, if the same key is found on both dictionary, the values of the second one is kept.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Merger
|
/// <param name="first">The first dictionary to merge</param>
|
||||||
|
/// <param name="second">The second dictionary to merge</param>
|
||||||
|
/// <param name="hasChanged">
|
||||||
|
/// <c>true</c> if a new items has been added to the dictionary, <c>false</c> otherwise.
|
||||||
|
/// </param>
|
||||||
|
/// <typeparam name="T">The type of the keys in dictionaries</typeparam>
|
||||||
|
/// <typeparam name="T2">The type of values in the dictionaries</typeparam>
|
||||||
|
/// <returns>
|
||||||
|
/// A dictionary with the missing elements of <paramref name="second"/>
|
||||||
|
/// set to those of <paramref name="first"/>.
|
||||||
|
/// </returns>
|
||||||
|
public static IDictionary<T, T2>? CompleteDictionaries<T, T2>(
|
||||||
|
IDictionary<T, T2>? first,
|
||||||
|
IDictionary<T, T2>? second,
|
||||||
|
out bool hasChanged
|
||||||
|
)
|
||||||
{
|
{
|
||||||
/// <summary>
|
if (first == null)
|
||||||
/// Merge two dictionary, if the same key is found on both dictionary, the values of the second one is kept.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="first">The first dictionary to merge</param>
|
|
||||||
/// <param name="second">The second dictionary to merge</param>
|
|
||||||
/// <param name="hasChanged">
|
|
||||||
/// <c>true</c> if a new items has been added to the dictionary, <c>false</c> otherwise.
|
|
||||||
/// </param>
|
|
||||||
/// <typeparam name="T">The type of the keys in dictionaries</typeparam>
|
|
||||||
/// <typeparam name="T2">The type of values in the dictionaries</typeparam>
|
|
||||||
/// <returns>
|
|
||||||
/// A dictionary with the missing elements of <paramref name="second"/>
|
|
||||||
/// set to those of <paramref name="first"/>.
|
|
||||||
/// </returns>
|
|
||||||
public static IDictionary<T, T2>? CompleteDictionaries<T, T2>(
|
|
||||||
IDictionary<T, T2>? first,
|
|
||||||
IDictionary<T, T2>? second,
|
|
||||||
out bool hasChanged
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (first == null)
|
hasChanged = true;
|
||||||
{
|
|
||||||
hasChanged = true;
|
|
||||||
return second;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasChanged = false;
|
|
||||||
if (second == null)
|
|
||||||
return first;
|
|
||||||
hasChanged = second.Any(x =>
|
|
||||||
!first.ContainsKey(x.Key) || x.Value?.Equals(first[x.Key]) == false
|
|
||||||
);
|
|
||||||
foreach ((T key, T2 value) in first)
|
|
||||||
second.TryAdd(key, value);
|
|
||||||
return second;
|
return second;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
hasChanged = false;
|
||||||
/// Set every non-default values of seconds to the corresponding property of second.
|
if (second == null)
|
||||||
/// Dictionaries are handled like anonymous objects with a property per key/pair value
|
|
||||||
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <example>
|
|
||||||
/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"}
|
|
||||||
/// </example>
|
|
||||||
/// <param name="first">
|
|
||||||
/// The object to complete
|
|
||||||
/// </param>
|
|
||||||
/// <param name="second">
|
|
||||||
/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="where">
|
|
||||||
/// Filter fields that will be merged
|
|
||||||
/// </param>
|
|
||||||
/// <typeparam name="T">Fields of T will be completed</typeparam>
|
|
||||||
/// <returns><paramref name="first"/></returns>
|
|
||||||
public static T Complete<T>(T first, T? second, Func<PropertyInfo, bool>? where = null)
|
|
||||||
{
|
|
||||||
if (second == null)
|
|
||||||
return first;
|
|
||||||
|
|
||||||
Type type = typeof(T);
|
|
||||||
IEnumerable<PropertyInfo> properties = type.GetProperties()
|
|
||||||
.Where(x =>
|
|
||||||
x is { CanRead: true, CanWrite: true }
|
|
||||||
&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (where != null)
|
|
||||||
properties = properties.Where(where);
|
|
||||||
|
|
||||||
foreach (PropertyInfo property in properties)
|
|
||||||
{
|
|
||||||
object? value = property.GetValue(second);
|
|
||||||
|
|
||||||
if (value?.Equals(property.GetValue(first)) == true)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>)))
|
|
||||||
{
|
|
||||||
Type[] dictionaryTypes = Utility
|
|
||||||
.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))!
|
|
||||||
.GenericTypeArguments;
|
|
||||||
object?[] parameters = { property.GetValue(first), value, false };
|
|
||||||
object newDictionary = Utility.RunGenericMethod<object>(
|
|
||||||
typeof(Merger),
|
|
||||||
nameof(CompleteDictionaries),
|
|
||||||
dictionaryTypes,
|
|
||||||
parameters
|
|
||||||
)!;
|
|
||||||
if ((bool)parameters[2]!)
|
|
||||||
property.SetValue(first, newDictionary);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
property.SetValue(first, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (first is IOnMerge merge)
|
|
||||||
merge.OnMerge(second);
|
|
||||||
return first;
|
return first;
|
||||||
|
hasChanged = second.Any(x =>
|
||||||
|
!first.ContainsKey(x.Key) || x.Value?.Equals(first[x.Key]) == false
|
||||||
|
);
|
||||||
|
foreach ((T key, T2 value) in first)
|
||||||
|
second.TryAdd(key, value);
|
||||||
|
return second;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set every non-default values of seconds to the corresponding property of second.
|
||||||
|
/// Dictionaries are handled like anonymous objects with a property per key/pair value
|
||||||
|
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"}
|
||||||
|
/// </example>
|
||||||
|
/// <param name="first">
|
||||||
|
/// The object to complete
|
||||||
|
/// </param>
|
||||||
|
/// <param name="second">
|
||||||
|
/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="where">
|
||||||
|
/// Filter fields that will be merged
|
||||||
|
/// </param>
|
||||||
|
/// <typeparam name="T">Fields of T will be completed</typeparam>
|
||||||
|
/// <returns><paramref name="first"/></returns>
|
||||||
|
public static T Complete<T>(T first, T? second, Func<PropertyInfo, bool>? where = null)
|
||||||
|
{
|
||||||
|
if (second == null)
|
||||||
|
return first;
|
||||||
|
|
||||||
|
Type type = typeof(T);
|
||||||
|
IEnumerable<PropertyInfo> properties = type.GetProperties()
|
||||||
|
.Where(x =>
|
||||||
|
x is { CanRead: true, CanWrite: true }
|
||||||
|
&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (where != null)
|
||||||
|
properties = properties.Where(where);
|
||||||
|
|
||||||
|
foreach (PropertyInfo property in properties)
|
||||||
|
{
|
||||||
|
object? value = property.GetValue(second);
|
||||||
|
|
||||||
|
if (value?.Equals(property.GetValue(first)) == true)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>)))
|
||||||
|
{
|
||||||
|
Type[] dictionaryTypes = Utility
|
||||||
|
.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))!
|
||||||
|
.GenericTypeArguments;
|
||||||
|
object?[] parameters = { property.GetValue(first), value, false };
|
||||||
|
object newDictionary = Utility.RunGenericMethod<object>(
|
||||||
|
typeof(Merger),
|
||||||
|
nameof(CompleteDictionaries),
|
||||||
|
dictionaryTypes,
|
||||||
|
parameters
|
||||||
|
)!;
|
||||||
|
if ((bool)parameters[2]!)
|
||||||
|
property.SetValue(first, newDictionary);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
property.SetValue(first, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (first is IOnMerge merge)
|
||||||
|
merge.OnMerge(second);
|
||||||
|
return first;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,341 +25,338 @@ using System.Reflection;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Kyoo.Utils
|
namespace Kyoo.Utils;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A set of utility functions that can be used everywhere.
|
||||||
|
/// </summary>
|
||||||
|
public static class Utility
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A set of utility functions that can be used everywhere.
|
/// Convert a string to snake case. Stollen from
|
||||||
|
/// https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.cs
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Utility
|
/// <param name="name">The string to convert.</param>
|
||||||
|
/// <returns>The string in snake case</returns>
|
||||||
|
public static string ToSnakeCase(this string name)
|
||||||
{
|
{
|
||||||
/// <summary>
|
StringBuilder builder = new(name.Length + Math.Min(2, name.Length / 5));
|
||||||
/// Convert a string to snake case. Stollen from
|
UnicodeCategory? previousCategory = default;
|
||||||
/// https://github.com/efcore/EFCore.NamingConventions/blob/main/EFCore.NamingConventions/Internal/SnakeCaseNameRewriter.cs
|
|
||||||
/// </summary>
|
for (int currentIndex = 0; currentIndex < name.Length; currentIndex++)
|
||||||
/// <param name="name">The string to convert.</param>
|
|
||||||
/// <returns>The string in snake case</returns>
|
|
||||||
public static string ToSnakeCase(this string name)
|
|
||||||
{
|
{
|
||||||
StringBuilder builder = new(name.Length + Math.Min(2, name.Length / 5));
|
char currentChar = name[currentIndex];
|
||||||
UnicodeCategory? previousCategory = default;
|
if (currentChar == '_')
|
||||||
|
|
||||||
for (int currentIndex = 0; currentIndex < name.Length; currentIndex++)
|
|
||||||
{
|
{
|
||||||
char currentChar = name[currentIndex];
|
builder.Append('_');
|
||||||
if (currentChar == '_')
|
previousCategory = null;
|
||||||
{
|
continue;
|
||||||
builder.Append('_');
|
}
|
||||||
previousCategory = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
UnicodeCategory currentCategory = char.GetUnicodeCategory(currentChar);
|
UnicodeCategory currentCategory = char.GetUnicodeCategory(currentChar);
|
||||||
switch (currentCategory)
|
switch (currentCategory)
|
||||||
{
|
{
|
||||||
case UnicodeCategory.UppercaseLetter:
|
case UnicodeCategory.UppercaseLetter:
|
||||||
case UnicodeCategory.TitlecaseLetter:
|
case UnicodeCategory.TitlecaseLetter:
|
||||||
if (
|
if (
|
||||||
previousCategory == UnicodeCategory.SpaceSeparator
|
previousCategory == UnicodeCategory.SpaceSeparator
|
||||||
|| previousCategory == UnicodeCategory.LowercaseLetter
|
|| previousCategory == UnicodeCategory.LowercaseLetter
|
||||||
|| (
|
|| (
|
||||||
previousCategory != UnicodeCategory.DecimalDigitNumber
|
previousCategory != UnicodeCategory.DecimalDigitNumber
|
||||||
&& previousCategory != null
|
&& previousCategory != null
|
||||||
&& currentIndex > 0
|
&& currentIndex > 0
|
||||||
&& currentIndex + 1 < name.Length
|
&& currentIndex + 1 < name.Length
|
||||||
&& char.IsLower(name[currentIndex + 1])
|
&& char.IsLower(name[currentIndex + 1])
|
||||||
)
|
|
||||||
)
|
)
|
||||||
{
|
)
|
||||||
builder.Append('_');
|
{
|
||||||
}
|
builder.Append('_');
|
||||||
|
}
|
||||||
|
|
||||||
currentChar = char.ToLowerInvariant(currentChar);
|
currentChar = char.ToLowerInvariant(currentChar);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case UnicodeCategory.LowercaseLetter:
|
case UnicodeCategory.LowercaseLetter:
|
||||||
case UnicodeCategory.DecimalDigitNumber:
|
case UnicodeCategory.DecimalDigitNumber:
|
||||||
if (previousCategory == UnicodeCategory.SpaceSeparator)
|
if (previousCategory == UnicodeCategory.SpaceSeparator)
|
||||||
{
|
{
|
||||||
builder.Append('_');
|
builder.Append('_');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if (previousCategory != null)
|
if (previousCategory != null)
|
||||||
{
|
{
|
||||||
previousCategory = UnicodeCategory.SpaceSeparator;
|
previousCategory = UnicodeCategory.SpaceSeparator;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
builder.Append(currentChar);
|
|
||||||
previousCategory = currentCategory;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.ToString();
|
builder.Append(currentChar);
|
||||||
|
previousCategory = currentCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
return builder.ToString();
|
||||||
/// Is the lambda expression a member (like x => x.Body).
|
}
|
||||||
/// </summary>
|
|
||||||
/// <param name="ex">The expression that should be checked</param>
|
|
||||||
/// <returns>True if the expression is a member, false otherwise</returns>
|
|
||||||
public static bool IsPropertyExpression(LambdaExpression ex)
|
|
||||||
{
|
|
||||||
return ex.Body is MemberExpression
|
|
||||||
|| (
|
|
||||||
ex.Body.NodeType == ExpressionType.Convert
|
|
||||||
&& ((UnaryExpression)ex.Body).Operand is MemberExpression
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows)
|
/// Is the lambda expression a member (like x => x.Body).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ex">The expression</param>
|
/// <param name="ex">The expression that should be checked</param>
|
||||||
/// <returns>The name of the expression</returns>
|
/// <returns>True if the expression is a member, false otherwise</returns>
|
||||||
/// <exception cref="ArgumentException">If the expression is not a property, ArgumentException is thrown.</exception>
|
public static bool IsPropertyExpression(LambdaExpression ex)
|
||||||
public static string GetPropertyName(LambdaExpression ex)
|
{
|
||||||
{
|
return ex.Body is MemberExpression
|
||||||
if (!IsPropertyExpression(ex))
|
|| (
|
||||||
throw new ArgumentException($"{ex} is not a property expression.");
|
|
||||||
MemberExpression? member =
|
|
||||||
ex.Body.NodeType == ExpressionType.Convert
|
ex.Body.NodeType == ExpressionType.Convert
|
||||||
? ((UnaryExpression)ex.Body).Operand as MemberExpression
|
&& ((UnaryExpression)ex.Body).Operand is MemberExpression
|
||||||
: ex.Body as MemberExpression;
|
|
||||||
return member!.Member.Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Slugify a string (Replace spaces by -, Uniformize accents)
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="str">The string to slugify</param>
|
|
||||||
/// <returns>The slug version of the given string</returns>
|
|
||||||
public static string ToSlug(string str)
|
|
||||||
{
|
|
||||||
str = str.ToLowerInvariant();
|
|
||||||
|
|
||||||
string normalizedString = str.Normalize(NormalizationForm.FormD);
|
|
||||||
StringBuilder stringBuilder = new();
|
|
||||||
foreach (char c in normalizedString)
|
|
||||||
{
|
|
||||||
UnicodeCategory unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
|
||||||
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
|
||||||
stringBuilder.Append(c);
|
|
||||||
}
|
|
||||||
str = stringBuilder.ToString().Normalize(NormalizationForm.FormC);
|
|
||||||
|
|
||||||
str = Regex.Replace(str, @"\s", "-", RegexOptions.Compiled);
|
|
||||||
str = Regex.Replace(str, @"[^\w\s\p{Pd}]", string.Empty, RegexOptions.Compiled);
|
|
||||||
str = str.Trim('-', '_');
|
|
||||||
str = Regex.Replace(str, @"([-_]){2,}", "$1", RegexOptions.Compiled);
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return every <see cref="Type"/> in the inheritance tree of the parameter (interfaces are not returned)
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="self">The starting type</param>
|
|
||||||
/// <returns>A list of types</returns>
|
|
||||||
public static IEnumerable<Type> GetInheritanceTree(this Type self)
|
|
||||||
{
|
|
||||||
for (Type? type = self; type != null; type = type.BaseType)
|
|
||||||
yield return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check if <paramref name="type"/> inherit from a generic type <paramref name="genericType"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="type">The type to check</param>
|
|
||||||
/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).</param>
|
|
||||||
/// <returns>True if obj inherit from genericType. False otherwise</returns>
|
|
||||||
public static bool IsOfGenericType(Type type, Type genericType)
|
|
||||||
{
|
|
||||||
if (!genericType.IsGenericType)
|
|
||||||
throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
|
|
||||||
|
|
||||||
IEnumerable<Type> types = genericType.IsInterface
|
|
||||||
? type.GetInterfaces()
|
|
||||||
: type.GetInheritanceTree();
|
|
||||||
return types
|
|
||||||
.Prepend(type)
|
|
||||||
.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get the generic definition of <paramref name="genericType"/>.
|
|
||||||
/// For example, calling this function with List<string> and typeof(IEnumerable<>) will return IEnumerable<string>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="type">The type to check</param>
|
|
||||||
/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).</param>
|
|
||||||
/// <returns>The generic definition of genericType that type inherit or null if type does not implement the generic type.</returns>
|
|
||||||
/// <exception cref="ArgumentException"><paramref name="genericType"/> must be a generic type</exception>
|
|
||||||
public static Type? GetGenericDefinition(Type type, Type genericType)
|
|
||||||
{
|
|
||||||
if (!genericType.IsGenericType)
|
|
||||||
throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
|
|
||||||
|
|
||||||
IEnumerable<Type> types = genericType.IsInterface
|
|
||||||
? type.GetInterfaces()
|
|
||||||
: type.GetInheritanceTree();
|
|
||||||
return types
|
|
||||||
.Prepend(type)
|
|
||||||
.FirstOrDefault(x =>
|
|
||||||
x.IsGenericType && x.GetGenericTypeDefinition() == genericType
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieve a method from an <see cref="Type"/> with the given name and respect the
|
|
||||||
/// amount of parameters and generic parameters. This works for polymorphic methods.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="type">
|
|
||||||
/// The type owning the method. For non static methods, this is the <c>this</c>.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="flag">
|
|
||||||
/// The binding flags of the method. This allow you to specify public/private and so on.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="name">
|
|
||||||
/// The name of the method.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="generics">
|
|
||||||
/// The list of generic parameters.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="args">
|
|
||||||
/// The list of parameters.
|
|
||||||
/// </param>
|
|
||||||
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
|
|
||||||
/// <returns>The method handle of the matching method.</returns>
|
|
||||||
public static MethodInfo GetMethod(
|
|
||||||
Type type,
|
|
||||||
BindingFlags flag,
|
|
||||||
string name,
|
|
||||||
Type[] generics,
|
|
||||||
object?[] args
|
|
||||||
)
|
|
||||||
{
|
|
||||||
MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public)
|
|
||||||
.Where(x => x.Name == name)
|
|
||||||
.Where(x => x.GetGenericArguments().Length == generics.Length)
|
|
||||||
.Where(x => x.GetParameters().Length == args.Length)
|
|
||||||
.IfEmpty(() =>
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
$"A method named {name} with "
|
|
||||||
+ $"{args.Length} arguments and {generics.Length} generic "
|
|
||||||
+ $"types could not be found on {type.Name}."
|
|
||||||
);
|
|
||||||
})
|
|
||||||
// TODO this won't work but I don't know why.
|
|
||||||
// .Where(x =>
|
|
||||||
// {
|
|
||||||
// int i = 0;
|
|
||||||
// return x.GetGenericArguments().All(y => y.IsAssignableFrom(generics[i++]));
|
|
||||||
// })
|
|
||||||
// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the generics specified."))
|
|
||||||
|
|
||||||
// TODO this won't work for Type<T> because T is specified in arguments but not in the parameters type.
|
|
||||||
// .Where(x =>
|
|
||||||
// {
|
|
||||||
// int i = 0;
|
|
||||||
// return x.GetParameters().All(y => y.ParameterType.IsInstanceOfType(args[i++]));
|
|
||||||
// })
|
|
||||||
// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the parameters's types."))
|
|
||||||
.Take(2)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
if (methods.Length == 1)
|
|
||||||
return methods[0];
|
|
||||||
throw new ArgumentException(
|
|
||||||
$"Multiple methods named {name} match the generics and parameters constraints."
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Run a generic static method for a runtime <see cref="Type"/>.
|
/// Get the name of a property. Useful for selectors as members ex: Load(x => x.Shows)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <example>
|
/// <param name="ex">The expression</param>
|
||||||
/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type,
|
/// <returns>The name of the expression</returns>
|
||||||
/// you could do:
|
/// <exception cref="ArgumentException">If the expression is not a property, ArgumentException is thrown.</exception>
|
||||||
/// <code lang="C#">
|
public static string GetPropertyName(LambdaExpression ex)
|
||||||
/// Utility.RunGenericMethod<object>(
|
{
|
||||||
/// typeof(Utility),
|
if (!IsPropertyExpression(ex))
|
||||||
/// nameof(MergeLists),
|
throw new ArgumentException($"{ex} is not a property expression.");
|
||||||
/// enumerableType,
|
MemberExpression? member =
|
||||||
/// oldValue, newValue, equalityComparer)
|
ex.Body.NodeType == ExpressionType.Convert
|
||||||
/// </code>
|
? ((UnaryExpression)ex.Body).Operand as MemberExpression
|
||||||
/// </example>
|
: ex.Body as MemberExpression;
|
||||||
/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param>
|
return member!.Member.Name;
|
||||||
/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param>
|
}
|
||||||
/// <param name="type">The generic type to run the method with.</param>
|
|
||||||
/// <param name="args">The list of arguments of the method</param>
|
|
||||||
/// <typeparam name="T">
|
|
||||||
/// The return type of the method. You can put <see cref="object"/> for an unknown one.
|
|
||||||
/// </typeparam>
|
|
||||||
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
|
|
||||||
/// <returns>The return of the method you wanted to run.</returns>
|
|
||||||
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type[],object[])"/>
|
|
||||||
public static T? RunGenericMethod<T>(
|
|
||||||
Type owner,
|
|
||||||
string methodName,
|
|
||||||
Type type,
|
|
||||||
params object[] args
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return RunGenericMethod<T>(owner, methodName, new[] { type }, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Run a generic static method for a multiple runtime <see cref="Type"/>.
|
/// Slugify a string (Replace spaces by -, Uniformize accents)
|
||||||
/// If your generic method only needs one type, see
|
/// </summary>
|
||||||
/// <see cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
|
/// <param name="str">The string to slugify</param>
|
||||||
/// </summary>
|
/// <returns>The slug version of the given string</returns>
|
||||||
/// <example>
|
public static string ToSlug(string str)
|
||||||
/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type,
|
{
|
||||||
/// you could do:
|
str = str.ToLowerInvariant();
|
||||||
/// <code>
|
|
||||||
/// Utility.RunGenericMethod<object>(
|
string normalizedString = str.Normalize(NormalizationForm.FormD);
|
||||||
/// typeof(Utility),
|
StringBuilder stringBuilder = new();
|
||||||
/// nameof(MergeLists),
|
foreach (char c in normalizedString)
|
||||||
/// enumerableType,
|
|
||||||
/// oldValue, newValue, equalityComparer)
|
|
||||||
/// </code>
|
|
||||||
/// </example>
|
|
||||||
/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param>
|
|
||||||
/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param>
|
|
||||||
/// <param name="types">The list of generic types to run the method with.</param>
|
|
||||||
/// <param name="args">The list of arguments of the method</param>
|
|
||||||
/// <typeparam name="T">
|
|
||||||
/// The return type of the method. You can put <see cref="object"/> for an unknown one.
|
|
||||||
/// </typeparam>
|
|
||||||
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
|
|
||||||
/// <returns>The return of the method you wanted to run.</returns>
|
|
||||||
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
|
|
||||||
public static T? RunGenericMethod<T>(
|
|
||||||
Type owner,
|
|
||||||
string methodName,
|
|
||||||
Type[] types,
|
|
||||||
params object?[] args
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (types.Length < 1)
|
UnicodeCategory unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||||
|
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
||||||
|
stringBuilder.Append(c);
|
||||||
|
}
|
||||||
|
str = stringBuilder.ToString().Normalize(NormalizationForm.FormC);
|
||||||
|
|
||||||
|
str = Regex.Replace(str, @"\s", "-", RegexOptions.Compiled);
|
||||||
|
str = Regex.Replace(str, @"[^\w\s\p{Pd}]", string.Empty, RegexOptions.Compiled);
|
||||||
|
str = str.Trim('-', '_');
|
||||||
|
str = Regex.Replace(str, @"([-_]){2,}", "$1", RegexOptions.Compiled);
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return every <see cref="Type"/> in the inheritance tree of the parameter (interfaces are not returned)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="self">The starting type</param>
|
||||||
|
/// <returns>A list of types</returns>
|
||||||
|
public static IEnumerable<Type> GetInheritanceTree(this Type self)
|
||||||
|
{
|
||||||
|
for (Type? type = self; type != null; type = type.BaseType)
|
||||||
|
yield return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if <paramref name="type"/> inherit from a generic type <paramref name="genericType"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The type to check</param>
|
||||||
|
/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).</param>
|
||||||
|
/// <returns>True if obj inherit from genericType. False otherwise</returns>
|
||||||
|
public static bool IsOfGenericType(Type type, Type genericType)
|
||||||
|
{
|
||||||
|
if (!genericType.IsGenericType)
|
||||||
|
throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
|
||||||
|
|
||||||
|
IEnumerable<Type> types = genericType.IsInterface
|
||||||
|
? type.GetInterfaces()
|
||||||
|
: type.GetInheritanceTree();
|
||||||
|
return types
|
||||||
|
.Prepend(type)
|
||||||
|
.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the generic definition of <paramref name="genericType"/>.
|
||||||
|
/// For example, calling this function with List<string> and typeof(IEnumerable<>) will return IEnumerable<string>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The type to check</param>
|
||||||
|
/// <param name="genericType">The generic type to check against (Only generic types are supported like typeof(IEnumerable<>).</param>
|
||||||
|
/// <returns>The generic definition of genericType that type inherit or null if type does not implement the generic type.</returns>
|
||||||
|
/// <exception cref="ArgumentException"><paramref name="genericType"/> must be a generic type</exception>
|
||||||
|
public static Type? GetGenericDefinition(Type type, Type genericType)
|
||||||
|
{
|
||||||
|
if (!genericType.IsGenericType)
|
||||||
|
throw new ArgumentException($"{nameof(genericType)} is not a generic type.");
|
||||||
|
|
||||||
|
IEnumerable<Type> types = genericType.IsInterface
|
||||||
|
? type.GetInterfaces()
|
||||||
|
: type.GetInheritanceTree();
|
||||||
|
return types
|
||||||
|
.Prepend(type)
|
||||||
|
.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve a method from an <see cref="Type"/> with the given name and respect the
|
||||||
|
/// amount of parameters and generic parameters. This works for polymorphic methods.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">
|
||||||
|
/// The type owning the method. For non static methods, this is the <c>this</c>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="flag">
|
||||||
|
/// The binding flags of the method. This allow you to specify public/private and so on.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="name">
|
||||||
|
/// The name of the method.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="generics">
|
||||||
|
/// The list of generic parameters.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="args">
|
||||||
|
/// The list of parameters.
|
||||||
|
/// </param>
|
||||||
|
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
|
||||||
|
/// <returns>The method handle of the matching method.</returns>
|
||||||
|
public static MethodInfo GetMethod(
|
||||||
|
Type type,
|
||||||
|
BindingFlags flag,
|
||||||
|
string name,
|
||||||
|
Type[] generics,
|
||||||
|
object?[] args
|
||||||
|
)
|
||||||
|
{
|
||||||
|
MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public)
|
||||||
|
.Where(x => x.Name == name)
|
||||||
|
.Where(x => x.GetGenericArguments().Length == generics.Length)
|
||||||
|
.Where(x => x.GetParameters().Length == args.Length)
|
||||||
|
.IfEmpty(() =>
|
||||||
|
{
|
||||||
throw new ArgumentException(
|
throw new ArgumentException(
|
||||||
$"The {nameof(types)} array is empty. At least one type is needed."
|
$"A method named {name} with "
|
||||||
|
+ $"{args.Length} arguments and {generics.Length} generic "
|
||||||
|
+ $"types could not be found on {type.Name}."
|
||||||
);
|
);
|
||||||
MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args);
|
})
|
||||||
return (T?)method.MakeGenericMethod(types).Invoke(null, args);
|
// TODO this won't work but I don't know why.
|
||||||
}
|
// .Where(x =>
|
||||||
|
// {
|
||||||
|
// int i = 0;
|
||||||
|
// return x.GetGenericArguments().All(y => y.IsAssignableFrom(generics[i++]));
|
||||||
|
// })
|
||||||
|
// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the generics specified."))
|
||||||
|
|
||||||
/// <summary>
|
// TODO this won't work for Type<T> because T is specified in arguments but not in the parameters type.
|
||||||
/// Convert a dictionary to a query string.
|
// .Where(x =>
|
||||||
/// </summary>
|
// {
|
||||||
/// <param name="query">The list of query parameters.</param>
|
// int i = 0;
|
||||||
/// <returns>A valid query string with all items in the dictionary.</returns>
|
// return x.GetParameters().All(y => y.ParameterType.IsInstanceOfType(args[i++]));
|
||||||
public static string ToQueryString(this Dictionary<string, string> query)
|
// })
|
||||||
{
|
// .IfEmpty(() => throw new NullReferenceException($"No method {name} match the parameters's types."))
|
||||||
if (!query.Any())
|
.Take(2)
|
||||||
return string.Empty;
|
.ToArray();
|
||||||
return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}"));
|
|
||||||
}
|
if (methods.Length == 1)
|
||||||
|
return methods[0];
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Multiple methods named {name} match the generics and parameters constraints."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Run a generic static method for a runtime <see cref="Type"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type,
|
||||||
|
/// you could do:
|
||||||
|
/// <code lang="C#">
|
||||||
|
/// Utility.RunGenericMethod<object>(
|
||||||
|
/// typeof(Utility),
|
||||||
|
/// nameof(MergeLists),
|
||||||
|
/// enumerableType,
|
||||||
|
/// oldValue, newValue, equalityComparer)
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param>
|
||||||
|
/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param>
|
||||||
|
/// <param name="type">The generic type to run the method with.</param>
|
||||||
|
/// <param name="args">The list of arguments of the method</param>
|
||||||
|
/// <typeparam name="T">
|
||||||
|
/// The return type of the method. You can put <see cref="object"/> for an unknown one.
|
||||||
|
/// </typeparam>
|
||||||
|
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
|
||||||
|
/// <returns>The return of the method you wanted to run.</returns>
|
||||||
|
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type[],object[])"/>
|
||||||
|
public static T? RunGenericMethod<T>(
|
||||||
|
Type owner,
|
||||||
|
string methodName,
|
||||||
|
Type type,
|
||||||
|
params object[] args
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return RunGenericMethod<T>(owner, methodName, new[] { type }, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Run a generic static method for a multiple runtime <see cref="Type"/>.
|
||||||
|
/// If your generic method only needs one type, see
|
||||||
|
/// <see cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// To run Merger.MergeLists{T} for a List where you don't know the type at compile type,
|
||||||
|
/// you could do:
|
||||||
|
/// <code>
|
||||||
|
/// Utility.RunGenericMethod<object>(
|
||||||
|
/// typeof(Utility),
|
||||||
|
/// nameof(MergeLists),
|
||||||
|
/// enumerableType,
|
||||||
|
/// oldValue, newValue, equalityComparer)
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
/// <param name="owner">The type that owns the method. For non static methods, the type of <c>this</c>.</param>
|
||||||
|
/// <param name="methodName">The name of the method. You should use the <c>nameof</c> keyword.</param>
|
||||||
|
/// <param name="types">The list of generic types to run the method with.</param>
|
||||||
|
/// <param name="args">The list of arguments of the method</param>
|
||||||
|
/// <typeparam name="T">
|
||||||
|
/// The return type of the method. You can put <see cref="object"/> for an unknown one.
|
||||||
|
/// </typeparam>
|
||||||
|
/// <exception cref="ArgumentException">No method match the given constraints.</exception>
|
||||||
|
/// <returns>The return of the method you wanted to run.</returns>
|
||||||
|
/// <seealso cref="RunGenericMethod{T}(System.Type,string,System.Type,object[])"/>
|
||||||
|
public static T? RunGenericMethod<T>(
|
||||||
|
Type owner,
|
||||||
|
string methodName,
|
||||||
|
Type[] types,
|
||||||
|
params object?[] args
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (types.Length < 1)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"The {nameof(types)} array is empty. At least one type is needed."
|
||||||
|
);
|
||||||
|
MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args);
|
||||||
|
return (T?)method.MakeGenericMethod(types).Invoke(null, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert a dictionary to a query string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The list of query parameters.</param>
|
||||||
|
/// <returns>A valid query string with all items in the dictionary.</returns>
|
||||||
|
public static string ToQueryString(this Dictionary<string, string> query)
|
||||||
|
{
|
||||||
|
if (!query.Any())
|
||||||
|
return string.Empty;
|
||||||
|
return "?" + string.Join('&', query.Select(x => $"{x.Key}={x.Value}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -32,158 +32,151 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
namespace Kyoo.Authentication
|
namespace Kyoo.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A module that enable OpenID authentication for Kyoo.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Create a new authentication module instance and use the given configuration.
|
||||||
|
/// </remarks>
|
||||||
|
public class AuthenticationModule(
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<AuthenticationModule> logger
|
||||||
|
) : IPlugin
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Authentication";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A module that enable OpenID authentication for Kyoo.
|
/// The configuration to use.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
private readonly IConfiguration _configuration = configuration;
|
||||||
/// Create a new authentication module instance and use the given configuration.
|
|
||||||
/// </remarks>
|
/// <inheritdoc />
|
||||||
public class AuthenticationModule(
|
public void Configure(ContainerBuilder builder)
|
||||||
IConfiguration configuration,
|
|
||||||
ILogger<AuthenticationModule> logger
|
|
||||||
) : IPlugin
|
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
builder.RegisterType<PermissionValidator>().As<IPermissionValidator>().SingleInstance();
|
||||||
public string Name => "Authentication";
|
builder.RegisterType<TokenController>().As<ITokenController>().SingleInstance();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// The configuration to use.
|
public void Configure(IServiceCollection services)
|
||||||
/// </summary>
|
{
|
||||||
private readonly IConfiguration _configuration = configuration;
|
string secret = _configuration.GetValue(
|
||||||
|
"AUTHENTICATION_SECRET",
|
||||||
/// <inheritdoc />
|
AuthenticationOption.DefaultSecret
|
||||||
public void Configure(ContainerBuilder builder)
|
)!;
|
||||||
{
|
PermissionOption options =
|
||||||
builder.RegisterType<PermissionValidator>().As<IPermissionValidator>().SingleInstance();
|
new()
|
||||||
builder.RegisterType<TokenController>().As<ITokenController>().SingleInstance();
|
{
|
||||||
}
|
Default = _configuration
|
||||||
|
.GetValue("UNLOGGED_PERMISSIONS", "")!
|
||||||
/// <inheritdoc />
|
.Split(',')
|
||||||
public void Configure(IServiceCollection services)
|
.Where(x => x.Length > 0)
|
||||||
{
|
.ToArray(),
|
||||||
string secret = _configuration.GetValue(
|
NewUser = _configuration
|
||||||
"AUTHENTICATION_SECRET",
|
.GetValue("DEFAULT_PERMISSIONS", "overall.read,overall.play")!
|
||||||
AuthenticationOption.DefaultSecret
|
.Split(','),
|
||||||
)!;
|
RequireVerification = _configuration.GetValue("REQUIRE_ACCOUNT_VERIFICATION", true),
|
||||||
PermissionOption options =
|
PublicUrl =
|
||||||
new()
|
_configuration.GetValue<string?>("PUBLIC_URL") ?? "http://localhost:8901",
|
||||||
{
|
ApiKeys = _configuration.GetValue("KYOO_APIKEYS", string.Empty)!.Split(','),
|
||||||
Default = _configuration
|
OIDC = _configuration
|
||||||
.GetValue("UNLOGGED_PERMISSIONS", "")!
|
.AsEnumerable()
|
||||||
.Split(',')
|
.Where((pair) => pair.Key.StartsWith("OIDC_"))
|
||||||
.Where(x => x.Length > 0)
|
.Aggregate(
|
||||||
.ToArray(),
|
new Dictionary<string, OidcProvider>(),
|
||||||
NewUser = _configuration
|
(acc, val) =>
|
||||||
.GetValue("DEFAULT_PERMISSIONS", "overall.read,overall.play")!
|
{
|
||||||
.Split(','),
|
if (val.Value is null)
|
||||||
RequireVerification = _configuration.GetValue(
|
return acc;
|
||||||
"REQUIRE_ACCOUNT_VERIFICATION",
|
if (val.Key.Split("_") is not ["OIDC", string provider, string key])
|
||||||
true
|
|
||||||
),
|
|
||||||
PublicUrl =
|
|
||||||
_configuration.GetValue<string?>("PUBLIC_URL") ?? "http://localhost:8901",
|
|
||||||
ApiKeys = _configuration.GetValue("KYOO_APIKEYS", string.Empty)!.Split(','),
|
|
||||||
OIDC = _configuration
|
|
||||||
.AsEnumerable()
|
|
||||||
.Where((pair) => pair.Key.StartsWith("OIDC_"))
|
|
||||||
.Aggregate(
|
|
||||||
new Dictionary<string, OidcProvider>(),
|
|
||||||
(acc, val) =>
|
|
||||||
{
|
{
|
||||||
if (val.Value is null)
|
logger.LogError("Invalid oidc config value: {Key}", val.Key);
|
||||||
return acc;
|
|
||||||
if (val.Key.Split("_") is not ["OIDC", string provider, string key])
|
|
||||||
{
|
|
||||||
logger.LogError("Invalid oidc config value: {Key}", val.Key);
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
provider = provider.ToLowerInvariant();
|
|
||||||
key = key.ToLowerInvariant();
|
|
||||||
|
|
||||||
if (!acc.ContainsKey(provider))
|
|
||||||
acc.Add(provider, new(provider));
|
|
||||||
switch (key)
|
|
||||||
{
|
|
||||||
case "clientid":
|
|
||||||
acc[provider].ClientId = val.Value;
|
|
||||||
break;
|
|
||||||
case "secret":
|
|
||||||
acc[provider].Secret = val.Value;
|
|
||||||
break;
|
|
||||||
case "scope":
|
|
||||||
acc[provider].Scope = val.Value;
|
|
||||||
break;
|
|
||||||
case "authorization":
|
|
||||||
acc[provider].AuthorizationUrl = val.Value;
|
|
||||||
break;
|
|
||||||
case "token":
|
|
||||||
acc[provider].TokenUrl = val.Value;
|
|
||||||
break;
|
|
||||||
case "userinfo":
|
|
||||||
case "profile":
|
|
||||||
acc[provider].ProfileUrl = val.Value;
|
|
||||||
break;
|
|
||||||
case "name":
|
|
||||||
acc[provider].DisplayName = val.Value;
|
|
||||||
break;
|
|
||||||
case "logo":
|
|
||||||
acc[provider].LogoUrl = val.Value;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
logger.LogError("Invalid oidc config value: {Key}", key);
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
),
|
provider = provider.ToLowerInvariant();
|
||||||
};
|
key = key.ToLowerInvariant();
|
||||||
services.AddSingleton(options);
|
|
||||||
services.AddSingleton(
|
|
||||||
new AuthenticationOption() { Secret = secret, Permissions = options, }
|
|
||||||
);
|
|
||||||
|
|
||||||
services
|
if (!acc.ContainsKey(provider))
|
||||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
acc.Add(provider, new(provider));
|
||||||
.AddJwtBearer(options =>
|
switch (key)
|
||||||
{
|
|
||||||
options.Events = new()
|
|
||||||
{
|
|
||||||
OnMessageReceived = (ctx) =>
|
|
||||||
{
|
|
||||||
string prefix = "Bearer ";
|
|
||||||
if (
|
|
||||||
ctx.Request.Headers.TryGetValue(
|
|
||||||
"Authorization",
|
|
||||||
out StringValues val
|
|
||||||
)
|
|
||||||
&& val.ToString() is string auth
|
|
||||||
&& auth.StartsWith(prefix)
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
ctx.Token ??= auth[prefix.Length..];
|
case "clientid":
|
||||||
|
acc[provider].ClientId = val.Value;
|
||||||
|
break;
|
||||||
|
case "secret":
|
||||||
|
acc[provider].Secret = val.Value;
|
||||||
|
break;
|
||||||
|
case "scope":
|
||||||
|
acc[provider].Scope = val.Value;
|
||||||
|
break;
|
||||||
|
case "authorization":
|
||||||
|
acc[provider].AuthorizationUrl = val.Value;
|
||||||
|
break;
|
||||||
|
case "token":
|
||||||
|
acc[provider].TokenUrl = val.Value;
|
||||||
|
break;
|
||||||
|
case "userinfo":
|
||||||
|
case "profile":
|
||||||
|
acc[provider].ProfileUrl = val.Value;
|
||||||
|
break;
|
||||||
|
case "name":
|
||||||
|
acc[provider].DisplayName = val.Value;
|
||||||
|
break;
|
||||||
|
case "logo":
|
||||||
|
acc[provider].LogoUrl = val.Value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.LogError("Invalid oidc config value: {Key}", key);
|
||||||
|
return acc;
|
||||||
}
|
}
|
||||||
ctx.Token ??= ctx.Request.Cookies["X-Bearer"];
|
return acc;
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
};
|
),
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = false,
|
|
||||||
ValidateAudience = false,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret))
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IEnumerable<IStartupAction> ConfigureSteps =>
|
|
||||||
new IStartupAction[]
|
|
||||||
{
|
|
||||||
SA.New<IApplicationBuilder>(app => app.UseAuthentication(), SA.Authentication),
|
|
||||||
};
|
};
|
||||||
|
services.AddSingleton(options);
|
||||||
|
services.AddSingleton(
|
||||||
|
new AuthenticationOption() { Secret = secret, Permissions = options, }
|
||||||
|
);
|
||||||
|
|
||||||
|
services
|
||||||
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.Events = new()
|
||||||
|
{
|
||||||
|
OnMessageReceived = (ctx) =>
|
||||||
|
{
|
||||||
|
string prefix = "Bearer ";
|
||||||
|
if (
|
||||||
|
ctx.Request.Headers.TryGetValue("Authorization", out StringValues val)
|
||||||
|
&& val.ToString() is string auth
|
||||||
|
&& auth.StartsWith(prefix)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ctx.Token ??= auth[prefix.Length..];
|
||||||
|
}
|
||||||
|
ctx.Token ??= ctx.Request.Cookies["X-Bearer"];
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = false,
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret))
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<IStartupAction> ConfigureSteps =>
|
||||||
|
new IStartupAction[]
|
||||||
|
{
|
||||||
|
SA.New<IApplicationBuilder>(app => app.UseAuthentication(), SA.Authentication),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -21,34 +21,33 @@ using System.Threading.Tasks;
|
|||||||
using Kyoo.Abstractions.Models;
|
using Kyoo.Abstractions.Models;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
namespace Kyoo.Authentication
|
namespace Kyoo.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The service that controls jwt creation and validation.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITokenController
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The service that controls jwt creation and validation.
|
/// Create a new access token for the given user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ITokenController
|
/// <param name="user">The user to create a token for.</param>
|
||||||
{
|
/// <param name="expireIn">When this token will expire.</param>
|
||||||
/// <summary>
|
/// <returns>A new, valid access token.</returns>
|
||||||
/// Create a new access token for the given user.
|
string CreateAccessToken(User user, out TimeSpan expireIn);
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The user to create a token for.</param>
|
|
||||||
/// <param name="expireIn">When this token will expire.</param>
|
|
||||||
/// <returns>A new, valid access token.</returns>
|
|
||||||
string CreateAccessToken(User user, out TimeSpan expireIn);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new refresh token for the given user.
|
/// Create a new refresh token for the given user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user to create a token for.</param>
|
/// <param name="user">The user to create a token for.</param>
|
||||||
/// <returns>A new, valid refresh token.</returns>
|
/// <returns>A new, valid refresh token.</returns>
|
||||||
Task<string> CreateRefreshToken(User user);
|
Task<string> CreateRefreshToken(User user);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to.
|
/// Check if the given refresh token is valid and if it is, retrieve the id of the user this token belongs to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="refreshToken">The refresh token to validate.</param>
|
/// <param name="refreshToken">The refresh token to validate.</param>
|
||||||
/// <exception cref="SecurityTokenException">The given refresh token is not valid.</exception>
|
/// <exception cref="SecurityTokenException">The given refresh token is not valid.</exception>
|
||||||
/// <returns>The id of the token's user.</returns>
|
/// <returns>The id of the token's user.</returns>
|
||||||
Guid GetRefreshTokenUserID(string refreshToken);
|
Guid GetRefreshTokenUserID(string refreshToken);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -32,267 +32,253 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
namespace Kyoo.Authentication
|
namespace Kyoo.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A permission validator to validate permission with user Permission array
|
||||||
|
/// or the default array from the configurations if the user is not logged.
|
||||||
|
/// </summary>
|
||||||
|
public class PermissionValidator : IPermissionValidator
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A permission validator to validate permission with user Permission array
|
/// The permissions options to retrieve default permissions.
|
||||||
/// or the default array from the configurations if the user is not logged.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PermissionValidator : IPermissionValidator
|
private readonly PermissionOption _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new factory with the given options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The option containing default values.</param>
|
||||||
|
public PermissionValidator(PermissionOption options)
|
||||||
{
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IFilterMetadata Create(PermissionAttribute attribute)
|
||||||
|
{
|
||||||
|
return new PermissionValidatorFilter(
|
||||||
|
attribute.Type,
|
||||||
|
attribute.Kind,
|
||||||
|
attribute.Group,
|
||||||
|
_options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IFilterMetadata Create(PartialPermissionAttribute attribute)
|
||||||
|
{
|
||||||
|
return new PermissionValidatorFilter(
|
||||||
|
((object?)attribute.Type ?? attribute.Kind)!,
|
||||||
|
attribute.Group,
|
||||||
|
_options
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The authorization filter used by <see cref="PermissionValidator"/>.
|
||||||
|
/// </summary>
|
||||||
|
private class PermissionValidatorFilter : IAsyncAuthorizationFilter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The permission to validate.
|
||||||
|
/// </summary>
|
||||||
|
private readonly string? _permission;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The kind of permission needed.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Kind? _kind;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The group of he permission.
|
||||||
|
/// </summary>
|
||||||
|
private Group _group;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The permissions options to retrieve default permissions.
|
/// The permissions options to retrieve default permissions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly PermissionOption _options;
|
private readonly PermissionOption _options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new factory with the given options.
|
/// Create a new permission validator with the given options.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="permission">The permission to validate.</param>
|
||||||
|
/// <param name="kind">The kind of permission needed.</param>
|
||||||
|
/// <param name="group">The group of the permission.</param>
|
||||||
/// <param name="options">The option containing default values.</param>
|
/// <param name="options">The option containing default values.</param>
|
||||||
public PermissionValidator(PermissionOption options)
|
public PermissionValidatorFilter(
|
||||||
|
string permission,
|
||||||
|
Kind kind,
|
||||||
|
Group group,
|
||||||
|
PermissionOption options
|
||||||
|
)
|
||||||
{
|
{
|
||||||
|
_permission = permission;
|
||||||
|
_kind = kind;
|
||||||
|
_group = group;
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new permission validator with the given options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="partialInfo">The partial permission to validate.</param>
|
||||||
|
/// <param name="group">The group of the permission.</param>
|
||||||
|
/// <param name="options">The option containing default values.</param>
|
||||||
|
public PermissionValidatorFilter(object partialInfo, Group? group, PermissionOption options)
|
||||||
|
{
|
||||||
|
switch (partialInfo)
|
||||||
|
{
|
||||||
|
case Kind kind:
|
||||||
|
_kind = kind;
|
||||||
|
break;
|
||||||
|
case string perm:
|
||||||
|
_permission = perm;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"{nameof(partialInfo)} can only be a permission string or a kind."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group is not null and not Group.None)
|
||||||
|
_group = group.Value;
|
||||||
_options = options;
|
_options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IFilterMetadata Create(PermissionAttribute attribute)
|
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
|
||||||
{
|
{
|
||||||
return new PermissionValidatorFilter(
|
string? permission = _permission;
|
||||||
attribute.Type,
|
Kind? kind = _kind;
|
||||||
attribute.Kind,
|
|
||||||
attribute.Group,
|
|
||||||
_options
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
if (permission == null || kind == null)
|
||||||
public IFilterMetadata Create(PartialPermissionAttribute attribute)
|
|
||||||
{
|
|
||||||
return new PermissionValidatorFilter(
|
|
||||||
((object?)attribute.Type ?? attribute.Kind)!,
|
|
||||||
attribute.Group,
|
|
||||||
_options
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The authorization filter used by <see cref="PermissionValidator"/>.
|
|
||||||
/// </summary>
|
|
||||||
private class PermissionValidatorFilter : IAsyncAuthorizationFilter
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The permission to validate.
|
|
||||||
/// </summary>
|
|
||||||
private readonly string? _permission;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The kind of permission needed.
|
|
||||||
/// </summary>
|
|
||||||
private readonly Kind? _kind;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The group of he permission.
|
|
||||||
/// </summary>
|
|
||||||
private Group _group;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The permissions options to retrieve default permissions.
|
|
||||||
/// </summary>
|
|
||||||
private readonly PermissionOption _options;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new permission validator with the given options.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="permission">The permission to validate.</param>
|
|
||||||
/// <param name="kind">The kind of permission needed.</param>
|
|
||||||
/// <param name="group">The group of the permission.</param>
|
|
||||||
/// <param name="options">The option containing default values.</param>
|
|
||||||
public PermissionValidatorFilter(
|
|
||||||
string permission,
|
|
||||||
Kind kind,
|
|
||||||
Group group,
|
|
||||||
PermissionOption options
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
_permission = permission;
|
if (context.HttpContext.Items["PermissionGroup"] is Group group and not Group.None)
|
||||||
_kind = kind;
|
_group = group;
|
||||||
_group = group;
|
else if (_group == Group.None)
|
||||||
_options = options;
|
_group = Group.Overall;
|
||||||
}
|
else
|
||||||
|
context.HttpContext.Items["PermissionGroup"] = _group;
|
||||||
|
|
||||||
/// <summary>
|
switch (context.HttpContext.Items["PermissionType"])
|
||||||
/// Create a new permission validator with the given options.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="partialInfo">The partial permission to validate.</param>
|
|
||||||
/// <param name="group">The group of the permission.</param>
|
|
||||||
/// <param name="options">The option containing default values.</param>
|
|
||||||
public PermissionValidatorFilter(
|
|
||||||
object partialInfo,
|
|
||||||
Group? group,
|
|
||||||
PermissionOption options
|
|
||||||
)
|
|
||||||
{
|
|
||||||
switch (partialInfo)
|
|
||||||
{
|
{
|
||||||
case Kind kind:
|
|
||||||
_kind = kind;
|
|
||||||
break;
|
|
||||||
case string perm:
|
case string perm:
|
||||||
_permission = perm;
|
permission = perm;
|
||||||
break;
|
break;
|
||||||
|
case Kind kin:
|
||||||
|
kind = kin;
|
||||||
|
break;
|
||||||
|
case null when kind != null:
|
||||||
|
context.HttpContext.Items["PermissionType"] = kind;
|
||||||
|
return;
|
||||||
|
case null when permission != null:
|
||||||
|
context.HttpContext.Items["PermissionType"] = permission;
|
||||||
|
return;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentException(
|
throw new ArgumentException(
|
||||||
$"{nameof(partialInfo)} can only be a permission string or a kind."
|
"Multiple non-matching partial permission attribute "
|
||||||
|
+ "are not supported."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group is not null and not Group.None)
|
|
||||||
_group = group.Value;
|
|
||||||
_options = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
|
|
||||||
{
|
|
||||||
string? permission = _permission;
|
|
||||||
Kind? kind = _kind;
|
|
||||||
|
|
||||||
if (permission == null || kind == null)
|
if (permission == null || kind == null)
|
||||||
{
|
{
|
||||||
if (
|
throw new ArgumentException(
|
||||||
context.HttpContext.Items["PermissionGroup"]
|
"The permission type or kind is still missing after two partial "
|
||||||
is Group group
|
+ "permission attributes, this is unsupported."
|
||||||
and not Group.None
|
);
|
||||||
)
|
|
||||||
_group = group;
|
|
||||||
else if (_group == Group.None)
|
|
||||||
_group = Group.Overall;
|
|
||||||
else
|
|
||||||
context.HttpContext.Items["PermissionGroup"] = _group;
|
|
||||||
|
|
||||||
switch (context.HttpContext.Items["PermissionType"])
|
|
||||||
{
|
|
||||||
case string perm:
|
|
||||||
permission = perm;
|
|
||||||
break;
|
|
||||||
case Kind kin:
|
|
||||||
kind = kin;
|
|
||||||
break;
|
|
||||||
case null when kind != null:
|
|
||||||
context.HttpContext.Items["PermissionType"] = kind;
|
|
||||||
return;
|
|
||||||
case null when permission != null:
|
|
||||||
context.HttpContext.Items["PermissionType"] = permission;
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
throw new ArgumentException(
|
|
||||||
"Multiple non-matching partial permission attribute "
|
|
||||||
+ "are not supported."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (permission == null || kind == null)
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
"The permission type or kind is still missing after two partial "
|
|
||||||
+ "permission attributes, this is unsupported."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
string permStr = $"{permission.ToLower()}.{kind.ToString()!.ToLower()}";
|
string permStr = $"{permission.ToLower()}.{kind.ToString()!.ToLower()}";
|
||||||
string overallStr = $"{_group.ToString().ToLower()}.{kind.ToString()!.ToLower()}";
|
string overallStr = $"{_group.ToString().ToLower()}.{kind.ToString()!.ToLower()}";
|
||||||
AuthenticateResult res = _ApiKeyCheck(context);
|
AuthenticateResult res = _ApiKeyCheck(context);
|
||||||
if (res.None)
|
if (res.None)
|
||||||
res = await _JwtCheck(context);
|
res = await _JwtCheck(context);
|
||||||
|
|
||||||
if (res.Succeeded)
|
if (res.Succeeded)
|
||||||
{
|
{
|
||||||
ICollection<string> permissions = res.Principal.GetPermissions();
|
ICollection<string> permissions = res.Principal.GetPermissions();
|
||||||
if (permissions.All(x => x != permStr && x != overallStr))
|
if (permissions.All(x => x != permStr && x != overallStr))
|
||||||
context.Result = _ErrorResult(
|
|
||||||
$"Missing permission {permStr} or {overallStr}",
|
|
||||||
StatusCodes.Status403Forbidden
|
|
||||||
);
|
|
||||||
}
|
|
||||||
else if (res.None)
|
|
||||||
{
|
|
||||||
ICollection<string> permissions = _options.Default ?? Array.Empty<string>();
|
|
||||||
if (permissions.All(x => x != permStr && x != overallStr))
|
|
||||||
{
|
|
||||||
context.Result = _ErrorResult(
|
|
||||||
$"Unlogged user does not have permission {permStr} or {overallStr}",
|
|
||||||
StatusCodes.Status401Unauthorized
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (res.Failure != null)
|
|
||||||
context.Result = _ErrorResult(
|
context.Result = _ErrorResult(
|
||||||
res.Failure.Message,
|
$"Missing permission {permStr} or {overallStr}",
|
||||||
StatusCodes.Status403Forbidden
|
StatusCodes.Status403Forbidden
|
||||||
);
|
);
|
||||||
else
|
}
|
||||||
|
else if (res.None)
|
||||||
|
{
|
||||||
|
ICollection<string> permissions = _options.Default ?? Array.Empty<string>();
|
||||||
|
if (permissions.All(x => x != permStr && x != overallStr))
|
||||||
|
{
|
||||||
context.Result = _ErrorResult(
|
context.Result = _ErrorResult(
|
||||||
"Authentication panic",
|
$"Unlogged user does not have permission {permStr} or {overallStr}",
|
||||||
StatusCodes.Status500InternalServerError
|
StatusCodes.Status401Unauthorized
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
else if (res.Failure != null)
|
||||||
private AuthenticateResult _ApiKeyCheck(ActionContext context)
|
context.Result = _ErrorResult(res.Failure.Message, StatusCodes.Status403Forbidden);
|
||||||
{
|
else
|
||||||
if (
|
context.Result = _ErrorResult(
|
||||||
!context.HttpContext.Request.Headers.TryGetValue(
|
"Authentication panic",
|
||||||
"X-API-Key",
|
StatusCodes.Status500InternalServerError
|
||||||
out StringValues apiKey
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return AuthenticateResult.NoResult();
|
|
||||||
if (!_options.ApiKeys.Contains<string>(apiKey!))
|
|
||||||
return AuthenticateResult.Fail("Invalid API-Key.");
|
|
||||||
return AuthenticateResult.Success(
|
|
||||||
new AuthenticationTicket(
|
|
||||||
new ClaimsPrincipal(
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
new ClaimsIdentity(
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
// TODO: Make permission configurable, for now every APIKEY as all permissions.
|
|
||||||
new Claim(
|
|
||||||
Claims.Permissions,
|
|
||||||
string.Join(',', PermissionOption.Admin)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"apikey"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<AuthenticateResult> _JwtCheck(ActionContext context)
|
|
||||||
{
|
|
||||||
AuthenticateResult ret = await context.HttpContext.AuthenticateAsync(
|
|
||||||
JwtBearerDefaults.AuthenticationScheme
|
|
||||||
);
|
|
||||||
// Change the failure message to make the API nice to use.
|
|
||||||
if (ret.Failure != null)
|
|
||||||
return AuthenticateResult.Fail(
|
|
||||||
"Invalid JWT token. The token may have expired."
|
|
||||||
);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private AuthenticateResult _ApiKeyCheck(ActionContext context)
|
||||||
/// Create a new action result with the given error message and error code.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="error">The error message.</param>
|
|
||||||
/// <param name="code">The status code of the error.</param>
|
|
||||||
/// <returns>The resulting error action.</returns>
|
|
||||||
private static IActionResult _ErrorResult(string error, int code)
|
|
||||||
{
|
{
|
||||||
return new ObjectResult(new RequestError(error)) { StatusCode = code };
|
if (
|
||||||
|
!context.HttpContext.Request.Headers.TryGetValue(
|
||||||
|
"X-API-Key",
|
||||||
|
out StringValues apiKey
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return AuthenticateResult.NoResult();
|
||||||
|
if (!_options.ApiKeys.Contains<string>(apiKey!))
|
||||||
|
return AuthenticateResult.Fail("Invalid API-Key.");
|
||||||
|
return AuthenticateResult.Success(
|
||||||
|
new AuthenticationTicket(
|
||||||
|
new ClaimsPrincipal(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new ClaimsIdentity(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
// TODO: Make permission configurable, for now every APIKEY as all permissions.
|
||||||
|
new Claim(
|
||||||
|
Claims.Permissions,
|
||||||
|
string.Join(',', PermissionOption.Admin)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"apikey"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AuthenticateResult> _JwtCheck(ActionContext context)
|
||||||
|
{
|
||||||
|
AuthenticateResult ret = await context.HttpContext.AuthenticateAsync(
|
||||||
|
JwtBearerDefaults.AuthenticationScheme
|
||||||
|
);
|
||||||
|
// Change the failure message to make the API nice to use.
|
||||||
|
if (ret.Failure != null)
|
||||||
|
return AuthenticateResult.Fail("Invalid JWT token. The token may have expired.");
|
||||||
|
return ret;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new action result with the given error message and error code.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="error">The error message.</param>
|
||||||
|
/// <param name="code">The status code of the error.</param>
|
||||||
|
/// <returns>The resulting error action.</returns>
|
||||||
|
private static IActionResult _ErrorResult(string error, int code)
|
||||||
|
{
|
||||||
|
return new ObjectResult(new RequestError(error)) { StatusCode = code };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,109 +27,108 @@ using Kyoo.Abstractions.Models;
|
|||||||
using Kyoo.Authentication.Models;
|
using Kyoo.Authentication.Models;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
namespace Kyoo.Authentication
|
namespace Kyoo.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The service that controls jwt creation and validation.
|
||||||
|
/// </summary>
|
||||||
|
public class TokenController : ITokenController
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The service that controls jwt creation and validation.
|
/// The options that this controller will use.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TokenController : ITokenController
|
private readonly AuthenticationOption _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="TokenController"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The options that this controller will use.</param>
|
||||||
|
public TokenController(AuthenticationOption options)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_options = options;
|
||||||
/// The options that this controller will use.
|
}
|
||||||
/// </summary>
|
|
||||||
private readonly AuthenticationOption _options;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Create a new <see cref="TokenController"/>.
|
public string CreateAccessToken(User user, out TimeSpan expireIn)
|
||||||
/// </summary>
|
{
|
||||||
/// <param name="options">The options that this controller will use.</param>
|
expireIn = new TimeSpan(1, 0, 0);
|
||||||
public TokenController(AuthenticationOption options)
|
|
||||||
{
|
|
||||||
_options = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
|
||||||
public string CreateAccessToken(User user, out TimeSpan expireIn)
|
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
|
||||||
{
|
string permissions =
|
||||||
expireIn = new TimeSpan(1, 0, 0);
|
user.Permissions != null ? string.Join(',', user.Permissions) : string.Empty;
|
||||||
|
List<Claim> claims =
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
new Claim(Claims.Id, user.Id.ToString()),
|
||||||
|
new Claim(Claims.Name, user.Username),
|
||||||
|
new Claim(Claims.Permissions, permissions),
|
||||||
|
new Claim(Claims.Type, "access")
|
||||||
|
};
|
||||||
|
if (user.Email != null)
|
||||||
|
claims.Add(new Claim(Claims.Email, user.Email));
|
||||||
|
JwtSecurityToken token =
|
||||||
|
new(
|
||||||
|
signingCredentials: credential,
|
||||||
|
claims: claims,
|
||||||
|
expires: DateTime.UtcNow.Add(expireIn)
|
||||||
|
);
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
|
/// <inheritdoc />
|
||||||
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
|
public Task<string> CreateRefreshToken(User user)
|
||||||
string permissions =
|
{
|
||||||
user.Permissions != null ? string.Join(',', user.Permissions) : string.Empty;
|
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
|
||||||
List<Claim> claims =
|
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
|
||||||
new()
|
JwtSecurityToken token =
|
||||||
|
new(
|
||||||
|
signingCredentials: credential,
|
||||||
|
claims: new[]
|
||||||
{
|
{
|
||||||
new Claim(Claims.Id, user.Id.ToString()),
|
new Claim(Claims.Id, user.Id.ToString()),
|
||||||
new Claim(Claims.Name, user.Username),
|
new Claim(Claims.Guid, Guid.NewGuid().ToString()),
|
||||||
new Claim(Claims.Permissions, permissions),
|
new Claim(Claims.Type, "refresh")
|
||||||
new Claim(Claims.Type, "access")
|
},
|
||||||
};
|
expires: DateTime.UtcNow.AddYears(1)
|
||||||
if (user.Email != null)
|
);
|
||||||
claims.Add(new Claim(Claims.Email, user.Email));
|
// TODO: refresh keys are unique (thanks to the guid) but we could store them in DB to invalidate them if requested by the user.
|
||||||
JwtSecurityToken token =
|
return Task.FromResult(new JwtSecurityTokenHandler().WriteToken(token));
|
||||||
new(
|
}
|
||||||
signingCredentials: credential,
|
|
||||||
claims: claims,
|
|
||||||
expires: DateTime.UtcNow.Add(expireIn)
|
|
||||||
);
|
|
||||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<string> CreateRefreshToken(User user)
|
public Guid GetRefreshTokenUserID(string refreshToken)
|
||||||
|
{
|
||||||
|
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
|
||||||
|
JwtSecurityTokenHandler tokenHandler = new();
|
||||||
|
ClaimsPrincipal principal;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
|
principal = tokenHandler.ValidateToken(
|
||||||
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
|
refreshToken,
|
||||||
JwtSecurityToken token =
|
new TokenValidationParameters
|
||||||
new(
|
{
|
||||||
signingCredentials: credential,
|
ValidateIssuer = false,
|
||||||
claims: new[]
|
ValidateAudience = false,
|
||||||
{
|
ValidateIssuerSigningKey = true,
|
||||||
new Claim(Claims.Id, user.Id.ToString()),
|
ValidateLifetime = true,
|
||||||
new Claim(Claims.Guid, Guid.NewGuid().ToString()),
|
IssuerSigningKey = key
|
||||||
new Claim(Claims.Type, "refresh")
|
},
|
||||||
},
|
out SecurityToken _
|
||||||
expires: DateTime.UtcNow.AddYears(1)
|
);
|
||||||
);
|
|
||||||
// TODO: refresh keys are unique (thanks to the guid) but we could store them in DB to invalidate them if requested by the user.
|
|
||||||
return Task.FromResult(new JwtSecurityTokenHandler().WriteToken(token));
|
|
||||||
}
|
}
|
||||||
|
catch (Exception)
|
||||||
/// <inheritdoc />
|
|
||||||
public Guid GetRefreshTokenUserID(string refreshToken)
|
|
||||||
{
|
{
|
||||||
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
|
throw new SecurityTokenException("Invalid refresh token");
|
||||||
JwtSecurityTokenHandler tokenHandler = new();
|
|
||||||
ClaimsPrincipal principal;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
principal = tokenHandler.ValidateToken(
|
|
||||||
refreshToken,
|
|
||||||
new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = false,
|
|
||||||
ValidateAudience = false,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
IssuerSigningKey = key
|
|
||||||
},
|
|
||||||
out SecurityToken _
|
|
||||||
);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
throw new SecurityTokenException("Invalid refresh token");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (principal.Claims.First(x => x.Type == Claims.Type).Value != "refresh")
|
|
||||||
throw new SecurityTokenException(
|
|
||||||
"Invalid token type. The token should be a refresh token."
|
|
||||||
);
|
|
||||||
Claim identifier = principal.Claims.First(x => x.Type == Claims.Id);
|
|
||||||
if (Guid.TryParse(identifier.Value, out Guid id))
|
|
||||||
return id;
|
|
||||||
throw new SecurityTokenException("Token not associated to any user.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (principal.Claims.First(x => x.Type == Claims.Type).Value != "refresh")
|
||||||
|
throw new SecurityTokenException(
|
||||||
|
"Invalid token type. The token should be a refresh token."
|
||||||
|
);
|
||||||
|
Claim identifier = principal.Claims.First(x => x.Type == Claims.Id);
|
||||||
|
if (Guid.TryParse(identifier.Value, out Guid id))
|
||||||
|
return id;
|
||||||
|
throw new SecurityTokenException("Token not associated to any user.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,32 +16,31 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
namespace Kyoo.Authentication.Models.DTO
|
namespace Kyoo.Authentication.Models.DTO;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A model only used on login requests.
|
||||||
|
/// </summary>
|
||||||
|
public class LoginRequest
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A model only used on login requests.
|
/// The user's username.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LoginRequest
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's password.
|
||||||
|
/// </summary>
|
||||||
|
public string Password { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="LoginRequest"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">The user's username.</param>
|
||||||
|
/// <param name="password">The user's password.</param>
|
||||||
|
public LoginRequest(string username, string password)
|
||||||
{
|
{
|
||||||
/// <summary>
|
Username = username;
|
||||||
/// The user's username.
|
Password = password;
|
||||||
/// </summary>
|
|
||||||
public string Username { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The user's password.
|
|
||||||
/// </summary>
|
|
||||||
public string Password { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="LoginRequest"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="username">The user's username.</param>
|
|
||||||
/// <param name="password">The user's password.</param>
|
|
||||||
public LoginRequest(string username, string password)
|
|
||||||
{
|
|
||||||
Username = username;
|
|
||||||
Password = password;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,57 +21,56 @@ using Kyoo.Abstractions.Models;
|
|||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
using BCryptNet = BCrypt.Net.BCrypt;
|
using BCryptNet = BCrypt.Net.BCrypt;
|
||||||
|
|
||||||
namespace Kyoo.Authentication.Models.DTO
|
namespace Kyoo.Authentication.Models.DTO;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A model only used on register requests.
|
||||||
|
/// </summary>
|
||||||
|
public class RegisterRequest
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A model only used on register requests.
|
/// The user email address
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RegisterRequest
|
[EmailAddress(ErrorMessage = "The email must be a valid email address")]
|
||||||
|
public string Email { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's username.
|
||||||
|
/// </summary>
|
||||||
|
[MinLength(4, ErrorMessage = "The username must have at least {1} characters")]
|
||||||
|
public string Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's password.
|
||||||
|
/// </summary>
|
||||||
|
[MinLength(4, ErrorMessage = "The password must have at least {1} characters")]
|
||||||
|
public string Password { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RegisterRequest"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="email">The user email address.</param>
|
||||||
|
/// <param name="username">The user's username.</param>
|
||||||
|
/// <param name="password">The user's password.</param>
|
||||||
|
public RegisterRequest(string email, string username, string password)
|
||||||
{
|
{
|
||||||
/// <summary>
|
Email = email;
|
||||||
/// The user email address
|
Username = username;
|
||||||
/// </summary>
|
Password = password;
|
||||||
[EmailAddress(ErrorMessage = "The email must be a valid email address")]
|
}
|
||||||
public string Email { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The user's username.
|
/// Convert this register request to a new <see cref="User"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[MinLength(4, ErrorMessage = "The username must have at least {1} characters")]
|
/// <returns>A user representing this request.</returns>
|
||||||
public string Username { get; set; }
|
public User ToUser()
|
||||||
|
{
|
||||||
/// <summary>
|
return new User
|
||||||
/// The user's password.
|
|
||||||
/// </summary>
|
|
||||||
[MinLength(4, ErrorMessage = "The password must have at least {1} characters")]
|
|
||||||
public string Password { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="RegisterRequest"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="email">The user email address.</param>
|
|
||||||
/// <param name="username">The user's username.</param>
|
|
||||||
/// <param name="password">The user's password.</param>
|
|
||||||
public RegisterRequest(string email, string username, string password)
|
|
||||||
{
|
{
|
||||||
Email = email;
|
Slug = Utility.ToSlug(Username),
|
||||||
Username = username;
|
Username = Username,
|
||||||
Password = password;
|
Password = BCryptNet.HashPassword(Password),
|
||||||
}
|
Email = Email,
|
||||||
|
};
|
||||||
/// <summary>
|
|
||||||
/// Convert this register request to a new <see cref="User"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A user representing this request.</returns>
|
|
||||||
public User ToUser()
|
|
||||||
{
|
|
||||||
return new User
|
|
||||||
{
|
|
||||||
Slug = Utility.ToSlug(Username),
|
|
||||||
Username = Username,
|
|
||||||
Password = BCryptNet.HashPassword(Password),
|
|
||||||
Email = Email,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,31 +16,30 @@
|
|||||||
// You should have received a copy of the GNU General Public License
|
// You should have received a copy of the GNU General Public License
|
||||||
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
namespace Kyoo.Authentication.Models
|
namespace Kyoo.Authentication.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The main authentication options.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthenticationOption
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The main authentication options.
|
/// The path to get this option from the root configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuthenticationOption
|
public const string Path = "authentication";
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The path to get this option from the root configuration.
|
|
||||||
/// </summary>
|
|
||||||
public const string Path = "authentication";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The default jwt secret.
|
/// The default jwt secret.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string DefaultSecret = "4c@mraGB!KRfF@kpS8739y9FcHemKxBsqqxLbdR?";
|
public const string DefaultSecret = "4c@mraGB!KRfF@kpS8739y9FcHemKxBsqqxLbdR?";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The secret used to encrypt the jwt.
|
/// The secret used to encrypt the jwt.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Secret { get; set; } = DefaultSecret;
|
public string Secret { get; set; } = DefaultSecret;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Options for permissions
|
/// Options for permissions
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public PermissionOption Permissions { get; set; } = new();
|
public PermissionOption Permissions { get; set; } = new();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -35,464 +35,458 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
using BCryptNet = BCrypt.Net.BCrypt;
|
using BCryptNet = BCrypt.Net.BCrypt;
|
||||||
|
|
||||||
namespace Kyoo.Authentication.Views
|
namespace Kyoo.Authentication.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sign in, Sign up or refresh tokens.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("auth")]
|
||||||
|
[ApiDefinition("Authentication", Group = UsersGroup)]
|
||||||
|
public class AuthApi(
|
||||||
|
IUserRepository users,
|
||||||
|
OidcController oidc,
|
||||||
|
ITokenController tokenController,
|
||||||
|
IThumbnailsManager thumbs,
|
||||||
|
PermissionOption options
|
||||||
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sign in, Sign up or refresh tokens.
|
/// Create a new Forbidden result from an object.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
/// <param name="value">The json value to output on the response.</param>
|
||||||
[Route("auth")]
|
/// <returns>A new forbidden result with the given json object.</returns>
|
||||||
[ApiDefinition("Authentication", Group = UsersGroup)]
|
public static ObjectResult Forbid(object value)
|
||||||
public class AuthApi(
|
|
||||||
IUserRepository users,
|
|
||||||
OidcController oidc,
|
|
||||||
ITokenController tokenController,
|
|
||||||
IThumbnailsManager thumbs,
|
|
||||||
PermissionOption options
|
|
||||||
) : ControllerBase
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden };
|
||||||
/// Create a new Forbidden result from an object.
|
}
|
||||||
/// </summary>
|
|
||||||
/// <param name="value">The json value to output on the response.</param>
|
|
||||||
/// <returns>A new forbidden result with the given json object.</returns>
|
|
||||||
public static ObjectResult Forbid(object value)
|
|
||||||
{
|
|
||||||
return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string _BuildUrl(string baseUrl, Dictionary<string, string?> queryParams)
|
private static string _BuildUrl(string baseUrl, Dictionary<string, string?> queryParams)
|
||||||
|
{
|
||||||
|
char querySep = baseUrl.Contains('?') ? '&' : '?';
|
||||||
|
foreach ((string key, string? val) in queryParams)
|
||||||
{
|
{
|
||||||
char querySep = baseUrl.Contains('?') ? '&' : '?';
|
if (val is null)
|
||||||
foreach ((string key, string? val) in queryParams)
|
continue;
|
||||||
{
|
baseUrl += $"{querySep}{key}={val}";
|
||||||
if (val is null)
|
querySep = '&';
|
||||||
continue;
|
|
||||||
baseUrl += $"{querySep}{key}={val}";
|
|
||||||
querySep = '&';
|
|
||||||
}
|
|
||||||
return baseUrl;
|
|
||||||
}
|
}
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Oauth Login.
|
/// Oauth Login.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Login via a registered oauth provider.
|
/// Login via a registered oauth provider.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="provider">The provider code.</param>
|
/// <param name="provider">The provider code.</param>
|
||||||
/// <param name="redirectUrl">
|
/// <param name="redirectUrl">
|
||||||
/// A url where you will be redirected with the query params provider, code and error. It can be a deep link.
|
/// A url where you will be redirected with the query params provider, code and error. It can be a deep link.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>A redirect to the provider's login page.</returns>
|
/// <returns>A redirect to the provider's login page.</returns>
|
||||||
/// <response code="404">The provider is not register with this instance of kyoo.</response>
|
/// <response code="404">The provider is not register with this instance of kyoo.</response>
|
||||||
[HttpGet("login/{provider}")]
|
[HttpGet("login/{provider}")]
|
||||||
[ProducesResponseType(StatusCodes.Status302Found)]
|
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))]
|
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))]
|
||||||
public ActionResult<JwtToken> LoginVia(string provider, [FromQuery] string redirectUrl)
|
public ActionResult<JwtToken> LoginVia(string provider, [FromQuery] string redirectUrl)
|
||||||
|
{
|
||||||
|
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
|
||||||
{
|
{
|
||||||
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
|
return NotFound(
|
||||||
{
|
new RequestError(
|
||||||
return NotFound(
|
$"Invalid provider. {provider} is not registered no this instance of kyoo."
|
||||||
new RequestError(
|
|
||||||
$"Invalid provider. {provider} is not registered no this instance of kyoo."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
OidcProvider prov = options.OIDC[provider];
|
|
||||||
return Redirect(
|
|
||||||
_BuildUrl(
|
|
||||||
prov.AuthorizationUrl,
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
["response_type"] = "code",
|
|
||||||
["client_id"] = prov.ClientId,
|
|
||||||
["redirect_uri"] =
|
|
||||||
$"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}",
|
|
||||||
["scope"] = prov.Scope,
|
|
||||||
["state"] = redirectUrl,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
OidcProvider prov = options.OIDC[provider];
|
||||||
/// <summary>
|
return Redirect(
|
||||||
/// Oauth Code Redirect.
|
_BuildUrl(
|
||||||
/// </summary>
|
prov.AuthorizationUrl,
|
||||||
/// <remarks>
|
new()
|
||||||
/// This route is not meant to be called manually, the user should be redirected automatically here
|
|
||||||
/// after a successful login on the /login/{provider} page.
|
|
||||||
/// </remarks>
|
|
||||||
/// <returns>A redirect to the provider's login page.</returns>
|
|
||||||
/// <response code="403">The provider gave an error.</response>
|
|
||||||
[HttpGet("logged/{provider}")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status302Found)]
|
|
||||||
public ActionResult OauthCodeRedirect(
|
|
||||||
string provider,
|
|
||||||
string code,
|
|
||||||
string state,
|
|
||||||
string? error
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return Redirect(
|
|
||||||
_BuildUrl(
|
|
||||||
state,
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
["provider"] = provider,
|
|
||||||
["code"] = code,
|
|
||||||
["error"] = error,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Oauth callback
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// This route should be manually called by the page that got redirected to after a call to /login/{provider}.
|
|
||||||
/// </remarks>
|
|
||||||
/// <returns>A jwt token</returns>
|
|
||||||
/// <response code="400">Bad provider or code</response>
|
|
||||||
[HttpPost("callback/{provider}")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult<JwtToken>> OauthCallback(string provider, string code)
|
|
||||||
{
|
|
||||||
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
|
|
||||||
{
|
|
||||||
return NotFound(
|
|
||||||
new RequestError(
|
|
||||||
$"Invalid provider. {provider} is not registered no this instance of kyoo."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (code == null)
|
|
||||||
return BadRequest(new RequestError("Invalid code."));
|
|
||||||
|
|
||||||
Guid? userId = User.GetId();
|
|
||||||
User user = userId.HasValue
|
|
||||||
? await oidc.LinkAccountOrLogin(userId.Value, provider, code)
|
|
||||||
: await oidc.LoginViaCode(provider, code);
|
|
||||||
return new JwtToken(
|
|
||||||
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
|
||||||
await tokenController.CreateRefreshToken(user),
|
|
||||||
expireIn
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unlink account
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Unlink your account from an external account.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="provider">The provider code.</param>
|
|
||||||
/// <returns>Your updated user account</returns>
|
|
||||||
[HttpDelete("login/{provider}")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[UserOnly]
|
|
||||||
public Task<User> UnlinkAccount(string provider)
|
|
||||||
{
|
|
||||||
Guid id = User.GetIdOrThrow();
|
|
||||||
return users.DeleteExternalToken(id, provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Login.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Login as a user and retrieve an access and a refresh token.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="request">The body of the request.</param>
|
|
||||||
/// <returns>A new access and a refresh token.</returns>
|
|
||||||
/// <response code="403">The user and password does not match.</response>
|
|
||||||
[HttpPost("login")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request)
|
|
||||||
{
|
|
||||||
User? user = await users.GetOrDefault(
|
|
||||||
new Filter<User>.Eq(nameof(Abstractions.Models.User.Username), request.Username)
|
|
||||||
);
|
|
||||||
if (user == null || !BCryptNet.Verify(request.Password, user.Password))
|
|
||||||
return Forbid(new RequestError("The user and password does not match."));
|
|
||||||
|
|
||||||
return new JwtToken(
|
|
||||||
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
|
||||||
await tokenController.CreateRefreshToken(user),
|
|
||||||
expireIn
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Register.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Register a new user and get a new access/refresh token for this new user.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="request">The body of the request.</param>
|
|
||||||
/// <returns>A new access and a refresh token.</returns>
|
|
||||||
/// <response code="400">The request is invalid.</response>
|
|
||||||
/// <response code="409">A user already exists with this username or email address.</response>
|
|
||||||
[HttpPost("register")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
User user = await users.Create(request.ToUser());
|
|
||||||
return new JwtToken(
|
|
||||||
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
|
||||||
await tokenController.CreateRefreshToken(user),
|
|
||||||
expireIn
|
|
||||||
);
|
|
||||||
}
|
|
||||||
catch (DuplicatedItemException)
|
|
||||||
{
|
|
||||||
return Conflict(new RequestError("A user already exists with this username."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Refresh a token.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Refresh an access token using the given refresh token. A new access and refresh token are generated.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="token">A valid refresh token.</param>
|
|
||||||
/// <returns>A new access and refresh token.</returns>
|
|
||||||
/// <response code="403">The given refresh token is invalid.</response>
|
|
||||||
[HttpGet("refresh")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult<JwtToken>> Refresh([FromQuery] string token)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Guid userId = tokenController.GetRefreshTokenUserID(token);
|
|
||||||
User user = await users.Get(userId);
|
|
||||||
return new JwtToken(
|
|
||||||
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
|
||||||
await tokenController.CreateRefreshToken(user),
|
|
||||||
expireIn
|
|
||||||
);
|
|
||||||
}
|
|
||||||
catch (ItemNotFoundException)
|
|
||||||
{
|
|
||||||
return Forbid(new RequestError("Invalid refresh token."));
|
|
||||||
}
|
|
||||||
catch (SecurityTokenException ex)
|
|
||||||
{
|
|
||||||
return Forbid(new RequestError(ex.Message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reset your password
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Change your password.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="request">The old and new password</param>
|
|
||||||
/// <returns>Your account info.</returns>
|
|
||||||
/// <response code="403">The old password is invalid.</response>
|
|
||||||
[HttpPost("password-reset")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult<User>> ResetPassword([FromBody] PasswordResetRequest request)
|
|
||||||
{
|
|
||||||
User user = await users.Get(User.GetIdOrThrow());
|
|
||||||
if (user.HasPassword && !BCryptNet.Verify(request.OldPassword, user.Password))
|
|
||||||
return Forbid(new RequestError("The old password is invalid."));
|
|
||||||
return await users.Patch(
|
|
||||||
user.Id,
|
|
||||||
(user) =>
|
|
||||||
{
|
{
|
||||||
user.Password = BCryptNet.HashPassword(request.NewPassword);
|
["response_type"] = "code",
|
||||||
return user;
|
["client_id"] = prov.ClientId,
|
||||||
|
["redirect_uri"] =
|
||||||
|
$"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}",
|
||||||
|
["scope"] = prov.Scope,
|
||||||
|
["state"] = redirectUrl,
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Oauth Code Redirect.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This route is not meant to be called manually, the user should be redirected automatically here
|
||||||
|
/// after a successful login on the /login/{provider} page.
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns>A redirect to the provider's login page.</returns>
|
||||||
|
/// <response code="403">The provider gave an error.</response>
|
||||||
|
[HttpGet("logged/{provider}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||||
|
public ActionResult OauthCodeRedirect(string provider, string code, string state, string? error)
|
||||||
|
{
|
||||||
|
return Redirect(
|
||||||
|
_BuildUrl(
|
||||||
|
state,
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
["provider"] = provider,
|
||||||
|
["code"] = code,
|
||||||
|
["error"] = error,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Oauth callback
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This route should be manually called by the page that got redirected to after a call to /login/{provider}.
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns>A jwt token</returns>
|
||||||
|
/// <response code="400">Bad provider or code</response>
|
||||||
|
[HttpPost("callback/{provider}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult<JwtToken>> OauthCallback(string provider, string code)
|
||||||
|
{
|
||||||
|
if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
|
||||||
|
{
|
||||||
|
return NotFound(
|
||||||
|
new RequestError(
|
||||||
|
$"Invalid provider. {provider} is not registered no this instance of kyoo."
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (code == null)
|
||||||
|
return BadRequest(new RequestError("Invalid code."));
|
||||||
|
|
||||||
/// <summary>
|
Guid? userId = User.GetId();
|
||||||
/// Get authenticated user.
|
User user = userId.HasValue
|
||||||
/// </summary>
|
? await oidc.LinkAccountOrLogin(userId.Value, provider, code)
|
||||||
/// <remarks>
|
: await oidc.LoginViaCode(provider, code);
|
||||||
/// Get information about the currently authenticated user. This can also be used to ensure that you are
|
return new JwtToken(
|
||||||
/// logged in.
|
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
||||||
/// </remarks>
|
await tokenController.CreateRefreshToken(user),
|
||||||
/// <returns>The currently authenticated user.</returns>
|
expireIn
|
||||||
/// <response code="401">The user is not authenticated.</response>
|
);
|
||||||
/// <response code="403">The given access token is invalid.</response>
|
}
|
||||||
[HttpGet("me")]
|
|
||||||
[UserOnly]
|
/// <summary>
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
/// Unlink account
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
/// </summary>
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
/// <remarks>
|
||||||
public async Task<ActionResult<User>> GetMe()
|
/// Unlink your account from an external account.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="provider">The provider code.</param>
|
||||||
|
/// <returns>Your updated user account</returns>
|
||||||
|
[HttpDelete("login/{provider}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[UserOnly]
|
||||||
|
public Task<User> UnlinkAccount(string provider)
|
||||||
|
{
|
||||||
|
Guid id = User.GetIdOrThrow();
|
||||||
|
return users.DeleteExternalToken(id, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Login.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Login as a user and retrieve an access and a refresh token.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="request">The body of the request.</param>
|
||||||
|
/// <returns>A new access and a refresh token.</returns>
|
||||||
|
/// <response code="403">The user and password does not match.</response>
|
||||||
|
[HttpPost("login")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request)
|
||||||
|
{
|
||||||
|
User? user = await users.GetOrDefault(
|
||||||
|
new Filter<User>.Eq(nameof(Abstractions.Models.User.Username), request.Username)
|
||||||
|
);
|
||||||
|
if (user == null || !BCryptNet.Verify(request.Password, user.Password))
|
||||||
|
return Forbid(new RequestError("The user and password does not match."));
|
||||||
|
|
||||||
|
return new JwtToken(
|
||||||
|
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
||||||
|
await tokenController.CreateRefreshToken(user),
|
||||||
|
expireIn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Register a new user and get a new access/refresh token for this new user.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="request">The body of the request.</param>
|
||||||
|
/// <returns>A new access and a refresh token.</returns>
|
||||||
|
/// <response code="400">The request is invalid.</response>
|
||||||
|
/// <response code="409">A user already exists with this username or email address.</response>
|
||||||
|
[HttpPost("register")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult<JwtToken>> Register([FromBody] RegisterRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
try
|
User user = await users.Create(request.ToUser());
|
||||||
{
|
return new JwtToken(
|
||||||
return await users.Get(User.GetIdOrThrow());
|
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
||||||
}
|
await tokenController.CreateRefreshToken(user),
|
||||||
catch (ItemNotFoundException)
|
expireIn
|
||||||
{
|
);
|
||||||
return Forbid(new RequestError("Invalid token"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
catch (DuplicatedItemException)
|
||||||
/// <summary>
|
|
||||||
/// Edit self
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Edit information about the currently authenticated user.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="user">The new data for the current user.</param>
|
|
||||||
/// <returns>The currently authenticated user after modifications.</returns>
|
|
||||||
/// <response code="401">The user is not authenticated.</response>
|
|
||||||
/// <response code="403">The given access token is invalid.</response>
|
|
||||||
[HttpPut("me")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult<User>> EditMe(User user)
|
|
||||||
{
|
{
|
||||||
try
|
return Conflict(new RequestError("A user already exists with this username."));
|
||||||
{
|
|
||||||
user.Id = User.GetIdOrThrow();
|
|
||||||
return await users.Edit(user);
|
|
||||||
}
|
|
||||||
catch (ItemNotFoundException)
|
|
||||||
{
|
|
||||||
return Forbid(new RequestError("Invalid token"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Patch self
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Edit only provided informations about the currently authenticated user.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="patch">The new data for the current user.</param>
|
|
||||||
/// <returns>The currently authenticated user after modifications.</returns>
|
|
||||||
/// <response code="401">The user is not authenticated.</response>
|
|
||||||
/// <response code="403">The given access token is invalid.</response>
|
|
||||||
[HttpPatch("me")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult<User>> PatchMe([FromBody] Patch<User> patch)
|
|
||||||
{
|
|
||||||
Guid userId = User.GetIdOrThrow();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (patch.Id.HasValue && patch.Id != userId)
|
|
||||||
throw new ArgumentException("Can't edit your user id.");
|
|
||||||
if (patch.ContainsKey(nameof(Abstractions.Models.User.Password)))
|
|
||||||
throw new ArgumentException(
|
|
||||||
"Can't edit your password via a PATCH. Use /auth/password-reset"
|
|
||||||
);
|
|
||||||
return await users.Patch(userId, patch.Apply);
|
|
||||||
}
|
|
||||||
catch (ItemNotFoundException)
|
|
||||||
{
|
|
||||||
return Forbid(new RequestError("Invalid token"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete account
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Delete the current account.
|
|
||||||
/// </remarks>
|
|
||||||
/// <response code="401">The user is not authenticated.</response>
|
|
||||||
/// <response code="403">The given access token is invalid.</response>
|
|
||||||
[HttpDelete("me")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult<User>> DeleteMe()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await users.Delete(User.GetIdOrThrow());
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
catch (ItemNotFoundException)
|
|
||||||
{
|
|
||||||
return Forbid(new RequestError("Invalid token"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get profile picture
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Get your profile picture
|
|
||||||
/// </remarks>
|
|
||||||
/// <response code="401">The user is not authenticated.</response>
|
|
||||||
/// <response code="403">The given access token is invalid.</response>
|
|
||||||
[HttpGet("me/logo")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult> GetProfilePicture()
|
|
||||||
{
|
|
||||||
Stream img = await thumbs.GetUserImage(User.GetIdOrThrow());
|
|
||||||
// Allow clients to cache the image for 6 month.
|
|
||||||
Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}");
|
|
||||||
return File(img, "image/webp", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Set profile picture
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Set your profile picture
|
|
||||||
/// </remarks>
|
|
||||||
/// <response code="401">The user is not authenticated.</response>
|
|
||||||
/// <response code="403">The given access token is invalid.</response>
|
|
||||||
[HttpPost("me/logo")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult> SetProfilePicture(IFormFile picture)
|
|
||||||
{
|
|
||||||
if (picture == null || picture.Length == 0)
|
|
||||||
return BadRequest();
|
|
||||||
await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream());
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete profile picture
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Delete your profile picture
|
|
||||||
/// </remarks>
|
|
||||||
/// <response code="401">The user is not authenticated.</response>
|
|
||||||
/// <response code="403">The given access token is invalid.</response>
|
|
||||||
[HttpDelete("me/logo")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult> DeleteProfilePicture()
|
|
||||||
{
|
|
||||||
await thumbs.SetUserImage(User.GetIdOrThrow(), null);
|
|
||||||
return NoContent();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refresh a token.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Refresh an access token using the given refresh token. A new access and refresh token are generated.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="token">A valid refresh token.</param>
|
||||||
|
/// <returns>A new access and refresh token.</returns>
|
||||||
|
/// <response code="403">The given refresh token is invalid.</response>
|
||||||
|
[HttpGet("refresh")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult<JwtToken>> Refresh([FromQuery] string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Guid userId = tokenController.GetRefreshTokenUserID(token);
|
||||||
|
User user = await users.Get(userId);
|
||||||
|
return new JwtToken(
|
||||||
|
tokenController.CreateAccessToken(user, out TimeSpan expireIn),
|
||||||
|
await tokenController.CreateRefreshToken(user),
|
||||||
|
expireIn
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (ItemNotFoundException)
|
||||||
|
{
|
||||||
|
return Forbid(new RequestError("Invalid refresh token."));
|
||||||
|
}
|
||||||
|
catch (SecurityTokenException ex)
|
||||||
|
{
|
||||||
|
return Forbid(new RequestError(ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset your password
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Change your password.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="request">The old and new password</param>
|
||||||
|
/// <returns>Your account info.</returns>
|
||||||
|
/// <response code="403">The old password is invalid.</response>
|
||||||
|
[HttpPost("password-reset")]
|
||||||
|
[UserOnly]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult<User>> ResetPassword([FromBody] PasswordResetRequest request)
|
||||||
|
{
|
||||||
|
User user = await users.Get(User.GetIdOrThrow());
|
||||||
|
if (user.HasPassword && !BCryptNet.Verify(request.OldPassword, user.Password))
|
||||||
|
return Forbid(new RequestError("The old password is invalid."));
|
||||||
|
return await users.Patch(
|
||||||
|
user.Id,
|
||||||
|
(user) =>
|
||||||
|
{
|
||||||
|
user.Password = BCryptNet.HashPassword(request.NewPassword);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get authenticated user.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Get information about the currently authenticated user. This can also be used to ensure that you are
|
||||||
|
/// logged in.
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns>The currently authenticated user.</returns>
|
||||||
|
/// <response code="401">The user is not authenticated.</response>
|
||||||
|
/// <response code="403">The given access token is invalid.</response>
|
||||||
|
[HttpGet("me")]
|
||||||
|
[UserOnly]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult<User>> GetMe()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await users.Get(User.GetIdOrThrow());
|
||||||
|
}
|
||||||
|
catch (ItemNotFoundException)
|
||||||
|
{
|
||||||
|
return Forbid(new RequestError("Invalid token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edit self
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Edit information about the currently authenticated user.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="user">The new data for the current user.</param>
|
||||||
|
/// <returns>The currently authenticated user after modifications.</returns>
|
||||||
|
/// <response code="401">The user is not authenticated.</response>
|
||||||
|
/// <response code="403">The given access token is invalid.</response>
|
||||||
|
[HttpPut("me")]
|
||||||
|
[UserOnly]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult<User>> EditMe(User user)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
user.Id = User.GetIdOrThrow();
|
||||||
|
return await users.Edit(user);
|
||||||
|
}
|
||||||
|
catch (ItemNotFoundException)
|
||||||
|
{
|
||||||
|
return Forbid(new RequestError("Invalid token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Patch self
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Edit only provided informations about the currently authenticated user.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="patch">The new data for the current user.</param>
|
||||||
|
/// <returns>The currently authenticated user after modifications.</returns>
|
||||||
|
/// <response code="401">The user is not authenticated.</response>
|
||||||
|
/// <response code="403">The given access token is invalid.</response>
|
||||||
|
[HttpPatch("me")]
|
||||||
|
[UserOnly]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult<User>> PatchMe([FromBody] Patch<User> patch)
|
||||||
|
{
|
||||||
|
Guid userId = User.GetIdOrThrow();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (patch.Id.HasValue && patch.Id != userId)
|
||||||
|
throw new ArgumentException("Can't edit your user id.");
|
||||||
|
if (patch.ContainsKey(nameof(Abstractions.Models.User.Password)))
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Can't edit your password via a PATCH. Use /auth/password-reset"
|
||||||
|
);
|
||||||
|
return await users.Patch(userId, patch.Apply);
|
||||||
|
}
|
||||||
|
catch (ItemNotFoundException)
|
||||||
|
{
|
||||||
|
return Forbid(new RequestError("Invalid token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete account
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Delete the current account.
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="401">The user is not authenticated.</response>
|
||||||
|
/// <response code="403">The given access token is invalid.</response>
|
||||||
|
[HttpDelete("me")]
|
||||||
|
[UserOnly]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult<User>> DeleteMe()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await users.Delete(User.GetIdOrThrow());
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (ItemNotFoundException)
|
||||||
|
{
|
||||||
|
return Forbid(new RequestError("Invalid token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get profile picture
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Get your profile picture
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="401">The user is not authenticated.</response>
|
||||||
|
/// <response code="403">The given access token is invalid.</response>
|
||||||
|
[HttpGet("me/logo")]
|
||||||
|
[UserOnly]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult> GetProfilePicture()
|
||||||
|
{
|
||||||
|
Stream img = await thumbs.GetUserImage(User.GetIdOrThrow());
|
||||||
|
// Allow clients to cache the image for 6 month.
|
||||||
|
Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}");
|
||||||
|
return File(img, "image/webp", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set profile picture
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Set your profile picture
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="401">The user is not authenticated.</response>
|
||||||
|
/// <response code="403">The given access token is invalid.</response>
|
||||||
|
[HttpPost("me/logo")]
|
||||||
|
[UserOnly]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult> SetProfilePicture(IFormFile picture)
|
||||||
|
{
|
||||||
|
if (picture == null || picture.Length == 0)
|
||||||
|
return BadRequest();
|
||||||
|
await thumbs.SetUserImage(User.GetIdOrThrow(), picture.OpenReadStream());
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete profile picture
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Delete your profile picture
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="401">The user is not authenticated.</response>
|
||||||
|
/// <response code="403">The given access token is invalid.</response>
|
||||||
|
[HttpDelete("me/logo")]
|
||||||
|
[UserOnly]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult> DeleteProfilePicture()
|
||||||
|
{
|
||||||
|
await thumbs.SetUserImage(User.GetIdOrThrow(), null);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,23 +20,22 @@ using Kyoo.Abstractions.Models.Utils;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The route constraint that goes with the <see cref="Identifier"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class IdentifierRouteConstraint : IRouteConstraint
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// The route constraint that goes with the <see cref="Identifier"/>.
|
public bool Match(
|
||||||
/// </summary>
|
HttpContext? httpContext,
|
||||||
public class IdentifierRouteConstraint : IRouteConstraint
|
IRouter? route,
|
||||||
|
string routeKey,
|
||||||
|
RouteValueDictionary values,
|
||||||
|
RouteDirection routeDirection
|
||||||
|
)
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
return values.ContainsKey(routeKey);
|
||||||
public bool Match(
|
|
||||||
HttpContext? httpContext,
|
|
||||||
IRouter? route,
|
|
||||||
string routeKey,
|
|
||||||
RouteValueDictionary values,
|
|
||||||
RouteDirection routeDirection
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return values.ContainsKey(routeKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,87 +20,86 @@ using System.Linq;
|
|||||||
using Kyoo.Abstractions.Controllers;
|
using Kyoo.Abstractions.Controllers;
|
||||||
using Kyoo.Abstractions.Models;
|
using Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An class to interact with the database. Every repository is mapped through here.
|
||||||
|
/// </summary>
|
||||||
|
public class LibraryManager : ILibraryManager
|
||||||
{
|
{
|
||||||
/// <summary>
|
private readonly IBaseRepository[] _repositories;
|
||||||
/// An class to interact with the database. Every repository is mapped through here.
|
|
||||||
/// </summary>
|
public LibraryManager(
|
||||||
public class LibraryManager : ILibraryManager
|
IRepository<ILibraryItem> libraryItemRepository,
|
||||||
|
IRepository<INews> newsRepository,
|
||||||
|
IWatchStatusRepository watchStatusRepository,
|
||||||
|
IRepository<Collection> collectionRepository,
|
||||||
|
IRepository<Movie> movieRepository,
|
||||||
|
IRepository<Show> showRepository,
|
||||||
|
IRepository<Season> seasonRepository,
|
||||||
|
IRepository<Episode> episodeRepository,
|
||||||
|
IRepository<Studio> studioRepository,
|
||||||
|
IRepository<User> userRepository
|
||||||
|
)
|
||||||
{
|
{
|
||||||
private readonly IBaseRepository[] _repositories;
|
LibraryItems = libraryItemRepository;
|
||||||
|
News = newsRepository;
|
||||||
|
WatchStatus = watchStatusRepository;
|
||||||
|
Collections = collectionRepository;
|
||||||
|
Movies = movieRepository;
|
||||||
|
Shows = showRepository;
|
||||||
|
Seasons = seasonRepository;
|
||||||
|
Episodes = episodeRepository;
|
||||||
|
Studios = studioRepository;
|
||||||
|
Users = userRepository;
|
||||||
|
|
||||||
public LibraryManager(
|
_repositories = new IBaseRepository[]
|
||||||
IRepository<ILibraryItem> libraryItemRepository,
|
|
||||||
IRepository<INews> newsRepository,
|
|
||||||
IWatchStatusRepository watchStatusRepository,
|
|
||||||
IRepository<Collection> collectionRepository,
|
|
||||||
IRepository<Movie> movieRepository,
|
|
||||||
IRepository<Show> showRepository,
|
|
||||||
IRepository<Season> seasonRepository,
|
|
||||||
IRepository<Episode> episodeRepository,
|
|
||||||
IRepository<Studio> studioRepository,
|
|
||||||
IRepository<User> userRepository
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
LibraryItems = libraryItemRepository;
|
LibraryItems,
|
||||||
News = newsRepository;
|
News,
|
||||||
WatchStatus = watchStatusRepository;
|
Collections,
|
||||||
Collections = collectionRepository;
|
Movies,
|
||||||
Movies = movieRepository;
|
Shows,
|
||||||
Shows = showRepository;
|
Seasons,
|
||||||
Seasons = seasonRepository;
|
Episodes,
|
||||||
Episodes = episodeRepository;
|
Studios,
|
||||||
Studios = studioRepository;
|
Users
|
||||||
Users = userRepository;
|
};
|
||||||
|
}
|
||||||
|
|
||||||
_repositories = new IBaseRepository[]
|
/// <inheritdoc />
|
||||||
{
|
public IRepository<ILibraryItem> LibraryItems { get; }
|
||||||
LibraryItems,
|
|
||||||
News,
|
|
||||||
Collections,
|
|
||||||
Movies,
|
|
||||||
Shows,
|
|
||||||
Seasons,
|
|
||||||
Episodes,
|
|
||||||
Studios,
|
|
||||||
Users
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IRepository<ILibraryItem> LibraryItems { get; }
|
public IRepository<INews> News { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IRepository<INews> News { get; }
|
public IWatchStatusRepository WatchStatus { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IWatchStatusRepository WatchStatus { get; }
|
public IRepository<Collection> Collections { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IRepository<Collection> Collections { get; }
|
public IRepository<Movie> Movies { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IRepository<Movie> Movies { get; }
|
public IRepository<Show> Shows { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IRepository<Show> Shows { get; }
|
public IRepository<Season> Seasons { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IRepository<Season> Seasons { get; }
|
public IRepository<Episode> Episodes { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IRepository<Episode> Episodes { get; }
|
public IRepository<Studio> Studios { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IRepository<Studio> Studios { get; }
|
public IRepository<User> Users { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
public IRepository<T> Repository<T>()
|
||||||
public IRepository<User> Users { get; }
|
where T : IResource, IQuery
|
||||||
|
{
|
||||||
public IRepository<T> Repository<T>()
|
return (IRepository<T>)_repositories.First(x => x.RepositoryType == typeof(T));
|
||||||
where T : IResource, IQuery
|
|
||||||
{
|
|
||||||
return (IRepository<T>)_repositories.First(x => x.RepositoryType == typeof(T));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,78 +26,77 @@ using Kyoo.Abstractions.Models.Utils;
|
|||||||
using Kyoo.Postgresql;
|
using Kyoo.Postgresql;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local repository to handle collections
|
||||||
|
/// </summary>
|
||||||
|
public class CollectionRepository : LocalRepository<Collection>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A local repository to handle collections
|
/// The database handle
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CollectionRepository : LocalRepository<Collection>
|
private readonly DatabaseContext _database;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="CollectionRepository"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="database">The database handle to use</param>
|
||||||
|
/// <param name="thumbs">The thumbnail manager used to store images.</param>
|
||||||
|
public CollectionRepository(DatabaseContext database, IThumbnailsManager thumbs)
|
||||||
|
: base(database, thumbs)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_database = database;
|
||||||
/// The database handle
|
}
|
||||||
/// </summary>
|
|
||||||
private readonly DatabaseContext _database;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Create a new <see cref="CollectionRepository"/>.
|
public override async Task<ICollection<Collection>> Search(
|
||||||
/// </summary>
|
string query,
|
||||||
/// <param name="database">The database handle to use</param>
|
Include<Collection>? include = default
|
||||||
/// <param name="thumbs">The thumbnail manager used to store images.</param>
|
)
|
||||||
public CollectionRepository(DatabaseContext database, IThumbnailsManager thumbs)
|
{
|
||||||
: base(database, thumbs)
|
return await AddIncludes(_database.Collections, include)
|
||||||
{
|
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
|
||||||
_database = database;
|
.Take(20)
|
||||||
}
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override async Task<ICollection<Collection>> Search(
|
public override async Task<Collection> Create(Collection obj)
|
||||||
string query,
|
{
|
||||||
Include<Collection>? include = default
|
await base.Create(obj);
|
||||||
)
|
_database.Entry(obj).State = EntityState.Added;
|
||||||
{
|
await _database.SaveChangesAsync(() => Get(obj.Slug));
|
||||||
return await AddIncludes(_database.Collections, include)
|
await IRepository<Collection>.OnResourceCreated(obj);
|
||||||
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
|
return obj;
|
||||||
.Take(20)
|
}
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override async Task<Collection> Create(Collection obj)
|
protected override async Task Validate(Collection resource)
|
||||||
{
|
{
|
||||||
await base.Create(obj);
|
await base.Validate(resource);
|
||||||
_database.Entry(obj).State = EntityState.Added;
|
|
||||||
await _database.SaveChangesAsync(() => Get(obj.Slug));
|
|
||||||
await IRepository<Collection>.OnResourceCreated(obj);
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
if (string.IsNullOrEmpty(resource.Name))
|
||||||
protected override async Task Validate(Collection resource)
|
throw new ArgumentException("The collection's name must be set and not empty");
|
||||||
{
|
}
|
||||||
await base.Validate(resource);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(resource.Name))
|
public async Task AddMovie(Guid id, Guid movieId)
|
||||||
throw new ArgumentException("The collection's name must be set and not empty");
|
{
|
||||||
}
|
_database.AddLinks<Collection, Movie>(id, movieId);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task AddMovie(Guid id, Guid movieId)
|
public async Task AddShow(Guid id, Guid showId)
|
||||||
{
|
{
|
||||||
_database.AddLinks<Collection, Movie>(id, movieId);
|
_database.AddLinks<Collection, Show>(id, showId);
|
||||||
await _database.SaveChangesAsync();
|
await _database.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddShow(Guid id, Guid showId)
|
/// <inheritdoc />
|
||||||
{
|
public override async Task Delete(Collection obj)
|
||||||
_database.AddLinks<Collection, Show>(id, showId);
|
{
|
||||||
await _database.SaveChangesAsync();
|
_database.Entry(obj).State = EntityState.Deleted;
|
||||||
}
|
await _database.SaveChangesAsync();
|
||||||
|
await base.Delete(obj);
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task Delete(Collection obj)
|
|
||||||
{
|
|
||||||
_database.Entry(obj).State = EntityState.Deleted;
|
|
||||||
await _database.SaveChangesAsync();
|
|
||||||
await base.Delete(obj);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,130 +27,128 @@ using Kyoo.Postgresql;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local repository to handle episodes.
|
||||||
|
/// </summary>
|
||||||
|
public class EpisodeRepository : LocalRepository<Episode>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A local repository to handle episodes.
|
/// The database handle
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class EpisodeRepository : LocalRepository<Episode>
|
private readonly DatabaseContext _database;
|
||||||
|
|
||||||
|
private readonly IRepository<Show> _shows;
|
||||||
|
|
||||||
|
static EpisodeRepository()
|
||||||
{
|
{
|
||||||
/// <summary>
|
// Edit episode slugs when the show's slug changes.
|
||||||
/// The database handle
|
IRepository<Show>.OnEdited += async (show) =>
|
||||||
/// </summary>
|
|
||||||
private readonly DatabaseContext _database;
|
|
||||||
|
|
||||||
private readonly IRepository<Show> _shows;
|
|
||||||
|
|
||||||
static EpisodeRepository()
|
|
||||||
{
|
{
|
||||||
// Edit episode slugs when the show's slug changes.
|
await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope();
|
||||||
IRepository<Show>.OnEdited += async (show) =>
|
DatabaseContext database = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||||
{
|
List<Episode> episodes = await database
|
||||||
await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope();
|
.Episodes.AsTracking()
|
||||||
DatabaseContext database =
|
.Where(x => x.ShowId == show.Id)
|
||||||
scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
|
||||||
List<Episode> episodes = await database
|
|
||||||
.Episodes.AsTracking()
|
|
||||||
.Where(x => x.ShowId == show.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
foreach (Episode ep in episodes)
|
|
||||||
{
|
|
||||||
ep.ShowSlug = show.Slug;
|
|
||||||
await database.SaveChangesAsync();
|
|
||||||
await IRepository<Episode>.OnResourceEdited(ep);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="EpisodeRepository"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="database">The database handle to use.</param>
|
|
||||||
/// <param name="shows">A show repository</param>
|
|
||||||
/// <param name="thumbs">The thumbnail manager used to store images.</param>
|
|
||||||
public EpisodeRepository(
|
|
||||||
DatabaseContext database,
|
|
||||||
IRepository<Show> shows,
|
|
||||||
IThumbnailsManager thumbs
|
|
||||||
)
|
|
||||||
: base(database, thumbs)
|
|
||||||
{
|
|
||||||
_database = database;
|
|
||||||
_shows = shows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task<ICollection<Episode>> Search(
|
|
||||||
string query,
|
|
||||||
Include<Episode>? include = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return await AddIncludes(_database.Episodes, include)
|
|
||||||
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
|
|
||||||
.Take(20)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
foreach (Episode ep in episodes)
|
||||||
|
{
|
||||||
|
ep.ShowSlug = show.Slug;
|
||||||
|
await database.SaveChangesAsync();
|
||||||
|
await IRepository<Episode>.OnResourceEdited(ep);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
protected override Task<Episode?> GetDuplicated(Episode item)
|
/// <summary>
|
||||||
{
|
/// Create a new <see cref="EpisodeRepository"/>.
|
||||||
if (item is { SeasonNumber: not null, EpisodeNumber: not null })
|
/// </summary>
|
||||||
return _database.Episodes.FirstOrDefaultAsync(x =>
|
/// <param name="database">The database handle to use.</param>
|
||||||
x.ShowId == item.ShowId
|
/// <param name="shows">A show repository</param>
|
||||||
&& x.SeasonNumber == item.SeasonNumber
|
/// <param name="thumbs">The thumbnail manager used to store images.</param>
|
||||||
&& x.EpisodeNumber == item.EpisodeNumber
|
public EpisodeRepository(
|
||||||
);
|
DatabaseContext database,
|
||||||
|
IRepository<Show> shows,
|
||||||
|
IThumbnailsManager thumbs
|
||||||
|
)
|
||||||
|
: base(database, thumbs)
|
||||||
|
{
|
||||||
|
_database = database;
|
||||||
|
_shows = shows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task<ICollection<Episode>> Search(
|
||||||
|
string query,
|
||||||
|
Include<Episode>? include = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return await AddIncludes(_database.Episodes, include)
|
||||||
|
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
|
||||||
|
.Take(20)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<Episode?> GetDuplicated(Episode item)
|
||||||
|
{
|
||||||
|
if (item is { SeasonNumber: not null, EpisodeNumber: not null })
|
||||||
return _database.Episodes.FirstOrDefaultAsync(x =>
|
return _database.Episodes.FirstOrDefaultAsync(x =>
|
||||||
x.ShowId == item.ShowId && x.AbsoluteNumber == item.AbsoluteNumber
|
x.ShowId == item.ShowId
|
||||||
|
&& x.SeasonNumber == item.SeasonNumber
|
||||||
|
&& x.EpisodeNumber == item.EpisodeNumber
|
||||||
|
);
|
||||||
|
return _database.Episodes.FirstOrDefaultAsync(x =>
|
||||||
|
x.ShowId == item.ShowId && x.AbsoluteNumber == item.AbsoluteNumber
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task<Episode> Create(Episode obj)
|
||||||
|
{
|
||||||
|
obj.ShowSlug =
|
||||||
|
obj.Show?.Slug ?? (await _database.Shows.FirstAsync(x => x.Id == obj.ShowId)).Slug;
|
||||||
|
await base.Create(obj);
|
||||||
|
_database.Entry(obj).State = EntityState.Added;
|
||||||
|
await _database.SaveChangesAsync(() => GetDuplicated(obj));
|
||||||
|
await IRepository<Episode>.OnResourceCreated(obj);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task Validate(Episode resource)
|
||||||
|
{
|
||||||
|
await base.Validate(resource);
|
||||||
|
if (resource.ShowId == Guid.Empty)
|
||||||
|
{
|
||||||
|
if (resource.Show == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Can't store an episode not related "
|
||||||
|
+ $"to any show (showID: {resource.ShowId})."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resource.ShowId = resource.Show.Id;
|
||||||
|
}
|
||||||
|
if (resource.SeasonId == null && resource.SeasonNumber != null)
|
||||||
|
{
|
||||||
|
resource.Season = await _database.Seasons.FirstOrDefaultAsync(x =>
|
||||||
|
x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override async Task<Episode> Create(Episode obj)
|
public override async Task Delete(Episode obj)
|
||||||
{
|
{
|
||||||
obj.ShowSlug =
|
int epCount = await _database
|
||||||
obj.Show?.Slug ?? (await _database.Shows.FirstAsync(x => x.Id == obj.ShowId)).Slug;
|
.Episodes.Where(x => x.ShowId == obj.ShowId)
|
||||||
await base.Create(obj);
|
.Take(2)
|
||||||
_database.Entry(obj).State = EntityState.Added;
|
.CountAsync();
|
||||||
await _database.SaveChangesAsync(() => GetDuplicated(obj));
|
_database.Entry(obj).State = EntityState.Deleted;
|
||||||
await IRepository<Episode>.OnResourceCreated(obj);
|
await _database.SaveChangesAsync();
|
||||||
return obj;
|
await base.Delete(obj);
|
||||||
}
|
if (epCount == 1)
|
||||||
|
await _shows.Delete(obj.ShowId);
|
||||||
/// <inheritdoc />
|
|
||||||
protected override async Task Validate(Episode resource)
|
|
||||||
{
|
|
||||||
await base.Validate(resource);
|
|
||||||
if (resource.ShowId == Guid.Empty)
|
|
||||||
{
|
|
||||||
if (resource.Show == null)
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
$"Can't store an episode not related "
|
|
||||||
+ $"to any show (showID: {resource.ShowId})."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
resource.ShowId = resource.Show.Id;
|
|
||||||
}
|
|
||||||
if (resource.SeasonId == null && resource.SeasonNumber != null)
|
|
||||||
{
|
|
||||||
resource.Season = await _database.Seasons.FirstOrDefaultAsync(x =>
|
|
||||||
x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task Delete(Episode obj)
|
|
||||||
{
|
|
||||||
int epCount = await _database
|
|
||||||
.Episodes.Where(x => x.ShowId == obj.ShowId)
|
|
||||||
.Take(2)
|
|
||||||
.CountAsync();
|
|
||||||
_database.Entry(obj).State = EntityState.Deleted;
|
|
||||||
await _database.SaveChangesAsync();
|
|
||||||
await base.Delete(obj);
|
|
||||||
if (epCount == 1)
|
|
||||||
await _shows.Delete(obj.ShowId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,103 +25,102 @@ using Kyoo.Abstractions.Controllers;
|
|||||||
using Kyoo.Abstractions.Models;
|
using Kyoo.Abstractions.Models;
|
||||||
using Kyoo.Abstractions.Models.Utils;
|
using Kyoo.Abstractions.Models.Utils;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local repository to handle library items.
|
||||||
|
/// </summary>
|
||||||
|
public class LibraryItemRepository : DapperRepository<ILibraryItem>
|
||||||
{
|
{
|
||||||
/// <summary>
|
// language=PostgreSQL
|
||||||
/// A local repository to handle library items.
|
protected override FormattableString Sql =>
|
||||||
/// </summary>
|
$"""
|
||||||
public class LibraryItemRepository : DapperRepository<ILibraryItem>
|
select
|
||||||
|
s.*, -- Show as s
|
||||||
|
m.*,
|
||||||
|
c.*
|
||||||
|
/* includes */
|
||||||
|
from
|
||||||
|
shows as s
|
||||||
|
full outer join (
|
||||||
|
select
|
||||||
|
* -- Movie
|
||||||
|
from
|
||||||
|
movies) as m on false
|
||||||
|
full outer join(
|
||||||
|
select
|
||||||
|
c.* -- Collection as c
|
||||||
|
from
|
||||||
|
collections as c
|
||||||
|
left join link_collection_show as ls on ls.collection_id = c.id
|
||||||
|
left join link_collection_movie as lm on lm.collection_id = c.id
|
||||||
|
group by c.id
|
||||||
|
having count(*) > 1
|
||||||
|
) as c on false
|
||||||
|
""";
|
||||||
|
|
||||||
|
protected override Dictionary<string, Type> Config =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
{ "s", typeof(Show) },
|
||||||
|
{ "m", typeof(Movie) },
|
||||||
|
{ "c", typeof(Collection) }
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override ILibraryItem Mapper(List<object?> items)
|
||||||
|
{
|
||||||
|
if (items[0] is Show show && show.Id != Guid.Empty)
|
||||||
|
return show;
|
||||||
|
if (items[1] is Movie movie && movie.Id != Guid.Empty)
|
||||||
|
return movie;
|
||||||
|
if (items[2] is Collection collection && collection.Id != Guid.Empty)
|
||||||
|
return collection;
|
||||||
|
throw new InvalidDataException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public LibraryItemRepository(DbConnection database, SqlVariableContext context)
|
||||||
|
: base(database, context) { }
|
||||||
|
|
||||||
|
public async Task<ICollection<ILibraryItem>> GetAllOfCollection(
|
||||||
|
Guid collectionId,
|
||||||
|
Filter<ILibraryItem>? filter = default,
|
||||||
|
Sort<ILibraryItem>? sort = default,
|
||||||
|
Include<ILibraryItem>? include = default,
|
||||||
|
Pagination? limit = default
|
||||||
|
)
|
||||||
{
|
{
|
||||||
// language=PostgreSQL
|
// language=PostgreSQL
|
||||||
protected override FormattableString Sql =>
|
FormattableString sql = $"""
|
||||||
$"""
|
select
|
||||||
|
s.*,
|
||||||
|
m.*
|
||||||
|
/* includes */
|
||||||
|
from (
|
||||||
select
|
select
|
||||||
s.*, -- Show as s
|
* -- Show
|
||||||
m.*,
|
|
||||||
c.*
|
|
||||||
/* includes */
|
|
||||||
from
|
from
|
||||||
shows as s
|
shows
|
||||||
full outer join (
|
inner join link_collection_show as ls on ls.show_id = id and ls.collection_id = {collectionId}
|
||||||
select
|
) as s
|
||||||
* -- Movie
|
full outer join (
|
||||||
from
|
|
||||||
movies) as m on false
|
|
||||||
full outer join(
|
|
||||||
select
|
|
||||||
c.* -- Collection as c
|
|
||||||
from
|
|
||||||
collections as c
|
|
||||||
left join link_collection_show as ls on ls.collection_id = c.id
|
|
||||||
left join link_collection_movie as lm on lm.collection_id = c.id
|
|
||||||
group by c.id
|
|
||||||
having count(*) > 1
|
|
||||||
) as c on false
|
|
||||||
""";
|
|
||||||
|
|
||||||
protected override Dictionary<string, Type> Config =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
{ "s", typeof(Show) },
|
|
||||||
{ "m", typeof(Movie) },
|
|
||||||
{ "c", typeof(Collection) }
|
|
||||||
};
|
|
||||||
|
|
||||||
protected override ILibraryItem Mapper(List<object?> items)
|
|
||||||
{
|
|
||||||
if (items[0] is Show show && show.Id != Guid.Empty)
|
|
||||||
return show;
|
|
||||||
if (items[1] is Movie movie && movie.Id != Guid.Empty)
|
|
||||||
return movie;
|
|
||||||
if (items[2] is Collection collection && collection.Id != Guid.Empty)
|
|
||||||
return collection;
|
|
||||||
throw new InvalidDataException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public LibraryItemRepository(DbConnection database, SqlVariableContext context)
|
|
||||||
: base(database, context) { }
|
|
||||||
|
|
||||||
public async Task<ICollection<ILibraryItem>> GetAllOfCollection(
|
|
||||||
Guid collectionId,
|
|
||||||
Filter<ILibraryItem>? filter = default,
|
|
||||||
Sort<ILibraryItem>? sort = default,
|
|
||||||
Include<ILibraryItem>? include = default,
|
|
||||||
Pagination? limit = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
// language=PostgreSQL
|
|
||||||
FormattableString sql = $"""
|
|
||||||
select
|
select
|
||||||
s.*,
|
* -- Movie
|
||||||
m.*
|
from
|
||||||
/* includes */
|
movies
|
||||||
from (
|
inner join link_collection_movie as lm on lm.movie_id = id and lm.collection_id = {collectionId}
|
||||||
select
|
) as m on false
|
||||||
* -- Show
|
""";
|
||||||
from
|
|
||||||
shows
|
|
||||||
inner join link_collection_show as ls on ls.show_id = id and ls.collection_id = {collectionId}
|
|
||||||
) as s
|
|
||||||
full outer join (
|
|
||||||
select
|
|
||||||
* -- Movie
|
|
||||||
from
|
|
||||||
movies
|
|
||||||
inner join link_collection_movie as lm on lm.movie_id = id and lm.collection_id = {collectionId}
|
|
||||||
) as m on false
|
|
||||||
""";
|
|
||||||
|
|
||||||
return await Database.Query<ILibraryItem>(
|
return await Database.Query<ILibraryItem>(
|
||||||
sql,
|
sql,
|
||||||
new() { { "s", typeof(Show) }, { "m", typeof(Movie) }, },
|
new() { { "s", typeof(Show) }, { "m", typeof(Movie) }, },
|
||||||
Mapper,
|
Mapper,
|
||||||
(id) => Get(id),
|
(id) => Get(id),
|
||||||
Context,
|
Context,
|
||||||
include,
|
include,
|
||||||
filter,
|
filter,
|
||||||
sort ?? new Sort<ILibraryItem>.Default(),
|
sort ?? new Sort<ILibraryItem>.Default(),
|
||||||
limit ?? new()
|
limit ?? new()
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -32,468 +32,456 @@ using Kyoo.Postgresql;
|
|||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A base class to create repositories using Entity Framework.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of this repository</typeparam>
|
||||||
|
public abstract class LocalRepository<T> : IRepository<T>
|
||||||
|
where T : class, IResource, IQuery
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A base class to create repositories using Entity Framework.
|
/// The Entity Framework's Database handle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The type of this repository</typeparam>
|
protected DbContext Database { get; }
|
||||||
public abstract class LocalRepository<T> : IRepository<T>
|
|
||||||
where T : class, IResource, IQuery
|
/// <summary>
|
||||||
|
/// The thumbnail manager used to store images.
|
||||||
|
/// </summary>
|
||||||
|
private readonly IThumbnailsManager _thumbs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new base <see cref="LocalRepository{T}"/> with the given database handle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="database">A database connection to load resources of type <typeparamref name="T"/></param>
|
||||||
|
/// <param name="thumbs">The thumbnail manager used to store images.</param>
|
||||||
|
protected LocalRepository(DbContext database, IThumbnailsManager thumbs)
|
||||||
{
|
{
|
||||||
/// <summary>
|
Database = database;
|
||||||
/// The Entity Framework's Database handle.
|
_thumbs = thumbs;
|
||||||
/// </summary>
|
}
|
||||||
protected DbContext Database { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc/>
|
||||||
/// The thumbnail manager used to store images.
|
public Type RepositoryType => typeof(T);
|
||||||
/// </summary>
|
|
||||||
private readonly IThumbnailsManager _thumbs;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new base <see cref="LocalRepository{T}"/> with the given database handle.
|
/// Sort the given query.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="database">A database connection to load resources of type <typeparamref name="T"/></param>
|
/// <param name="query">The query to sort.</param>
|
||||||
/// <param name="thumbs">The thumbnail manager used to store images.</param>
|
/// <param name="sortBy">How to sort the query.</param>
|
||||||
protected LocalRepository(DbContext database, IThumbnailsManager thumbs)
|
/// <returns>The newly sorted query.</returns>
|
||||||
|
protected IOrderedQueryable<T> Sort(IQueryable<T> query, Sort<T>? sortBy)
|
||||||
|
{
|
||||||
|
sortBy ??= new Sort<T>.Default();
|
||||||
|
|
||||||
|
IOrderedQueryable<T> _SortBy(
|
||||||
|
IQueryable<T> qr,
|
||||||
|
Expression<Func<T, object>> sort,
|
||||||
|
bool desc,
|
||||||
|
bool then
|
||||||
|
)
|
||||||
{
|
{
|
||||||
Database = database;
|
if (then && qr is IOrderedQueryable<T> qro)
|
||||||
_thumbs = thumbs;
|
{
|
||||||
|
return desc ? qro.ThenByDescending(sort) : qro.ThenBy(sort);
|
||||||
|
}
|
||||||
|
return desc ? qr.OrderByDescending(sort) : qr.OrderBy(sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
IOrderedQueryable<T> _Sort(IQueryable<T> query, Sort<T> sortBy, bool then)
|
||||||
public Type RepositoryType => typeof(T);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sort the given query.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">The query to sort.</param>
|
|
||||||
/// <param name="sortBy">How to sort the query.</param>
|
|
||||||
/// <returns>The newly sorted query.</returns>
|
|
||||||
protected IOrderedQueryable<T> Sort(IQueryable<T> query, Sort<T>? sortBy)
|
|
||||||
{
|
{
|
||||||
sortBy ??= new Sort<T>.Default();
|
switch (sortBy)
|
||||||
|
|
||||||
IOrderedQueryable<T> _SortBy(
|
|
||||||
IQueryable<T> qr,
|
|
||||||
Expression<Func<T, object>> sort,
|
|
||||||
bool desc,
|
|
||||||
bool then
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (then && qr is IOrderedQueryable<T> qro)
|
case Sort<T>.Default(var value):
|
||||||
{
|
return _Sort(query, value, then);
|
||||||
return desc ? qro.ThenByDescending(sort) : qro.ThenBy(sort);
|
case Sort<T>.By(var key, var desc):
|
||||||
}
|
return _SortBy(query, x => EF.Property<T>(x, key), desc, then);
|
||||||
return desc ? qr.OrderByDescending(sort) : qr.OrderBy(sort);
|
case Sort<T>.Random(var seed):
|
||||||
|
// NOTE: To edit this, don't forget to edit the random handiling inside the KeysetPaginate function
|
||||||
|
return _SortBy(
|
||||||
|
query,
|
||||||
|
x => DatabaseContext.MD5(seed + x.Id.ToString()),
|
||||||
|
false,
|
||||||
|
then
|
||||||
|
);
|
||||||
|
case Sort<T>.Conglomerate(var sorts):
|
||||||
|
IOrderedQueryable<T> nQuery = _Sort(query, sorts.First(), false);
|
||||||
|
foreach (Sort<T> sort in sorts.Skip(1))
|
||||||
|
nQuery = _Sort(nQuery, sort, true);
|
||||||
|
return nQuery;
|
||||||
|
default:
|
||||||
|
// The language should not require me to do this...
|
||||||
|
throw new SwitchExpressionException();
|
||||||
}
|
}
|
||||||
|
|
||||||
IOrderedQueryable<T> _Sort(IQueryable<T> query, Sort<T> sortBy, bool then)
|
|
||||||
{
|
|
||||||
switch (sortBy)
|
|
||||||
{
|
|
||||||
case Sort<T>.Default(var value):
|
|
||||||
return _Sort(query, value, then);
|
|
||||||
case Sort<T>.By(var key, var desc):
|
|
||||||
return _SortBy(query, x => EF.Property<T>(x, key), desc, then);
|
|
||||||
case Sort<T>.Random(var seed):
|
|
||||||
// NOTE: To edit this, don't forget to edit the random handiling inside the KeysetPaginate function
|
|
||||||
return _SortBy(
|
|
||||||
query,
|
|
||||||
x => DatabaseContext.MD5(seed + x.Id.ToString()),
|
|
||||||
false,
|
|
||||||
then
|
|
||||||
);
|
|
||||||
case Sort<T>.Conglomerate(var sorts):
|
|
||||||
IOrderedQueryable<T> nQuery = _Sort(query, sorts.First(), false);
|
|
||||||
foreach (Sort<T> sort in sorts.Skip(1))
|
|
||||||
nQuery = _Sort(nQuery, sort, true);
|
|
||||||
return nQuery;
|
|
||||||
default:
|
|
||||||
// The language should not require me to do this...
|
|
||||||
throw new SwitchExpressionException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _Sort(query, sortBy, false).ThenBy(x => x.Id);
|
|
||||||
}
|
}
|
||||||
|
return _Sort(query, sortBy, false).ThenBy(x => x.Id);
|
||||||
|
}
|
||||||
|
|
||||||
protected IQueryable<T> AddIncludes(IQueryable<T> query, Include<T>? include)
|
protected IQueryable<T> AddIncludes(IQueryable<T> query, Include<T>? include)
|
||||||
{
|
{
|
||||||
if (include == null)
|
if (include == null)
|
||||||
return query;
|
|
||||||
foreach (string field in include.Fields)
|
|
||||||
query = query.Include(field);
|
|
||||||
return query;
|
return query;
|
||||||
}
|
foreach (string field in include.Fields)
|
||||||
|
query = query.Include(field);
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get a resource from it's ID and make the <see cref="Database"/> instance track it.
|
/// Get a resource from it's ID and make the <see cref="Database"/> instance track it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">The ID of the resource</param>
|
/// <param name="id">The ID of the resource</param>
|
||||||
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
/// <exception cref="ItemNotFoundException">If the item is not found</exception>
|
||||||
/// <returns>The tracked resource with the given ID</returns>
|
/// <returns>The tracked resource with the given ID</returns>
|
||||||
protected virtual async Task<T> GetWithTracking(Guid id)
|
protected virtual async Task<T> GetWithTracking(Guid id)
|
||||||
|
{
|
||||||
|
T? ret = await Database.Set<T>().AsTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (ret == null)
|
||||||
|
throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}");
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async Task<T> Get(Guid id, Include<T>? include = default)
|
||||||
|
{
|
||||||
|
T? ret = await GetOrDefault(id, include);
|
||||||
|
if (ret == null)
|
||||||
|
throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}");
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async Task<T> Get(string slug, Include<T>? include = default)
|
||||||
|
{
|
||||||
|
T? ret = await GetOrDefault(slug, include);
|
||||||
|
if (ret == null)
|
||||||
|
throw new ItemNotFoundException($"No {typeof(T).Name} found with the slug {slug}");
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async Task<T> Get(
|
||||||
|
Filter<T> filter,
|
||||||
|
Include<T>? include = default,
|
||||||
|
Sort<T>? sortBy = default,
|
||||||
|
bool reverse = false,
|
||||||
|
Guid? afterId = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId);
|
||||||
|
if (ret == null)
|
||||||
|
throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate.");
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual Task<T?> GetDuplicated(T item)
|
||||||
|
{
|
||||||
|
return GetOrDefault(item.Slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public virtual Task<T?> GetOrDefault(Guid id, Include<T>? include = default)
|
||||||
|
{
|
||||||
|
return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public virtual Task<T?> GetOrDefault(string slug, Include<T>? include = default)
|
||||||
|
{
|
||||||
|
if (slug == "random")
|
||||||
{
|
{
|
||||||
T? ret = await Database.Set<T>().AsTracking().FirstOrDefaultAsync(x => x.Id == id);
|
return AddIncludes(Database.Set<T>(), include)
|
||||||
if (ret == null)
|
.OrderBy(x => EF.Functions.Random())
|
||||||
throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}");
|
.FirstOrDefaultAsync();
|
||||||
return ret;
|
|
||||||
}
|
}
|
||||||
|
return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Slug == slug);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc />
|
||||||
public virtual async Task<T> Get(Guid id, Include<T>? include = default)
|
public virtual async Task<T?> GetOrDefault(
|
||||||
{
|
Filter<T>? filter,
|
||||||
T? ret = await GetOrDefault(id, include);
|
Include<T>? include = default,
|
||||||
if (ret == null)
|
Sort<T>? sortBy = default,
|
||||||
throw new ItemNotFoundException($"No {typeof(T).Name} found with the id {id}");
|
bool reverse = false,
|
||||||
return ret;
|
Guid? afterId = default
|
||||||
}
|
)
|
||||||
|
{
|
||||||
|
IQueryable<T> query = await ApplyFilters(
|
||||||
|
Database.Set<T>(),
|
||||||
|
filter,
|
||||||
|
sortBy,
|
||||||
|
new Pagination(1, afterId, reverse),
|
||||||
|
include
|
||||||
|
);
|
||||||
|
return await query.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<T> Get(string slug, Include<T>? include = default)
|
public virtual async Task<ICollection<T>> FromIds(
|
||||||
{
|
IList<Guid> ids,
|
||||||
T? ret = await GetOrDefault(slug, include);
|
Include<T>? include = default
|
||||||
if (ret == null)
|
)
|
||||||
throw new ItemNotFoundException($"No {typeof(T).Name} found with the slug {slug}");
|
{
|
||||||
return ret;
|
return (
|
||||||
}
|
await AddIncludes(Database.Set<T>(), include)
|
||||||
|
.Where(x => ids.Contains(x.Id))
|
||||||
/// <inheritdoc/>
|
.ToListAsync()
|
||||||
public virtual async Task<T> Get(
|
|
||||||
Filter<T> filter,
|
|
||||||
Include<T>? include = default,
|
|
||||||
Sort<T>? sortBy = default,
|
|
||||||
bool reverse = false,
|
|
||||||
Guid? afterId = default
|
|
||||||
)
|
)
|
||||||
{
|
.OrderBy(x => ids.IndexOf(x.Id))
|
||||||
T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId);
|
.ToList();
|
||||||
if (ret == null)
|
}
|
||||||
throw new ItemNotFoundException(
|
|
||||||
$"No {typeof(T).Name} found with the given predicate."
|
|
||||||
);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected virtual Task<T?> GetDuplicated(T item)
|
/// <inheritdoc/>
|
||||||
{
|
public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default);
|
||||||
return GetOrDefault(item.Slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc/>
|
||||||
public virtual Task<T?> GetOrDefault(Guid id, Include<T>? include = default)
|
public virtual async Task<ICollection<T>> GetAll(
|
||||||
{
|
Filter<T>? filter = null,
|
||||||
return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Id == id);
|
Sort<T>? sort = default,
|
||||||
}
|
Include<T>? include = default,
|
||||||
|
Pagination? limit = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
IQueryable<T> query = await ApplyFilters(Database.Set<T>(), filter, sort, limit, include);
|
||||||
|
return await query.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public virtual Task<T?> GetOrDefault(string slug, Include<T>? include = default)
|
/// Apply filters to a query to ease sort, pagination and where queries for resources of this repository
|
||||||
{
|
/// </summary>
|
||||||
if (slug == "random")
|
/// <param name="query">The base query to filter.</param>
|
||||||
{
|
/// <param name="filter">An expression to filter based on arbitrary conditions</param>
|
||||||
return AddIncludes(Database.Set<T>(), include)
|
/// <param name="sort">The sort settings (sort order and sort by)</param>
|
||||||
.OrderBy(x => EF.Functions.Random())
|
/// <param name="limit">Pagination information (where to start and how many to get)</param>
|
||||||
.FirstOrDefaultAsync();
|
/// <param name="include">Related fields to also load with this query.</param>
|
||||||
}
|
/// <returns>The filtered query</returns>
|
||||||
return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Slug == slug);
|
protected async Task<IQueryable<T>> ApplyFilters(
|
||||||
}
|
IQueryable<T> query,
|
||||||
|
Filter<T>? filter = null,
|
||||||
|
Sort<T>? sort = default,
|
||||||
|
Pagination? limit = default,
|
||||||
|
Include<T>? include = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
query = AddIncludes(query, include);
|
||||||
|
query = Sort(query, sort);
|
||||||
|
limit ??= new();
|
||||||
|
|
||||||
/// <inheritdoc />
|
if (limit.AfterID != null)
|
||||||
public virtual async Task<T?> GetOrDefault(
|
|
||||||
Filter<T>? filter,
|
|
||||||
Include<T>? include = default,
|
|
||||||
Sort<T>? sortBy = default,
|
|
||||||
bool reverse = false,
|
|
||||||
Guid? afterId = default
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
IQueryable<T> query = await ApplyFilters(
|
T reference = await Get(limit.AfterID.Value);
|
||||||
Database.Set<T>(),
|
Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate(
|
||||||
filter,
|
|
||||||
sortBy,
|
|
||||||
new Pagination(1, afterId, reverse),
|
|
||||||
include
|
|
||||||
);
|
|
||||||
return await query.FirstOrDefaultAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public virtual async Task<ICollection<T>> FromIds(
|
|
||||||
IList<Guid> ids,
|
|
||||||
Include<T>? include = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return (
|
|
||||||
await AddIncludes(Database.Set<T>(), include)
|
|
||||||
.Where(x => ids.Contains(x.Id))
|
|
||||||
.ToListAsync()
|
|
||||||
)
|
|
||||||
.OrderBy(x => ids.IndexOf(x.Id))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default);
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public virtual async Task<ICollection<T>> GetAll(
|
|
||||||
Filter<T>? filter = null,
|
|
||||||
Sort<T>? sort = default,
|
|
||||||
Include<T>? include = default,
|
|
||||||
Pagination? limit = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
IQueryable<T> query = await ApplyFilters(
|
|
||||||
Database.Set<T>(),
|
|
||||||
filter,
|
|
||||||
sort,
|
sort,
|
||||||
limit,
|
reference,
|
||||||
include
|
!limit.Reverse
|
||||||
);
|
);
|
||||||
return await query.ToListAsync();
|
filter = Filter.And(filter, keysetFilter);
|
||||||
}
|
}
|
||||||
|
if (filter != null)
|
||||||
|
query = query.Where(filter.ToEfLambda());
|
||||||
|
|
||||||
/// <summary>
|
if (limit.Reverse)
|
||||||
/// Apply filters to a query to ease sort, pagination and where queries for resources of this repository
|
query = query.Reverse();
|
||||||
/// </summary>
|
if (limit.Limit > 0)
|
||||||
/// <param name="query">The base query to filter.</param>
|
query = query.Take(limit.Limit);
|
||||||
/// <param name="filter">An expression to filter based on arbitrary conditions</param>
|
if (limit.Reverse)
|
||||||
/// <param name="sort">The sort settings (sort order and sort by)</param>
|
query = query.Reverse();
|
||||||
/// <param name="limit">Pagination information (where to start and how many to get)</param>
|
|
||||||
/// <param name="include">Related fields to also load with this query.</param>
|
|
||||||
/// <returns>The filtered query</returns>
|
|
||||||
protected async Task<IQueryable<T>> ApplyFilters(
|
|
||||||
IQueryable<T> query,
|
|
||||||
Filter<T>? filter = null,
|
|
||||||
Sort<T>? sort = default,
|
|
||||||
Pagination? limit = default,
|
|
||||||
Include<T>? include = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
query = AddIncludes(query, include);
|
|
||||||
query = Sort(query, sort);
|
|
||||||
limit ??= new();
|
|
||||||
|
|
||||||
if (limit.AfterID != null)
|
return query;
|
||||||
{
|
}
|
||||||
T reference = await Get(limit.AfterID.Value);
|
|
||||||
Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate(
|
|
||||||
sort,
|
|
||||||
reference,
|
|
||||||
!limit.Reverse
|
|
||||||
);
|
|
||||||
filter = Filter.And(filter, keysetFilter);
|
|
||||||
}
|
|
||||||
if (filter != null)
|
|
||||||
query = query.Where(filter.ToEfLambda());
|
|
||||||
|
|
||||||
if (limit.Reverse)
|
/// <inheritdoc/>
|
||||||
query = query.Reverse();
|
public virtual Task<int> GetCount(Filter<T>? filter = null)
|
||||||
if (limit.Limit > 0)
|
{
|
||||||
query = query.Take(limit.Limit);
|
IQueryable<T> query = Database.Set<T>();
|
||||||
if (limit.Reverse)
|
if (filter != null)
|
||||||
query = query.Reverse();
|
query = query.Where(filter.ToEfLambda());
|
||||||
|
return query.CountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
return query;
|
/// <inheritdoc/>
|
||||||
}
|
public virtual async Task<T> Create(T obj)
|
||||||
|
{
|
||||||
/// <inheritdoc/>
|
await Validate(obj);
|
||||||
public virtual Task<int> GetCount(Filter<T>? filter = null)
|
if (obj is IThumbnails thumbs)
|
||||||
{
|
|
||||||
IQueryable<T> query = Database.Set<T>();
|
|
||||||
if (filter != null)
|
|
||||||
query = query.Where(filter.ToEfLambda());
|
|
||||||
return query.CountAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public virtual async Task<T> Create(T obj)
|
|
||||||
{
|
|
||||||
await Validate(obj);
|
|
||||||
if (obj is IThumbnails thumbs)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _thumbs.DownloadImages(thumbs);
|
|
||||||
}
|
|
||||||
catch (DuplicatedItemException e) when (e.Existing is null)
|
|
||||||
{
|
|
||||||
throw new DuplicatedItemException(await GetDuplicated(obj));
|
|
||||||
}
|
|
||||||
if (thumbs.Poster != null)
|
|
||||||
Database.Entry(thumbs).Reference(x => x.Poster).TargetEntry!.State =
|
|
||||||
EntityState.Added;
|
|
||||||
if (thumbs.Thumbnail != null)
|
|
||||||
Database.Entry(thumbs).Reference(x => x.Thumbnail).TargetEntry!.State =
|
|
||||||
EntityState.Added;
|
|
||||||
if (thumbs.Logo != null)
|
|
||||||
Database.Entry(thumbs).Reference(x => x.Logo).TargetEntry!.State =
|
|
||||||
EntityState.Added;
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public virtual async Task<T> CreateIfNotExists(T obj)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
T? old = await GetOrDefault(obj.Slug);
|
await _thumbs.DownloadImages(thumbs);
|
||||||
if (old != null)
|
|
||||||
return old;
|
|
||||||
|
|
||||||
return await Create(obj);
|
|
||||||
}
|
}
|
||||||
catch (DuplicatedItemException)
|
catch (DuplicatedItemException e) when (e.Existing is null)
|
||||||
{
|
{
|
||||||
return await Get(obj.Slug);
|
throw new DuplicatedItemException(await GetDuplicated(obj));
|
||||||
}
|
}
|
||||||
|
if (thumbs.Poster != null)
|
||||||
|
Database.Entry(thumbs).Reference(x => x.Poster).TargetEntry!.State =
|
||||||
|
EntityState.Added;
|
||||||
|
if (thumbs.Thumbnail != null)
|
||||||
|
Database.Entry(thumbs).Reference(x => x.Thumbnail).TargetEntry!.State =
|
||||||
|
EntityState.Added;
|
||||||
|
if (thumbs.Logo != null)
|
||||||
|
Database.Entry(thumbs).Reference(x => x.Logo).TargetEntry!.State =
|
||||||
|
EntityState.Added;
|
||||||
}
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task<T> Edit(T edited)
|
public virtual async Task<T> CreateIfNotExists(T obj)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled;
|
T? old = await GetOrDefault(obj.Slug);
|
||||||
Database.ChangeTracker.LazyLoadingEnabled = false;
|
if (old != null)
|
||||||
try
|
|
||||||
{
|
|
||||||
T old = await GetWithTracking(edited.Id);
|
|
||||||
|
|
||||||
Merger.Complete(
|
|
||||||
old,
|
|
||||||
edited,
|
|
||||||
x => x.GetCustomAttribute<LoadableRelationAttribute>() == null
|
|
||||||
);
|
|
||||||
await EditRelations(old, edited);
|
|
||||||
await Database.SaveChangesAsync();
|
|
||||||
await IRepository<T>.OnResourceEdited(old);
|
|
||||||
return old;
|
return old;
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
|
|
||||||
Database.ChangeTracker.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
return await Create(obj);
|
||||||
public virtual async Task<T> Patch(Guid id, Func<T, T> patch)
|
}
|
||||||
|
catch (DuplicatedItemException)
|
||||||
|
{
|
||||||
|
return await Get(obj.Slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async Task<T> Edit(T edited)
|
||||||
|
{
|
||||||
|
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled;
|
||||||
|
Database.ChangeTracker.LazyLoadingEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
T old = await GetWithTracking(edited.Id);
|
||||||
|
|
||||||
|
Merger.Complete(
|
||||||
|
old,
|
||||||
|
edited,
|
||||||
|
x => x.GetCustomAttribute<LoadableRelationAttribute>() == null
|
||||||
|
);
|
||||||
|
await EditRelations(old, edited);
|
||||||
|
await Database.SaveChangesAsync();
|
||||||
|
await IRepository<T>.OnResourceEdited(old);
|
||||||
|
return old;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
|
||||||
|
Database.ChangeTracker.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async Task<T> Patch(Guid id, Func<T, T> patch)
|
||||||
|
{
|
||||||
|
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled;
|
||||||
|
Database.ChangeTracker.LazyLoadingEnabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
T resource = await GetWithTracking(id);
|
||||||
|
|
||||||
|
resource = patch(resource);
|
||||||
|
|
||||||
|
await Database.SaveChangesAsync();
|
||||||
|
await IRepository<T>.OnResourceEdited(resource);
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
|
||||||
|
Database.ChangeTracker.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An overridable method to edit relation of a resource.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resource">
|
||||||
|
/// The non edited resource
|
||||||
|
/// </param>
|
||||||
|
/// <param name="changed">
|
||||||
|
/// The new version of <paramref name="resource"/>.
|
||||||
|
/// This item will be saved on the database and replace <paramref name="resource"/>
|
||||||
|
/// </param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
protected virtual Task EditRelations(T resource, T changed)
|
||||||
|
{
|
||||||
|
if (resource is IThumbnails thumbs && changed is IThumbnails chng)
|
||||||
|
{
|
||||||
|
Database.Entry(thumbs).Reference(x => x.Poster).IsModified =
|
||||||
|
thumbs.Poster != chng.Poster;
|
||||||
|
Database.Entry(thumbs).Reference(x => x.Thumbnail).IsModified =
|
||||||
|
thumbs.Thumbnail != chng.Thumbnail;
|
||||||
|
Database.Entry(thumbs).Reference(x => x.Logo).IsModified = thumbs.Logo != chng.Logo;
|
||||||
|
}
|
||||||
|
return Validate(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A method called just before saving a new resource to the database.
|
||||||
|
/// It is also called on the default implementation of <see cref="EditRelations"/>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resource">The resource that will be saved</param>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// You can throw this if the resource is illegal and should not be saved.
|
||||||
|
/// </exception>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
protected virtual Task Validate(T resource)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
typeof(T).GetProperty(nameof(resource.Slug))!.GetCustomAttribute<ComputedAttribute>()
|
||||||
|
!= null
|
||||||
|
)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
if (string.IsNullOrEmpty(resource.Slug))
|
||||||
|
throw new ArgumentException("Resource can't have null as a slug.");
|
||||||
|
if (int.TryParse(resource.Slug, out int _) || resource.Slug == "random")
|
||||||
{
|
{
|
||||||
bool lazyLoading = Database.ChangeTracker.LazyLoadingEnabled;
|
|
||||||
Database.ChangeTracker.LazyLoadingEnabled = false;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
T resource = await GetWithTracking(id);
|
MethodInfo? setter = typeof(T).GetProperty(nameof(resource.Slug))!.GetSetMethod();
|
||||||
|
if (setter != null)
|
||||||
resource = patch(resource);
|
setter.Invoke(resource, new object[] { resource.Slug + '!' });
|
||||||
|
else
|
||||||
await Database.SaveChangesAsync();
|
|
||||||
await IRepository<T>.OnResourceEdited(resource);
|
|
||||||
return resource;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
|
|
||||||
Database.ChangeTracker.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An overridable method to edit relation of a resource.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="resource">
|
|
||||||
/// The non edited resource
|
|
||||||
/// </param>
|
|
||||||
/// <param name="changed">
|
|
||||||
/// The new version of <paramref name="resource"/>.
|
|
||||||
/// This item will be saved on the database and replace <paramref name="resource"/>
|
|
||||||
/// </param>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
protected virtual Task EditRelations(T resource, T changed)
|
|
||||||
{
|
|
||||||
if (resource is IThumbnails thumbs && changed is IThumbnails chng)
|
|
||||||
{
|
|
||||||
Database.Entry(thumbs).Reference(x => x.Poster).IsModified =
|
|
||||||
thumbs.Poster != chng.Poster;
|
|
||||||
Database.Entry(thumbs).Reference(x => x.Thumbnail).IsModified =
|
|
||||||
thumbs.Thumbnail != chng.Thumbnail;
|
|
||||||
Database.Entry(thumbs).Reference(x => x.Logo).IsModified = thumbs.Logo != chng.Logo;
|
|
||||||
}
|
|
||||||
return Validate(resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A method called just before saving a new resource to the database.
|
|
||||||
/// It is also called on the default implementation of <see cref="EditRelations"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="resource">The resource that will be saved</param>
|
|
||||||
/// <exception cref="ArgumentException">
|
|
||||||
/// You can throw this if the resource is illegal and should not be saved.
|
|
||||||
/// </exception>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
protected virtual Task Validate(T resource)
|
|
||||||
{
|
|
||||||
if (
|
|
||||||
typeof(T)
|
|
||||||
.GetProperty(nameof(resource.Slug))!
|
|
||||||
.GetCustomAttribute<ComputedAttribute>() != null
|
|
||||||
)
|
|
||||||
return Task.CompletedTask;
|
|
||||||
if (string.IsNullOrEmpty(resource.Slug))
|
|
||||||
throw new ArgumentException("Resource can't have null as a slug.");
|
|
||||||
if (int.TryParse(resource.Slug, out int _) || resource.Slug == "random")
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
MethodInfo? setter = typeof(T)
|
|
||||||
.GetProperty(nameof(resource.Slug))!
|
|
||||||
.GetSetMethod();
|
|
||||||
if (setter != null)
|
|
||||||
setter.Invoke(resource, new object[] { resource.Slug + '!' });
|
|
||||||
else
|
|
||||||
throw new ArgumentException(
|
|
||||||
"Resources slug can't be number only or the literal \"random\"."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
throw new ArgumentException(
|
||||||
"Resources slug can't be number only or the literal \"random\"."
|
"Resources slug can't be number only or the literal \"random\"."
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
catch
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Resources slug can't be number only or the literal \"random\"."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public virtual async Task Delete(Guid id)
|
public virtual async Task Delete(Guid id)
|
||||||
{
|
{
|
||||||
T resource = await Get(id);
|
T resource = await Get(id);
|
||||||
|
await Delete(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual async Task Delete(string slug)
|
||||||
|
{
|
||||||
|
T resource = await Get(slug);
|
||||||
|
await Delete(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public virtual Task Delete(T obj)
|
||||||
|
{
|
||||||
|
IRepository<T>.OnResourceDeleted(obj);
|
||||||
|
if (obj is IThumbnails thumbs)
|
||||||
|
return _thumbs.DeleteImages(thumbs);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task DeleteAll(Filter<T> filter)
|
||||||
|
{
|
||||||
|
foreach (T resource in await GetAll(filter))
|
||||||
await Delete(resource);
|
await Delete(resource);
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public virtual async Task Delete(string slug)
|
|
||||||
{
|
|
||||||
T resource = await Get(slug);
|
|
||||||
await Delete(resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public virtual Task Delete(T obj)
|
|
||||||
{
|
|
||||||
IRepository<T>.OnResourceDeleted(obj);
|
|
||||||
if (obj is IThumbnails thumbs)
|
|
||||||
return _thumbs.DeleteImages(thumbs);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public async Task DeleteAll(Filter<T> filter)
|
|
||||||
{
|
|
||||||
foreach (T resource in await GetAll(filter))
|
|
||||||
await Delete(resource);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,85 +25,84 @@ using Kyoo.Abstractions.Models.Utils;
|
|||||||
using Kyoo.Postgresql;
|
using Kyoo.Postgresql;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local repository to handle shows
|
||||||
|
/// </summary>
|
||||||
|
public class MovieRepository : LocalRepository<Movie>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A local repository to handle shows
|
/// The database handle
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MovieRepository : LocalRepository<Movie>
|
private readonly DatabaseContext _database;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A studio repository to handle creation/validation of related studios.
|
||||||
|
/// </summary>
|
||||||
|
private readonly IRepository<Studio> _studios;
|
||||||
|
|
||||||
|
public MovieRepository(
|
||||||
|
DatabaseContext database,
|
||||||
|
IRepository<Studio> studios,
|
||||||
|
IThumbnailsManager thumbs
|
||||||
|
)
|
||||||
|
: base(database, thumbs)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_database = database;
|
||||||
/// The database handle
|
_studios = studios;
|
||||||
/// </summary>
|
}
|
||||||
private readonly DatabaseContext _database;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// A studio repository to handle creation/validation of related studios.
|
public override async Task<ICollection<Movie>> Search(
|
||||||
/// </summary>
|
string query,
|
||||||
private readonly IRepository<Studio> _studios;
|
Include<Movie>? include = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return await AddIncludes(_database.Movies, include)
|
||||||
|
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
|
||||||
|
.Take(20)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public MovieRepository(
|
/// <inheritdoc />
|
||||||
DatabaseContext database,
|
public override async Task<Movie> Create(Movie obj)
|
||||||
IRepository<Studio> studios,
|
{
|
||||||
IThumbnailsManager thumbs
|
await base.Create(obj);
|
||||||
)
|
_database.Entry(obj).State = EntityState.Added;
|
||||||
: base(database, thumbs)
|
await _database.SaveChangesAsync(() => Get(obj.Slug));
|
||||||
|
await IRepository<Movie>.OnResourceCreated(obj);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task Validate(Movie resource)
|
||||||
|
{
|
||||||
|
await base.Validate(resource);
|
||||||
|
if (resource.Studio != null)
|
||||||
{
|
{
|
||||||
_database = database;
|
resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
|
||||||
_studios = studios;
|
resource.StudioId = resource.Studio.Id;
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task<ICollection<Movie>> Search(
|
|
||||||
string query,
|
|
||||||
Include<Movie>? include = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return await AddIncludes(_database.Movies, include)
|
|
||||||
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
|
|
||||||
.Take(20)
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task<Movie> Create(Movie obj)
|
|
||||||
{
|
|
||||||
await base.Create(obj);
|
|
||||||
_database.Entry(obj).State = EntityState.Added;
|
|
||||||
await _database.SaveChangesAsync(() => Get(obj.Slug));
|
|
||||||
await IRepository<Movie>.OnResourceCreated(obj);
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override async Task Validate(Movie resource)
|
|
||||||
{
|
|
||||||
await base.Validate(resource);
|
|
||||||
if (resource.Studio != null)
|
|
||||||
{
|
|
||||||
resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
|
|
||||||
resource.StudioId = resource.Studio.Id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override async Task EditRelations(Movie resource, Movie changed)
|
|
||||||
{
|
|
||||||
await Validate(changed);
|
|
||||||
|
|
||||||
if (changed.Studio != null || changed.StudioId == null)
|
|
||||||
{
|
|
||||||
await Database.Entry(resource).Reference(x => x.Studio).LoadAsync();
|
|
||||||
resource.Studio = changed.Studio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task Delete(Movie obj)
|
|
||||||
{
|
|
||||||
_database.Remove(obj);
|
|
||||||
await _database.SaveChangesAsync();
|
|
||||||
await base.Delete(obj);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task EditRelations(Movie resource, Movie changed)
|
||||||
|
{
|
||||||
|
await Validate(changed);
|
||||||
|
|
||||||
|
if (changed.Studio != null || changed.StudioId == null)
|
||||||
|
{
|
||||||
|
await Database.Entry(resource).Reference(x => x.Studio).LoadAsync();
|
||||||
|
resource.Studio = changed.Studio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task Delete(Movie obj)
|
||||||
|
{
|
||||||
|
_database.Remove(obj);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
await base.Delete(obj);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,43 +22,42 @@ using System.Data.Common;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using Kyoo.Abstractions.Models;
|
using Kyoo.Abstractions.Models;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local repository to handle shows
|
||||||
|
/// </summary>
|
||||||
|
public class NewsRepository : DapperRepository<INews>
|
||||||
{
|
{
|
||||||
/// <summary>
|
// language=PostgreSQL
|
||||||
/// A local repository to handle shows
|
protected override FormattableString Sql =>
|
||||||
/// </summary>
|
$"""
|
||||||
public class NewsRepository : DapperRepository<INews>
|
select
|
||||||
{
|
e.*, -- Episode as e
|
||||||
// language=PostgreSQL
|
m.*
|
||||||
protected override FormattableString Sql =>
|
/* includes */
|
||||||
$"""
|
from
|
||||||
|
episodes as e
|
||||||
|
full outer join (
|
||||||
select
|
select
|
||||||
e.*, -- Episode as e
|
* -- Movie
|
||||||
m.*
|
|
||||||
/* includes */
|
|
||||||
from
|
from
|
||||||
episodes as e
|
movies
|
||||||
full outer join (
|
) as m on false
|
||||||
select
|
""";
|
||||||
* -- Movie
|
|
||||||
from
|
|
||||||
movies
|
|
||||||
) as m on false
|
|
||||||
""";
|
|
||||||
|
|
||||||
protected override Dictionary<string, Type> Config =>
|
protected override Dictionary<string, Type> Config =>
|
||||||
new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, };
|
new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, };
|
||||||
|
|
||||||
protected override INews Mapper(List<object?> items)
|
protected override INews Mapper(List<object?> items)
|
||||||
{
|
{
|
||||||
if (items[0] is Episode episode && episode.Id != Guid.Empty)
|
if (items[0] is Episode episode && episode.Id != Guid.Empty)
|
||||||
return episode;
|
return episode;
|
||||||
if (items[1] is Movie movie && movie.Id != Guid.Empty)
|
if (items[1] is Movie movie && movie.Id != Guid.Empty)
|
||||||
return movie;
|
return movie;
|
||||||
throw new InvalidDataException();
|
throw new InvalidDataException();
|
||||||
}
|
|
||||||
|
|
||||||
public NewsRepository(DbConnection database, SqlVariableContext context)
|
|
||||||
: base(database, context) { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public NewsRepository(DbConnection database, SqlVariableContext context)
|
||||||
|
: base(database, context) { }
|
||||||
}
|
}
|
||||||
|
@ -29,105 +29,103 @@ using Kyoo.Postgresql;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local repository to handle seasons.
|
||||||
|
/// </summary>
|
||||||
|
public class SeasonRepository : LocalRepository<Season>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A local repository to handle seasons.
|
/// The database handle
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SeasonRepository : LocalRepository<Season>
|
private readonly DatabaseContext _database;
|
||||||
|
|
||||||
|
static SeasonRepository()
|
||||||
{
|
{
|
||||||
/// <summary>
|
// Edit seasons slugs when the show's slug changes.
|
||||||
/// The database handle
|
IRepository<Show>.OnEdited += async (show) =>
|
||||||
/// </summary>
|
|
||||||
private readonly DatabaseContext _database;
|
|
||||||
|
|
||||||
static SeasonRepository()
|
|
||||||
{
|
{
|
||||||
// Edit seasons slugs when the show's slug changes.
|
await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope();
|
||||||
IRepository<Show>.OnEdited += async (show) =>
|
DatabaseContext database = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||||
{
|
List<Season> seasons = await database
|
||||||
await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope();
|
.Seasons.AsTracking()
|
||||||
DatabaseContext database =
|
.Where(x => x.ShowId == show.Id)
|
||||||
scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
|
||||||
List<Season> seasons = await database
|
|
||||||
.Seasons.AsTracking()
|
|
||||||
.Where(x => x.ShowId == show.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
foreach (Season season in seasons)
|
|
||||||
{
|
|
||||||
season.ShowSlug = show.Slug;
|
|
||||||
await database.SaveChangesAsync();
|
|
||||||
await IRepository<Season>.OnResourceEdited(season);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="SeasonRepository"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="database">The database handle that will be used</param>
|
|
||||||
/// <param name="thumbs">The thumbnail manager used to store images.</param>
|
|
||||||
public SeasonRepository(DatabaseContext database, IThumbnailsManager thumbs)
|
|
||||||
: base(database, thumbs)
|
|
||||||
{
|
|
||||||
_database = database;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override Task<Season?> GetDuplicated(Season item)
|
|
||||||
{
|
|
||||||
return _database.Seasons.FirstOrDefaultAsync(x =>
|
|
||||||
x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public override async Task<ICollection<Season>> Search(
|
|
||||||
string query,
|
|
||||||
Include<Season>? include = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return await AddIncludes(_database.Seasons, include)
|
|
||||||
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
|
|
||||||
.Take(20)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
foreach (Season season in seasons)
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public override async Task<Season> Create(Season obj)
|
|
||||||
{
|
|
||||||
await base.Create(obj);
|
|
||||||
obj.ShowSlug =
|
|
||||||
(await _database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug
|
|
||||||
?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}");
|
|
||||||
_database.Entry(obj).State = EntityState.Added;
|
|
||||||
await _database.SaveChangesAsync(() => GetDuplicated(obj));
|
|
||||||
await IRepository<Season>.OnResourceCreated(obj);
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
protected override async Task Validate(Season resource)
|
|
||||||
{
|
|
||||||
await base.Validate(resource);
|
|
||||||
if (resource.ShowId == Guid.Empty)
|
|
||||||
{
|
{
|
||||||
if (resource.Show == null)
|
season.ShowSlug = show.Slug;
|
||||||
{
|
await database.SaveChangesAsync();
|
||||||
throw new ValidationException(
|
await IRepository<Season>.OnResourceEdited(season);
|
||||||
$"Can't store a season not related to any show "
|
|
||||||
+ $"(showID: {resource.ShowId})."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
resource.ShowId = resource.Show.Id;
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <summary>
|
||||||
public override async Task Delete(Season obj)
|
/// Create a new <see cref="SeasonRepository"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="database">The database handle that will be used</param>
|
||||||
|
/// <param name="thumbs">The thumbnail manager used to store images.</param>
|
||||||
|
public SeasonRepository(DatabaseContext database, IThumbnailsManager thumbs)
|
||||||
|
: base(database, thumbs)
|
||||||
|
{
|
||||||
|
_database = database;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<Season?> GetDuplicated(Season item)
|
||||||
|
{
|
||||||
|
return _database.Seasons.FirstOrDefaultAsync(x =>
|
||||||
|
x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async Task<ICollection<Season>> Search(
|
||||||
|
string query,
|
||||||
|
Include<Season>? include = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return await AddIncludes(_database.Seasons, include)
|
||||||
|
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
|
||||||
|
.Take(20)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async Task<Season> Create(Season obj)
|
||||||
|
{
|
||||||
|
await base.Create(obj);
|
||||||
|
obj.ShowSlug =
|
||||||
|
(await _database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug
|
||||||
|
?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}");
|
||||||
|
_database.Entry(obj).State = EntityState.Added;
|
||||||
|
await _database.SaveChangesAsync(() => GetDuplicated(obj));
|
||||||
|
await IRepository<Season>.OnResourceCreated(obj);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
protected override async Task Validate(Season resource)
|
||||||
|
{
|
||||||
|
await base.Validate(resource);
|
||||||
|
if (resource.ShowId == Guid.Empty)
|
||||||
{
|
{
|
||||||
_database.Remove(obj);
|
if (resource.Show == null)
|
||||||
await _database.SaveChangesAsync();
|
{
|
||||||
await base.Delete(obj);
|
throw new ValidationException(
|
||||||
|
$"Can't store a season not related to any show "
|
||||||
|
+ $"(showID: {resource.ShowId})."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resource.ShowId = resource.Show.Id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override async Task Delete(Season obj)
|
||||||
|
{
|
||||||
|
_database.Remove(obj);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
await base.Delete(obj);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,85 +26,84 @@ using Kyoo.Postgresql;
|
|||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local repository to handle shows
|
||||||
|
/// </summary>
|
||||||
|
public class ShowRepository : LocalRepository<Show>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A local repository to handle shows
|
/// The database handle
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ShowRepository : LocalRepository<Show>
|
private readonly DatabaseContext _database;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A studio repository to handle creation/validation of related studios.
|
||||||
|
/// </summary>
|
||||||
|
private readonly IRepository<Studio> _studios;
|
||||||
|
|
||||||
|
public ShowRepository(
|
||||||
|
DatabaseContext database,
|
||||||
|
IRepository<Studio> studios,
|
||||||
|
IThumbnailsManager thumbs
|
||||||
|
)
|
||||||
|
: base(database, thumbs)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_database = database;
|
||||||
/// The database handle
|
_studios = studios;
|
||||||
/// </summary>
|
}
|
||||||
private readonly DatabaseContext _database;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// A studio repository to handle creation/validation of related studios.
|
public override async Task<ICollection<Show>> Search(
|
||||||
/// </summary>
|
string query,
|
||||||
private readonly IRepository<Studio> _studios;
|
Include<Show>? include = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return await AddIncludes(_database.Shows, include)
|
||||||
|
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
|
||||||
|
.Take(20)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public ShowRepository(
|
/// <inheritdoc />
|
||||||
DatabaseContext database,
|
public override async Task<Show> Create(Show obj)
|
||||||
IRepository<Studio> studios,
|
{
|
||||||
IThumbnailsManager thumbs
|
await base.Create(obj);
|
||||||
)
|
_database.Entry(obj).State = EntityState.Added;
|
||||||
: base(database, thumbs)
|
await _database.SaveChangesAsync(() => Get(obj.Slug));
|
||||||
|
await IRepository<Show>.OnResourceCreated(obj);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task Validate(Show resource)
|
||||||
|
{
|
||||||
|
await base.Validate(resource);
|
||||||
|
if (resource.Studio != null)
|
||||||
{
|
{
|
||||||
_database = database;
|
resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
|
||||||
_studios = studios;
|
resource.StudioId = resource.Studio.Id;
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task<ICollection<Show>> Search(
|
|
||||||
string query,
|
|
||||||
Include<Show>? include = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return await AddIncludes(_database.Shows, include)
|
|
||||||
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))
|
|
||||||
.Take(20)
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task<Show> Create(Show obj)
|
|
||||||
{
|
|
||||||
await base.Create(obj);
|
|
||||||
_database.Entry(obj).State = EntityState.Added;
|
|
||||||
await _database.SaveChangesAsync(() => Get(obj.Slug));
|
|
||||||
await IRepository<Show>.OnResourceCreated(obj);
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override async Task Validate(Show resource)
|
|
||||||
{
|
|
||||||
await base.Validate(resource);
|
|
||||||
if (resource.Studio != null)
|
|
||||||
{
|
|
||||||
resource.Studio = await _studios.CreateIfNotExists(resource.Studio);
|
|
||||||
resource.StudioId = resource.Studio.Id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override async Task EditRelations(Show resource, Show changed)
|
|
||||||
{
|
|
||||||
await Validate(changed);
|
|
||||||
|
|
||||||
if (changed.Studio != null || changed.StudioId == null)
|
|
||||||
{
|
|
||||||
await Database.Entry(resource).Reference(x => x.Studio).LoadAsync();
|
|
||||||
resource.Studio = changed.Studio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task Delete(Show obj)
|
|
||||||
{
|
|
||||||
_database.Remove(obj);
|
|
||||||
await _database.SaveChangesAsync();
|
|
||||||
await base.Delete(obj);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task EditRelations(Show resource, Show changed)
|
||||||
|
{
|
||||||
|
await Validate(changed);
|
||||||
|
|
||||||
|
if (changed.Studio != null || changed.StudioId == null)
|
||||||
|
{
|
||||||
|
await Database.Entry(resource).Reference(x => x.Studio).LoadAsync();
|
||||||
|
resource.Studio = changed.Studio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task Delete(Show obj)
|
||||||
|
{
|
||||||
|
_database.Remove(obj);
|
||||||
|
await _database.SaveChangesAsync();
|
||||||
|
await base.Delete(obj);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,57 +26,56 @@ using Kyoo.Postgresql;
|
|||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A local repository to handle studios
|
||||||
|
/// </summary>
|
||||||
|
public class StudioRepository : LocalRepository<Studio>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A local repository to handle studios
|
/// The database handle
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StudioRepository : LocalRepository<Studio>
|
private readonly DatabaseContext _database;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="StudioRepository"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="database">The database handle</param>
|
||||||
|
/// <param name="thumbs">The thumbnail manager used to store images.</param>
|
||||||
|
public StudioRepository(DatabaseContext database, IThumbnailsManager thumbs)
|
||||||
|
: base(database, thumbs)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_database = database;
|
||||||
/// The database handle
|
}
|
||||||
/// </summary>
|
|
||||||
private readonly DatabaseContext _database;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Create a new <see cref="StudioRepository"/>.
|
public override async Task<ICollection<Studio>> Search(
|
||||||
/// </summary>
|
string query,
|
||||||
/// <param name="database">The database handle</param>
|
Include<Studio>? include = default
|
||||||
/// <param name="thumbs">The thumbnail manager used to store images.</param>
|
)
|
||||||
public StudioRepository(DatabaseContext database, IThumbnailsManager thumbs)
|
{
|
||||||
: base(database, thumbs)
|
return await AddIncludes(_database.Studios, include)
|
||||||
{
|
.Where(x => EF.Functions.ILike(x.Name, $"%{query}%"))
|
||||||
_database = database;
|
.Take(20)
|
||||||
}
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override async Task<ICollection<Studio>> Search(
|
public override async Task<Studio> Create(Studio obj)
|
||||||
string query,
|
{
|
||||||
Include<Studio>? include = default
|
await base.Create(obj);
|
||||||
)
|
_database.Entry(obj).State = EntityState.Added;
|
||||||
{
|
await _database.SaveChangesAsync(() => Get(obj.Slug));
|
||||||
return await AddIncludes(_database.Studios, include)
|
await IRepository<Studio>.OnResourceCreated(obj);
|
||||||
.Where(x => EF.Functions.ILike(x.Name, $"%{query}%"))
|
return obj;
|
||||||
.Take(20)
|
}
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override async Task<Studio> Create(Studio obj)
|
public override async Task Delete(Studio obj)
|
||||||
{
|
{
|
||||||
await base.Create(obj);
|
_database.Entry(obj).State = EntityState.Deleted;
|
||||||
_database.Entry(obj).State = EntityState.Added;
|
await _database.SaveChangesAsync();
|
||||||
await _database.SaveChangesAsync(() => Get(obj.Slug));
|
await base.Delete(obj);
|
||||||
await IRepository<Studio>.OnResourceCreated(obj);
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override async Task Delete(Studio obj)
|
|
||||||
{
|
|
||||||
_database.Entry(obj).State = EntityState.Deleted;
|
|
||||||
await _database.SaveChangesAsync();
|
|
||||||
await base.Delete(obj);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,238 +31,236 @@ using Kyoo.Abstractions.Models.Exceptions;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SkiaSharp;
|
using SkiaSharp;
|
||||||
|
|
||||||
namespace Kyoo.Core.Controllers
|
namespace Kyoo.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download images and retrieve the path of those images for a resource.
|
||||||
|
/// </summary>
|
||||||
|
public class ThumbnailsManager(
|
||||||
|
IHttpClientFactory clientFactory,
|
||||||
|
ILogger<ThumbnailsManager> logger,
|
||||||
|
Lazy<IRepository<User>> users
|
||||||
|
) : IThumbnailsManager
|
||||||
{
|
{
|
||||||
/// <summary>
|
private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading = new();
|
||||||
/// Download images and retrieve the path of those images for a resource.
|
|
||||||
/// </summary>
|
private static async Task _WriteTo(SKBitmap bitmap, string path, int quality)
|
||||||
public class ThumbnailsManager(
|
|
||||||
IHttpClientFactory clientFactory,
|
|
||||||
ILogger<ThumbnailsManager> logger,
|
|
||||||
Lazy<IRepository<User>> users
|
|
||||||
) : IThumbnailsManager
|
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading =
|
SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality);
|
||||||
new();
|
await using Stream reader = data.AsStream();
|
||||||
|
await using Stream file = File.Create(path);
|
||||||
|
await reader.CopyToAsync(file);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task _WriteTo(SKBitmap bitmap, string path, int quality)
|
private async Task _DownloadImage(Image? image, string localPath, string what)
|
||||||
|
{
|
||||||
|
if (image == null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
SKData data = bitmap.Encode(SKEncodedImageFormat.Webp, quality);
|
logger.LogInformation("Downloading image {What}", what);
|
||||||
await using Stream reader = data.AsStream();
|
|
||||||
await using Stream file = File.Create(path);
|
|
||||||
await reader.CopyToAsync(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task _DownloadImage(Image? image, string localPath, string what)
|
HttpClient client = clientFactory.CreateClient();
|
||||||
{
|
HttpResponseMessage response = await client.GetAsync(image.Source);
|
||||||
if (image == null)
|
response.EnsureSuccessStatusCode();
|
||||||
return;
|
await using Stream reader = await response.Content.ReadAsStreamAsync();
|
||||||
try
|
using SKCodec codec = SKCodec.Create(reader);
|
||||||
|
if (codec == null)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Downloading image {What}", what);
|
logger.LogError("Unsupported codec for {What}", what);
|
||||||
|
|
||||||
HttpClient client = clientFactory.CreateClient();
|
|
||||||
HttpResponseMessage response = await client.GetAsync(image.Source);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
await using Stream reader = await response.Content.ReadAsStreamAsync();
|
|
||||||
using SKCodec codec = SKCodec.Create(reader);
|
|
||||||
if (codec == null)
|
|
||||||
{
|
|
||||||
logger.LogError("Unsupported codec for {What}", what);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SKImageInfo info = codec.Info;
|
|
||||||
info.ColorType = SKColorType.Rgba8888;
|
|
||||||
using SKBitmap original = SKBitmap.Decode(codec, info);
|
|
||||||
|
|
||||||
using SKBitmap high = original.Resize(
|
|
||||||
new SKSizeI(original.Width, original.Height),
|
|
||||||
SKFilterQuality.High
|
|
||||||
);
|
|
||||||
await _WriteTo(
|
|
||||||
original,
|
|
||||||
$"{localPath}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp",
|
|
||||||
90
|
|
||||||
);
|
|
||||||
|
|
||||||
using SKBitmap medium = high.Resize(
|
|
||||||
new SKSizeI((int)(high.Width / 1.5), (int)(high.Height / 1.5)),
|
|
||||||
SKFilterQuality.Medium
|
|
||||||
);
|
|
||||||
await _WriteTo(
|
|
||||||
medium,
|
|
||||||
$"{localPath}.{ImageQuality.Medium.ToString().ToLowerInvariant()}.webp",
|
|
||||||
75
|
|
||||||
);
|
|
||||||
|
|
||||||
using SKBitmap low = medium.Resize(
|
|
||||||
new SKSizeI(original.Width / 2, original.Height / 2),
|
|
||||||
SKFilterQuality.Low
|
|
||||||
);
|
|
||||||
await _WriteTo(
|
|
||||||
low,
|
|
||||||
$"{localPath}.{ImageQuality.Low.ToString().ToLowerInvariant()}.webp",
|
|
||||||
50
|
|
||||||
);
|
|
||||||
|
|
||||||
image.Blurhash = Blurhasher.Encode(low, 4, 3);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "{What} could not be downloaded", what);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task DownloadImages<T>(T item)
|
|
||||||
where T : IThumbnails
|
|
||||||
{
|
|
||||||
string name = item is IResource res ? res.Slug : "???";
|
|
||||||
|
|
||||||
string posterPath =
|
|
||||||
$"{_GetBaseImagePath(item, "poster")}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp";
|
|
||||||
bool duplicated = false;
|
|
||||||
TaskCompletionSource<object>? sync = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
lock (_downloading)
|
|
||||||
{
|
|
||||||
if (_downloading.ContainsKey(posterPath))
|
|
||||||
{
|
|
||||||
duplicated = true;
|
|
||||||
sync = _downloading.GetValueOrDefault(posterPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sync = new();
|
|
||||||
_downloading.Add(posterPath, sync);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (duplicated)
|
|
||||||
{
|
|
||||||
object? dup = sync != null ? await sync.Task : null;
|
|
||||||
if (dup != null)
|
|
||||||
throw new DuplicatedItemException(dup);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _DownloadImage(
|
|
||||||
item.Poster,
|
|
||||||
_GetBaseImagePath(item, "poster"),
|
|
||||||
$"The poster of {name}"
|
|
||||||
);
|
|
||||||
await _DownloadImage(
|
|
||||||
item.Thumbnail,
|
|
||||||
_GetBaseImagePath(item, "thumbnail"),
|
|
||||||
$"The poster of {name}"
|
|
||||||
);
|
|
||||||
await _DownloadImage(
|
|
||||||
item.Logo,
|
|
||||||
_GetBaseImagePath(item, "logo"),
|
|
||||||
$"The poster of {name}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (!duplicated)
|
|
||||||
{
|
|
||||||
lock (_downloading)
|
|
||||||
{
|
|
||||||
_downloading.Remove(posterPath);
|
|
||||||
sync!.SetResult(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string _GetBaseImagePath<T>(T item, string image)
|
|
||||||
{
|
|
||||||
string directory = item switch
|
|
||||||
{
|
|
||||||
IResource res
|
|
||||||
=> Path.Combine("./metadata", item.GetType().Name.ToLowerInvariant(), res.Slug),
|
|
||||||
_ => Path.Combine("./metadata", typeof(T).Name.ToLowerInvariant())
|
|
||||||
};
|
|
||||||
Directory.CreateDirectory(directory);
|
|
||||||
return Path.Combine(directory, image);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public string GetImagePath<T>(T item, string image, ImageQuality quality)
|
|
||||||
where T : IThumbnails
|
|
||||||
{
|
|
||||||
return $"{_GetBaseImagePath(item, image)}.{quality.ToString().ToLowerInvariant()}.webp";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task DeleteImages<T>(T item)
|
|
||||||
where T : IThumbnails
|
|
||||||
{
|
|
||||||
IEnumerable<string> images = new[] { "poster", "thumbnail", "logo" }
|
|
||||||
.SelectMany(x => _GetBaseImagePath(item, x))
|
|
||||||
.SelectMany(x =>
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
ImageQuality.High.ToString().ToLowerInvariant(),
|
|
||||||
ImageQuality.Medium.ToString().ToLowerInvariant(),
|
|
||||||
ImageQuality.Low.ToString().ToLowerInvariant(),
|
|
||||||
}.Select(quality => $"{x}.{quality}.webp")
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (string image in images)
|
|
||||||
File.Delete(image);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Stream> GetUserImage(Guid userId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return File.Open($"/metadata/user/{userId}.webp", FileMode.Open);
|
|
||||||
}
|
|
||||||
catch (FileNotFoundException) { }
|
|
||||||
catch (DirectoryNotFoundException) { }
|
|
||||||
|
|
||||||
User user = await users.Value.Get(userId);
|
|
||||||
if (user.Email == null)
|
|
||||||
throw new ItemNotFoundException();
|
|
||||||
using MD5 md5 = MD5.Create();
|
|
||||||
string hash = Convert
|
|
||||||
.ToHexString(md5.ComputeHash(Encoding.ASCII.GetBytes(user.Email)))
|
|
||||||
.ToLower();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
HttpClient client = clientFactory.CreateClient();
|
|
||||||
HttpResponseMessage response = await client.GetAsync(
|
|
||||||
$"https://www.gravatar.com/avatar/{hash}.jpg?d=404&s=250"
|
|
||||||
);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
return await response.Content.ReadAsStreamAsync();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
throw new ItemNotFoundException();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SetUserImage(Guid userId, Stream? image)
|
|
||||||
{
|
|
||||||
if (image == null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.Delete($"/metadata/user/{userId}.webp");
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
using SKCodec codec = SKCodec.Create(image);
|
|
||||||
SKImageInfo info = codec.Info;
|
SKImageInfo info = codec.Info;
|
||||||
info.ColorType = SKColorType.Rgba8888;
|
info.ColorType = SKColorType.Rgba8888;
|
||||||
using SKBitmap original = SKBitmap.Decode(codec, info);
|
using SKBitmap original = SKBitmap.Decode(codec, info);
|
||||||
using SKBitmap ret = original.Resize(new SKSizeI(250, 250), SKFilterQuality.High);
|
|
||||||
Directory.CreateDirectory("/metadata/user");
|
using SKBitmap high = original.Resize(
|
||||||
await _WriteTo(ret, $"/metadata/user/{userId}.webp", 75);
|
new SKSizeI(original.Width, original.Height),
|
||||||
|
SKFilterQuality.High
|
||||||
|
);
|
||||||
|
await _WriteTo(
|
||||||
|
original,
|
||||||
|
$"{localPath}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp",
|
||||||
|
90
|
||||||
|
);
|
||||||
|
|
||||||
|
using SKBitmap medium = high.Resize(
|
||||||
|
new SKSizeI((int)(high.Width / 1.5), (int)(high.Height / 1.5)),
|
||||||
|
SKFilterQuality.Medium
|
||||||
|
);
|
||||||
|
await _WriteTo(
|
||||||
|
medium,
|
||||||
|
$"{localPath}.{ImageQuality.Medium.ToString().ToLowerInvariant()}.webp",
|
||||||
|
75
|
||||||
|
);
|
||||||
|
|
||||||
|
using SKBitmap low = medium.Resize(
|
||||||
|
new SKSizeI(original.Width / 2, original.Height / 2),
|
||||||
|
SKFilterQuality.Low
|
||||||
|
);
|
||||||
|
await _WriteTo(
|
||||||
|
low,
|
||||||
|
$"{localPath}.{ImageQuality.Low.ToString().ToLowerInvariant()}.webp",
|
||||||
|
50
|
||||||
|
);
|
||||||
|
|
||||||
|
image.Blurhash = Blurhasher.Encode(low, 4, 3);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "{What} could not be downloaded", what);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DownloadImages<T>(T item)
|
||||||
|
where T : IThumbnails
|
||||||
|
{
|
||||||
|
string name = item is IResource res ? res.Slug : "???";
|
||||||
|
|
||||||
|
string posterPath =
|
||||||
|
$"{_GetBaseImagePath(item, "poster")}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp";
|
||||||
|
bool duplicated = false;
|
||||||
|
TaskCompletionSource<object>? sync = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_downloading)
|
||||||
|
{
|
||||||
|
if (_downloading.ContainsKey(posterPath))
|
||||||
|
{
|
||||||
|
duplicated = true;
|
||||||
|
sync = _downloading.GetValueOrDefault(posterPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sync = new();
|
||||||
|
_downloading.Add(posterPath, sync);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (duplicated)
|
||||||
|
{
|
||||||
|
object? dup = sync != null ? await sync.Task : null;
|
||||||
|
if (dup != null)
|
||||||
|
throw new DuplicatedItemException(dup);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _DownloadImage(
|
||||||
|
item.Poster,
|
||||||
|
_GetBaseImagePath(item, "poster"),
|
||||||
|
$"The poster of {name}"
|
||||||
|
);
|
||||||
|
await _DownloadImage(
|
||||||
|
item.Thumbnail,
|
||||||
|
_GetBaseImagePath(item, "thumbnail"),
|
||||||
|
$"The poster of {name}"
|
||||||
|
);
|
||||||
|
await _DownloadImage(
|
||||||
|
item.Logo,
|
||||||
|
_GetBaseImagePath(item, "logo"),
|
||||||
|
$"The poster of {name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!duplicated)
|
||||||
|
{
|
||||||
|
lock (_downloading)
|
||||||
|
{
|
||||||
|
_downloading.Remove(posterPath);
|
||||||
|
sync!.SetResult(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string _GetBaseImagePath<T>(T item, string image)
|
||||||
|
{
|
||||||
|
string directory = item switch
|
||||||
|
{
|
||||||
|
IResource res
|
||||||
|
=> Path.Combine("./metadata", item.GetType().Name.ToLowerInvariant(), res.Slug),
|
||||||
|
_ => Path.Combine("./metadata", typeof(T).Name.ToLowerInvariant())
|
||||||
|
};
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
return Path.Combine(directory, image);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetImagePath<T>(T item, string image, ImageQuality quality)
|
||||||
|
where T : IThumbnails
|
||||||
|
{
|
||||||
|
return $"{_GetBaseImagePath(item, image)}.{quality.ToString().ToLowerInvariant()}.webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task DeleteImages<T>(T item)
|
||||||
|
where T : IThumbnails
|
||||||
|
{
|
||||||
|
IEnumerable<string> images = new[] { "poster", "thumbnail", "logo" }
|
||||||
|
.SelectMany(x => _GetBaseImagePath(item, x))
|
||||||
|
.SelectMany(x =>
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
ImageQuality.High.ToString().ToLowerInvariant(),
|
||||||
|
ImageQuality.Medium.ToString().ToLowerInvariant(),
|
||||||
|
ImageQuality.Low.ToString().ToLowerInvariant(),
|
||||||
|
}.Select(quality => $"{x}.{quality}.webp")
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (string image in images)
|
||||||
|
File.Delete(image);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Stream> GetUserImage(Guid userId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.Open($"/metadata/user/{userId}.webp", FileMode.Open);
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException) { }
|
||||||
|
catch (DirectoryNotFoundException) { }
|
||||||
|
|
||||||
|
User user = await users.Value.Get(userId);
|
||||||
|
if (user.Email == null)
|
||||||
|
throw new ItemNotFoundException();
|
||||||
|
using MD5 md5 = MD5.Create();
|
||||||
|
string hash = Convert
|
||||||
|
.ToHexString(md5.ComputeHash(Encoding.ASCII.GetBytes(user.Email)))
|
||||||
|
.ToLower();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HttpClient client = clientFactory.CreateClient();
|
||||||
|
HttpResponseMessage response = await client.GetAsync(
|
||||||
|
$"https://www.gravatar.com/avatar/{hash}.jpg?d=404&s=250"
|
||||||
|
);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await response.Content.ReadAsStreamAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
throw new ItemNotFoundException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetUserImage(Guid userId, Stream? image)
|
||||||
|
{
|
||||||
|
if (image == null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete($"/metadata/user/{userId}.webp");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
using SKCodec codec = SKCodec.Create(image);
|
||||||
|
SKImageInfo info = codec.Info;
|
||||||
|
info.ColorType = SKColorType.Rgba8888;
|
||||||
|
using SKBitmap original = SKBitmap.Decode(codec, info);
|
||||||
|
using SKBitmap ret = original.Resize(new SKSizeI(250, 250), SKFilterQuality.High);
|
||||||
|
Directory.CreateDirectory("/metadata/user");
|
||||||
|
await _WriteTo(ret, $"/metadata/user/{userId}.webp", 75);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,116 +33,115 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Kyoo.Core
|
namespace Kyoo.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The core module containing default implementations
|
||||||
|
/// </summary>
|
||||||
|
public class CoreModule : IPlugin
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The core module containing default implementations
|
/// A service provider to access services in static context (in events for example).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CoreModule : IPlugin
|
/// <remarks>Don't forget to create a scope.</remarks>
|
||||||
|
public static IServiceProvider Services { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Core";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Configure(ContainerBuilder builder)
|
||||||
{
|
{
|
||||||
/// <summary>
|
builder
|
||||||
/// A service provider to access services in static context (in events for example).
|
.RegisterType<ThumbnailsManager>()
|
||||||
/// </summary>
|
.As<IThumbnailsManager>()
|
||||||
/// <remarks>Don't forget to create a scope.</remarks>
|
.InstancePerLifetimeScope();
|
||||||
public static IServiceProvider Services { get; set; }
|
builder.RegisterType<LibraryManager>().As<ILibraryManager>().InstancePerLifetimeScope();
|
||||||
|
|
||||||
/// <inheritdoc />
|
builder.RegisterRepository<LibraryItemRepository>();
|
||||||
public string Name => "Core";
|
builder.RegisterRepository<CollectionRepository>();
|
||||||
|
builder.RegisterRepository<MovieRepository>();
|
||||||
/// <inheritdoc />
|
builder.RegisterRepository<ShowRepository>();
|
||||||
public void Configure(ContainerBuilder builder)
|
builder.RegisterRepository<SeasonRepository>();
|
||||||
{
|
builder.RegisterRepository<EpisodeRepository>();
|
||||||
builder
|
builder.RegisterRepository<StudioRepository>();
|
||||||
.RegisterType<ThumbnailsManager>()
|
builder.RegisterRepository<UserRepository>().As<IUserRepository>();
|
||||||
.As<IThumbnailsManager>()
|
builder.RegisterRepository<NewsRepository>();
|
||||||
.InstancePerLifetimeScope();
|
builder
|
||||||
builder.RegisterType<LibraryManager>().As<ILibraryManager>().InstancePerLifetimeScope();
|
.RegisterType<WatchStatusRepository>()
|
||||||
|
.As<IWatchStatusRepository>()
|
||||||
builder.RegisterRepository<LibraryItemRepository>();
|
.AsSelf()
|
||||||
builder.RegisterRepository<CollectionRepository>();
|
.InstancePerLifetimeScope();
|
||||||
builder.RegisterRepository<MovieRepository>();
|
builder
|
||||||
builder.RegisterRepository<ShowRepository>();
|
.RegisterType<IssueRepository>()
|
||||||
builder.RegisterRepository<SeasonRepository>();
|
.As<IIssueRepository>()
|
||||||
builder.RegisterRepository<EpisodeRepository>();
|
.AsSelf()
|
||||||
builder.RegisterRepository<StudioRepository>();
|
.InstancePerLifetimeScope();
|
||||||
builder.RegisterRepository<UserRepository>().As<IUserRepository>();
|
builder.RegisterType<SqlVariableContext>().InstancePerLifetimeScope();
|
||||||
builder.RegisterRepository<NewsRepository>();
|
|
||||||
builder
|
|
||||||
.RegisterType<WatchStatusRepository>()
|
|
||||||
.As<IWatchStatusRepository>()
|
|
||||||
.AsSelf()
|
|
||||||
.InstancePerLifetimeScope();
|
|
||||||
builder
|
|
||||||
.RegisterType<IssueRepository>()
|
|
||||||
.As<IIssueRepository>()
|
|
||||||
.AsSelf()
|
|
||||||
.InstancePerLifetimeScope();
|
|
||||||
builder.RegisterType<SqlVariableContext>().InstancePerLifetimeScope();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Configure(IServiceCollection services)
|
|
||||||
{
|
|
||||||
services.AddHttpContextAccessor();
|
|
||||||
|
|
||||||
services
|
|
||||||
.AddMvcCore(options =>
|
|
||||||
{
|
|
||||||
options.Filters.Add<ExceptionFilter>();
|
|
||||||
options.ModelBinderProviders.Insert(0, new SortBinder.Provider());
|
|
||||||
options.ModelBinderProviders.Insert(0, new IncludeBinder.Provider());
|
|
||||||
options.ModelBinderProviders.Insert(0, new FilterBinder.Provider());
|
|
||||||
})
|
|
||||||
.AddJsonOptions(x =>
|
|
||||||
{
|
|
||||||
x.JsonSerializerOptions.TypeInfoResolver = new WithKindResolver()
|
|
||||||
{
|
|
||||||
Modifiers = { WithKindResolver.HandleLoadableFields }
|
|
||||||
};
|
|
||||||
x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
|
||||||
x.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
|
||||||
})
|
|
||||||
.AddDataAnnotations()
|
|
||||||
.AddControllersAsServices()
|
|
||||||
.AddApiExplorer()
|
|
||||||
.ConfigureApiBehaviorOptions(options =>
|
|
||||||
{
|
|
||||||
options.SuppressMapClientErrors = true;
|
|
||||||
options.InvalidModelStateResponseFactory = ctx =>
|
|
||||||
{
|
|
||||||
string[] errors = ctx
|
|
||||||
.ModelState.SelectMany(x => x.Value!.Errors)
|
|
||||||
.Select(x => x.ErrorMessage)
|
|
||||||
.ToArray();
|
|
||||||
return new BadRequestObjectResult(new RequestError(errors));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
services.Configure<RouteOptions>(x =>
|
|
||||||
{
|
|
||||||
x.ConstraintMap.Add("id", typeof(IdentifierRouteConstraint));
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddResponseCompression(x =>
|
|
||||||
{
|
|
||||||
x.EnableForHttps = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
services.AddProxies();
|
|
||||||
services.AddHttpClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IEnumerable<IStartupAction> ConfigureSteps =>
|
|
||||||
new IStartupAction[]
|
|
||||||
{
|
|
||||||
SA.New<IApplicationBuilder>(app => app.UseHsts(), SA.Before),
|
|
||||||
SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1),
|
|
||||||
SA.New<IApplicationBuilder>(app => app.UseRouting(), SA.Routing),
|
|
||||||
SA.New<IApplicationBuilder>(
|
|
||||||
app => app.UseEndpoints(x => x.MapControllers()),
|
|
||||||
SA.Endpoint
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Configure(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
services
|
||||||
|
.AddMvcCore(options =>
|
||||||
|
{
|
||||||
|
options.Filters.Add<ExceptionFilter>();
|
||||||
|
options.ModelBinderProviders.Insert(0, new SortBinder.Provider());
|
||||||
|
options.ModelBinderProviders.Insert(0, new IncludeBinder.Provider());
|
||||||
|
options.ModelBinderProviders.Insert(0, new FilterBinder.Provider());
|
||||||
|
})
|
||||||
|
.AddJsonOptions(x =>
|
||||||
|
{
|
||||||
|
x.JsonSerializerOptions.TypeInfoResolver = new WithKindResolver()
|
||||||
|
{
|
||||||
|
Modifiers = { WithKindResolver.HandleLoadableFields }
|
||||||
|
};
|
||||||
|
x.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
|
x.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
})
|
||||||
|
.AddDataAnnotations()
|
||||||
|
.AddControllersAsServices()
|
||||||
|
.AddApiExplorer()
|
||||||
|
.ConfigureApiBehaviorOptions(options =>
|
||||||
|
{
|
||||||
|
options.SuppressMapClientErrors = true;
|
||||||
|
options.InvalidModelStateResponseFactory = ctx =>
|
||||||
|
{
|
||||||
|
string[] errors = ctx
|
||||||
|
.ModelState.SelectMany(x => x.Value!.Errors)
|
||||||
|
.Select(x => x.ErrorMessage)
|
||||||
|
.ToArray();
|
||||||
|
return new BadRequestObjectResult(new RequestError(errors));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
services.Configure<RouteOptions>(x =>
|
||||||
|
{
|
||||||
|
x.ConstraintMap.Add("id", typeof(IdentifierRouteConstraint));
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddResponseCompression(x =>
|
||||||
|
{
|
||||||
|
x.EnableForHttps = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddProxies();
|
||||||
|
services.AddHttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<IStartupAction> ConfigureSteps =>
|
||||||
|
new IStartupAction[]
|
||||||
|
{
|
||||||
|
SA.New<IApplicationBuilder>(app => app.UseHsts(), SA.Before),
|
||||||
|
SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1),
|
||||||
|
SA.New<IApplicationBuilder>(app => app.UseRouting(), SA.Routing),
|
||||||
|
SA.New<IApplicationBuilder>(
|
||||||
|
app => app.UseEndpoints(x => x.MapControllers()),
|
||||||
|
SA.Endpoint
|
||||||
|
)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -25,59 +25,58 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Kyoo.Core
|
namespace Kyoo.Core;
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// A middleware to handle errors globally.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Initializes a new instance of the <see cref="ExceptionFilter"/> class.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="logger">The logger used to log errors.</param>
|
|
||||||
public class ExceptionFilter(ILogger<ExceptionFilter> logger) : IExceptionFilter
|
|
||||||
{
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public void OnException(ExceptionContext context)
|
|
||||||
{
|
|
||||||
switch (context.Exception)
|
|
||||||
{
|
|
||||||
case ValidationException ex:
|
|
||||||
context.Result = new BadRequestObjectResult(new RequestError(ex.Message));
|
|
||||||
break;
|
|
||||||
case ItemNotFoundException ex:
|
|
||||||
context.Result = new NotFoundObjectResult(new RequestError(ex.Message));
|
|
||||||
break;
|
|
||||||
case DuplicatedItemException ex when ex.Existing is not null:
|
|
||||||
context.Result = new ConflictObjectResult(ex.Existing);
|
|
||||||
break;
|
|
||||||
case DuplicatedItemException:
|
|
||||||
// Should not happen but if it does, it is better than returning a 409 with no body since clients expect json content
|
|
||||||
context.Result = new ConflictObjectResult(new RequestError("Duplicated item"));
|
|
||||||
break;
|
|
||||||
case UnauthorizedException ex:
|
|
||||||
context.Result = new UnauthorizedObjectResult(new RequestError(ex.Message));
|
|
||||||
break;
|
|
||||||
case Exception ex:
|
|
||||||
logger.LogError(ex, "Unhandled error");
|
|
||||||
context.Result = new ServerErrorObjectResult(
|
|
||||||
new RequestError("Internal Server Error")
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public class ServerErrorObjectResult : ObjectResult
|
/// A middleware to handle errors globally.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="ExceptionFilter"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="logger">The logger used to log errors.</param>
|
||||||
|
public class ExceptionFilter(ILogger<ExceptionFilter> logger) : IExceptionFilter
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void OnException(ExceptionContext context)
|
||||||
|
{
|
||||||
|
switch (context.Exception)
|
||||||
{
|
{
|
||||||
/// <summary>
|
case ValidationException ex:
|
||||||
/// Initializes a new instance of the <see cref="ServerErrorObjectResult"/> class.
|
context.Result = new BadRequestObjectResult(new RequestError(ex.Message));
|
||||||
/// </summary>
|
break;
|
||||||
/// <param name="value">The object to return.</param>
|
case ItemNotFoundException ex:
|
||||||
public ServerErrorObjectResult(object value)
|
context.Result = new NotFoundObjectResult(new RequestError(ex.Message));
|
||||||
: base(value)
|
break;
|
||||||
{
|
case DuplicatedItemException ex when ex.Existing is not null:
|
||||||
StatusCode = StatusCodes.Status500InternalServerError;
|
context.Result = new ConflictObjectResult(ex.Existing);
|
||||||
}
|
break;
|
||||||
|
case DuplicatedItemException:
|
||||||
|
// Should not happen but if it does, it is better than returning a 409 with no body since clients expect json content
|
||||||
|
context.Result = new ConflictObjectResult(new RequestError("Duplicated item"));
|
||||||
|
break;
|
||||||
|
case UnauthorizedException ex:
|
||||||
|
context.Result = new UnauthorizedObjectResult(new RequestError(ex.Message));
|
||||||
|
break;
|
||||||
|
case Exception ex:
|
||||||
|
logger.LogError(ex, "Unhandled error");
|
||||||
|
context.Result = new ServerErrorObjectResult(
|
||||||
|
new RequestError("Internal Server Error")
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class ServerErrorObjectResult : ObjectResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ServerErrorObjectResult"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The object to return.</param>
|
||||||
|
public ServerErrorObjectResult(object value)
|
||||||
|
: base(value)
|
||||||
|
{
|
||||||
|
StatusCode = StatusCodes.Status500InternalServerError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,54 +22,53 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An API endpoint to check the health.
|
||||||
|
/// </summary>
|
||||||
|
[Route("health")]
|
||||||
|
[ApiController]
|
||||||
|
[ApiDefinition("Health")]
|
||||||
|
public class Health : BaseApi
|
||||||
{
|
{
|
||||||
|
private readonly HealthCheckService _healthCheckService;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An API endpoint to check the health.
|
/// Create a new <see cref="Health"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("health")]
|
/// <param name="healthCheckService">The service to check health.</param>
|
||||||
[ApiController]
|
public Health(HealthCheckService healthCheckService)
|
||||||
[ApiDefinition("Health")]
|
|
||||||
public class Health : BaseApi
|
|
||||||
{
|
{
|
||||||
private readonly HealthCheckService _healthCheckService;
|
_healthCheckService = healthCheckService;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="Health"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="healthCheckService">The service to check health.</param>
|
|
||||||
public Health(HealthCheckService healthCheckService)
|
|
||||||
{
|
|
||||||
_healthCheckService = healthCheckService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Check if the api is ready to accept requests.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A status indicating the health of the api.</returns>
|
|
||||||
[HttpGet]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
|
||||||
public async Task<IActionResult> CheckHealth()
|
|
||||||
{
|
|
||||||
IHeaderDictionary headers = HttpContext.Response.Headers;
|
|
||||||
headers.CacheControl = "no-store, no-cache";
|
|
||||||
headers.Pragma = "no-cache";
|
|
||||||
headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT";
|
|
||||||
|
|
||||||
HealthReport result = await _healthCheckService.CheckHealthAsync();
|
|
||||||
return result.Status switch
|
|
||||||
{
|
|
||||||
HealthStatus.Healthy => Ok(new HealthResult("Healthy")),
|
|
||||||
HealthStatus.Unhealthy => Ok(new HealthResult("Unstable")),
|
|
||||||
HealthStatus.Degraded => StatusCode(StatusCodes.Status503ServiceUnavailable),
|
|
||||||
_ => StatusCode(StatusCodes.Status500InternalServerError),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The result of a health operation.
|
|
||||||
/// </summary>
|
|
||||||
public record HealthResult(string Status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if the api is ready to accept requests.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A status indicating the health of the api.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||||
|
public async Task<IActionResult> CheckHealth()
|
||||||
|
{
|
||||||
|
IHeaderDictionary headers = HttpContext.Response.Headers;
|
||||||
|
headers.CacheControl = "no-store, no-cache";
|
||||||
|
headers.Pragma = "no-cache";
|
||||||
|
headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT";
|
||||||
|
|
||||||
|
HealthReport result = await _healthCheckService.CheckHealthAsync();
|
||||||
|
return result.Status switch
|
||||||
|
{
|
||||||
|
HealthStatus.Healthy => Ok(new HealthResult("Healthy")),
|
||||||
|
HealthStatus.Unhealthy => Ok(new HealthResult("Unstable")),
|
||||||
|
HealthStatus.Degraded => StatusCode(StatusCodes.Status503ServiceUnavailable),
|
||||||
|
_ => StatusCode(StatusCodes.Status500InternalServerError),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The result of a health operation.
|
||||||
|
/// </summary>
|
||||||
|
public record HealthResult(string Status);
|
||||||
}
|
}
|
||||||
|
@ -25,76 +25,75 @@ using Kyoo.Abstractions.Models;
|
|||||||
using Kyoo.Utils;
|
using Kyoo.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A common API containing custom methods to help inheritors.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class BaseApi : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A common API containing custom methods to help inheritors.
|
/// Construct and return a page from an api.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class BaseApi : ControllerBase
|
/// <param name="resources">The list of resources that should be included in the current page.</param>
|
||||||
|
/// <param name="limit">
|
||||||
|
/// The max number of items that should be present per page. This should be the same as in the request,
|
||||||
|
/// it is used to calculate if this is the last page and so on.
|
||||||
|
/// </param>
|
||||||
|
/// <typeparam name="TResult">The type of items on the page.</typeparam>
|
||||||
|
/// <returns>A Page representing the response.</returns>
|
||||||
|
protected Page<TResult> Page<TResult>(ICollection<TResult> resources, int limit)
|
||||||
|
where TResult : IResource
|
||||||
{
|
{
|
||||||
/// <summary>
|
Dictionary<string, string> query = Request.Query.ToDictionary(
|
||||||
/// Construct and return a page from an api.
|
x => x.Key,
|
||||||
/// </summary>
|
x => x.Value.ToString(),
|
||||||
/// <param name="resources">The list of resources that should be included in the current page.</param>
|
StringComparer.InvariantCultureIgnoreCase
|
||||||
/// <param name="limit">
|
);
|
||||||
/// The max number of items that should be present per page. This should be the same as in the request,
|
|
||||||
/// it is used to calculate if this is the last page and so on.
|
// If the query was sorted randomly, add the seed to the url to get reproducible links (next,prev,first...)
|
||||||
/// </param>
|
if (query.ContainsKey("sortBy"))
|
||||||
/// <typeparam name="TResult">The type of items on the page.</typeparam>
|
|
||||||
/// <returns>A Page representing the response.</returns>
|
|
||||||
protected Page<TResult> Page<TResult>(ICollection<TResult> resources, int limit)
|
|
||||||
where TResult : IResource
|
|
||||||
{
|
{
|
||||||
Dictionary<string, string> query = Request.Query.ToDictionary(
|
object seed = HttpContext.Items["seed"]!;
|
||||||
x => x.Key,
|
|
||||||
x => x.Value.ToString(),
|
|
||||||
StringComparer.InvariantCultureIgnoreCase
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the query was sorted randomly, add the seed to the url to get reproducible links (next,prev,first...)
|
query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}");
|
||||||
if (query.ContainsKey("sortBy"))
|
}
|
||||||
{
|
return new Page<TResult>(resources, Request.Path, query, limit);
|
||||||
object seed = HttpContext.Items["seed"]!;
|
}
|
||||||
|
|
||||||
query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}");
|
protected SearchPage<TResult> SearchPage<TResult>(SearchPage<TResult>.SearchResult result)
|
||||||
}
|
where TResult : IResource
|
||||||
return new Page<TResult>(resources, Request.Path, query, limit);
|
{
|
||||||
|
Dictionary<string, string> query = Request.Query.ToDictionary(
|
||||||
|
x => x.Key,
|
||||||
|
x => x.Value.ToString(),
|
||||||
|
StringComparer.InvariantCultureIgnoreCase
|
||||||
|
);
|
||||||
|
|
||||||
|
string self = Request.Path + query.ToQueryString();
|
||||||
|
string? previous = null;
|
||||||
|
string? next = null;
|
||||||
|
string first;
|
||||||
|
int limit = query.TryGetValue("limit", out string? limitStr)
|
||||||
|
? int.Parse(limitStr)
|
||||||
|
: new SearchPagination().Limit;
|
||||||
|
int? skip = query.TryGetValue("skip", out string? skipStr) ? int.Parse(skipStr) : null;
|
||||||
|
|
||||||
|
if (skip != null)
|
||||||
|
{
|
||||||
|
query["skip"] = Math.Max(0, skip.Value - limit).ToString();
|
||||||
|
previous = Request.Path + query.ToQueryString();
|
||||||
|
}
|
||||||
|
if (result.Items.Count == limit && limit > 0)
|
||||||
|
{
|
||||||
|
int newSkip = skip.HasValue ? skip.Value + limit : limit;
|
||||||
|
query["skip"] = newSkip.ToString();
|
||||||
|
next = Request.Path + query.ToQueryString();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected SearchPage<TResult> SearchPage<TResult>(SearchPage<TResult>.SearchResult result)
|
query.Remove("skip");
|
||||||
where TResult : IResource
|
first = Request.Path + query.ToQueryString();
|
||||||
{
|
|
||||||
Dictionary<string, string> query = Request.Query.ToDictionary(
|
|
||||||
x => x.Key,
|
|
||||||
x => x.Value.ToString(),
|
|
||||||
StringComparer.InvariantCultureIgnoreCase
|
|
||||||
);
|
|
||||||
|
|
||||||
string self = Request.Path + query.ToQueryString();
|
return new SearchPage<TResult>(result, self, previous, next, first);
|
||||||
string? previous = null;
|
|
||||||
string? next = null;
|
|
||||||
string first;
|
|
||||||
int limit = query.TryGetValue("limit", out string? limitStr)
|
|
||||||
? int.Parse(limitStr)
|
|
||||||
: new SearchPagination().Limit;
|
|
||||||
int? skip = query.TryGetValue("skip", out string? skipStr) ? int.Parse(skipStr) : null;
|
|
||||||
|
|
||||||
if (skip != null)
|
|
||||||
{
|
|
||||||
query["skip"] = Math.Max(0, skip.Value - limit).ToString();
|
|
||||||
previous = Request.Path + query.ToQueryString();
|
|
||||||
}
|
|
||||||
if (result.Items.Count == limit && limit > 0)
|
|
||||||
{
|
|
||||||
int newSkip = skip.HasValue ? skip.Value + limit : limit;
|
|
||||||
query["skip"] = newSkip.ToString();
|
|
||||||
next = Request.Path + query.ToQueryString();
|
|
||||||
}
|
|
||||||
|
|
||||||
query.Remove("skip");
|
|
||||||
first = Request.Path + query.ToQueryString();
|
|
||||||
|
|
||||||
return new SearchPage<TResult>(result, self, previous, next, first);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,250 +27,246 @@ using Kyoo.Models;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A base class to handle CRUD operations on a specific resource type <typeparamref name="T"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of resource to make CRUD apis for.</typeparam>
|
||||||
|
[ApiController]
|
||||||
|
public class CrudApi<T> : BaseApi
|
||||||
|
where T : class, IResource, IQuery
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A base class to handle CRUD operations on a specific resource type <typeparamref name="T"/>.
|
/// The repository of the resource, used to retrieve, save and do operations on the baking store.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The type of resource to make CRUD apis for.</typeparam>
|
protected IRepository<T> Repository { get; }
|
||||||
[ApiController]
|
|
||||||
public class CrudApi<T> : BaseApi
|
/// <summary>
|
||||||
where T : class, IResource, IQuery
|
/// Create a new <see cref="CrudApi{T}"/> using the given repository and base url.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">
|
||||||
|
/// The repository to use as a baking store for the type <typeparamref name="T"/>.
|
||||||
|
/// </param>
|
||||||
|
public CrudApi(IRepository<T> repository)
|
||||||
{
|
{
|
||||||
/// <summary>
|
Repository = repository;
|
||||||
/// The repository of the resource, used to retrieve, save and do operations on the baking store.
|
}
|
||||||
/// </summary>
|
|
||||||
protected IRepository<T> Repository { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="CrudApi{T}"/> using the given repository and base url.
|
/// Get item
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="repository">
|
/// <remarks>
|
||||||
/// The repository to use as a baking store for the type <typeparamref name="T"/>.
|
/// Get a specific resource via it's ID or it's slug.
|
||||||
/// </param>
|
/// </remarks>
|
||||||
public CrudApi(IRepository<T> repository)
|
/// <param name="identifier">The ID or slug of the resource to retrieve.</param>
|
||||||
{
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
Repository = repository;
|
/// <returns>The retrieved resource.</returns>
|
||||||
}
|
/// <response code="404">A resource with the given ID or slug does not exist.</response>
|
||||||
|
[HttpGet("{identifier:id}")]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<T>> Get(Identifier identifier, [FromQuery] Include<T>? fields)
|
||||||
|
{
|
||||||
|
T? ret = await identifier.Match(
|
||||||
|
id => Repository.GetOrDefault(id, fields),
|
||||||
|
slug => Repository.GetOrDefault(slug, fields)
|
||||||
|
);
|
||||||
|
if (ret == null)
|
||||||
|
return NotFound();
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get item
|
/// Get count
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Get a specific resource via it's ID or it's slug.
|
/// Get the number of resources that match the filters.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the resource to retrieve.</param>
|
/// <param name="filter">A list of filters to respect.</param>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
/// <returns>How many resources matched that filter.</returns>
|
||||||
/// <returns>The retrieved resource.</returns>
|
/// <response code="400">Invalid filters.</response>
|
||||||
/// <response code="404">A resource with the given ID or slug does not exist.</response>
|
[HttpGet("count")]
|
||||||
[HttpGet("{identifier:id}")]
|
[PartialPermission(Kind.Read)]
|
||||||
[PartialPermission(Kind.Read)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
public async Task<ActionResult<int>> GetCount([FromQuery] Filter<T> filter)
|
||||||
public async Task<ActionResult<T>> Get(
|
{
|
||||||
Identifier identifier,
|
return await Repository.GetCount(filter);
|
||||||
[FromQuery] Include<T>? fields
|
}
|
||||||
)
|
|
||||||
{
|
|
||||||
T? ret = await identifier.Match(
|
|
||||||
id => Repository.GetOrDefault(id, fields),
|
|
||||||
slug => Repository.GetOrDefault(slug, fields)
|
|
||||||
);
|
|
||||||
if (ret == null)
|
|
||||||
return NotFound();
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get count
|
/// Get all
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Get the number of resources that match the filters.
|
/// Get all resources that match the given filter.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="filter">A list of filters to respect.</param>
|
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
||||||
/// <returns>How many resources matched that filter.</returns>
|
/// <param name="filter">Filter the returned items.</param>
|
||||||
/// <response code="400">Invalid filters.</response>
|
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
||||||
[HttpGet("count")]
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
[PartialPermission(Kind.Read)]
|
/// <returns>A list of resources that match every filters.</returns>
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
/// <response code="400">Invalid filters or sort information.</response>
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
[HttpGet]
|
||||||
public async Task<ActionResult<int>> GetCount([FromQuery] Filter<T> filter)
|
[PartialPermission(Kind.Read)]
|
||||||
{
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
return await Repository.GetCount(filter);
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
}
|
public async Task<ActionResult<Page<T>>> GetAll(
|
||||||
|
[FromQuery] Sort<T> sortBy,
|
||||||
|
[FromQuery] Filter<T>? filter,
|
||||||
|
[FromQuery] Pagination pagination,
|
||||||
|
[FromQuery] Include<T>? fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ICollection<T> resources = await Repository.GetAll(filter, sortBy, fields, pagination);
|
||||||
|
|
||||||
/// <summary>
|
return Page(resources, pagination.Limit);
|
||||||
/// Get all
|
}
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Get all resources that match the given filter.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
|
||||||
/// <param name="filter">Filter the returned items.</param>
|
|
||||||
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
|
||||||
/// <returns>A list of resources that match every filters.</returns>
|
|
||||||
/// <response code="400">Invalid filters or sort information.</response>
|
|
||||||
[HttpGet]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult<Page<T>>> GetAll(
|
|
||||||
[FromQuery] Sort<T> sortBy,
|
|
||||||
[FromQuery] Filter<T>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<T>? fields
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ICollection<T> resources = await Repository.GetAll(filter, sortBy, fields, pagination);
|
|
||||||
|
|
||||||
return Page(resources, pagination.Limit);
|
/// <summary>
|
||||||
}
|
/// Create new
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="resource">The resource to create.</param>
|
||||||
|
/// <returns>The created resource.</returns>
|
||||||
|
/// <response code="400">The resource in the request body is invalid.</response>
|
||||||
|
/// <response code="409">This item already exists (maybe a duplicated slug).</response>
|
||||||
|
[HttpPost]
|
||||||
|
[PartialPermission(Kind.Create)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))]
|
||||||
|
public virtual async Task<ActionResult<T>> Create([FromBody] T resource)
|
||||||
|
{
|
||||||
|
return await Repository.Create(resource);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create new
|
/// Edit
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Create a new item and store it. You may leave the ID unspecified, it will be filed by Kyoo.
|
/// Edit an item. If the ID is specified it will be used to identify the resource.
|
||||||
/// </remarks>
|
/// If not, the slug will be used to identify it.
|
||||||
/// <param name="resource">The resource to create.</param>
|
/// </remarks>
|
||||||
/// <returns>The created resource.</returns>
|
/// <param name="resource">The resource to edit.</param>
|
||||||
/// <response code="400">The resource in the request body is invalid.</response>
|
/// <returns>The edited resource.</returns>
|
||||||
/// <response code="409">This item already exists (maybe a duplicated slug).</response>
|
/// <response code="400">The resource in the request body is invalid.</response>
|
||||||
[HttpPost]
|
/// <response code="404">No item found with the specified ID (or slug).</response>
|
||||||
[PartialPermission(Kind.Create)]
|
[HttpPut]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[PartialPermission(Kind.Write)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict, Type = typeof(ActionResult<>))]
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
public virtual async Task<ActionResult<T>> Create([FromBody] T resource)
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
{
|
public async Task<ActionResult<T>> Edit([FromBody] T resource)
|
||||||
return await Repository.Create(resource);
|
{
|
||||||
}
|
if (resource.Id != Guid.Empty)
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Edit
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Edit an item. If the ID is specified it will be used to identify the resource.
|
|
||||||
/// If not, the slug will be used to identify it.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="resource">The resource to edit.</param>
|
|
||||||
/// <returns>The edited resource.</returns>
|
|
||||||
/// <response code="400">The resource in the request body is invalid.</response>
|
|
||||||
/// <response code="404">No item found with the specified ID (or slug).</response>
|
|
||||||
[HttpPut]
|
|
||||||
[PartialPermission(Kind.Write)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<T>> Edit([FromBody] T resource)
|
|
||||||
{
|
|
||||||
if (resource.Id != Guid.Empty)
|
|
||||||
return await Repository.Edit(resource);
|
|
||||||
|
|
||||||
T old = await Repository.Get(resource.Slug);
|
|
||||||
resource.Id = old.Id;
|
|
||||||
return await Repository.Edit(resource);
|
return await Repository.Edit(resource);
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
T old = await Repository.Get(resource.Slug);
|
||||||
/// Patch
|
resource.Id = old.Id;
|
||||||
/// </summary>
|
return await Repository.Edit(resource);
|
||||||
/// <remarks>
|
}
|
||||||
/// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource.
|
|
||||||
/// If not, the slug will be used to identify it.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="patch">The resource to patch.</param>
|
|
||||||
/// <returns>The edited resource.</returns>
|
|
||||||
/// <response code="400">The resource in the request body is invalid.</response>
|
|
||||||
/// <response code="404">No item found with the specified ID (or slug).</response>
|
|
||||||
[HttpPatch]
|
|
||||||
[PartialPermission(Kind.Write)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<T>> Patch([FromBody] Patch<T> patch)
|
|
||||||
{
|
|
||||||
if (patch.Id.HasValue)
|
|
||||||
return await Repository.Patch(patch.Id.Value, patch.Apply);
|
|
||||||
if (patch.Slug == null)
|
|
||||||
throw new ArgumentException(
|
|
||||||
"Either the Id or the slug of the resource has to be defined to edit it."
|
|
||||||
);
|
|
||||||
|
|
||||||
T old = await Repository.Get(patch.Slug);
|
/// <summary>
|
||||||
return await Repository.Patch(old.Id, patch.Apply);
|
/// Patch
|
||||||
}
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
/// <summary>
|
/// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource.
|
||||||
/// Patch
|
/// If not, the slug will be used to identify it.
|
||||||
/// </summary>
|
/// </remarks>
|
||||||
/// <remarks>
|
/// <param name="patch">The resource to patch.</param>
|
||||||
/// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource.
|
/// <returns>The edited resource.</returns>
|
||||||
/// If not, the slug will be used to identify it.
|
/// <response code="400">The resource in the request body is invalid.</response>
|
||||||
/// </remarks>
|
/// <response code="404">No item found with the specified ID (or slug).</response>
|
||||||
/// <param name="identifier">The id or slug of the resource.</param>
|
[HttpPatch]
|
||||||
/// <param name="patch">The resource to patch.</param>
|
[PartialPermission(Kind.Write)]
|
||||||
/// <returns>The edited resource.</returns>
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
/// <response code="400">The resource in the request body is invalid.</response>
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
/// <response code="404">No item found with the specified ID (or slug).</response>
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[HttpPatch("{identifier:id}")]
|
public async Task<ActionResult<T>> Patch([FromBody] Patch<T> patch)
|
||||||
[PartialPermission(Kind.Write)]
|
{
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
if (patch.Id.HasValue)
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
return await Repository.Patch(patch.Id.Value, patch.Apply);
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
if (patch.Slug == null)
|
||||||
public async Task<ActionResult<T>> Patch(Identifier identifier, [FromBody] Patch<T> patch)
|
throw new ArgumentException(
|
||||||
{
|
"Either the Id or the slug of the resource has to be defined to edit it."
|
||||||
Guid id = await identifier.Match(
|
|
||||||
id => Task.FromResult(id),
|
|
||||||
async slug => (await Repository.Get(slug)).Id
|
|
||||||
);
|
);
|
||||||
if (patch.Id.HasValue && patch.Id.Value != id)
|
|
||||||
throw new ArgumentException("Can not edit id of a resource.");
|
|
||||||
return await Repository.Patch(id, patch.Apply);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
T old = await Repository.Get(patch.Slug);
|
||||||
/// Delete an item
|
return await Repository.Patch(old.Id, patch.Apply);
|
||||||
/// </summary>
|
}
|
||||||
/// <remarks>
|
|
||||||
/// Delete one item via it's ID or it's slug.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the resource to delete.</param>
|
|
||||||
/// <returns>The item has successfully been deleted.</returns>
|
|
||||||
/// <response code="404">No item could be found with the given id or slug.</response>
|
|
||||||
[HttpDelete("{identifier:id}")]
|
|
||||||
[PartialPermission(Kind.Delete)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> Delete(Identifier identifier)
|
|
||||||
{
|
|
||||||
await identifier.Match(id => Repository.Delete(id), slug => Repository.Delete(slug));
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delete all where
|
/// Patch
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Delete all items matching the given filters. If no filter is specified, delete all items.
|
/// Edit only specified properties of an item. If the ID is specified it will be used to identify the resource.
|
||||||
/// </remarks>
|
/// If not, the slug will be used to identify it.
|
||||||
/// <param name="filter">The list of filters.</param>
|
/// </remarks>
|
||||||
/// <returns>The item(s) has successfully been deleted.</returns>
|
/// <param name="identifier">The id or slug of the resource.</param>
|
||||||
/// <response code="400">One or multiple filters are invalid.</response>
|
/// <param name="patch">The resource to patch.</param>
|
||||||
[HttpDelete]
|
/// <returns>The edited resource.</returns>
|
||||||
[PartialPermission(Kind.Delete)]
|
/// <response code="400">The resource in the request body is invalid.</response>
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
/// <response code="404">No item found with the specified ID (or slug).</response>
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
[HttpPatch("{identifier:id}")]
|
||||||
public async Task<IActionResult> Delete([FromQuery] Filter<T> filter)
|
[PartialPermission(Kind.Write)]
|
||||||
{
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
if (filter == null)
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
return BadRequest(
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
new RequestError("Incule a filter to delete items, all items won't be deleted.")
|
public async Task<ActionResult<T>> Patch(Identifier identifier, [FromBody] Patch<T> patch)
|
||||||
);
|
{
|
||||||
|
Guid id = await identifier.Match(
|
||||||
|
id => Task.FromResult(id),
|
||||||
|
async slug => (await Repository.Get(slug)).Id
|
||||||
|
);
|
||||||
|
if (patch.Id.HasValue && patch.Id.Value != id)
|
||||||
|
throw new ArgumentException("Can not edit id of a resource.");
|
||||||
|
return await Repository.Patch(id, patch.Apply);
|
||||||
|
}
|
||||||
|
|
||||||
await Repository.DeleteAll(filter);
|
/// <summary>
|
||||||
return NoContent();
|
/// Delete an item
|
||||||
}
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Delete one item via it's ID or it's slug.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="identifier">The ID or slug of the resource to delete.</param>
|
||||||
|
/// <returns>The item has successfully been deleted.</returns>
|
||||||
|
/// <response code="404">No item could be found with the given id or slug.</response>
|
||||||
|
[HttpDelete("{identifier:id}")]
|
||||||
|
[PartialPermission(Kind.Delete)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(Identifier identifier)
|
||||||
|
{
|
||||||
|
await identifier.Match(id => Repository.Delete(id), slug => Repository.Delete(slug));
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete all where
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Delete all items matching the given filters. If no filter is specified, delete all items.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="filter">The list of filters.</param>
|
||||||
|
/// <returns>The item(s) has successfully been deleted.</returns>
|
||||||
|
/// <response code="400">One or multiple filters are invalid.</response>
|
||||||
|
[HttpDelete]
|
||||||
|
[PartialPermission(Kind.Delete)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
public async Task<IActionResult> Delete([FromQuery] Filter<T> filter)
|
||||||
|
{
|
||||||
|
if (filter == null)
|
||||||
|
return BadRequest(
|
||||||
|
new RequestError("Incule a filter to delete items, all items won't be deleted.")
|
||||||
|
);
|
||||||
|
|
||||||
|
await Repository.DeleteAll(filter);
|
||||||
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,126 +26,119 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A base class to handle CRUD operations and services thumbnails for
|
||||||
|
/// a specific resource type <typeparamref name="T"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of resource to make CRUD and thumbnails apis for.</typeparam>
|
||||||
|
[ApiController]
|
||||||
|
public class CrudThumbsApi<T> : CrudApi<T>
|
||||||
|
where T : class, IResource, IThumbnails, IQuery
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A base class to handle CRUD operations and services thumbnails for
|
/// The thumbnail manager used to retrieve images paths.
|
||||||
/// a specific resource type <typeparamref name="T"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <typeparam name="T">The type of resource to make CRUD and thumbnails apis for.</typeparam>
|
private readonly IThumbnailsManager _thumbs;
|
||||||
[ApiController]
|
|
||||||
public class CrudThumbsApi<T> : CrudApi<T>
|
/// <summary>
|
||||||
where T : class, IResource, IThumbnails, IQuery
|
/// Create a new <see cref="CrudThumbsApi{T}"/> that handles crud requests and thumbnails.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">
|
||||||
|
/// The repository to use as a baking store for the type <typeparamref name="T"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
|
||||||
|
public CrudThumbsApi(IRepository<T> repository, IThumbnailsManager thumbs)
|
||||||
|
: base(repository)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_thumbs = thumbs;
|
||||||
/// The thumbnail manager used to retrieve images paths.
|
}
|
||||||
/// </summary>
|
|
||||||
private readonly IThumbnailsManager _thumbs;
|
|
||||||
|
|
||||||
/// <summary>
|
private async Task<IActionResult> _GetImage(
|
||||||
/// Create a new <see cref="CrudThumbsApi{T}"/> that handles crud requests and thumbnails.
|
Identifier identifier,
|
||||||
/// </summary>
|
string image,
|
||||||
/// <param name="repository">
|
ImageQuality? quality
|
||||||
/// The repository to use as a baking store for the type <typeparamref name="T"/>.
|
)
|
||||||
/// </param>
|
{
|
||||||
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
|
T? resource = await identifier.Match(
|
||||||
public CrudThumbsApi(IRepository<T> repository, IThumbnailsManager thumbs)
|
id => Repository.GetOrDefault(id),
|
||||||
: base(repository)
|
slug => Repository.GetOrDefault(slug)
|
||||||
|
);
|
||||||
|
if (resource == null)
|
||||||
|
return NotFound();
|
||||||
|
string path = _thumbs.GetImagePath(resource, image, quality ?? ImageQuality.High);
|
||||||
|
if (!System.IO.File.Exists(path))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
if (!identifier.Match(id => false, slug => slug == "random"))
|
||||||
{
|
{
|
||||||
_thumbs = thumbs;
|
// Allow clients to cache the image for 6 month.
|
||||||
|
Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}");
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
Response.Headers.Add("Cache-Control", $"public, no-store");
|
||||||
|
return PhysicalFile(Path.GetFullPath(path), "image/webp", true);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> _GetImage(
|
/// <summary>
|
||||||
Identifier identifier,
|
/// Get Poster
|
||||||
string image,
|
/// </summary>
|
||||||
ImageQuality? quality
|
/// <remarks>
|
||||||
)
|
/// Get the poster for the specified item.
|
||||||
{
|
/// </remarks>
|
||||||
T? resource = await identifier.Match(
|
/// <param name="identifier">The ID or slug of the resource to get the image for.</param>
|
||||||
id => Repository.GetOrDefault(id),
|
/// <param name="quality">The quality of the image to retrieve.</param>
|
||||||
slug => Repository.GetOrDefault(slug)
|
/// <returns>The image asked.</returns>
|
||||||
);
|
/// <response code="404">
|
||||||
if (resource == null)
|
/// No item exist with the specific identifier or the image does not exists on kyoo.
|
||||||
return NotFound();
|
/// </response>
|
||||||
string path = _thumbs.GetImagePath(resource, image, quality ?? ImageQuality.High);
|
[HttpGet("{identifier:id}/poster")]
|
||||||
if (!System.IO.File.Exists(path))
|
[PartialPermission(Kind.Read)]
|
||||||
return NotFound();
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public Task<IActionResult> GetPoster(Identifier identifier, [FromQuery] ImageQuality? quality)
|
||||||
|
{
|
||||||
|
return _GetImage(identifier, "poster", quality);
|
||||||
|
}
|
||||||
|
|
||||||
if (!identifier.Match(id => false, slug => slug == "random"))
|
/// <summary>
|
||||||
{
|
/// Get Logo
|
||||||
// Allow clients to cache the image for 6 month.
|
/// </summary>
|
||||||
Response.Headers.Add("Cache-Control", $"public, max-age={60 * 60 * 24 * 31 * 6}");
|
/// <remarks>
|
||||||
}
|
/// Get the logo for the specified item.
|
||||||
else
|
/// </remarks>
|
||||||
Response.Headers.Add("Cache-Control", $"public, no-store");
|
/// <param name="identifier">The ID or slug of the resource to get the image for.</param>
|
||||||
return PhysicalFile(Path.GetFullPath(path), "image/webp", true);
|
/// <param name="quality">The quality of the image to retrieve.</param>
|
||||||
}
|
/// <returns>The image asked.</returns>
|
||||||
|
/// <response code="404">
|
||||||
|
/// No item exist with the specific identifier or the image does not exists on kyoo.
|
||||||
|
/// </response>
|
||||||
|
[HttpGet("{identifier:id}/logo")]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public Task<IActionResult> GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality)
|
||||||
|
{
|
||||||
|
return _GetImage(identifier, "logo", quality);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get Poster
|
/// Get Thumbnail
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Get the poster for the specified item.
|
/// Get the thumbnail for the specified item.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the resource to get the image for.</param>
|
/// <param name="identifier">The ID or slug of the resource to get the image for.</param>
|
||||||
/// <param name="quality">The quality of the image to retrieve.</param>
|
/// <param name="quality">The quality of the image to retrieve.</param>
|
||||||
/// <returns>The image asked.</returns>
|
/// <returns>The image asked.</returns>
|
||||||
/// <response code="404">
|
/// <response code="404">
|
||||||
/// No item exist with the specific identifier or the image does not exists on kyoo.
|
/// No item exist with the specific identifier or the image does not exists on kyoo.
|
||||||
/// </response>
|
/// </response>
|
||||||
[HttpGet("{identifier:id}/poster")]
|
[HttpGet("{identifier:id}/thumbnail")]
|
||||||
[PartialPermission(Kind.Read)]
|
[HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
public Task<IActionResult> GetBackdrop(Identifier identifier, [FromQuery] ImageQuality? quality)
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
{
|
||||||
public Task<IActionResult> GetPoster(
|
return _GetImage(identifier, "thumbnail", quality);
|
||||||
Identifier identifier,
|
|
||||||
[FromQuery] ImageQuality? quality
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return _GetImage(identifier, "poster", quality);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get Logo
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Get the logo for the specified item.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the resource to get the image for.</param>
|
|
||||||
/// <param name="quality">The quality of the image to retrieve.</param>
|
|
||||||
/// <returns>The image asked.</returns>
|
|
||||||
/// <response code="404">
|
|
||||||
/// No item exist with the specific identifier or the image does not exists on kyoo.
|
|
||||||
/// </response>
|
|
||||||
[HttpGet("{identifier:id}/logo")]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public Task<IActionResult> GetLogo(Identifier identifier, [FromQuery] ImageQuality? quality)
|
|
||||||
{
|
|
||||||
return _GetImage(identifier, "logo", quality);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get Thumbnail
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Get the thumbnail for the specified item.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the resource to get the image for.</param>
|
|
||||||
/// <param name="quality">The quality of the image to retrieve.</param>
|
|
||||||
/// <returns>The image asked.</returns>
|
|
||||||
/// <response code="404">
|
|
||||||
/// No item exist with the specific identifier or the image does not exists on kyoo.
|
|
||||||
/// </response>
|
|
||||||
[HttpGet("{identifier:id}/thumbnail")]
|
|
||||||
[HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)]
|
|
||||||
public Task<IActionResult> GetBackdrop(
|
|
||||||
Identifier identifier,
|
|
||||||
[FromQuery] ImageQuality? quality
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return _GetImage(identifier, "thumbnail", quality);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,76 +28,75 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about one or multiple <see cref="Studio"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Route("studios")]
|
||||||
|
[Route("studio", Order = AlternativeRoute)]
|
||||||
|
[ApiController]
|
||||||
|
[PartialPermission(nameof(Show))]
|
||||||
|
[ApiDefinition("Studios", Group = MetadataGroup)]
|
||||||
|
public class StudioApi : CrudApi<Studio>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Information about one or multiple <see cref="Studio"/>.
|
/// The library manager used to modify or retrieve information in the data store.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("studios")]
|
private readonly ILibraryManager _libraryManager;
|
||||||
[Route("studio", Order = AlternativeRoute)]
|
|
||||||
[ApiController]
|
/// <summary>
|
||||||
[PartialPermission(nameof(Show))]
|
/// Create a new <see cref="StudioApi"/>.
|
||||||
[ApiDefinition("Studios", Group = MetadataGroup)]
|
/// </summary>
|
||||||
public class StudioApi : CrudApi<Studio>
|
/// <param name="libraryManager">
|
||||||
|
/// The library manager used to modify or retrieve information in the data store.
|
||||||
|
/// </param>
|
||||||
|
public StudioApi(ILibraryManager libraryManager)
|
||||||
|
: base(libraryManager.Studios)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_libraryManager = libraryManager;
|
||||||
/// The library manager used to modify or retrieve information in the data store.
|
}
|
||||||
/// </summary>
|
|
||||||
private readonly ILibraryManager _libraryManager;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="StudioApi"/>.
|
/// Get shows
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="libraryManager">
|
/// <remarks>
|
||||||
/// The library manager used to modify or retrieve information in the data store.
|
/// List shows that were made by this specific studio.
|
||||||
/// </param>
|
/// </remarks>
|
||||||
public StudioApi(ILibraryManager libraryManager)
|
/// <param name="identifier">The ID or slug of the <see cref="Studio"/>.</param>
|
||||||
: base(libraryManager.Studios)
|
/// <param name="sortBy">A key to sort shows by.</param>
|
||||||
{
|
/// <param name="filter">An optional list of filters.</param>
|
||||||
_libraryManager = libraryManager;
|
/// <param name="pagination">The number of shows to return.</param>
|
||||||
}
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
|
/// <returns>A page of shows.</returns>
|
||||||
|
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
||||||
|
/// <response code="404">No studio with the given ID or slug could be found.</response>
|
||||||
|
[HttpGet("{identifier:id}/shows")]
|
||||||
|
[HttpGet("{identifier:id}/show", Order = AlternativeRoute)]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Page<Show>>> GetShows(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Sort<Show> sortBy,
|
||||||
|
[FromQuery] Filter<Show>? filter,
|
||||||
|
[FromQuery] Pagination pagination,
|
||||||
|
[FromQuery] Include<Show> fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ICollection<Show> resources = await _libraryManager.Shows.GetAll(
|
||||||
|
Filter.And(filter, identifier.Matcher<Show>(x => x.StudioId, x => x.Studio!.Slug)),
|
||||||
|
sortBy,
|
||||||
|
fields,
|
||||||
|
pagination
|
||||||
|
);
|
||||||
|
|
||||||
/// <summary>
|
if (
|
||||||
/// Get shows
|
!resources.Any()
|
||||||
/// </summary>
|
&& await _libraryManager.Studios.GetOrDefault(identifier.IsSame<Studio>()) == null
|
||||||
/// <remarks>
|
|
||||||
/// List shows that were made by this specific studio.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Studio"/>.</param>
|
|
||||||
/// <param name="sortBy">A key to sort shows by.</param>
|
|
||||||
/// <param name="filter">An optional list of filters.</param>
|
|
||||||
/// <param name="pagination">The number of shows to return.</param>
|
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
|
||||||
/// <returns>A page of shows.</returns>
|
|
||||||
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
|
||||||
/// <response code="404">No studio with the given ID or slug could be found.</response>
|
|
||||||
[HttpGet("{identifier:id}/shows")]
|
|
||||||
[HttpGet("{identifier:id}/show", Order = AlternativeRoute)]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<Page<Show>>> GetShows(
|
|
||||||
Identifier identifier,
|
|
||||||
[FromQuery] Sort<Show> sortBy,
|
|
||||||
[FromQuery] Filter<Show>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<Show> fields
|
|
||||||
)
|
)
|
||||||
{
|
return NotFound();
|
||||||
ICollection<Show> resources = await _libraryManager.Shows.GetAll(
|
return Page(resources, pagination.Limit);
|
||||||
Filter.And(filter, identifier.Matcher<Show>(x => x.StudioId, x => x.Studio!.Slug)),
|
|
||||||
sortBy,
|
|
||||||
fields,
|
|
||||||
pagination
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!resources.Any()
|
|
||||||
&& await _libraryManager.Studios.GetOrDefault(identifier.IsSame<Studio>()) == null
|
|
||||||
)
|
|
||||||
return NotFound();
|
|
||||||
return Page(resources, pagination.Limit);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,234 +30,233 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about one or multiple <see cref="Collection"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Route("collections")]
|
||||||
|
[Route("collection", Order = AlternativeRoute)]
|
||||||
|
[ApiController]
|
||||||
|
[PartialPermission(nameof(Collection))]
|
||||||
|
[ApiDefinition("Collections", Group = ResourcesGroup)]
|
||||||
|
public class CollectionApi : CrudThumbsApi<Collection>
|
||||||
{
|
{
|
||||||
/// <summary>
|
private readonly ILibraryManager _libraryManager;
|
||||||
/// Information about one or multiple <see cref="Collection"/>.
|
private readonly CollectionRepository _collections;
|
||||||
/// </summary>
|
private readonly LibraryItemRepository _items;
|
||||||
[Route("collections")]
|
|
||||||
[Route("collection", Order = AlternativeRoute)]
|
public CollectionApi(
|
||||||
[ApiController]
|
ILibraryManager libraryManager,
|
||||||
[PartialPermission(nameof(Collection))]
|
CollectionRepository collections,
|
||||||
[ApiDefinition("Collections", Group = ResourcesGroup)]
|
LibraryItemRepository items,
|
||||||
public class CollectionApi : CrudThumbsApi<Collection>
|
IThumbnailsManager thumbs
|
||||||
|
)
|
||||||
|
: base(libraryManager.Collections, thumbs)
|
||||||
{
|
{
|
||||||
private readonly ILibraryManager _libraryManager;
|
_libraryManager = libraryManager;
|
||||||
private readonly CollectionRepository _collections;
|
_collections = collections;
|
||||||
private readonly LibraryItemRepository _items;
|
_items = items;
|
||||||
|
}
|
||||||
|
|
||||||
public CollectionApi(
|
/// <summary>
|
||||||
ILibraryManager libraryManager,
|
/// Add a movie
|
||||||
CollectionRepository collections,
|
/// </summary>
|
||||||
LibraryItemRepository items,
|
/// <remarks>
|
||||||
IThumbnailsManager thumbs
|
/// Add a movie in the collection.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
|
||||||
|
/// <param name="movie">The ID or slug of the <see cref="Movie"/> to add.</param>
|
||||||
|
/// <returns>Nothing if successful.</returns>
|
||||||
|
/// <response code="404">No collection or movie with the given ID could be found.</response>
|
||||||
|
/// <response code="409">The specified movie is already in this collection.</response>
|
||||||
|
[HttpPut("{identifier:id}/movies/{movie:id}")]
|
||||||
|
[HttpPut("{identifier:id}/movie/{movie:id}", Order = AlternativeRoute)]
|
||||||
|
[PartialPermission(Kind.Write)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult> AddMovie(Identifier identifier, Identifier movie)
|
||||||
|
{
|
||||||
|
Guid collectionId = await identifier.Match(
|
||||||
|
async id => (await _libraryManager.Collections.Get(id)).Id,
|
||||||
|
async slug => (await _libraryManager.Collections.Get(slug)).Id
|
||||||
|
);
|
||||||
|
Guid movieId = await movie.Match(
|
||||||
|
async id => (await _libraryManager.Movies.Get(id)).Id,
|
||||||
|
async slug => (await _libraryManager.Movies.Get(slug)).Id
|
||||||
|
);
|
||||||
|
await _collections.AddMovie(collectionId, movieId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add a show
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Add a show in the collection.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
|
||||||
|
/// <param name="show">The ID or slug of the <see cref="Show"/> to add.</param>
|
||||||
|
/// <returns>Nothing if successful.</returns>
|
||||||
|
/// <response code="404">No collection or show with the given ID could be found.</response>
|
||||||
|
/// <response code="409">The specified show is already in this collection.</response>
|
||||||
|
[HttpPut("{identifier:id}/shows/{show:id}")]
|
||||||
|
[HttpPut("{identifier:id}/show/{show:id}", Order = AlternativeRoute)]
|
||||||
|
[PartialPermission(Kind.Write)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult> AddShow(Identifier identifier, Identifier show)
|
||||||
|
{
|
||||||
|
Guid collectionId = await identifier.Match(
|
||||||
|
async id => (await _libraryManager.Collections.Get(id)).Id,
|
||||||
|
async slug => (await _libraryManager.Collections.Get(slug)).Id
|
||||||
|
);
|
||||||
|
Guid showId = await show.Match(
|
||||||
|
async id => (await _libraryManager.Shows.Get(id)).Id,
|
||||||
|
async slug => (await _libraryManager.Shows.Get(slug)).Id
|
||||||
|
);
|
||||||
|
await _collections.AddShow(collectionId, showId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get items in collection
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Lists the items that are contained in the collection with the given id or slug.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
|
||||||
|
/// <param name="sortBy">A key to sort items by.</param>
|
||||||
|
/// <param name="filter">An optional list of filters.</param>
|
||||||
|
/// <param name="pagination">The number of items to return.</param>
|
||||||
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
|
/// <returns>A page of items.</returns>
|
||||||
|
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
||||||
|
/// <response code="404">No collection with the given ID could be found.</response>
|
||||||
|
[HttpGet("{identifier:id}/items")]
|
||||||
|
[HttpGet("{identifier:id}/item", Order = AlternativeRoute)]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Page<ILibraryItem>>> GetItems(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Sort<ILibraryItem> sortBy,
|
||||||
|
[FromQuery] Filter<ILibraryItem>? filter,
|
||||||
|
[FromQuery] Pagination pagination,
|
||||||
|
[FromQuery] Include<ILibraryItem>? fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Guid collectionId = await identifier.Match(
|
||||||
|
id => Task.FromResult(id),
|
||||||
|
async slug => (await _libraryManager.Collections.Get(slug)).Id
|
||||||
|
);
|
||||||
|
ICollection<ILibraryItem> resources = await _items.GetAllOfCollection(
|
||||||
|
collectionId,
|
||||||
|
filter,
|
||||||
|
sortBy == new Sort<ILibraryItem>.Default()
|
||||||
|
? new Sort<ILibraryItem>.By(nameof(Movie.AirDate))
|
||||||
|
: sortBy,
|
||||||
|
fields,
|
||||||
|
pagination
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!resources.Any()
|
||||||
|
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
|
||||||
|
== null
|
||||||
)
|
)
|
||||||
: base(libraryManager.Collections, thumbs)
|
return NotFound();
|
||||||
{
|
return Page(resources, pagination.Limit);
|
||||||
_libraryManager = libraryManager;
|
}
|
||||||
_collections = collections;
|
|
||||||
_items = items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Add a movie
|
/// Get shows in collection
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Add a movie in the collection.
|
/// Lists the shows that are contained in the collection with the given id or slug.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
|
||||||
/// <param name="movie">The ID or slug of the <see cref="Movie"/> to add.</param>
|
/// <param name="sortBy">A key to sort shows by.</param>
|
||||||
/// <returns>Nothing if successful.</returns>
|
/// <param name="filter">An optional list of filters.</param>
|
||||||
/// <response code="404">No collection or movie with the given ID could be found.</response>
|
/// <param name="pagination">The number of shows to return.</param>
|
||||||
/// <response code="409">The specified movie is already in this collection.</response>
|
/// <param name="fields">The additional fields to include in the result.</param>
|
||||||
[HttpPut("{identifier:id}/movies/{movie:id}")]
|
/// <returns>A page of shows.</returns>
|
||||||
[HttpPut("{identifier:id}/movie/{movie:id}", Order = AlternativeRoute)]
|
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
||||||
[PartialPermission(Kind.Write)]
|
/// <response code="404">No collection with the given ID could be found.</response>
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[HttpGet("{identifier:id}/shows")]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[HttpGet("{identifier:id}/show", Order = AlternativeRoute)]
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
[PartialPermission(Kind.Read)]
|
||||||
public async Task<ActionResult> AddMovie(Identifier identifier, Identifier movie)
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
{
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
Guid collectionId = await identifier.Match(
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
async id => (await _libraryManager.Collections.Get(id)).Id,
|
public async Task<ActionResult<Page<Show>>> GetShows(
|
||||||
async slug => (await _libraryManager.Collections.Get(slug)).Id
|
Identifier identifier,
|
||||||
);
|
[FromQuery] Sort<Show> sortBy,
|
||||||
Guid movieId = await movie.Match(
|
[FromQuery] Filter<Show>? filter,
|
||||||
async id => (await _libraryManager.Movies.Get(id)).Id,
|
[FromQuery] Pagination pagination,
|
||||||
async slug => (await _libraryManager.Movies.Get(slug)).Id
|
[FromQuery] Include<Show>? fields
|
||||||
);
|
)
|
||||||
await _collections.AddMovie(collectionId, movieId);
|
{
|
||||||
return NoContent();
|
ICollection<Show> resources = await _libraryManager.Shows.GetAll(
|
||||||
}
|
Filter.And(filter, identifier.IsContainedIn<Show, Collection>(x => x.Collections)),
|
||||||
|
sortBy == new Sort<Show>.Default() ? new Sort<Show>.By(x => x.AirDate) : sortBy,
|
||||||
|
fields,
|
||||||
|
pagination
|
||||||
|
);
|
||||||
|
|
||||||
/// <summary>
|
if (
|
||||||
/// Add a show
|
!resources.Any()
|
||||||
/// </summary>
|
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
|
||||||
/// <remarks>
|
== null
|
||||||
/// Add a show in the collection.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
|
|
||||||
/// <param name="show">The ID or slug of the <see cref="Show"/> to add.</param>
|
|
||||||
/// <returns>Nothing if successful.</returns>
|
|
||||||
/// <response code="404">No collection or show with the given ID could be found.</response>
|
|
||||||
/// <response code="409">The specified show is already in this collection.</response>
|
|
||||||
[HttpPut("{identifier:id}/shows/{show:id}")]
|
|
||||||
[HttpPut("{identifier:id}/show/{show:id}", Order = AlternativeRoute)]
|
|
||||||
[PartialPermission(Kind.Write)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
||||||
public async Task<ActionResult> AddShow(Identifier identifier, Identifier show)
|
|
||||||
{
|
|
||||||
Guid collectionId = await identifier.Match(
|
|
||||||
async id => (await _libraryManager.Collections.Get(id)).Id,
|
|
||||||
async slug => (await _libraryManager.Collections.Get(slug)).Id
|
|
||||||
);
|
|
||||||
Guid showId = await show.Match(
|
|
||||||
async id => (await _libraryManager.Shows.Get(id)).Id,
|
|
||||||
async slug => (await _libraryManager.Shows.Get(slug)).Id
|
|
||||||
);
|
|
||||||
await _collections.AddShow(collectionId, showId);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get items in collection
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Lists the items that are contained in the collection with the given id or slug.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
|
|
||||||
/// <param name="sortBy">A key to sort items by.</param>
|
|
||||||
/// <param name="filter">An optional list of filters.</param>
|
|
||||||
/// <param name="pagination">The number of items to return.</param>
|
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
|
||||||
/// <returns>A page of items.</returns>
|
|
||||||
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
|
||||||
/// <response code="404">No collection with the given ID could be found.</response>
|
|
||||||
[HttpGet("{identifier:id}/items")]
|
|
||||||
[HttpGet("{identifier:id}/item", Order = AlternativeRoute)]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<Page<ILibraryItem>>> GetItems(
|
|
||||||
Identifier identifier,
|
|
||||||
[FromQuery] Sort<ILibraryItem> sortBy,
|
|
||||||
[FromQuery] Filter<ILibraryItem>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<ILibraryItem>? fields
|
|
||||||
)
|
)
|
||||||
{
|
return NotFound();
|
||||||
Guid collectionId = await identifier.Match(
|
return Page(resources, pagination.Limit);
|
||||||
id => Task.FromResult(id),
|
}
|
||||||
async slug => (await _libraryManager.Collections.Get(slug)).Id
|
|
||||||
);
|
|
||||||
ICollection<ILibraryItem> resources = await _items.GetAllOfCollection(
|
|
||||||
collectionId,
|
|
||||||
filter,
|
|
||||||
sortBy == new Sort<ILibraryItem>.Default()
|
|
||||||
? new Sort<ILibraryItem>.By(nameof(Movie.AirDate))
|
|
||||||
: sortBy,
|
|
||||||
fields,
|
|
||||||
pagination
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
/// <summary>
|
||||||
!resources.Any()
|
/// Get movies in collection
|
||||||
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
|
/// </summary>
|
||||||
== null
|
/// <remarks>
|
||||||
)
|
/// Lists the movies that are contained in the collection with the given id or slug.
|
||||||
return NotFound();
|
/// </remarks>
|
||||||
return Page(resources, pagination.Limit);
|
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
|
||||||
}
|
/// <param name="sortBy">A key to sort movies by.</param>
|
||||||
|
/// <param name="filter">An optional list of filters.</param>
|
||||||
|
/// <param name="pagination">The number of movies to return.</param>
|
||||||
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
|
/// <returns>A page of movies.</returns>
|
||||||
|
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
||||||
|
/// <response code="404">No collection with the given ID could be found.</response>
|
||||||
|
[HttpGet("{identifier:id}/movies")]
|
||||||
|
[HttpGet("{identifier:id}/movie", Order = AlternativeRoute)]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Page<Movie>>> GetMovies(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Sort<Movie> sortBy,
|
||||||
|
[FromQuery] Filter<Movie>? filter,
|
||||||
|
[FromQuery] Pagination pagination,
|
||||||
|
[FromQuery] Include<Movie>? fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ICollection<Movie> resources = await _libraryManager.Movies.GetAll(
|
||||||
|
Filter.And(filter, identifier.IsContainedIn<Movie, Collection>(x => x.Collections)),
|
||||||
|
sortBy == new Sort<Movie>.Default() ? new Sort<Movie>.By(x => x.AirDate) : sortBy,
|
||||||
|
fields,
|
||||||
|
pagination
|
||||||
|
);
|
||||||
|
|
||||||
/// <summary>
|
if (
|
||||||
/// Get shows in collection
|
!resources.Any()
|
||||||
/// </summary>
|
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
|
||||||
/// <remarks>
|
== null
|
||||||
/// Lists the shows that are contained in the collection with the given id or slug.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
|
|
||||||
/// <param name="sortBy">A key to sort shows by.</param>
|
|
||||||
/// <param name="filter">An optional list of filters.</param>
|
|
||||||
/// <param name="pagination">The number of shows to return.</param>
|
|
||||||
/// <param name="fields">The additional fields to include in the result.</param>
|
|
||||||
/// <returns>A page of shows.</returns>
|
|
||||||
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
|
||||||
/// <response code="404">No collection with the given ID could be found.</response>
|
|
||||||
[HttpGet("{identifier:id}/shows")]
|
|
||||||
[HttpGet("{identifier:id}/show", Order = AlternativeRoute)]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<Page<Show>>> GetShows(
|
|
||||||
Identifier identifier,
|
|
||||||
[FromQuery] Sort<Show> sortBy,
|
|
||||||
[FromQuery] Filter<Show>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<Show>? fields
|
|
||||||
)
|
)
|
||||||
{
|
return NotFound();
|
||||||
ICollection<Show> resources = await _libraryManager.Shows.GetAll(
|
return Page(resources, pagination.Limit);
|
||||||
Filter.And(filter, identifier.IsContainedIn<Show, Collection>(x => x.Collections)),
|
|
||||||
sortBy == new Sort<Show>.Default() ? new Sort<Show>.By(x => x.AirDate) : sortBy,
|
|
||||||
fields,
|
|
||||||
pagination
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!resources.Any()
|
|
||||||
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
|
|
||||||
== null
|
|
||||||
)
|
|
||||||
return NotFound();
|
|
||||||
return Page(resources, pagination.Limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Get movies in collection
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Lists the movies that are contained in the collection with the given id or slug.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Collection"/>.</param>
|
|
||||||
/// <param name="sortBy">A key to sort movies by.</param>
|
|
||||||
/// <param name="filter">An optional list of filters.</param>
|
|
||||||
/// <param name="pagination">The number of movies to return.</param>
|
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
|
||||||
/// <returns>A page of movies.</returns>
|
|
||||||
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
|
||||||
/// <response code="404">No collection with the given ID could be found.</response>
|
|
||||||
[HttpGet("{identifier:id}/movies")]
|
|
||||||
[HttpGet("{identifier:id}/movie", Order = AlternativeRoute)]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<Page<Movie>>> GetMovies(
|
|
||||||
Identifier identifier,
|
|
||||||
[FromQuery] Sort<Movie> sortBy,
|
|
||||||
[FromQuery] Filter<Movie>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<Movie>? fields
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ICollection<Movie> resources = await _libraryManager.Movies.GetAll(
|
|
||||||
Filter.And(filter, identifier.IsContainedIn<Movie, Collection>(x => x.Collections)),
|
|
||||||
sortBy == new Sort<Movie>.Default() ? new Sort<Movie>.By(x => x.AirDate) : sortBy,
|
|
||||||
fields,
|
|
||||||
pagination
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!resources.Any()
|
|
||||||
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
|
|
||||||
== null
|
|
||||||
)
|
|
||||||
return NotFound();
|
|
||||||
return Page(resources, pagination.Limit);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,172 +28,171 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about one or multiple <see cref="Episode"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Route("episodes")]
|
||||||
|
[Route("episode", Order = AlternativeRoute)]
|
||||||
|
[ApiController]
|
||||||
|
[PartialPermission(nameof(Episode))]
|
||||||
|
[ApiDefinition("Episodes", Group = ResourcesGroup)]
|
||||||
|
public class EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails)
|
||||||
|
: TranscoderApi<Episode>(libraryManager.Episodes, thumbnails)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Information about one or multiple <see cref="Episode"/>.
|
/// Get episode's show
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("episodes")]
|
/// <remarks>
|
||||||
[Route("episode", Order = AlternativeRoute)]
|
/// Get the show that this episode is part of.
|
||||||
[ApiController]
|
/// </remarks>
|
||||||
[PartialPermission(nameof(Episode))]
|
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
|
||||||
[ApiDefinition("Episodes", Group = ResourcesGroup)]
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
public class EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails)
|
/// <returns>The show that contains this episode.</returns>
|
||||||
: TranscoderApi<Episode>(libraryManager.Episodes, thumbnails)
|
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
||||||
|
[HttpGet("{identifier:id}/show")]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Show>> GetShow(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Include<Show> fields
|
||||||
|
)
|
||||||
{
|
{
|
||||||
/// <summary>
|
return await libraryManager.Shows.Get(
|
||||||
/// Get episode's show
|
identifier.IsContainedIn<Show, Episode>(x => x.Episodes!),
|
||||||
/// </summary>
|
fields
|
||||||
/// <remarks>
|
);
|
||||||
/// Get the show that this episode is part of.
|
}
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
|
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
|
||||||
/// <returns>The show that contains this episode.</returns>
|
|
||||||
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
|
||||||
[HttpGet("{identifier:id}/show")]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<Show>> GetShow(
|
|
||||||
Identifier identifier,
|
|
||||||
[FromQuery] Include<Show> fields
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return await libraryManager.Shows.Get(
|
|
||||||
identifier.IsContainedIn<Show, Episode>(x => x.Episodes!),
|
|
||||||
fields
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get episode's season
|
/// Get episode's season
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Get the season that this episode is part of.
|
/// Get the season that this episode is part of.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
/// <returns>The season that contains this episode.</returns>
|
/// <returns>The season that contains this episode.</returns>
|
||||||
/// <response code="204">The episode is not part of a season.</response>
|
/// <response code="204">The episode is not part of a season.</response>
|
||||||
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
||||||
[HttpGet("{identifier:id}/season")]
|
[HttpGet("{identifier:id}/season")]
|
||||||
[PartialPermission(Kind.Read)]
|
[PartialPermission(Kind.Read)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<Season>> GetSeason(
|
public async Task<ActionResult<Season>> GetSeason(
|
||||||
Identifier identifier,
|
Identifier identifier,
|
||||||
[FromQuery] Include<Season> fields
|
[FromQuery] Include<Season> fields
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Season? ret = await libraryManager.Seasons.GetOrDefault(
|
Season? ret = await libraryManager.Seasons.GetOrDefault(
|
||||||
identifier.IsContainedIn<Season, Episode>(x => x.Episodes!),
|
identifier.IsContainedIn<Season, Episode>(x => x.Episodes!),
|
||||||
fields
|
fields
|
||||||
);
|
);
|
||||||
if (ret != null)
|
if (ret != null)
|
||||||
return ret;
|
return ret;
|
||||||
Episode? episode = await identifier.Match(
|
Episode? episode = await identifier.Match(
|
||||||
id => libraryManager.Episodes.GetOrDefault(id),
|
id => libraryManager.Episodes.GetOrDefault(id),
|
||||||
slug => libraryManager.Episodes.GetOrDefault(slug)
|
slug => libraryManager.Episodes.GetOrDefault(slug)
|
||||||
);
|
);
|
||||||
return episode == null ? NotFound() : NoContent();
|
return episode == null ? NotFound() : NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get watch status
|
/// Get watch status
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Get when an item has been wathed and if it was watched.
|
/// Get when an item has been wathed and if it was watched.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
|
||||||
/// <returns>The status.</returns>
|
/// <returns>The status.</returns>
|
||||||
/// <response code="204">This episode does not have a specific status.</response>
|
/// <response code="204">This episode does not have a specific status.</response>
|
||||||
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
||||||
[HttpGet("{identifier:id}/watchStatus")]
|
[HttpGet("{identifier:id}/watchStatus")]
|
||||||
[UserOnly]
|
[UserOnly]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<EpisodeWatchStatus?> GetWatchStatus(Identifier identifier)
|
public async Task<EpisodeWatchStatus?> GetWatchStatus(Identifier identifier)
|
||||||
{
|
{
|
||||||
Guid id = await identifier.Match(
|
Guid id = await identifier.Match(
|
||||||
id => Task.FromResult(id),
|
id => Task.FromResult(id),
|
||||||
async slug => (await libraryManager.Episodes.Get(slug)).Id
|
async slug => (await libraryManager.Episodes.Get(slug)).Id
|
||||||
);
|
);
|
||||||
return await libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow());
|
return await libraryManager.WatchStatus.GetEpisodeStatus(id, User.GetIdOrThrow());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set watch status
|
/// Set watch status
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Set when an item has been wathed and if it was watched.
|
/// Set when an item has been wathed and if it was watched.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
|
||||||
/// <param name="status">The new watch status.</param>
|
/// <param name="status">The new watch status.</param>
|
||||||
/// <param name="watchedTime">Where the user stopped watching (in seconds).</param>
|
/// <param name="watchedTime">Where the user stopped watching (in seconds).</param>
|
||||||
/// <param name="percent">Where the user stopped watching (in percent).</param>
|
/// <param name="percent">Where the user stopped watching (in percent).</param>
|
||||||
/// <returns>The newly set status.</returns>
|
/// <returns>The newly set status.</returns>
|
||||||
/// <response code="200">The status has been set</response>
|
/// <response code="200">The status has been set</response>
|
||||||
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
|
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
|
||||||
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
||||||
[HttpPost("{identifier:id}/watchStatus")]
|
[HttpPost("{identifier:id}/watchStatus")]
|
||||||
[UserOnly]
|
[UserOnly]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<EpisodeWatchStatus?> SetWatchStatus(
|
public async Task<EpisodeWatchStatus?> SetWatchStatus(
|
||||||
Identifier identifier,
|
Identifier identifier,
|
||||||
WatchStatus status,
|
WatchStatus status,
|
||||||
int? watchedTime,
|
int? watchedTime,
|
||||||
int? percent
|
int? percent
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Guid id = await identifier.Match(
|
Guid id = await identifier.Match(
|
||||||
id => Task.FromResult(id),
|
id => Task.FromResult(id),
|
||||||
async slug => (await libraryManager.Episodes.Get(slug)).Id
|
async slug => (await libraryManager.Episodes.Get(slug)).Id
|
||||||
);
|
);
|
||||||
return await libraryManager.WatchStatus.SetEpisodeStatus(
|
return await libraryManager.WatchStatus.SetEpisodeStatus(
|
||||||
id,
|
id,
|
||||||
User.GetIdOrThrow(),
|
User.GetIdOrThrow(),
|
||||||
status,
|
status,
|
||||||
watchedTime,
|
watchedTime,
|
||||||
percent
|
percent
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delete watch status
|
/// Delete watch status
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Delete watch status (to rewatch for example).
|
/// Delete watch status (to rewatch for example).
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Episode"/>.</param>
|
||||||
/// <returns>The newly set status.</returns>
|
/// <returns>The newly set status.</returns>
|
||||||
/// <response code="204">The status has been deleted.</response>
|
/// <response code="204">The status has been deleted.</response>
|
||||||
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
||||||
[HttpDelete("{identifier:id}/watchStatus")]
|
[HttpDelete("{identifier:id}/watchStatus")]
|
||||||
[UserOnly]
|
[UserOnly]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task DeleteWatchStatus(Identifier identifier)
|
public async Task DeleteWatchStatus(Identifier identifier)
|
||||||
{
|
{
|
||||||
Guid id = await identifier.Match(
|
Guid id = await identifier.Match(
|
||||||
id => Task.FromResult(id),
|
id => Task.FromResult(id),
|
||||||
async slug => (await libraryManager.Episodes.Get(slug)).Id
|
async slug => (await libraryManager.Episodes.Get(slug)).Id
|
||||||
);
|
);
|
||||||
await libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow());
|
await libraryManager.WatchStatus.DeleteEpisodeStatus(id, User.GetIdOrThrow());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task<(string path, string route)> GetPath(Identifier identifier)
|
protected override async Task<(string path, string route)> GetPath(Identifier identifier)
|
||||||
{
|
{
|
||||||
string path = await identifier.Match(
|
string path = await identifier.Match(
|
||||||
async id => (await Repository.Get(id)).Path,
|
async id => (await Repository.Get(id)).Path,
|
||||||
async slug => (await Repository.Get(slug)).Path
|
async slug => (await Repository.Get(slug)).Path
|
||||||
);
|
);
|
||||||
return (path, $"/episodes/{identifier}");
|
return (path, $"/episodes/{identifier}");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,35 +23,34 @@ using Kyoo.Abstractions.Models.Permissions;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Endpoint for items that are not part of a specific library.
|
||||||
|
/// An item can ether represent a collection or a show.
|
||||||
|
/// </summary>
|
||||||
|
[Route("items")]
|
||||||
|
[Route("item", Order = AlternativeRoute)]
|
||||||
|
[ApiController]
|
||||||
|
[PartialPermission("LibraryItem")]
|
||||||
|
[ApiDefinition("Items", Group = ResourcesGroup)]
|
||||||
|
public class LibraryItemApi : CrudThumbsApi<ILibraryItem>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Endpoint for items that are not part of a specific library.
|
/// The library item repository used to modify or retrieve information in the data store.
|
||||||
/// An item can ether represent a collection or a show.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("items")]
|
private readonly IRepository<ILibraryItem> _libraryItems;
|
||||||
[Route("item", Order = AlternativeRoute)]
|
|
||||||
[ApiController]
|
|
||||||
[PartialPermission("LibraryItem")]
|
|
||||||
[ApiDefinition("Items", Group = ResourcesGroup)]
|
|
||||||
public class LibraryItemApi : CrudThumbsApi<ILibraryItem>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The library item repository used to modify or retrieve information in the data store.
|
|
||||||
/// </summary>
|
|
||||||
private readonly IRepository<ILibraryItem> _libraryItems;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="LibraryItemApi"/>.
|
/// Create a new <see cref="LibraryItemApi"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="libraryItems">
|
/// <param name="libraryItems">
|
||||||
/// The library item repository used to modify or retrieve information in the data store.
|
/// The library item repository used to modify or retrieve information in the data store.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="thumbs">Thumbnail manager to retrieve images.</param>
|
/// <param name="thumbs">Thumbnail manager to retrieve images.</param>
|
||||||
public LibraryItemApi(IRepository<ILibraryItem> libraryItems, IThumbnailsManager thumbs)
|
public LibraryItemApi(IRepository<ILibraryItem> libraryItems, IThumbnailsManager thumbs)
|
||||||
: base(libraryItems, thumbs)
|
: base(libraryItems, thumbs)
|
||||||
{
|
{
|
||||||
_libraryItems = libraryItems;
|
_libraryItems = libraryItems;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,182 +30,181 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about one or multiple <see cref="Movie"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Route("movies")]
|
||||||
|
[Route("movie", Order = AlternativeRoute)]
|
||||||
|
[ApiController]
|
||||||
|
[PartialPermission(nameof(Show))]
|
||||||
|
[ApiDefinition("Shows", Group = ResourcesGroup)]
|
||||||
|
public class MovieApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
|
||||||
|
: TranscoderApi<Movie>(libraryManager.Movies, thumbs)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Information about one or multiple <see cref="Movie"/>.
|
/// Get studio that made the show
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("movies")]
|
/// <remarks>
|
||||||
[Route("movie", Order = AlternativeRoute)]
|
/// Get the studio that made the show.
|
||||||
[ApiController]
|
/// </remarks>
|
||||||
[PartialPermission(nameof(Show))]
|
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
||||||
[ApiDefinition("Shows", Group = ResourcesGroup)]
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
public class MovieApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
|
/// <returns>The studio that made the show.</returns>
|
||||||
: TranscoderApi<Movie>(libraryManager.Movies, thumbs)
|
/// <response code="404">No show with the given ID or slug could be found.</response>
|
||||||
|
[HttpGet("{identifier:id}/studio")]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Studio>> GetStudio(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Include<Studio> fields
|
||||||
|
)
|
||||||
{
|
{
|
||||||
/// <summary>
|
return await libraryManager.Studios.Get(
|
||||||
/// Get studio that made the show
|
identifier.IsContainedIn<Studio, Movie>(x => x.Movies!),
|
||||||
/// </summary>
|
fields
|
||||||
/// <remarks>
|
);
|
||||||
/// Get the studio that made the show.
|
}
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
/// <summary>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
/// Get collections containing this show
|
||||||
/// <returns>The studio that made the show.</returns>
|
/// </summary>
|
||||||
/// <response code="404">No show with the given ID or slug could be found.</response>
|
/// <remarks>
|
||||||
[HttpGet("{identifier:id}/studio")]
|
/// List the collections that contain this show.
|
||||||
[PartialPermission(Kind.Read)]
|
/// </remarks>
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
/// <param name="sortBy">A key to sort collections by.</param>
|
||||||
public async Task<ActionResult<Studio>> GetStudio(
|
/// <param name="filter">An optional list of filters.</param>
|
||||||
Identifier identifier,
|
/// <param name="pagination">The number of collections to return.</param>
|
||||||
[FromQuery] Include<Studio> fields
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
|
/// <returns>A page of collections.</returns>
|
||||||
|
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
||||||
|
/// <response code="404">No show with the given ID or slug could be found.</response>
|
||||||
|
[HttpGet("{identifier:id}/collections")]
|
||||||
|
[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Page<Collection>>> GetCollections(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Sort<Collection> sortBy,
|
||||||
|
[FromQuery] Filter<Collection>? filter,
|
||||||
|
[FromQuery] Pagination pagination,
|
||||||
|
[FromQuery] Include<Collection> fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ICollection<Collection> resources = await libraryManager.Collections.GetAll(
|
||||||
|
Filter.And(filter, identifier.IsContainedIn<Collection, Movie>(x => x.Movies)),
|
||||||
|
sortBy,
|
||||||
|
fields,
|
||||||
|
pagination
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!resources.Any()
|
||||||
|
&& await libraryManager.Movies.GetOrDefault(identifier.IsSame<Movie>()) == null
|
||||||
)
|
)
|
||||||
{
|
return NotFound();
|
||||||
return await libraryManager.Studios.Get(
|
return Page(resources, pagination.Limit);
|
||||||
identifier.IsContainedIn<Studio, Movie>(x => x.Movies!),
|
}
|
||||||
fields
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get collections containing this show
|
/// Get watch status
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// List the collections that contain this show.
|
/// Get when an item has been wathed and if it was watched.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
|
||||||
/// <param name="sortBy">A key to sort collections by.</param>
|
/// <returns>The status.</returns>
|
||||||
/// <param name="filter">An optional list of filters.</param>
|
/// <response code="204">This movie does not have a specific status.</response>
|
||||||
/// <param name="pagination">The number of collections to return.</param>
|
/// <response code="404">No movie with the given ID or slug could be found.</response>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
[HttpGet("{identifier:id}/watchStatus")]
|
||||||
/// <returns>A page of collections.</returns>
|
[UserOnly]
|
||||||
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
/// <response code="404">No show with the given ID or slug could be found.</response>
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[HttpGet("{identifier:id}/collections")]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)]
|
public async Task<MovieWatchStatus?> GetWatchStatus(Identifier identifier)
|
||||||
[PartialPermission(Kind.Read)]
|
{
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
Guid id = await identifier.Match(
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
id => Task.FromResult(id),
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
async slug => (await libraryManager.Movies.Get(slug)).Id
|
||||||
public async Task<ActionResult<Page<Collection>>> GetCollections(
|
);
|
||||||
Identifier identifier,
|
return await libraryManager.WatchStatus.GetMovieStatus(id, User.GetIdOrThrow());
|
||||||
[FromQuery] Sort<Collection> sortBy,
|
}
|
||||||
[FromQuery] Filter<Collection>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<Collection> fields
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ICollection<Collection> resources = await libraryManager.Collections.GetAll(
|
|
||||||
Filter.And(filter, identifier.IsContainedIn<Collection, Movie>(x => x.Movies)),
|
|
||||||
sortBy,
|
|
||||||
fields,
|
|
||||||
pagination
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
/// <summary>
|
||||||
!resources.Any()
|
/// Set watch status
|
||||||
&& await libraryManager.Movies.GetOrDefault(identifier.IsSame<Movie>()) == null
|
/// </summary>
|
||||||
)
|
/// <remarks>
|
||||||
return NotFound();
|
/// Set when an item has been wathed and if it was watched.
|
||||||
return Page(resources, pagination.Limit);
|
/// </remarks>
|
||||||
}
|
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
|
||||||
|
/// <param name="status">The new watch status.</param>
|
||||||
|
/// <param name="watchedTime">Where the user stopped watching.</param>
|
||||||
|
/// <param name="percent">Where the user stopped watching (in percent).</param>
|
||||||
|
/// <returns>The newly set status.</returns>
|
||||||
|
/// <response code="200">The status has been set</response>
|
||||||
|
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
|
||||||
|
/// <response code="400">WatchedTime can't be specified if status is not watching.</response>
|
||||||
|
/// <response code="404">No movie with the given ID or slug could be found.</response>
|
||||||
|
[HttpPost("{identifier:id}/watchStatus")]
|
||||||
|
[UserOnly]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<MovieWatchStatus?> SetWatchStatus(
|
||||||
|
Identifier identifier,
|
||||||
|
WatchStatus status,
|
||||||
|
int? watchedTime,
|
||||||
|
int? percent
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Guid id = await identifier.Match(
|
||||||
|
id => Task.FromResult(id),
|
||||||
|
async slug => (await libraryManager.Movies.Get(slug)).Id
|
||||||
|
);
|
||||||
|
return await libraryManager.WatchStatus.SetMovieStatus(
|
||||||
|
id,
|
||||||
|
User.GetIdOrThrow(),
|
||||||
|
status,
|
||||||
|
watchedTime,
|
||||||
|
percent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get watch status
|
/// Delete watch status
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Get when an item has been wathed and if it was watched.
|
/// Delete watch status (to rewatch for example).
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
|
||||||
/// <returns>The status.</returns>
|
/// <returns>The newly set status.</returns>
|
||||||
/// <response code="204">This movie does not have a specific status.</response>
|
/// <response code="204">The status has been deleted.</response>
|
||||||
/// <response code="404">No movie with the given ID or slug could be found.</response>
|
/// <response code="404">No movie with the given ID or slug could be found.</response>
|
||||||
[HttpGet("{identifier:id}/watchStatus")]
|
[HttpDelete("{identifier:id}/watchStatus")]
|
||||||
[UserOnly]
|
[UserOnly]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
public async Task DeleteWatchStatus(Identifier identifier)
|
||||||
public async Task<MovieWatchStatus?> GetWatchStatus(Identifier identifier)
|
{
|
||||||
{
|
Guid id = await identifier.Match(
|
||||||
Guid id = await identifier.Match(
|
id => Task.FromResult(id),
|
||||||
id => Task.FromResult(id),
|
async slug => (await libraryManager.Movies.Get(slug)).Id
|
||||||
async slug => (await libraryManager.Movies.Get(slug)).Id
|
);
|
||||||
);
|
await libraryManager.WatchStatus.DeleteMovieStatus(id, User.GetIdOrThrow());
|
||||||
return await libraryManager.WatchStatus.GetMovieStatus(id, User.GetIdOrThrow());
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
protected override async Task<(string path, string route)> GetPath(Identifier identifier)
|
||||||
/// Set watch status
|
{
|
||||||
/// </summary>
|
string path = await identifier.Match(
|
||||||
/// <remarks>
|
async id => (await Repository.Get(id)).Path,
|
||||||
/// Set when an item has been wathed and if it was watched.
|
async slug => (await Repository.Get(slug)).Path
|
||||||
/// </remarks>
|
);
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
|
return (path, $"/movies/{identifier}");
|
||||||
/// <param name="status">The new watch status.</param>
|
|
||||||
/// <param name="watchedTime">Where the user stopped watching.</param>
|
|
||||||
/// <param name="percent">Where the user stopped watching (in percent).</param>
|
|
||||||
/// <returns>The newly set status.</returns>
|
|
||||||
/// <response code="200">The status has been set</response>
|
|
||||||
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
|
|
||||||
/// <response code="400">WatchedTime can't be specified if status is not watching.</response>
|
|
||||||
/// <response code="404">No movie with the given ID or slug could be found.</response>
|
|
||||||
[HttpPost("{identifier:id}/watchStatus")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<MovieWatchStatus?> SetWatchStatus(
|
|
||||||
Identifier identifier,
|
|
||||||
WatchStatus status,
|
|
||||||
int? watchedTime,
|
|
||||||
int? percent
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Guid id = await identifier.Match(
|
|
||||||
id => Task.FromResult(id),
|
|
||||||
async slug => (await libraryManager.Movies.Get(slug)).Id
|
|
||||||
);
|
|
||||||
return await libraryManager.WatchStatus.SetMovieStatus(
|
|
||||||
id,
|
|
||||||
User.GetIdOrThrow(),
|
|
||||||
status,
|
|
||||||
watchedTime,
|
|
||||||
percent
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete watch status
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Delete watch status (to rewatch for example).
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Movie"/>.</param>
|
|
||||||
/// <returns>The newly set status.</returns>
|
|
||||||
/// <response code="204">The status has been deleted.</response>
|
|
||||||
/// <response code="404">No movie with the given ID or slug could be found.</response>
|
|
||||||
[HttpDelete("{identifier:id}/watchStatus")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task DeleteWatchStatus(Identifier identifier)
|
|
||||||
{
|
|
||||||
Guid id = await identifier.Match(
|
|
||||||
id => Task.FromResult(id),
|
|
||||||
async slug => (await libraryManager.Movies.Get(slug)).Id
|
|
||||||
);
|
|
||||||
await libraryManager.WatchStatus.DeleteMovieStatus(id, User.GetIdOrThrow());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task<(string path, string route)> GetPath(Identifier identifier)
|
|
||||||
{
|
|
||||||
string path = await identifier.Match(
|
|
||||||
async id => (await Repository.Get(id)).Path,
|
|
||||||
async slug => (await Repository.Get(slug)).Path
|
|
||||||
);
|
|
||||||
return (path, $"/movies/{identifier}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,19 +23,18 @@ using Kyoo.Abstractions.Models.Permissions;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List new items added to kyoo.
|
||||||
|
/// </summary>
|
||||||
|
[Route("news")]
|
||||||
|
[Route("new", Order = AlternativeRoute)]
|
||||||
|
[ApiController]
|
||||||
|
[PartialPermission("LibraryItem")]
|
||||||
|
[ApiDefinition("News", Group = ResourcesGroup)]
|
||||||
|
public class NewsApi : CrudThumbsApi<INews>
|
||||||
{
|
{
|
||||||
/// <summary>
|
public NewsApi(IRepository<INews> news, IThumbnailsManager thumbs)
|
||||||
/// List new items added to kyoo.
|
: base(news, thumbs) { }
|
||||||
/// </summary>
|
|
||||||
[Route("news")]
|
|
||||||
[Route("new", Order = AlternativeRoute)]
|
|
||||||
[ApiController]
|
|
||||||
[PartialPermission("LibraryItem")]
|
|
||||||
[ApiDefinition("News", Group = ResourcesGroup)]
|
|
||||||
public class NewsApi : CrudThumbsApi<INews>
|
|
||||||
{
|
|
||||||
public NewsApi(IRepository<INews> news, IThumbnailsManager thumbs)
|
|
||||||
: base(news, thumbs) { }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -26,182 +26,179 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An endpoint to search for every resources of kyoo. Searching for only a specific type of resource
|
||||||
|
/// is available on the said endpoint.
|
||||||
|
/// </summary>
|
||||||
|
[Route("search")]
|
||||||
|
[ApiController]
|
||||||
|
[ApiDefinition("Search", Group = ResourcesGroup)]
|
||||||
|
public class SearchApi : BaseApi
|
||||||
{
|
{
|
||||||
/// <summary>
|
private readonly ISearchManager _searchManager;
|
||||||
/// An endpoint to search for every resources of kyoo. Searching for only a specific type of resource
|
|
||||||
/// is available on the said endpoint.
|
public SearchApi(ISearchManager searchManager)
|
||||||
/// </summary>
|
|
||||||
[Route("search")]
|
|
||||||
[ApiController]
|
|
||||||
[ApiDefinition("Search", Group = ResourcesGroup)]
|
|
||||||
public class SearchApi : BaseApi
|
|
||||||
{
|
{
|
||||||
private readonly ISearchManager _searchManager;
|
_searchManager = searchManager;
|
||||||
|
}
|
||||||
|
|
||||||
public SearchApi(ISearchManager searchManager)
|
// TODO: add filters and facets
|
||||||
{
|
|
||||||
_searchManager = searchManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: add filters and facets
|
/// <summary>
|
||||||
|
/// Search collections
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Search for collections
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="q">The query to search for.</param>
|
||||||
|
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
||||||
|
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
||||||
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
|
/// <returns>A list of collections found for the specified query.</returns>
|
||||||
|
[HttpGet("collections")]
|
||||||
|
[HttpGet("collection", Order = AlternativeRoute)]
|
||||||
|
[Permission(nameof(Collection), Kind.Read)]
|
||||||
|
[ApiDefinition("Collections")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public async Task<SearchPage<Collection>> SearchCollections(
|
||||||
|
[FromQuery] string? q,
|
||||||
|
[FromQuery] Sort<Collection> sortBy,
|
||||||
|
[FromQuery] SearchPagination pagination,
|
||||||
|
[FromQuery] Include<Collection> fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return SearchPage(await _searchManager.SearchCollections(q, sortBy, pagination, fields));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Search collections
|
/// Search shows
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Search for collections
|
/// Search for shows
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="q">The query to search for.</param>
|
/// <param name="q">The query to search for.</param>
|
||||||
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
||||||
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
/// <returns>A list of collections found for the specified query.</returns>
|
/// <returns>A list of shows found for the specified query.</returns>
|
||||||
[HttpGet("collections")]
|
[HttpGet("shows")]
|
||||||
[HttpGet("collection", Order = AlternativeRoute)]
|
[HttpGet("show", Order = AlternativeRoute)]
|
||||||
[Permission(nameof(Collection), Kind.Read)]
|
[Permission(nameof(Show), Kind.Read)]
|
||||||
[ApiDefinition("Collections")]
|
[ApiDefinition("Show")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<SearchPage<Collection>> SearchCollections(
|
public async Task<SearchPage<Show>> SearchShows(
|
||||||
[FromQuery] string? q,
|
[FromQuery] string? q,
|
||||||
[FromQuery] Sort<Collection> sortBy,
|
[FromQuery] Sort<Show> sortBy,
|
||||||
[FromQuery] SearchPagination pagination,
|
[FromQuery] SearchPagination pagination,
|
||||||
[FromQuery] Include<Collection> fields
|
[FromQuery] Include<Show> fields
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return SearchPage(
|
return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields));
|
||||||
await _searchManager.SearchCollections(q, sortBy, pagination, fields)
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Search shows
|
/// Search movie
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Search for shows
|
/// Search for movie
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="q">The query to search for.</param>
|
/// <param name="q">The query to search for.</param>
|
||||||
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
||||||
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
/// <returns>A list of shows found for the specified query.</returns>
|
/// <returns>A list of movies found for the specified query.</returns>
|
||||||
[HttpGet("shows")]
|
[HttpGet("movies")]
|
||||||
[HttpGet("show", Order = AlternativeRoute)]
|
[HttpGet("movie", Order = AlternativeRoute)]
|
||||||
[Permission(nameof(Show), Kind.Read)]
|
[Permission(nameof(Movie), Kind.Read)]
|
||||||
[ApiDefinition("Show")]
|
[ApiDefinition("Movie")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<SearchPage<Show>> SearchShows(
|
public async Task<SearchPage<Movie>> SearchMovies(
|
||||||
[FromQuery] string? q,
|
[FromQuery] string? q,
|
||||||
[FromQuery] Sort<Show> sortBy,
|
[FromQuery] Sort<Movie> sortBy,
|
||||||
[FromQuery] SearchPagination pagination,
|
[FromQuery] SearchPagination pagination,
|
||||||
[FromQuery] Include<Show> fields
|
[FromQuery] Include<Movie> fields
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields));
|
return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Search movie
|
/// Search items
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Search for movie
|
/// Search for items
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="q">The query to search for.</param>
|
/// <param name="q">The query to search for.</param>
|
||||||
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
||||||
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
/// <returns>A list of movies found for the specified query.</returns>
|
/// <returns>A list of items found for the specified query.</returns>
|
||||||
[HttpGet("movies")]
|
[HttpGet("items")]
|
||||||
[HttpGet("movie", Order = AlternativeRoute)]
|
[HttpGet("item", Order = AlternativeRoute)]
|
||||||
[Permission(nameof(Movie), Kind.Read)]
|
[Permission(nameof(ILibraryItem), Kind.Read)]
|
||||||
[ApiDefinition("Movie")]
|
[ApiDefinition("Item")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<SearchPage<Movie>> SearchMovies(
|
public async Task<SearchPage<ILibraryItem>> SearchItems(
|
||||||
[FromQuery] string? q,
|
[FromQuery] string? q,
|
||||||
[FromQuery] Sort<Movie> sortBy,
|
[FromQuery] Sort<ILibraryItem> sortBy,
|
||||||
[FromQuery] SearchPagination pagination,
|
[FromQuery] SearchPagination pagination,
|
||||||
[FromQuery] Include<Movie> fields
|
[FromQuery] Include<ILibraryItem> fields
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields));
|
return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Search items
|
/// Search episodes
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Search for items
|
/// Search for episodes
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="q">The query to search for.</param>
|
/// <param name="q">The query to search for.</param>
|
||||||
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
||||||
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
/// <returns>A list of items found for the specified query.</returns>
|
/// <returns>A list of episodes found for the specified query.</returns>
|
||||||
[HttpGet("items")]
|
[HttpGet("episodes")]
|
||||||
[HttpGet("item", Order = AlternativeRoute)]
|
[HttpGet("episode", Order = AlternativeRoute)]
|
||||||
[Permission(nameof(ILibraryItem), Kind.Read)]
|
[Permission(nameof(Episode), Kind.Read)]
|
||||||
[ApiDefinition("Item")]
|
[ApiDefinition("Episodes")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<SearchPage<ILibraryItem>> SearchItems(
|
public async Task<SearchPage<Episode>> SearchEpisodes(
|
||||||
[FromQuery] string? q,
|
[FromQuery] string? q,
|
||||||
[FromQuery] Sort<ILibraryItem> sortBy,
|
[FromQuery] Sort<Episode> sortBy,
|
||||||
[FromQuery] SearchPagination pagination,
|
[FromQuery] SearchPagination pagination,
|
||||||
[FromQuery] Include<ILibraryItem> fields
|
[FromQuery] Include<Episode> fields
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields));
|
return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Search episodes
|
/// Search studios
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Search for episodes
|
/// Search for studios
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="q">The query to search for.</param>
|
/// <param name="q">The query to search for.</param>
|
||||||
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
||||||
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
/// <returns>A list of episodes found for the specified query.</returns>
|
/// <returns>A list of studios found for the specified query.</returns>
|
||||||
[HttpGet("episodes")]
|
[HttpGet("studios")]
|
||||||
[HttpGet("episode", Order = AlternativeRoute)]
|
[HttpGet("studio", Order = AlternativeRoute)]
|
||||||
[Permission(nameof(Episode), Kind.Read)]
|
[Permission(nameof(Studio), Kind.Read)]
|
||||||
[ApiDefinition("Episodes")]
|
[ApiDefinition("Studios")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<SearchPage<Episode>> SearchEpisodes(
|
public async Task<SearchPage<Studio>> SearchStudios(
|
||||||
[FromQuery] string? q,
|
[FromQuery] string? q,
|
||||||
[FromQuery] Sort<Episode> sortBy,
|
[FromQuery] Sort<Studio> sortBy,
|
||||||
[FromQuery] SearchPagination pagination,
|
[FromQuery] SearchPagination pagination,
|
||||||
[FromQuery] Include<Episode> fields
|
[FromQuery] Include<Studio> fields
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields));
|
return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields));
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Search studios
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Search for studios
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="q">The query to search for.</param>
|
|
||||||
/// <param name="sortBy">Sort information about the query (sort by, sort order).</param>
|
|
||||||
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
|
||||||
/// <returns>A list of studios found for the specified query.</returns>
|
|
||||||
[HttpGet("studios")]
|
|
||||||
[HttpGet("studio", Order = AlternativeRoute)]
|
|
||||||
[Permission(nameof(Studio), Kind.Read)]
|
|
||||||
[ApiDefinition("Studios")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
public async Task<SearchPage<Studio>> SearchStudios(
|
|
||||||
[FromQuery] string? q,
|
|
||||||
[FromQuery] Sort<Studio> sortBy,
|
|
||||||
[FromQuery] SearchPagination pagination,
|
|
||||||
[FromQuery] Include<Studio> fields
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,108 +28,104 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about one or multiple <see cref="Season"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Route("seasons")]
|
||||||
|
[Route("season", Order = AlternativeRoute)]
|
||||||
|
[ApiController]
|
||||||
|
[PartialPermission(nameof(Season))]
|
||||||
|
[ApiDefinition("Seasons", Group = ResourcesGroup)]
|
||||||
|
public class SeasonApi : CrudThumbsApi<Season>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Information about one or multiple <see cref="Season"/>.
|
/// The library manager used to modify or retrieve information in the data store.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("seasons")]
|
private readonly ILibraryManager _libraryManager;
|
||||||
[Route("season", Order = AlternativeRoute)]
|
|
||||||
[ApiController]
|
/// <summary>
|
||||||
[PartialPermission(nameof(Season))]
|
/// Create a new <see cref="SeasonApi"/>.
|
||||||
[ApiDefinition("Seasons", Group = ResourcesGroup)]
|
/// </summary>
|
||||||
public class SeasonApi : CrudThumbsApi<Season>
|
/// <param name="libraryManager">
|
||||||
|
/// The library manager used to modify or retrieve information in the data store.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
|
||||||
|
public SeasonApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
|
||||||
|
: base(libraryManager.Seasons, thumbs)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_libraryManager = libraryManager;
|
||||||
/// The library manager used to modify or retrieve information in the data store.
|
}
|
||||||
/// </summary>
|
|
||||||
private readonly ILibraryManager _libraryManager;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="SeasonApi"/>.
|
/// Get episodes in the season
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="libraryManager">
|
/// <remarks>
|
||||||
/// The library manager used to modify or retrieve information in the data store.
|
/// List the episodes that are part of the specified season.
|
||||||
/// </param>
|
/// </remarks>
|
||||||
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param>
|
||||||
public SeasonApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
|
/// <param name="sortBy">A key to sort episodes by.</param>
|
||||||
: base(libraryManager.Seasons, thumbs)
|
/// <param name="filter">An optional list of filters.</param>
|
||||||
{
|
/// <param name="pagination">The number of episodes to return.</param>
|
||||||
_libraryManager = libraryManager;
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
}
|
/// <returns>A page of episodes.</returns>
|
||||||
|
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
||||||
|
/// <response code="404">No season with the given ID or slug could be found.</response>
|
||||||
|
[HttpGet("{identifier:id}/episodes")]
|
||||||
|
[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Page<Episode>>> GetEpisode(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Sort<Episode> sortBy,
|
||||||
|
[FromQuery] Filter<Episode>? filter,
|
||||||
|
[FromQuery] Pagination pagination,
|
||||||
|
[FromQuery] Include<Episode> fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll(
|
||||||
|
Filter.And(filter, identifier.Matcher<Episode>(x => x.SeasonId, x => x.Season!.Slug)),
|
||||||
|
sortBy,
|
||||||
|
fields,
|
||||||
|
pagination
|
||||||
|
);
|
||||||
|
|
||||||
/// <summary>
|
if (
|
||||||
/// Get episodes in the season
|
!resources.Any()
|
||||||
/// </summary>
|
&& await _libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null
|
||||||
/// <remarks>
|
|
||||||
/// List the episodes that are part of the specified season.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param>
|
|
||||||
/// <param name="sortBy">A key to sort episodes by.</param>
|
|
||||||
/// <param name="filter">An optional list of filters.</param>
|
|
||||||
/// <param name="pagination">The number of episodes to return.</param>
|
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
|
||||||
/// <returns>A page of episodes.</returns>
|
|
||||||
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
|
||||||
/// <response code="404">No season with the given ID or slug could be found.</response>
|
|
||||||
[HttpGet("{identifier:id}/episodes")]
|
|
||||||
[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<Page<Episode>>> GetEpisode(
|
|
||||||
Identifier identifier,
|
|
||||||
[FromQuery] Sort<Episode> sortBy,
|
|
||||||
[FromQuery] Filter<Episode>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<Episode> fields
|
|
||||||
)
|
)
|
||||||
{
|
return NotFound();
|
||||||
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll(
|
return Page(resources, pagination.Limit);
|
||||||
Filter.And(
|
}
|
||||||
filter,
|
|
||||||
identifier.Matcher<Episode>(x => x.SeasonId, x => x.Season!.Slug)
|
|
||||||
),
|
|
||||||
sortBy,
|
|
||||||
fields,
|
|
||||||
pagination
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
/// <summary>
|
||||||
!resources.Any()
|
/// Get season's show
|
||||||
&& await _libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null
|
/// </summary>
|
||||||
)
|
/// <remarks>
|
||||||
return NotFound();
|
/// Get the show that this season is part of.
|
||||||
return Page(resources, pagination.Limit);
|
/// </remarks>
|
||||||
}
|
/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param>
|
||||||
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
/// <summary>
|
/// <returns>The show that contains this season.</returns>
|
||||||
/// Get season's show
|
/// <response code="404">No season with the given ID or slug could be found.</response>
|
||||||
/// </summary>
|
[HttpGet("{identifier:id}/show")]
|
||||||
/// <remarks>
|
[PartialPermission(Kind.Read)]
|
||||||
/// Get the show that this season is part of.
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
/// </remarks>
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Season"/>.</param>
|
public async Task<ActionResult<Show>> GetShow(
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
Identifier identifier,
|
||||||
/// <returns>The show that contains this season.</returns>
|
[FromQuery] Include<Show> fields
|
||||||
/// <response code="404">No season with the given ID or slug could be found.</response>
|
)
|
||||||
[HttpGet("{identifier:id}/show")]
|
{
|
||||||
[PartialPermission(Kind.Read)]
|
Show? ret = await _libraryManager.Shows.GetOrDefault(
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
identifier.IsContainedIn<Show, Season>(x => x.Seasons!),
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
fields
|
||||||
public async Task<ActionResult<Show>> GetShow(
|
);
|
||||||
Identifier identifier,
|
if (ret == null)
|
||||||
[FromQuery] Include<Show> fields
|
return NotFound();
|
||||||
)
|
return ret;
|
||||||
{
|
|
||||||
Show? ret = await _libraryManager.Shows.GetOrDefault(
|
|
||||||
identifier.IsContainedIn<Show, Season>(x => x.Seasons!),
|
|
||||||
fields
|
|
||||||
);
|
|
||||||
if (ret == null)
|
|
||||||
return NotFound();
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,265 +30,261 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about one or multiple <see cref="Show"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Route("shows")]
|
||||||
|
[Route("show", Order = AlternativeRoute)]
|
||||||
|
[ApiController]
|
||||||
|
[PartialPermission(nameof(Show))]
|
||||||
|
[ApiDefinition("Shows", Group = ResourcesGroup)]
|
||||||
|
public class ShowApi : CrudThumbsApi<Show>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Information about one or multiple <see cref="Show"/>.
|
/// The library manager used to modify or retrieve information in the data store.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("shows")]
|
private readonly ILibraryManager _libraryManager;
|
||||||
[Route("show", Order = AlternativeRoute)]
|
|
||||||
[ApiController]
|
/// <summary>
|
||||||
[PartialPermission(nameof(Show))]
|
/// Create a new <see cref="ShowApi"/>.
|
||||||
[ApiDefinition("Shows", Group = ResourcesGroup)]
|
/// </summary>
|
||||||
public class ShowApi : CrudThumbsApi<Show>
|
/// <param name="libraryManager">
|
||||||
|
/// The library manager used to modify or retrieve information about the data store.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
|
||||||
|
public ShowApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
|
||||||
|
: base(libraryManager.Shows, thumbs)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_libraryManager = libraryManager;
|
||||||
/// The library manager used to modify or retrieve information in the data store.
|
}
|
||||||
/// </summary>
|
|
||||||
private readonly ILibraryManager _libraryManager;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="ShowApi"/>.
|
/// Get seasons of this show
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="libraryManager">
|
/// <remarks>
|
||||||
/// The library manager used to modify or retrieve information about the data store.
|
/// List the seasons that are part of the specified show.
|
||||||
/// </param>
|
/// </remarks>
|
||||||
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
||||||
public ShowApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
|
/// <param name="sortBy">A key to sort seasons by.</param>
|
||||||
: base(libraryManager.Shows, thumbs)
|
/// <param name="filter">An optional list of filters.</param>
|
||||||
{
|
/// <param name="pagination">The number of seasons to return.</param>
|
||||||
_libraryManager = libraryManager;
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
}
|
/// <returns>A page of seasons.</returns>
|
||||||
|
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
||||||
|
/// <response code="404">No show with the given ID or slug could be found.</response>
|
||||||
|
[HttpGet("{identifier:id}/seasons")]
|
||||||
|
[HttpGet("{identifier:id}/season", Order = AlternativeRoute)]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Page<Season>>> GetSeasons(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Sort<Season> sortBy,
|
||||||
|
[FromQuery] Filter<Season>? filter,
|
||||||
|
[FromQuery] Pagination pagination,
|
||||||
|
[FromQuery] Include<Season> fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ICollection<Season> resources = await _libraryManager.Seasons.GetAll(
|
||||||
|
Filter.And(filter, identifier.Matcher<Season>(x => x.ShowId, x => x.Show!.Slug)),
|
||||||
|
sortBy,
|
||||||
|
fields,
|
||||||
|
pagination
|
||||||
|
);
|
||||||
|
|
||||||
/// <summary>
|
if (
|
||||||
/// Get seasons of this show
|
!resources.Any()
|
||||||
/// </summary>
|
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
|
||||||
/// <remarks>
|
|
||||||
/// List the seasons that are part of the specified show.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
|
||||||
/// <param name="sortBy">A key to sort seasons by.</param>
|
|
||||||
/// <param name="filter">An optional list of filters.</param>
|
|
||||||
/// <param name="pagination">The number of seasons to return.</param>
|
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
|
||||||
/// <returns>A page of seasons.</returns>
|
|
||||||
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
|
||||||
/// <response code="404">No show with the given ID or slug could be found.</response>
|
|
||||||
[HttpGet("{identifier:id}/seasons")]
|
|
||||||
[HttpGet("{identifier:id}/season", Order = AlternativeRoute)]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<Page<Season>>> GetSeasons(
|
|
||||||
Identifier identifier,
|
|
||||||
[FromQuery] Sort<Season> sortBy,
|
|
||||||
[FromQuery] Filter<Season>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<Season> fields
|
|
||||||
)
|
)
|
||||||
{
|
return NotFound();
|
||||||
ICollection<Season> resources = await _libraryManager.Seasons.GetAll(
|
return Page(resources, pagination.Limit);
|
||||||
Filter.And(filter, identifier.Matcher<Season>(x => x.ShowId, x => x.Show!.Slug)),
|
}
|
||||||
sortBy,
|
|
||||||
fields,
|
|
||||||
pagination
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
/// <summary>
|
||||||
!resources.Any()
|
/// Get episodes of this show
|
||||||
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
|
/// </summary>
|
||||||
)
|
/// <remarks>
|
||||||
return NotFound();
|
/// List the episodes that are part of the specified show.
|
||||||
return Page(resources, pagination.Limit);
|
/// </remarks>
|
||||||
}
|
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
||||||
|
/// <param name="sortBy">A key to sort episodes by.</param>
|
||||||
|
/// <param name="filter">An optional list of filters.</param>
|
||||||
|
/// <param name="pagination">The number of episodes to return.</param>
|
||||||
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
|
/// <returns>A page of episodes.</returns>
|
||||||
|
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
||||||
|
/// <response code="404">No show with the given ID or slug could be found.</response>
|
||||||
|
[HttpGet("{identifier:id}/episodes")]
|
||||||
|
[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Page<Episode>>> GetEpisodes(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Sort<Episode> sortBy,
|
||||||
|
[FromQuery] Filter<Episode>? filter,
|
||||||
|
[FromQuery] Pagination pagination,
|
||||||
|
[FromQuery] Include<Episode> fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll(
|
||||||
|
Filter.And(filter, identifier.Matcher<Episode>(x => x.ShowId, x => x.Show!.Slug)),
|
||||||
|
sortBy,
|
||||||
|
fields,
|
||||||
|
pagination
|
||||||
|
);
|
||||||
|
|
||||||
/// <summary>
|
if (
|
||||||
/// Get episodes of this show
|
!resources.Any()
|
||||||
/// </summary>
|
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
|
||||||
/// <remarks>
|
|
||||||
/// List the episodes that are part of the specified show.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
|
||||||
/// <param name="sortBy">A key to sort episodes by.</param>
|
|
||||||
/// <param name="filter">An optional list of filters.</param>
|
|
||||||
/// <param name="pagination">The number of episodes to return.</param>
|
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
|
||||||
/// <returns>A page of episodes.</returns>
|
|
||||||
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
|
||||||
/// <response code="404">No show with the given ID or slug could be found.</response>
|
|
||||||
[HttpGet("{identifier:id}/episodes")]
|
|
||||||
[HttpGet("{identifier:id}/episode", Order = AlternativeRoute)]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ActionResult<Page<Episode>>> GetEpisodes(
|
|
||||||
Identifier identifier,
|
|
||||||
[FromQuery] Sort<Episode> sortBy,
|
|
||||||
[FromQuery] Filter<Episode>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<Episode> fields
|
|
||||||
)
|
)
|
||||||
{
|
return NotFound();
|
||||||
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll(
|
return Page(resources, pagination.Limit);
|
||||||
Filter.And(filter, identifier.Matcher<Episode>(x => x.ShowId, x => x.Show!.Slug)),
|
}
|
||||||
sortBy,
|
|
||||||
fields,
|
|
||||||
pagination
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
/// <summary>
|
||||||
!resources.Any()
|
/// Get studio that made the show
|
||||||
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
|
/// </summary>
|
||||||
)
|
/// <remarks>
|
||||||
return NotFound();
|
/// Get the studio that made the show.
|
||||||
return Page(resources, pagination.Limit);
|
/// </remarks>
|
||||||
}
|
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
||||||
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
|
/// <returns>The studio that made the show.</returns>
|
||||||
|
/// <response code="404">No show with the given ID or slug could be found.</response>
|
||||||
|
[HttpGet("{identifier:id}/studio")]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Studio>> GetStudio(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Include<Studio> fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return await _libraryManager.Studios.Get(
|
||||||
|
identifier.IsContainedIn<Studio, Show>(x => x.Shows!),
|
||||||
|
fields
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get studio that made the show
|
/// Get collections containing this show
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Get the studio that made the show.
|
/// List the collections that contain this show.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
/// <param name="sortBy">A key to sort collections by.</param>
|
||||||
/// <returns>The studio that made the show.</returns>
|
/// <param name="filter">An optional list of filters.</param>
|
||||||
/// <response code="404">No show with the given ID or slug could be found.</response>
|
/// <param name="pagination">The number of collections to return.</param>
|
||||||
[HttpGet("{identifier:id}/studio")]
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
[PartialPermission(Kind.Read)]
|
/// <returns>A page of collections.</returns>
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
/// <response code="404">No show with the given ID or slug could be found.</response>
|
||||||
public async Task<ActionResult<Studio>> GetStudio(
|
[HttpGet("{identifier:id}/collections")]
|
||||||
Identifier identifier,
|
[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)]
|
||||||
[FromQuery] Include<Studio> fields
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Page<Collection>>> GetCollections(
|
||||||
|
Identifier identifier,
|
||||||
|
[FromQuery] Sort<Collection> sortBy,
|
||||||
|
[FromQuery] Filter<Collection>? filter,
|
||||||
|
[FromQuery] Pagination pagination,
|
||||||
|
[FromQuery] Include<Collection> fields
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ICollection<Collection> resources = await _libraryManager.Collections.GetAll(
|
||||||
|
Filter.And(filter, identifier.IsContainedIn<Collection, Show>(x => x.Shows!)),
|
||||||
|
sortBy,
|
||||||
|
fields,
|
||||||
|
pagination
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!resources.Any()
|
||||||
|
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
|
||||||
)
|
)
|
||||||
{
|
return NotFound();
|
||||||
return await _libraryManager.Studios.Get(
|
return Page(resources, pagination.Limit);
|
||||||
identifier.IsContainedIn<Studio, Show>(x => x.Shows!),
|
}
|
||||||
fields
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get collections containing this show
|
/// Get watch status
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// List the collections that contain this show.
|
/// Get when an item has been wathed and if it was watched.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
||||||
/// <param name="sortBy">A key to sort collections by.</param>
|
/// <returns>The status.</returns>
|
||||||
/// <param name="filter">An optional list of filters.</param>
|
/// <response code="204">This show does not have a specific status.</response>
|
||||||
/// <param name="pagination">The number of collections to return.</param>
|
/// <response code="404">No show with the given ID or slug could be found.</response>
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
[HttpGet("{identifier:id}/watchStatus")]
|
||||||
/// <returns>A page of collections.</returns>
|
[UserOnly]
|
||||||
/// <response code="400">The filters or the sort parameters are invalid.</response>
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
/// <response code="404">No show with the given ID or slug could be found.</response>
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[HttpGet("{identifier:id}/collections")]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[HttpGet("{identifier:id}/collection", Order = AlternativeRoute)]
|
public async Task<ShowWatchStatus?> GetWatchStatus(Identifier identifier)
|
||||||
[PartialPermission(Kind.Read)]
|
{
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
Guid id = await identifier.Match(
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
id => Task.FromResult(id),
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
async slug => (await _libraryManager.Shows.Get(slug)).Id
|
||||||
public async Task<ActionResult<Page<Collection>>> GetCollections(
|
);
|
||||||
Identifier identifier,
|
return await _libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow());
|
||||||
[FromQuery] Sort<Collection> sortBy,
|
}
|
||||||
[FromQuery] Filter<Collection>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<Collection> fields
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ICollection<Collection> resources = await _libraryManager.Collections.GetAll(
|
|
||||||
Filter.And(filter, identifier.IsContainedIn<Collection, Show>(x => x.Shows!)),
|
|
||||||
sortBy,
|
|
||||||
fields,
|
|
||||||
pagination
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
/// <summary>
|
||||||
!resources.Any()
|
/// Set watch status
|
||||||
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
|
/// </summary>
|
||||||
)
|
/// <remarks>
|
||||||
return NotFound();
|
/// Set when an item has been wathed and if it was watched.
|
||||||
return Page(resources, pagination.Limit);
|
/// </remarks>
|
||||||
}
|
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
||||||
|
/// <param name="status">The new watch status.</param>
|
||||||
|
/// <returns>The newly set status.</returns>
|
||||||
|
/// <response code="200">The status has been set</response>
|
||||||
|
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
|
||||||
|
/// <response code="404">No movie with the given ID or slug could be found.</response>
|
||||||
|
[HttpPost("{identifier:id}/watchStatus")]
|
||||||
|
[UserOnly]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ShowWatchStatus?> SetWatchStatus(Identifier identifier, WatchStatus status)
|
||||||
|
{
|
||||||
|
Guid id = await identifier.Match(
|
||||||
|
id => Task.FromResult(id),
|
||||||
|
async slug => (await _libraryManager.Shows.Get(slug)).Id
|
||||||
|
);
|
||||||
|
return await _libraryManager.WatchStatus.SetShowStatus(id, User.GetIdOrThrow(), status);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get watch status
|
/// Delete watch status
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Get when an item has been wathed and if it was watched.
|
/// Delete watch status (to rewatch for example).
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
||||||
/// <returns>The status.</returns>
|
/// <returns>The newly set status.</returns>
|
||||||
/// <response code="204">This show does not have a specific status.</response>
|
/// <response code="204">The status has been deleted.</response>
|
||||||
/// <response code="404">No show with the given ID or slug could be found.</response>
|
/// <response code="404">No show with the given ID or slug could be found.</response>
|
||||||
[HttpGet("{identifier:id}/watchStatus")]
|
[HttpDelete("{identifier:id}/watchStatus")]
|
||||||
[UserOnly]
|
[UserOnly]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
public async Task DeleteWatchStatus(Identifier identifier)
|
||||||
public async Task<ShowWatchStatus?> GetWatchStatus(Identifier identifier)
|
{
|
||||||
{
|
Guid id = await identifier.Match(
|
||||||
Guid id = await identifier.Match(
|
id => Task.FromResult(id),
|
||||||
id => Task.FromResult(id),
|
async slug => (await _libraryManager.Shows.Get(slug)).Id
|
||||||
async slug => (await _libraryManager.Shows.Get(slug)).Id
|
);
|
||||||
);
|
await _libraryManager.WatchStatus.DeleteShowStatus(id, User.GetIdOrThrow());
|
||||||
return await _libraryManager.WatchStatus.GetShowStatus(id, User.GetIdOrThrow());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Set watch status
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Set when an item has been wathed and if it was watched.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
|
||||||
/// <param name="status">The new watch status.</param>
|
|
||||||
/// <returns>The newly set status.</returns>
|
|
||||||
/// <response code="200">The status has been set</response>
|
|
||||||
/// <response code="204">The status was not considered impactfull enough to be saved (less then 5% of watched for example).</response>
|
|
||||||
/// <response code="404">No movie with the given ID or slug could be found.</response>
|
|
||||||
[HttpPost("{identifier:id}/watchStatus")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<ShowWatchStatus?> SetWatchStatus(
|
|
||||||
Identifier identifier,
|
|
||||||
WatchStatus status
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Guid id = await identifier.Match(
|
|
||||||
id => Task.FromResult(id),
|
|
||||||
async slug => (await _libraryManager.Shows.Get(slug)).Id
|
|
||||||
);
|
|
||||||
return await _libraryManager.WatchStatus.SetShowStatus(id, User.GetIdOrThrow(), status);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delete watch status
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Delete watch status (to rewatch for example).
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
|
||||||
/// <returns>The newly set status.</returns>
|
|
||||||
/// <response code="204">The status has been deleted.</response>
|
|
||||||
/// <response code="404">No show with the given ID or slug could be found.</response>
|
|
||||||
[HttpDelete("{identifier:id}/watchStatus")]
|
|
||||||
[UserOnly]
|
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task DeleteWatchStatus(Identifier identifier)
|
|
||||||
{
|
|
||||||
Guid id = await identifier.Match(
|
|
||||||
id => Task.FromResult(id),
|
|
||||||
async slug => (await _libraryManager.Shows.Get(slug)).Id
|
|
||||||
);
|
|
||||||
await _libraryManager.WatchStatus.DeleteShowStatus(id, User.GetIdOrThrow());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,44 +29,43 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List new items added to kyoo.
|
||||||
|
/// </summary>
|
||||||
|
[Route("watchlist")]
|
||||||
|
[ApiController]
|
||||||
|
[PartialPermission("LibraryItem")]
|
||||||
|
[ApiDefinition("News", Group = ResourcesGroup)]
|
||||||
|
[UserOnly]
|
||||||
|
public class WatchlistApi(IWatchStatusRepository repository) : BaseApi
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// List new items added to kyoo.
|
/// Get all
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Route("watchlist")]
|
/// <remarks>
|
||||||
[ApiController]
|
/// Get all resources that match the given filter.
|
||||||
[PartialPermission("LibraryItem")]
|
/// </remarks>
|
||||||
[ApiDefinition("News", Group = ResourcesGroup)]
|
/// <param name="filter">Filter the returned items.</param>
|
||||||
[UserOnly]
|
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
||||||
public class WatchlistApi(IWatchStatusRepository repository) : BaseApi
|
/// <param name="fields">The aditional fields to include in the result.</param>
|
||||||
|
/// <returns>A list of resources that match every filters.</returns>
|
||||||
|
/// <response code="400">Invalid filters or sort information.</response>
|
||||||
|
[HttpGet]
|
||||||
|
[PartialPermission(Kind.Read)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
||||||
|
public async Task<ActionResult<Page<IWatchlist>>> GetAll(
|
||||||
|
[FromQuery] Filter<IWatchlist>? filter,
|
||||||
|
[FromQuery] Pagination pagination,
|
||||||
|
[FromQuery] Include<IWatchlist>? fields
|
||||||
|
)
|
||||||
{
|
{
|
||||||
/// <summary>
|
if (User.GetId() == null)
|
||||||
/// Get all
|
throw new UnauthorizedException();
|
||||||
/// </summary>
|
ICollection<IWatchlist> resources = await repository.GetAll(filter, fields, pagination);
|
||||||
/// <remarks>
|
|
||||||
/// Get all resources that match the given filter.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="filter">Filter the returned items.</param>
|
|
||||||
/// <param name="pagination">How many items per page should be returned, where should the page start...</param>
|
|
||||||
/// <param name="fields">The aditional fields to include in the result.</param>
|
|
||||||
/// <returns>A list of resources that match every filters.</returns>
|
|
||||||
/// <response code="400">Invalid filters or sort information.</response>
|
|
||||||
[HttpGet]
|
|
||||||
[PartialPermission(Kind.Read)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
|
|
||||||
public async Task<ActionResult<Page<IWatchlist>>> GetAll(
|
|
||||||
[FromQuery] Filter<IWatchlist>? filter,
|
|
||||||
[FromQuery] Pagination pagination,
|
|
||||||
[FromQuery] Include<IWatchlist>? fields
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (User.GetId() == null)
|
|
||||||
throw new UnauthorizedException();
|
|
||||||
ICollection<IWatchlist> resources = await repository.GetAll(filter, fields, pagination);
|
|
||||||
|
|
||||||
return Page(resources, pagination.Limit);
|
return Page(resources, pagination.Limit);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,69 +28,68 @@ using Kyoo.Utils;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Kyoo.Core.Api
|
namespace Kyoo.Core.Api;
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Proxy to other services
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Obsolete("Use /episode/id/master.m3u8 or routes like that")]
|
|
||||||
public class ProxyApi(ILibraryManager library) : Controller
|
|
||||||
{
|
|
||||||
private Task _Proxy(string route, (string path, string route) info)
|
|
||||||
{
|
|
||||||
HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder
|
|
||||||
.Instance.WithBeforeSend(
|
|
||||||
(ctx, req) =>
|
|
||||||
{
|
|
||||||
req.Headers.Add("X-Path", info.path);
|
|
||||||
req.Headers.Add("X-Route", info.route);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.WithHandleFailure(
|
|
||||||
async (context, exception) =>
|
|
||||||
{
|
|
||||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
|
||||||
await context.Response.WriteAsJsonAsync(
|
|
||||||
new RequestError("Service unavailable")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.Build();
|
|
||||||
return this.HttpProxyAsync($"http://transcoder:7666/{route}", proxyOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Transcoder proxy
|
/// Proxy to other services
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
[ApiController]
|
||||||
/// Simply proxy requests to the transcoder
|
[Obsolete("Use /episode/id/master.m3u8 or routes like that")]
|
||||||
/// </remarks>
|
public class ProxyApi(ILibraryManager library) : Controller
|
||||||
/// <param name="rest">The path of the transcoder.</param>
|
{
|
||||||
/// <returns>The return value of the transcoder.</returns>
|
private Task _Proxy(string route, (string path, string route) info)
|
||||||
[Route("video/{type}/{id:id}/{**rest}")]
|
{
|
||||||
[Permission("video", Kind.Read)]
|
HttpProxyOptions proxyOptions = HttpProxyOptionsBuilder
|
||||||
[Obsolete("Use /episode/id/master.m3u8 or routes like that")]
|
.Instance.WithBeforeSend(
|
||||||
public async Task Proxy(
|
(ctx, req) =>
|
||||||
string type,
|
{
|
||||||
Identifier id,
|
req.Headers.Add("X-Path", info.path);
|
||||||
string rest,
|
req.Headers.Add("X-Route", info.route);
|
||||||
[FromQuery] Dictionary<string, string> query
|
return Task.CompletedTask;
|
||||||
)
|
}
|
||||||
{
|
)
|
||||||
string path = await (
|
.WithHandleFailure(
|
||||||
type is "movie" or "movies"
|
async (context, exception) =>
|
||||||
? id.Match(
|
{
|
||||||
async id => (await library.Movies.Get(id)).Path,
|
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||||
async slug => (await library.Movies.Get(slug)).Path
|
await context.Response.WriteAsJsonAsync(
|
||||||
)
|
new RequestError("Service unavailable")
|
||||||
: id.Match(
|
);
|
||||||
async id => (await library.Episodes.Get(id)).Path,
|
}
|
||||||
async slug => (await library.Episodes.Get(slug)).Path
|
)
|
||||||
)
|
.Build();
|
||||||
);
|
return this.HttpProxyAsync($"http://transcoder:7666/{route}", proxyOptions);
|
||||||
await _Proxy(rest + query.ToQueryString(), (path, $"{type}/{id}"));
|
}
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transcoder proxy
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Simply proxy requests to the transcoder
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="rest">The path of the transcoder.</param>
|
||||||
|
/// <returns>The return value of the transcoder.</returns>
|
||||||
|
[Route("video/{type}/{id:id}/{**rest}")]
|
||||||
|
[Permission("video", Kind.Read)]
|
||||||
|
[Obsolete("Use /episode/id/master.m3u8 or routes like that")]
|
||||||
|
public async Task Proxy(
|
||||||
|
string type,
|
||||||
|
Identifier id,
|
||||||
|
string rest,
|
||||||
|
[FromQuery] Dictionary<string, string> query
|
||||||
|
)
|
||||||
|
{
|
||||||
|
string path = await (
|
||||||
|
type is "movie" or "movies"
|
||||||
|
? id.Match(
|
||||||
|
async id => (await library.Movies.Get(id)).Path,
|
||||||
|
async slug => (await library.Movies.Get(slug)).Path
|
||||||
|
)
|
||||||
|
: id.Match(
|
||||||
|
async id => (await library.Episodes.Get(id)).Path,
|
||||||
|
async slug => (await library.Episodes.Get(slug)).Path
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await _Proxy(rest + query.ToQueryString(), (path, $"{type}/{id}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,166 +37,160 @@ using Serilog.Templates;
|
|||||||
using Serilog.Templates.Themes;
|
using Serilog.Templates.Themes;
|
||||||
using ILogger = Serilog.ILogger;
|
using ILogger = Serilog.ILogger;
|
||||||
|
|
||||||
namespace Kyoo.Host
|
namespace Kyoo.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hosts of kyoo (main functions) generally only create a new <see cref="Application"/>
|
||||||
|
/// and return <see cref="Start(string[])"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class Application
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hosts of kyoo (main functions) generally only create a new <see cref="Application"/>
|
/// The environment in witch Kyoo will run (ether "Production" or "Development").
|
||||||
/// and return <see cref="Start(string[])"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Application
|
private readonly string _environment;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The logger used for startup and error messages.
|
||||||
|
/// </summary>
|
||||||
|
private ILogger _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="Application"/> that will use the specified environment.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="environment">The environment to run in.</param>
|
||||||
|
public Application(string environment)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_environment = environment;
|
||||||
/// The environment in witch Kyoo will run (ether "Production" or "Development").
|
}
|
||||||
/// </summary>
|
|
||||||
private readonly string _environment;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The logger used for startup and error messages.
|
/// Start the application with the given console args.
|
||||||
/// </summary>
|
/// This is generally called from the Main entrypoint of Kyoo.
|
||||||
private ILogger _logger;
|
/// </summary>
|
||||||
|
/// <param name="args">The console arguments to use for kyoo.</param>
|
||||||
|
/// <returns>A task representing the whole process</returns>
|
||||||
|
public Task Start(string[] args)
|
||||||
|
{
|
||||||
|
return Start(args, _ => { });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="Application"/> that will use the specified environment.
|
/// Start the application with the given console args.
|
||||||
/// </summary>
|
/// This is generally called from the Main entrypoint of Kyoo.
|
||||||
/// <param name="environment">The environment to run in.</param>
|
/// </summary>
|
||||||
public Application(string environment)
|
/// <param name="args">The console arguments to use for kyoo.</param>
|
||||||
|
/// <param name="configure">A custom action to configure the container before the start</param>
|
||||||
|
/// <returns>A task representing the whole process</returns>
|
||||||
|
public async Task Start(string[] args, Action<ContainerBuilder> configure)
|
||||||
|
{
|
||||||
|
IConfiguration parsed = _SetupConfig(new ConfigurationBuilder(), args).Build();
|
||||||
|
string path = Path.GetFullPath(parsed.GetValue("DATADIR", "/kyoo"));
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
Environment.CurrentDirectory = path;
|
||||||
|
|
||||||
|
LoggerConfiguration config = new();
|
||||||
|
_ConfigureLogging(config);
|
||||||
|
Log.Logger = config.CreateBootstrapLogger();
|
||||||
|
_logger = Log.Logger.ForContext<Application>();
|
||||||
|
|
||||||
|
AppDomain.CurrentDomain.ProcessExit += (_, _) => Log.CloseAndFlush();
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (_, ex) =>
|
||||||
|
Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception");
|
||||||
|
|
||||||
|
IHost host = _CreateWebHostBuilder(args).ConfigureContainer(configure).Build();
|
||||||
|
|
||||||
|
await using (AsyncServiceScope scope = host.Services.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
_environment = environment;
|
PostgresModule.Initialize(scope.ServiceProvider);
|
||||||
|
await MeilisearchModule.Initialize(scope.ServiceProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
await _StartWithHost(host);
|
||||||
/// Start the application with the given console args.
|
}
|
||||||
/// This is generally called from the Main entrypoint of Kyoo.
|
|
||||||
/// </summary>
|
/// <summary>
|
||||||
/// <param name="args">The console arguments to use for kyoo.</param>
|
/// Start the given host and log failing exceptions.
|
||||||
/// <returns>A task representing the whole process</returns>
|
/// </summary>
|
||||||
public Task Start(string[] args)
|
/// <param name="host">The host to start.</param>
|
||||||
|
/// <param name="cancellationToken">A token to allow one to stop the host.</param>
|
||||||
|
private async Task _StartWithHost(IHost host, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
return Start(args, _ => { });
|
CoreModule.Services = host.Services;
|
||||||
|
_logger.Information(
|
||||||
|
"Version: {Version}",
|
||||||
|
Assembly.GetExecutingAssembly().GetName().Version.ToString(3)
|
||||||
|
);
|
||||||
|
_logger.Information("Data directory: {DataDirectory}", Environment.CurrentDirectory);
|
||||||
|
await host.RunAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
/// <summary>
|
|
||||||
/// Start the application with the given console args.
|
|
||||||
/// This is generally called from the Main entrypoint of Kyoo.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="args">The console arguments to use for kyoo.</param>
|
|
||||||
/// <param name="configure">A custom action to configure the container before the start</param>
|
|
||||||
/// <returns>A task representing the whole process</returns>
|
|
||||||
public async Task Start(string[] args, Action<ContainerBuilder> configure)
|
|
||||||
{
|
{
|
||||||
IConfiguration parsed = _SetupConfig(new ConfigurationBuilder(), args).Build();
|
_logger.Fatal(ex, "Unhandled exception");
|
||||||
string path = Path.GetFullPath(parsed.GetValue("DATADIR", "/kyoo"));
|
|
||||||
if (!Directory.Exists(path))
|
|
||||||
Directory.CreateDirectory(path);
|
|
||||||
Environment.CurrentDirectory = path;
|
|
||||||
|
|
||||||
LoggerConfiguration config = new();
|
|
||||||
_ConfigureLogging(config);
|
|
||||||
Log.Logger = config.CreateBootstrapLogger();
|
|
||||||
_logger = Log.Logger.ForContext<Application>();
|
|
||||||
|
|
||||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => Log.CloseAndFlush();
|
|
||||||
AppDomain.CurrentDomain.UnhandledException += (_, ex) =>
|
|
||||||
Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception");
|
|
||||||
|
|
||||||
IHost host = _CreateWebHostBuilder(args).ConfigureContainer(configure).Build();
|
|
||||||
|
|
||||||
await using (AsyncServiceScope scope = host.Services.CreateAsyncScope())
|
|
||||||
{
|
|
||||||
PostgresModule.Initialize(scope.ServiceProvider);
|
|
||||||
await MeilisearchModule.Initialize(scope.ServiceProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _StartWithHost(host);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Start the given host and log failing exceptions.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="host">The host to start.</param>
|
|
||||||
/// <param name="cancellationToken">A token to allow one to stop the host.</param>
|
|
||||||
private async Task _StartWithHost(IHost host, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
CoreModule.Services = host.Services;
|
|
||||||
_logger.Information(
|
|
||||||
"Version: {Version}",
|
|
||||||
Assembly.GetExecutingAssembly().GetName().Version.ToString(3)
|
|
||||||
);
|
|
||||||
_logger.Information(
|
|
||||||
"Data directory: {DataDirectory}",
|
|
||||||
Environment.CurrentDirectory
|
|
||||||
);
|
|
||||||
await host.RunAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.Fatal(ex, "Unhandled exception");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a a web host
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="args">Command line parameters that can be handled by kestrel</param>
|
|
||||||
/// <returns>A new web host instance</returns>
|
|
||||||
private IHostBuilder _CreateWebHostBuilder(string[] args)
|
|
||||||
{
|
|
||||||
return new HostBuilder()
|
|
||||||
.UseServiceProviderFactory(new AutofacServiceProviderFactory())
|
|
||||||
.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
|
|
||||||
.UseEnvironment(_environment)
|
|
||||||
.ConfigureAppConfiguration(x => _SetupConfig(x, args))
|
|
||||||
.UseSerilog((host, services, builder) => _ConfigureLogging(builder))
|
|
||||||
.ConfigureServices(x => x.AddRouting())
|
|
||||||
.ConfigureWebHost(x =>
|
|
||||||
x.UseKestrel(options =>
|
|
||||||
{
|
|
||||||
options.AddServerHeader = false;
|
|
||||||
})
|
|
||||||
.UseIIS()
|
|
||||||
.UseIISIntegration()
|
|
||||||
.UseUrls(
|
|
||||||
Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000"
|
|
||||||
)
|
|
||||||
.UseStartup(host =>
|
|
||||||
PluginsStartup.FromWebHost(host, new LoggerFactory().AddSerilog())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Register settings.json, environment variables and command lines arguments as configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="builder">The configuration builder to use</param>
|
|
||||||
/// <param name="args">The command line arguments</param>
|
|
||||||
/// <returns>The modified configuration builder</returns>
|
|
||||||
private IConfigurationBuilder _SetupConfig(IConfigurationBuilder builder, string[] args)
|
|
||||||
{
|
|
||||||
return builder
|
|
||||||
.AddEnvironmentVariables()
|
|
||||||
.AddEnvironmentVariables("KYOO_")
|
|
||||||
.AddCommandLine(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configure the logging.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="builder">The logger builder to configure.</param>
|
|
||||||
private void _ConfigureLogging(LoggerConfiguration builder)
|
|
||||||
{
|
|
||||||
const string template =
|
|
||||||
"[{@t:HH:mm:ss} {@l:u3} {Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), 25} "
|
|
||||||
+ "({@i:D10})] {@m}{#if not EndsWith(@m, '\n')}\n{#end}{@x}";
|
|
||||||
builder
|
|
||||||
.MinimumLevel.Warning()
|
|
||||||
.MinimumLevel.Override("Kyoo", LogEventLevel.Verbose)
|
|
||||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Verbose)
|
|
||||||
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Fatal)
|
|
||||||
.WriteTo.Console(new ExpressionTemplate(template, theme: TemplateTheme.Code))
|
|
||||||
.Enrich.WithThreadId()
|
|
||||||
.Enrich.FromLogContext();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a a web host
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="args">Command line parameters that can be handled by kestrel</param>
|
||||||
|
/// <returns>A new web host instance</returns>
|
||||||
|
private IHostBuilder _CreateWebHostBuilder(string[] args)
|
||||||
|
{
|
||||||
|
return new HostBuilder()
|
||||||
|
.UseServiceProviderFactory(new AutofacServiceProviderFactory())
|
||||||
|
.UseContentRoot(AppDomain.CurrentDomain.BaseDirectory)
|
||||||
|
.UseEnvironment(_environment)
|
||||||
|
.ConfigureAppConfiguration(x => _SetupConfig(x, args))
|
||||||
|
.UseSerilog((host, services, builder) => _ConfigureLogging(builder))
|
||||||
|
.ConfigureServices(x => x.AddRouting())
|
||||||
|
.ConfigureWebHost(x =>
|
||||||
|
x.UseKestrel(options =>
|
||||||
|
{
|
||||||
|
options.AddServerHeader = false;
|
||||||
|
})
|
||||||
|
.UseIIS()
|
||||||
|
.UseIISIntegration()
|
||||||
|
.UseUrls(Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000")
|
||||||
|
.UseStartup(host =>
|
||||||
|
PluginsStartup.FromWebHost(host, new LoggerFactory().AddSerilog())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register settings.json, environment variables and command lines arguments as configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The configuration builder to use</param>
|
||||||
|
/// <param name="args">The command line arguments</param>
|
||||||
|
/// <returns>The modified configuration builder</returns>
|
||||||
|
private IConfigurationBuilder _SetupConfig(IConfigurationBuilder builder, string[] args)
|
||||||
|
{
|
||||||
|
return builder
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.AddEnvironmentVariables("KYOO_")
|
||||||
|
.AddCommandLine(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configure the logging.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The logger builder to configure.</param>
|
||||||
|
private void _ConfigureLogging(LoggerConfiguration builder)
|
||||||
|
{
|
||||||
|
const string template =
|
||||||
|
"[{@t:HH:mm:ss} {@l:u3} {Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), 25} "
|
||||||
|
+ "({@i:D10})] {@m}{#if not EndsWith(@m, '\n')}\n{#end}{@x}";
|
||||||
|
builder
|
||||||
|
.MinimumLevel.Warning()
|
||||||
|
.MinimumLevel.Override("Kyoo", LogEventLevel.Verbose)
|
||||||
|
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Verbose)
|
||||||
|
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Fatal)
|
||||||
|
.WriteTo.Console(new ExpressionTemplate(template, theme: TemplateTheme.Code))
|
||||||
|
.Enrich.WithThreadId()
|
||||||
|
.Enrich.FromLogContext();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,73 +23,70 @@ using Kyoo.Abstractions.Controllers;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Kyoo.Host.Controllers
|
namespace Kyoo.Host.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An implementation of <see cref="IPluginManager"/>.
|
||||||
|
/// This is used to load plugins and retrieve information from them.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginManager : IPluginManager
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An implementation of <see cref="IPluginManager"/>.
|
/// The service provider. It allow plugin's activation.
|
||||||
/// This is used to load plugins and retrieve information from them.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PluginManager : IPluginManager
|
private readonly IServiceProvider _provider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The logger used by this class.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ILogger<PluginManager> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The list of plugins that are currently loaded.
|
||||||
|
/// </summary>
|
||||||
|
private readonly List<IPlugin> _plugins = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="PluginManager"/> instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">A service container to allow initialization of plugins</param>
|
||||||
|
/// <param name="logger">The logger used by this class.</param>
|
||||||
|
public PluginManager(IServiceProvider provider, ILogger<PluginManager> logger)
|
||||||
{
|
{
|
||||||
/// <summary>
|
_provider = provider;
|
||||||
/// The service provider. It allow plugin's activation.
|
_logger = logger;
|
||||||
/// </summary>
|
}
|
||||||
private readonly IServiceProvider _provider;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// The logger used by this class.
|
public T GetPlugin<T>(string name)
|
||||||
/// </summary>
|
{
|
||||||
private readonly ILogger<PluginManager> _logger;
|
return (T)_plugins?.FirstOrDefault(x => x.Name == name && x is T);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// The list of plugins that are currently loaded.
|
public ICollection<T> GetPlugins<T>()
|
||||||
/// </summary>
|
{
|
||||||
private readonly List<IPlugin> _plugins = new();
|
return _plugins?.OfType<T>().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Create a new <see cref="PluginManager"/> instance.
|
public ICollection<IPlugin> GetAllPlugins()
|
||||||
/// </summary>
|
{
|
||||||
/// <param name="provider">A service container to allow initialization of plugins</param>
|
return _plugins;
|
||||||
/// <param name="logger">The logger used by this class.</param>
|
}
|
||||||
public PluginManager(IServiceProvider provider, ILogger<PluginManager> logger)
|
|
||||||
{
|
|
||||||
_provider = provider;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public T GetPlugin<T>(string name)
|
public void LoadPlugins(ICollection<IPlugin> plugins)
|
||||||
{
|
{
|
||||||
return (T)_plugins?.FirstOrDefault(x => x.Name == name && x is T);
|
_plugins.AddRange(plugins);
|
||||||
}
|
_logger.LogInformation("Modules enabled: {Plugins}", _plugins.Select(x => x.Name));
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ICollection<T> GetPlugins<T>()
|
public void LoadPlugins(params Type[] plugins)
|
||||||
{
|
{
|
||||||
return _plugins?.OfType<T>().ToArray();
|
LoadPlugins(
|
||||||
}
|
plugins.Select(x => (IPlugin)ActivatorUtilities.CreateInstance(_provider, x)).ToArray()
|
||||||
|
);
|
||||||
/// <inheritdoc />
|
|
||||||
public ICollection<IPlugin> GetAllPlugins()
|
|
||||||
{
|
|
||||||
return _plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void LoadPlugins(ICollection<IPlugin> plugins)
|
|
||||||
{
|
|
||||||
_plugins.AddRange(plugins);
|
|
||||||
_logger.LogInformation("Modules enabled: {Plugins}", _plugins.Select(x => x.Name));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void LoadPlugins(params Type[] plugins)
|
|
||||||
{
|
|
||||||
LoadPlugins(
|
|
||||||
plugins
|
|
||||||
.Select(x => (IPlugin)ActivatorUtilities.CreateInstance(_provider, x))
|
|
||||||
.ToArray()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,39 +23,38 @@ using Kyoo.Abstractions.Controllers;
|
|||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
namespace Kyoo.Host
|
namespace Kyoo.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A module that registers host controllers and other needed things.
|
||||||
|
/// </summary>
|
||||||
|
public class HostModule : IPlugin
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Host";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A module that registers host controllers and other needed things.
|
/// The plugin manager that loaded all plugins.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class HostModule : IPlugin
|
private readonly IPluginManager _plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="HostModule"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="plugins">The plugin manager that loaded all plugins.</param>
|
||||||
|
public HostModule(IPluginManager plugins)
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
_plugins = plugins;
|
||||||
public string Name => "Host";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The plugin manager that loaded all plugins.
|
|
||||||
/// </summary>
|
|
||||||
private readonly IPluginManager _plugins;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="HostModule"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="plugins">The plugin manager that loaded all plugins.</param>
|
|
||||||
public HostModule(IPluginManager plugins)
|
|
||||||
{
|
|
||||||
_plugins = plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Configure(ContainerBuilder builder)
|
|
||||||
{
|
|
||||||
builder.RegisterModule<AttributedMetadataModule>();
|
|
||||||
builder.RegisterInstance(_plugins).As<IPluginManager>().ExternallyOwned();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IEnumerable<IStartupAction> ConfigureSteps =>
|
|
||||||
new[] { SA.New<IApplicationBuilder>(app => app.UseSerilogRequestLogging(), SA.Before) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Configure(ContainerBuilder builder)
|
||||||
|
{
|
||||||
|
builder.RegisterModule<AttributedMetadataModule>();
|
||||||
|
builder.RegisterInstance(_plugins).As<IPluginManager>().ExternallyOwned();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<IStartupAction> ConfigureSteps =>
|
||||||
|
new[] { SA.New<IApplicationBuilder>(app => app.UseSerilogRequestLogging(), SA.Before) };
|
||||||
}
|
}
|
||||||
|
@ -36,167 +36,163 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Kyoo.Host
|
namespace Kyoo.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Startup class is used to configure the AspNet's webhost.
|
||||||
|
/// </summary>
|
||||||
|
public class PluginsStartup
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Startup class is used to configure the AspNet's webhost.
|
/// A plugin manager used to load plugins and allow them to configure services / asp net.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PluginsStartup
|
private readonly IPluginManager _plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The plugin that adds controllers and tasks specific to this host.
|
||||||
|
/// </summary>
|
||||||
|
private readonly IPlugin _hostModule;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Created from the DI container, those services are needed to load information and instantiate plugins.s
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="plugins">The plugin manager to use to load new plugins and configure the host.</param>
|
||||||
|
public PluginsStartup(IPluginManager plugins)
|
||||||
|
{
|
||||||
|
_plugins = plugins;
|
||||||
|
_hostModule = new HostModule(_plugins);
|
||||||
|
_plugins.LoadPlugins(
|
||||||
|
typeof(CoreModule),
|
||||||
|
typeof(AuthenticationModule),
|
||||||
|
typeof(PostgresModule),
|
||||||
|
typeof(MeilisearchModule),
|
||||||
|
typeof(SwaggerModule)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new <see cref="PluginsStartup"/> from a webhost.
|
||||||
|
/// This is meant to be used from <see cref="WebHostBuilderExtensions.UseStartup"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="host">The context of the web host.</param>
|
||||||
|
/// <param name="logger">
|
||||||
|
/// The logger factory used to log while the application is setting itself up.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>A new <see cref="PluginsStartup"/>.</returns>
|
||||||
|
public static PluginsStartup FromWebHost(WebHostBuilderContext host, ILoggerFactory logger)
|
||||||
|
{
|
||||||
|
HostServiceProvider hostProvider = new(host.HostingEnvironment, host.Configuration, logger);
|
||||||
|
PluginManager plugins = new(hostProvider, logger.CreateLogger<PluginManager>());
|
||||||
|
return new PluginsStartup(plugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configure the services context via the <see cref="PluginManager"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">The service collection to fill.</param>
|
||||||
|
public void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
foreach (Assembly assembly in _plugins.GetAllPlugins().Select(x => x.GetType().Assembly))
|
||||||
|
services.AddMvcCore().AddApplicationPart(assembly);
|
||||||
|
|
||||||
|
_hostModule.Configure(services);
|
||||||
|
foreach (IPlugin plugin in _plugins.GetAllPlugins())
|
||||||
|
plugin.Configure(services);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configure the autofac container via the <see cref="PluginManager"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The builder to configure.</param>
|
||||||
|
public void ConfigureContainer(ContainerBuilder builder)
|
||||||
|
{
|
||||||
|
_hostModule.Configure(builder);
|
||||||
|
foreach (IPlugin plugin in _plugins.GetAllPlugins())
|
||||||
|
plugin.Configure(builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configure the asp net host.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="app">The asp net host to configure</param>
|
||||||
|
/// <param name="container">An autofac container used to create a new scope to configure asp-net.</param>
|
||||||
|
public void Configure(IApplicationBuilder app, ILifetimeScope container)
|
||||||
|
{
|
||||||
|
IEnumerable<IStartupAction> steps = _plugins
|
||||||
|
.GetAllPlugins()
|
||||||
|
.Append(_hostModule)
|
||||||
|
.SelectMany(x => x.ConfigureSteps)
|
||||||
|
.OrderByDescending(x => x.Priority);
|
||||||
|
|
||||||
|
using ILifetimeScope scope = container.BeginLifetimeScope(x =>
|
||||||
|
x.RegisterInstance(app).SingleInstance().ExternallyOwned()
|
||||||
|
);
|
||||||
|
IServiceProvider provider = scope.Resolve<IServiceProvider>();
|
||||||
|
foreach (IStartupAction step in steps)
|
||||||
|
step.Run(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A simple host service provider used to activate plugins instance.
|
||||||
|
/// The same services as a generic host are available and an <see cref="ILoggerFactory"/> has been added.
|
||||||
|
/// </summary>
|
||||||
|
private class HostServiceProvider : IServiceProvider
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A plugin manager used to load plugins and allow them to configure services / asp net.
|
/// The host environment that could be used by plugins to configure themself.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly IPluginManager _plugins;
|
private readonly IWebHostEnvironment _hostEnvironment;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The plugin that adds controllers and tasks specific to this host.
|
/// The configuration context.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly IPlugin _hostModule;
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Created from the DI container, those services are needed to load information and instantiate plugins.s
|
/// A logger factory used to create a logger for the plugin manager.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="plugins">The plugin manager to use to load new plugins and configure the host.</param>
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
public PluginsStartup(IPluginManager plugins)
|
|
||||||
{
|
|
||||||
_plugins = plugins;
|
|
||||||
_hostModule = new HostModule(_plugins);
|
|
||||||
_plugins.LoadPlugins(
|
|
||||||
typeof(CoreModule),
|
|
||||||
typeof(AuthenticationModule),
|
|
||||||
typeof(PostgresModule),
|
|
||||||
typeof(MeilisearchModule),
|
|
||||||
typeof(SwaggerModule)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new <see cref="PluginsStartup"/> from a webhost.
|
/// Create a new <see cref="HostServiceProvider"/> that will return given services when asked.
|
||||||
/// This is meant to be used from <see cref="WebHostBuilderExtensions.UseStartup"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="host">The context of the web host.</param>
|
/// <param name="hostEnvironment">
|
||||||
/// <param name="logger">
|
/// The host environment that could be used by plugins to configure themself.
|
||||||
/// The logger factory used to log while the application is setting itself up.
|
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <returns>A new <see cref="PluginsStartup"/>.</returns>
|
/// <param name="configuration">The configuration context</param>
|
||||||
public static PluginsStartup FromWebHost(WebHostBuilderContext host, ILoggerFactory logger)
|
/// <param name="loggerFactory">A logger factory used to create a logger for the plugin manager.</param>
|
||||||
|
public HostServiceProvider(
|
||||||
|
IWebHostEnvironment hostEnvironment,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILoggerFactory loggerFactory
|
||||||
|
)
|
||||||
{
|
{
|
||||||
HostServiceProvider hostProvider =
|
_hostEnvironment = hostEnvironment;
|
||||||
new(host.HostingEnvironment, host.Configuration, logger);
|
_configuration = configuration;
|
||||||
PluginManager plugins = new(hostProvider, logger.CreateLogger<PluginManager>());
|
_loggerFactory = loggerFactory;
|
||||||
return new PluginsStartup(plugins);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Configure the services context via the <see cref="PluginManager"/>.
|
public object GetService(Type serviceType)
|
||||||
/// </summary>
|
|
||||||
/// <param name="services">The service collection to fill.</param>
|
|
||||||
public void ConfigureServices(IServiceCollection services)
|
|
||||||
{
|
{
|
||||||
foreach (
|
if (
|
||||||
Assembly assembly in _plugins.GetAllPlugins().Select(x => x.GetType().Assembly)
|
serviceType == typeof(IWebHostEnvironment)
|
||||||
)
|
|| serviceType == typeof(IHostEnvironment)
|
||||||
services.AddMvcCore().AddApplicationPart(assembly);
|
|
||||||
|
|
||||||
_hostModule.Configure(services);
|
|
||||||
foreach (IPlugin plugin in _plugins.GetAllPlugins())
|
|
||||||
plugin.Configure(services);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configure the autofac container via the <see cref="PluginManager"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="builder">The builder to configure.</param>
|
|
||||||
public void ConfigureContainer(ContainerBuilder builder)
|
|
||||||
{
|
|
||||||
_hostModule.Configure(builder);
|
|
||||||
foreach (IPlugin plugin in _plugins.GetAllPlugins())
|
|
||||||
plugin.Configure(builder);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configure the asp net host.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="app">The asp net host to configure</param>
|
|
||||||
/// <param name="container">An autofac container used to create a new scope to configure asp-net.</param>
|
|
||||||
public void Configure(IApplicationBuilder app, ILifetimeScope container)
|
|
||||||
{
|
|
||||||
IEnumerable<IStartupAction> steps = _plugins
|
|
||||||
.GetAllPlugins()
|
|
||||||
.Append(_hostModule)
|
|
||||||
.SelectMany(x => x.ConfigureSteps)
|
|
||||||
.OrderByDescending(x => x.Priority);
|
|
||||||
|
|
||||||
using ILifetimeScope scope = container.BeginLifetimeScope(x =>
|
|
||||||
x.RegisterInstance(app).SingleInstance().ExternallyOwned()
|
|
||||||
);
|
|
||||||
IServiceProvider provider = scope.Resolve<IServiceProvider>();
|
|
||||||
foreach (IStartupAction step in steps)
|
|
||||||
step.Run(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A simple host service provider used to activate plugins instance.
|
|
||||||
/// The same services as a generic host are available and an <see cref="ILoggerFactory"/> has been added.
|
|
||||||
/// </summary>
|
|
||||||
private class HostServiceProvider : IServiceProvider
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The host environment that could be used by plugins to configure themself.
|
|
||||||
/// </summary>
|
|
||||||
private readonly IWebHostEnvironment _hostEnvironment;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The configuration context.
|
|
||||||
/// </summary>
|
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A logger factory used to create a logger for the plugin manager.
|
|
||||||
/// </summary>
|
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Create a new <see cref="HostServiceProvider"/> that will return given services when asked.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="hostEnvironment">
|
|
||||||
/// The host environment that could be used by plugins to configure themself.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="configuration">The configuration context</param>
|
|
||||||
/// <param name="loggerFactory">A logger factory used to create a logger for the plugin manager.</param>
|
|
||||||
public HostServiceProvider(
|
|
||||||
IWebHostEnvironment hostEnvironment,
|
|
||||||
IConfiguration configuration,
|
|
||||||
ILoggerFactory loggerFactory
|
|
||||||
)
|
)
|
||||||
|
return _hostEnvironment;
|
||||||
|
if (serviceType == typeof(IConfiguration))
|
||||||
|
return _configuration;
|
||||||
|
if (serviceType.GetGenericTypeDefinition() == typeof(ILogger<>))
|
||||||
{
|
{
|
||||||
_hostEnvironment = hostEnvironment;
|
return Utility.RunGenericMethod<object>(
|
||||||
_configuration = configuration;
|
typeof(LoggerFactoryExtensions),
|
||||||
_loggerFactory = loggerFactory;
|
nameof(LoggerFactoryExtensions.CreateLogger),
|
||||||
|
serviceType.GetGenericArguments().First(),
|
||||||
|
_loggerFactory
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
return null;
|
||||||
public object GetService(Type serviceType)
|
|
||||||
{
|
|
||||||
if (
|
|
||||||
serviceType == typeof(IWebHostEnvironment)
|
|
||||||
|| serviceType == typeof(IHostEnvironment)
|
|
||||||
)
|
|
||||||
return _hostEnvironment;
|
|
||||||
if (serviceType == typeof(IConfiguration))
|
|
||||||
return _configuration;
|
|
||||||
if (serviceType.GetGenericTypeDefinition() == typeof(ILogger<>))
|
|
||||||
{
|
|
||||||
return Utility.RunGenericMethod<object>(
|
|
||||||
typeof(LoggerFactoryExtensions),
|
|
||||||
nameof(LoggerFactoryExtensions.CreateLogger),
|
|
||||||
serviceType.GetGenericArguments().First(),
|
|
||||||
_loggerFactory
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,31 +19,30 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
|
||||||
namespace Kyoo.Host
|
namespace Kyoo.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Program entrypoint.
|
||||||
|
/// </summary>
|
||||||
|
public static class Program
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Program entrypoint.
|
/// The string representation of the environment used in <see cref="IWebHostEnvironment"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Program
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The string representation of the environment used in <see cref="IWebHostEnvironment"/>.
|
|
||||||
/// </summary>
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private const string Environment = "Development";
|
private const string Environment = "Development";
|
||||||
#else
|
#else
|
||||||
private const string Environment = "Production";
|
private const string Environment = "Production";
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Main function of the program
|
/// Main function of the program
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="args">Command line arguments</param>
|
/// <param name="args">Command line arguments</param>
|
||||||
/// <returns>A <see cref="Task"/> representing the lifetime of the program.</returns>
|
/// <returns>A <see cref="Task"/> representing the lifetime of the program.</returns>
|
||||||
public static Task Main(string[] args)
|
public static Task Main(string[] args)
|
||||||
{
|
{
|
||||||
Application application = new(Environment);
|
Application application = new(Environment);
|
||||||
return application.Start(args);
|
return application.Start(args);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,171 +24,166 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using static System.Text.Json.JsonNamingPolicy;
|
using static System.Text.Json.JsonNamingPolicy;
|
||||||
|
|
||||||
namespace Kyoo.Meiliseach
|
namespace Kyoo.Meiliseach;
|
||||||
|
|
||||||
|
public class MeilisearchModule : IPlugin
|
||||||
{
|
{
|
||||||
public class MeilisearchModule : IPlugin
|
/// <inheritdoc />
|
||||||
|
public string Name => "Meilisearch";
|
||||||
|
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
|
public static Dictionary<string, Settings> IndexSettings =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"items",
|
||||||
|
new Settings()
|
||||||
|
{
|
||||||
|
SearchableAttributes = new[]
|
||||||
|
{
|
||||||
|
CamelCase.ConvertName(nameof(Movie.Name)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.Slug)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.Aliases)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.Path)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.Tags)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.Overview)),
|
||||||
|
},
|
||||||
|
FilterableAttributes = new[]
|
||||||
|
{
|
||||||
|
CamelCase.ConvertName(nameof(Movie.Genres)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.Status)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.AirDate)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.StudioId)),
|
||||||
|
"kind"
|
||||||
|
},
|
||||||
|
SortableAttributes = new[]
|
||||||
|
{
|
||||||
|
CamelCase.ConvertName(nameof(Movie.AirDate)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.AddedDate)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.Rating)),
|
||||||
|
CamelCase.ConvertName(nameof(Movie.Runtime)),
|
||||||
|
},
|
||||||
|
DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Movie.Id)), "kind" },
|
||||||
|
RankingRules = new[]
|
||||||
|
{
|
||||||
|
"words",
|
||||||
|
"typo",
|
||||||
|
"proximity",
|
||||||
|
"attribute",
|
||||||
|
"sort",
|
||||||
|
"exactness",
|
||||||
|
$"{CamelCase.ConvertName(nameof(Movie.Rating))}:desc",
|
||||||
|
}
|
||||||
|
// TODO: Add stopwords
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nameof(Episode),
|
||||||
|
new Settings()
|
||||||
|
{
|
||||||
|
SearchableAttributes = new[]
|
||||||
|
{
|
||||||
|
CamelCase.ConvertName(nameof(Episode.Name)),
|
||||||
|
CamelCase.ConvertName(nameof(Episode.Overview)),
|
||||||
|
CamelCase.ConvertName(nameof(Episode.Slug)),
|
||||||
|
CamelCase.ConvertName(nameof(Episode.Path)),
|
||||||
|
},
|
||||||
|
FilterableAttributes = new[]
|
||||||
|
{
|
||||||
|
CamelCase.ConvertName(nameof(Episode.SeasonNumber)),
|
||||||
|
},
|
||||||
|
SortableAttributes = new[]
|
||||||
|
{
|
||||||
|
CamelCase.ConvertName(nameof(Episode.ReleaseDate)),
|
||||||
|
CamelCase.ConvertName(nameof(Episode.AddedDate)),
|
||||||
|
CamelCase.ConvertName(nameof(Episode.SeasonNumber)),
|
||||||
|
CamelCase.ConvertName(nameof(Episode.EpisodeNumber)),
|
||||||
|
CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)),
|
||||||
|
},
|
||||||
|
DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Episode.Id)), },
|
||||||
|
// TODO: Add stopwords
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nameof(Studio),
|
||||||
|
new Settings()
|
||||||
|
{
|
||||||
|
SearchableAttributes = new[]
|
||||||
|
{
|
||||||
|
CamelCase.ConvertName(nameof(Studio.Name)),
|
||||||
|
CamelCase.ConvertName(nameof(Studio.Slug)),
|
||||||
|
},
|
||||||
|
FilterableAttributes = Array.Empty<string>(),
|
||||||
|
SortableAttributes = Array.Empty<string>(),
|
||||||
|
DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Studio.Id)), },
|
||||||
|
// TODO: Add stopwords
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public MeilisearchModule(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
_configuration = configuration;
|
||||||
public string Name => "Meilisearch";
|
}
|
||||||
|
|
||||||
private readonly IConfiguration _configuration;
|
/// <summary>
|
||||||
|
/// Init meilisearch indexes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The service list to retrieve the meilisearch client</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
public static async Task Initialize(IServiceProvider provider)
|
||||||
|
{
|
||||||
|
MeilisearchClient client = provider.GetRequiredService<MeilisearchClient>();
|
||||||
|
|
||||||
public static Dictionary<string, Settings> IndexSettings =>
|
await _CreateIndex(client, "items", true);
|
||||||
new()
|
await _CreateIndex(client, nameof(Episode), false);
|
||||||
{
|
await _CreateIndex(client, nameof(Studio), false);
|
||||||
{
|
|
||||||
"items",
|
|
||||||
new Settings()
|
|
||||||
{
|
|
||||||
SearchableAttributes = new[]
|
|
||||||
{
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Name)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Slug)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Aliases)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Path)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Tags)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Overview)),
|
|
||||||
},
|
|
||||||
FilterableAttributes = new[]
|
|
||||||
{
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Genres)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Status)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.AirDate)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.StudioId)),
|
|
||||||
"kind"
|
|
||||||
},
|
|
||||||
SortableAttributes = new[]
|
|
||||||
{
|
|
||||||
CamelCase.ConvertName(nameof(Movie.AirDate)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.AddedDate)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Rating)),
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Runtime)),
|
|
||||||
},
|
|
||||||
DisplayedAttributes = new[]
|
|
||||||
{
|
|
||||||
CamelCase.ConvertName(nameof(Movie.Id)),
|
|
||||||
"kind"
|
|
||||||
},
|
|
||||||
RankingRules = new[]
|
|
||||||
{
|
|
||||||
"words",
|
|
||||||
"typo",
|
|
||||||
"proximity",
|
|
||||||
"attribute",
|
|
||||||
"sort",
|
|
||||||
"exactness",
|
|
||||||
$"{CamelCase.ConvertName(nameof(Movie.Rating))}:desc",
|
|
||||||
}
|
|
||||||
// TODO: Add stopwords
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nameof(Episode),
|
|
||||||
new Settings()
|
|
||||||
{
|
|
||||||
SearchableAttributes = new[]
|
|
||||||
{
|
|
||||||
CamelCase.ConvertName(nameof(Episode.Name)),
|
|
||||||
CamelCase.ConvertName(nameof(Episode.Overview)),
|
|
||||||
CamelCase.ConvertName(nameof(Episode.Slug)),
|
|
||||||
CamelCase.ConvertName(nameof(Episode.Path)),
|
|
||||||
},
|
|
||||||
FilterableAttributes = new[]
|
|
||||||
{
|
|
||||||
CamelCase.ConvertName(nameof(Episode.SeasonNumber)),
|
|
||||||
},
|
|
||||||
SortableAttributes = new[]
|
|
||||||
{
|
|
||||||
CamelCase.ConvertName(nameof(Episode.ReleaseDate)),
|
|
||||||
CamelCase.ConvertName(nameof(Episode.AddedDate)),
|
|
||||||
CamelCase.ConvertName(nameof(Episode.SeasonNumber)),
|
|
||||||
CamelCase.ConvertName(nameof(Episode.EpisodeNumber)),
|
|
||||||
CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)),
|
|
||||||
},
|
|
||||||
DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Episode.Id)), },
|
|
||||||
// TODO: Add stopwords
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nameof(Studio),
|
|
||||||
new Settings()
|
|
||||||
{
|
|
||||||
SearchableAttributes = new[]
|
|
||||||
{
|
|
||||||
CamelCase.ConvertName(nameof(Studio.Name)),
|
|
||||||
CamelCase.ConvertName(nameof(Studio.Slug)),
|
|
||||||
},
|
|
||||||
FilterableAttributes = Array.Empty<string>(),
|
|
||||||
SortableAttributes = Array.Empty<string>(),
|
|
||||||
DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Studio.Id)), },
|
|
||||||
// TODO: Add stopwords
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
public MeilisearchModule(IConfiguration configuration)
|
IndexStats info = await client.Index("items").GetStatsAsync();
|
||||||
|
// If there is no documents in meilisearch, if a db exist and is not empty, add items to meilisearch.
|
||||||
|
if (info.NumberOfDocuments == 0)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
ILibraryManager database = provider.GetRequiredService<ILibraryManager>();
|
||||||
}
|
MeiliSync search = provider.GetRequiredService<MeiliSync>();
|
||||||
|
|
||||||
/// <summary>
|
// This is a naive implementation that absolutly does not care about performances.
|
||||||
/// Init meilisearch indexes.
|
// This will run only once on users that already had a database when they upgrade.
|
||||||
/// </summary>
|
foreach (Movie movie in await database.Movies.GetAll(limit: 0))
|
||||||
/// <param name="provider">The service list to retrieve the meilisearch client</param>
|
await search.CreateOrUpdate("items", movie, nameof(Movie));
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
foreach (Show show in await database.Shows.GetAll(limit: 0))
|
||||||
public static async Task Initialize(IServiceProvider provider)
|
await search.CreateOrUpdate("items", show, nameof(Show));
|
||||||
{
|
foreach (Collection collection in await database.Collections.GetAll(limit: 0))
|
||||||
MeilisearchClient client = provider.GetRequiredService<MeilisearchClient>();
|
await search.CreateOrUpdate("items", collection, nameof(Collection));
|
||||||
|
foreach (Episode episode in await database.Episodes.GetAll(limit: 0))
|
||||||
await _CreateIndex(client, "items", true);
|
await search.CreateOrUpdate(nameof(Episode), episode);
|
||||||
await _CreateIndex(client, nameof(Episode), false);
|
foreach (Studio studio in await database.Studios.GetAll(limit: 0))
|
||||||
await _CreateIndex(client, nameof(Studio), false);
|
await search.CreateOrUpdate(nameof(Studio), studio);
|
||||||
|
|
||||||
IndexStats info = await client.Index("items").GetStatsAsync();
|
|
||||||
// If there is no documents in meilisearch, if a db exist and is not empty, add items to meilisearch.
|
|
||||||
if (info.NumberOfDocuments == 0)
|
|
||||||
{
|
|
||||||
ILibraryManager database = provider.GetRequiredService<ILibraryManager>();
|
|
||||||
MeiliSync search = provider.GetRequiredService<MeiliSync>();
|
|
||||||
|
|
||||||
// This is a naive implementation that absolutly does not care about performances.
|
|
||||||
// This will run only once on users that already had a database when they upgrade.
|
|
||||||
foreach (Movie movie in await database.Movies.GetAll(limit: 0))
|
|
||||||
await search.CreateOrUpdate("items", movie, nameof(Movie));
|
|
||||||
foreach (Show show in await database.Shows.GetAll(limit: 0))
|
|
||||||
await search.CreateOrUpdate("items", show, nameof(Show));
|
|
||||||
foreach (Collection collection in await database.Collections.GetAll(limit: 0))
|
|
||||||
await search.CreateOrUpdate("items", collection, nameof(Collection));
|
|
||||||
foreach (Episode episode in await database.Episodes.GetAll(limit: 0))
|
|
||||||
await search.CreateOrUpdate(nameof(Episode), episode);
|
|
||||||
foreach (Studio studio in await database.Studios.GetAll(limit: 0))
|
|
||||||
await search.CreateOrUpdate(nameof(Studio), studio);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind)
|
|
||||||
{
|
|
||||||
TaskInfo task = await client.CreateIndexAsync(
|
|
||||||
index,
|
|
||||||
hasKind ? "ref" : CamelCase.ConvertName(nameof(IResource.Id))
|
|
||||||
);
|
|
||||||
await client.WaitForTaskAsync(task.TaskUid);
|
|
||||||
await client.Index(index).UpdateSettingsAsync(IndexSettings[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Configure(ContainerBuilder builder)
|
|
||||||
{
|
|
||||||
builder
|
|
||||||
.RegisterInstance(
|
|
||||||
new MeilisearchClient(
|
|
||||||
_configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"),
|
|
||||||
_configuration.GetValue<string?>("MEILI_MASTER_KEY")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.SingleInstance();
|
|
||||||
builder.RegisterType<MeiliSync>().AsSelf().SingleInstance().AutoActivate();
|
|
||||||
builder.RegisterType<SearchManager>().As<ISearchManager>().InstancePerLifetimeScope();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind)
|
||||||
|
{
|
||||||
|
TaskInfo task = await client.CreateIndexAsync(
|
||||||
|
index,
|
||||||
|
hasKind ? "ref" : CamelCase.ConvertName(nameof(IResource.Id))
|
||||||
|
);
|
||||||
|
await client.WaitForTaskAsync(task.TaskUid);
|
||||||
|
await client.Index(index).UpdateSettingsAsync(IndexSettings[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Configure(ContainerBuilder builder)
|
||||||
|
{
|
||||||
|
builder
|
||||||
|
.RegisterInstance(
|
||||||
|
new MeilisearchClient(
|
||||||
|
_configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"),
|
||||||
|
_configuration.GetValue<string?>("MEILI_MASTER_KEY")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.SingleInstance();
|
||||||
|
builder.RegisterType<MeiliSync>().AsSelf().SingleInstance().AutoActivate();
|
||||||
|
builder.RegisterType<SearchManager>().As<ISearchManager>().InstancePerLifetimeScope();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -25,114 +25,113 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
|
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
namespace Kyoo.Postgresql
|
namespace Kyoo.Postgresql;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A postgresql implementation of <see cref="DatabaseContext"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class PostgresContext : DatabaseContext
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A postgresql implementation of <see cref="DatabaseContext"/>.
|
/// Is this instance in debug mode?
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PostgresContext : DatabaseContext
|
private readonly bool _debugMode;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Should the configure step be skipped? This is used when the database is created via DbContextOptions.
|
||||||
|
/// </summary>
|
||||||
|
private readonly bool _skipConfigure;
|
||||||
|
|
||||||
|
// TODO: This needs ot be updated but ef-core still does not offer a way to use this.
|
||||||
|
[Obsolete]
|
||||||
|
static PostgresContext()
|
||||||
{
|
{
|
||||||
/// <summary>
|
NpgsqlConnection.GlobalTypeMapper.MapEnum<Status>();
|
||||||
/// Is this instance in debug mode?
|
NpgsqlConnection.GlobalTypeMapper.MapEnum<Genre>();
|
||||||
/// </summary>
|
NpgsqlConnection.GlobalTypeMapper.MapEnum<WatchStatus>();
|
||||||
private readonly bool _debugMode;
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Should the configure step be skipped? This is used when the database is created via DbContextOptions.
|
/// Design time constructor (dotnet ef migrations add). Do not use
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly bool _skipConfigure;
|
public PostgresContext()
|
||||||
|
: base(null!) { }
|
||||||
|
|
||||||
// TODO: This needs ot be updated but ef-core still does not offer a way to use this.
|
public PostgresContext(DbContextOptions options, IHttpContextAccessor accessor)
|
||||||
[Obsolete]
|
: base(options, accessor)
|
||||||
static PostgresContext()
|
{
|
||||||
|
_skipConfigure = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PostgresContext(string connection, bool debugMode, IHttpContextAccessor accessor)
|
||||||
|
: base(accessor)
|
||||||
|
{
|
||||||
|
_debugMode = debugMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set connection information for this database context
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="optionsBuilder">An option builder to fill.</param>
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
if (!_skipConfigure)
|
||||||
{
|
{
|
||||||
NpgsqlConnection.GlobalTypeMapper.MapEnum<Status>();
|
optionsBuilder.UseNpgsql();
|
||||||
NpgsqlConnection.GlobalTypeMapper.MapEnum<Genre>();
|
if (_debugMode)
|
||||||
NpgsqlConnection.GlobalTypeMapper.MapEnum<WatchStatus>();
|
optionsBuilder.EnableDetailedErrors().EnableSensitiveDataLogging();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
optionsBuilder.UseSnakeCaseNamingConvention();
|
||||||
/// Design time constructor (dotnet ef migrations add). Do not use
|
base.OnConfiguring(optionsBuilder);
|
||||||
/// </summary>
|
}
|
||||||
public PostgresContext()
|
|
||||||
: base(null!) { }
|
|
||||||
|
|
||||||
public PostgresContext(DbContextOptions options, IHttpContextAccessor accessor)
|
/// <summary>
|
||||||
: base(options, accessor)
|
/// Set database parameters to support every types of Kyoo.
|
||||||
{
|
/// </summary>
|
||||||
_skipConfigure = true;
|
/// <param name="modelBuilder">The database's model builder.</param>
|
||||||
}
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasPostgresEnum<Status>();
|
||||||
|
modelBuilder.HasPostgresEnum<Genre>();
|
||||||
|
modelBuilder.HasPostgresEnum<WatchStatus>();
|
||||||
|
|
||||||
public PostgresContext(string connection, bool debugMode, IHttpContextAccessor accessor)
|
modelBuilder
|
||||||
: base(accessor)
|
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!)
|
||||||
{
|
.HasTranslation(args => new SqlFunctionExpression(
|
||||||
_debugMode = debugMode;
|
"md5",
|
||||||
}
|
args,
|
||||||
|
nullable: true,
|
||||||
|
argumentsPropagateNullability: new[] { false },
|
||||||
|
type: args[0].Type,
|
||||||
|
typeMapping: args[0].TypeMapping
|
||||||
|
));
|
||||||
|
|
||||||
/// <summary>
|
base.OnModelCreating(modelBuilder);
|
||||||
/// Set connection information for this database context
|
}
|
||||||
/// </summary>
|
|
||||||
/// <param name="optionsBuilder">An option builder to fill.</param>
|
/// <inheritdoc />
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override string LinkName<T, T2>()
|
||||||
{
|
{
|
||||||
if (!_skipConfigure)
|
SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
|
||||||
|
return rewriter.RewriteName("Link" + typeof(T).Name + typeof(T2).Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string LinkNameFk<T>()
|
||||||
|
{
|
||||||
|
SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
|
||||||
|
return rewriter.RewriteName(typeof(T).Name + "ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override bool IsDuplicateException(Exception ex)
|
||||||
|
{
|
||||||
|
return ex.InnerException
|
||||||
|
is PostgresException
|
||||||
{
|
{
|
||||||
optionsBuilder.UseNpgsql();
|
SqlState: PostgresErrorCodes.UniqueViolation
|
||||||
if (_debugMode)
|
or PostgresErrorCodes.ForeignKeyViolation
|
||||||
optionsBuilder.EnableDetailedErrors().EnableSensitiveDataLogging();
|
};
|
||||||
}
|
|
||||||
|
|
||||||
optionsBuilder.UseSnakeCaseNamingConvention();
|
|
||||||
base.OnConfiguring(optionsBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Set database parameters to support every types of Kyoo.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="modelBuilder">The database's model builder.</param>
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
modelBuilder.HasPostgresEnum<Status>();
|
|
||||||
modelBuilder.HasPostgresEnum<Genre>();
|
|
||||||
modelBuilder.HasPostgresEnum<WatchStatus>();
|
|
||||||
|
|
||||||
modelBuilder
|
|
||||||
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!)
|
|
||||||
.HasTranslation(args => new SqlFunctionExpression(
|
|
||||||
"md5",
|
|
||||||
args,
|
|
||||||
nullable: true,
|
|
||||||
argumentsPropagateNullability: new[] { false },
|
|
||||||
type: args[0].Type,
|
|
||||||
typeMapping: args[0].TypeMapping
|
|
||||||
));
|
|
||||||
|
|
||||||
base.OnModelCreating(modelBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override string LinkName<T, T2>()
|
|
||||||
{
|
|
||||||
SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
|
|
||||||
return rewriter.RewriteName("Link" + typeof(T).Name + typeof(T2).Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override string LinkNameFk<T>()
|
|
||||||
{
|
|
||||||
SnakeCaseNameRewriter rewriter = new(CultureInfo.InvariantCulture);
|
|
||||||
return rewriter.RewriteName(typeof(T).Name + "ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override bool IsDuplicateException(Exception ex)
|
|
||||||
{
|
|
||||||
return ex.InnerException
|
|
||||||
is PostgresException
|
|
||||||
{
|
|
||||||
SqlState: PostgresErrorCodes.UniqueViolation
|
|
||||||
or PostgresErrorCodes.ForeignKeyViolation
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,115 +33,112 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
namespace Kyoo.Postgresql
|
namespace Kyoo.Postgresql;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A module to add postgresql capacity to the app.
|
||||||
|
/// </summary>
|
||||||
|
public class PostgresModule : IPlugin
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => "Postgresql";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A module to add postgresql capacity to the app.
|
/// The configuration to use. The database connection string is pulled from it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PostgresModule : IPlugin
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The host environment to check if the app is in debug mode.
|
||||||
|
/// </summary>
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new postgres module instance and use the given configuration and environment.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configuration">The configuration to use</param>
|
||||||
|
/// <param name="env">The environment that will be used (if the env is in development mode, more information will be displayed on errors.</param>
|
||||||
|
public PostgresModule(IConfiguration configuration, IWebHostEnvironment env)
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
_configuration = configuration;
|
||||||
public string Name => "Postgresql";
|
_environment = env;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The configuration to use. The database connection string is pulled from it.
|
/// Migrate the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly IConfiguration _configuration;
|
/// <param name="provider">The service list to retrieve the database context</param>
|
||||||
|
public static void Initialize(IServiceProvider provider)
|
||||||
|
{
|
||||||
|
DatabaseContext context = provider.GetRequiredService<DatabaseContext>();
|
||||||
|
context.Database.Migrate();
|
||||||
|
|
||||||
/// <summary>
|
using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection();
|
||||||
/// The host environment to check if the app is in debug mode.
|
conn.Open();
|
||||||
/// </summary>
|
conn.ReloadTypes();
|
||||||
private readonly IWebHostEnvironment _environment;
|
|
||||||
|
|
||||||
/// <summary>
|
SqlMapper.TypeMapProvider = (type) =>
|
||||||
/// Create a new postgres module instance and use the given configuration and environment.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="configuration">The configuration to use</param>
|
|
||||||
/// <param name="env">The environment that will be used (if the env is in development mode, more information will be displayed on errors.</param>
|
|
||||||
public PostgresModule(IConfiguration configuration, IWebHostEnvironment env)
|
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
return new CustomPropertyTypeMap(
|
||||||
_environment = env;
|
type,
|
||||||
}
|
(type, name) =>
|
||||||
|
{
|
||||||
|
string newName = Regex.Replace(
|
||||||
|
name,
|
||||||
|
"(^|_)([a-z])",
|
||||||
|
(match) => match.Groups[2].Value.ToUpperInvariant()
|
||||||
|
);
|
||||||
|
// TODO: Add images handling here (name: poster_source, newName: PosterSource) should set Poster.Source
|
||||||
|
return type.GetProperty(newName)!;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
SqlMapper.AddTypeHandler(
|
||||||
|
typeof(Dictionary<string, MetadataId>),
|
||||||
|
new JsonTypeHandler<Dictionary<string, MetadataId>>()
|
||||||
|
);
|
||||||
|
SqlMapper.AddTypeHandler(
|
||||||
|
typeof(Dictionary<string, string>),
|
||||||
|
new JsonTypeHandler<Dictionary<string, string>>()
|
||||||
|
);
|
||||||
|
SqlMapper.AddTypeHandler(
|
||||||
|
typeof(Dictionary<string, ExternalToken>),
|
||||||
|
new JsonTypeHandler<Dictionary<string, ExternalToken>>()
|
||||||
|
);
|
||||||
|
SqlMapper.AddTypeHandler(typeof(List<string>), new ListTypeHandler<string>());
|
||||||
|
SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>());
|
||||||
|
SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler());
|
||||||
|
InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true;
|
||||||
|
InterpolatedSqlBuilderOptions.DefaultOptions.AutoFixSingleQuotes = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Migrate the database.
|
public void Configure(IServiceCollection services)
|
||||||
/// </summary>
|
{
|
||||||
/// <param name="provider">The service list to retrieve the database context</param>
|
DbConnectionStringBuilder builder =
|
||||||
public static void Initialize(IServiceProvider provider)
|
new()
|
||||||
{
|
|
||||||
DatabaseContext context = provider.GetRequiredService<DatabaseContext>();
|
|
||||||
context.Database.Migrate();
|
|
||||||
|
|
||||||
using NpgsqlConnection conn = (NpgsqlConnection)context.Database.GetDbConnection();
|
|
||||||
conn.Open();
|
|
||||||
conn.ReloadTypes();
|
|
||||||
|
|
||||||
SqlMapper.TypeMapProvider = (type) =>
|
|
||||||
{
|
{
|
||||||
return new CustomPropertyTypeMap(
|
["USER ID"] = _configuration.GetValue("POSTGRES_USER", "KyooUser"),
|
||||||
type,
|
["PASSWORD"] = _configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"),
|
||||||
(type, name) =>
|
["SERVER"] = _configuration.GetValue("POSTGRES_SERVER", "db"),
|
||||||
{
|
["PORT"] = _configuration.GetValue("POSTGRES_PORT", "5432"),
|
||||||
string newName = Regex.Replace(
|
["DATABASE"] = _configuration.GetValue("POSTGRES_DB", "kyooDB"),
|
||||||
name,
|
["POOLING"] = "true",
|
||||||
"(^|_)([a-z])",
|
["MAXPOOLSIZE"] = "95",
|
||||||
(match) => match.Groups[2].Value.ToUpperInvariant()
|
["TIMEOUT"] = "30"
|
||||||
);
|
|
||||||
// TODO: Add images handling here (name: poster_source, newName: PosterSource) should set Poster.Source
|
|
||||||
return type.GetProperty(newName)!;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
SqlMapper.AddTypeHandler(
|
|
||||||
typeof(Dictionary<string, MetadataId>),
|
|
||||||
new JsonTypeHandler<Dictionary<string, MetadataId>>()
|
|
||||||
);
|
|
||||||
SqlMapper.AddTypeHandler(
|
|
||||||
typeof(Dictionary<string, string>),
|
|
||||||
new JsonTypeHandler<Dictionary<string, string>>()
|
|
||||||
);
|
|
||||||
SqlMapper.AddTypeHandler(
|
|
||||||
typeof(Dictionary<string, ExternalToken>),
|
|
||||||
new JsonTypeHandler<Dictionary<string, ExternalToken>>()
|
|
||||||
);
|
|
||||||
SqlMapper.AddTypeHandler(typeof(List<string>), new ListTypeHandler<string>());
|
|
||||||
SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>());
|
|
||||||
SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler());
|
|
||||||
InterpolatedSqlBuilderOptions.DefaultOptions.ReuseIdenticalParameters = true;
|
|
||||||
InterpolatedSqlBuilderOptions.DefaultOptions.AutoFixSingleQuotes = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
services.AddDbContext<DatabaseContext, PostgresContext>(
|
||||||
public void Configure(IServiceCollection services)
|
x =>
|
||||||
{
|
{
|
||||||
DbConnectionStringBuilder builder =
|
x.UseNpgsql(builder.ConnectionString).UseProjectables();
|
||||||
new()
|
if (_environment.IsDevelopment())
|
||||||
{
|
x.EnableDetailedErrors().EnableSensitiveDataLogging();
|
||||||
["USER ID"] = _configuration.GetValue("POSTGRES_USER", "KyooUser"),
|
},
|
||||||
["PASSWORD"] = _configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"),
|
ServiceLifetime.Transient
|
||||||
["SERVER"] = _configuration.GetValue("POSTGRES_SERVER", "db"),
|
);
|
||||||
["PORT"] = _configuration.GetValue("POSTGRES_PORT", "5432"),
|
services.AddTransient<DbConnection>((_) => new NpgsqlConnection(builder.ConnectionString));
|
||||||
["DATABASE"] = _configuration.GetValue("POSTGRES_DB", "kyooDB"),
|
|
||||||
["POOLING"] = "true",
|
|
||||||
["MAXPOOLSIZE"] = "95",
|
|
||||||
["TIMEOUT"] = "30"
|
|
||||||
};
|
|
||||||
|
|
||||||
services.AddDbContext<DatabaseContext, PostgresContext>(
|
services.AddHealthChecks().AddDbContextCheck<DatabaseContext>();
|
||||||
x =>
|
|
||||||
{
|
|
||||||
x.UseNpgsql(builder.ConnectionString).UseProjectables();
|
|
||||||
if (_environment.IsDevelopment())
|
|
||||||
x.EnableDetailedErrors().EnableSensitiveDataLogging();
|
|
||||||
},
|
|
||||||
ServiceLifetime.Transient
|
|
||||||
);
|
|
||||||
services.AddTransient<DbConnection>(
|
|
||||||
(_) => new NpgsqlConnection(builder.ConnectionString)
|
|
||||||
);
|
|
||||||
|
|
||||||
services.AddHealthChecks().AddDbContextCheck<DatabaseContext>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,45 +22,44 @@ using Kyoo.Swagger.Models;
|
|||||||
using NSwag;
|
using NSwag;
|
||||||
using NSwag.Generation.AspNetCore;
|
using NSwag.Generation.AspNetCore;
|
||||||
|
|
||||||
namespace Kyoo.Swagger
|
namespace Kyoo.Swagger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A class to sort apis.
|
||||||
|
/// </summary>
|
||||||
|
public static class ApiSorter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A class to sort apis.
|
/// Sort apis by alphabetical orders.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ApiSorter
|
/// <param name="options">The swagger settings to update.</param>
|
||||||
|
public static void SortApis(this AspNetCoreOpenApiDocumentGeneratorSettings options)
|
||||||
{
|
{
|
||||||
/// <summary>
|
options.PostProcess += postProcess =>
|
||||||
/// Sort apis by alphabetical orders.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="options">The swagger settings to update.</param>
|
|
||||||
public static void SortApis(this AspNetCoreOpenApiDocumentGeneratorSettings options)
|
|
||||||
{
|
{
|
||||||
options.PostProcess += postProcess =>
|
// We can't reorder items by assigning the sorted value to the Paths variable since it has no setter.
|
||||||
{
|
List<KeyValuePair<string, OpenApiPathItem>> sorted = postProcess
|
||||||
// We can't reorder items by assigning the sorted value to the Paths variable since it has no setter.
|
.Paths.OrderBy(x => x.Key)
|
||||||
List<KeyValuePair<string, OpenApiPathItem>> sorted = postProcess
|
.ToList();
|
||||||
.Paths.OrderBy(x => x.Key)
|
postProcess.Paths.Clear();
|
||||||
.ToList();
|
foreach ((string key, OpenApiPathItem value) in sorted)
|
||||||
postProcess.Paths.Clear();
|
postProcess.Paths.Add(key, value);
|
||||||
foreach ((string key, OpenApiPathItem value) in sorted)
|
};
|
||||||
postProcess.Paths.Add(key, value);
|
|
||||||
};
|
|
||||||
|
|
||||||
options.PostProcess += postProcess =>
|
options.PostProcess += postProcess =>
|
||||||
{
|
{
|
||||||
if (!postProcess.ExtensionData.TryGetValue("x-tagGroups", out object list))
|
if (!postProcess.ExtensionData.TryGetValue("x-tagGroups", out object list))
|
||||||
return;
|
return;
|
||||||
List<TagGroups> tagGroups = (List<TagGroups>)list;
|
List<TagGroups> tagGroups = (List<TagGroups>)list;
|
||||||
postProcess.ExtensionData["x-tagGroups"] = tagGroups
|
postProcess.ExtensionData["x-tagGroups"] = tagGroups
|
||||||
.OrderBy(x => x.Name)
|
.OrderBy(x => x.Name)
|
||||||
.Select(x =>
|
.Select(x =>
|
||||||
{
|
{
|
||||||
x.Name = x.Name[(x.Name.IndexOf(':') + 1)..];
|
x.Name = x.Name[(x.Name.IndexOf(':') + 1)..];
|
||||||
x.Tags = x.Tags.OrderBy(y => y).ToList();
|
x.Tags = x.Tags.OrderBy(y => y).ToList();
|
||||||
return x;
|
return x;
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,99 +26,98 @@ using NSwag;
|
|||||||
using NSwag.Generation.AspNetCore;
|
using NSwag.Generation.AspNetCore;
|
||||||
using NSwag.Generation.Processors.Contexts;
|
using NSwag.Generation.Processors.Contexts;
|
||||||
|
|
||||||
namespace Kyoo.Swagger
|
namespace Kyoo.Swagger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A class to handle Api Groups (OpenApi tags and x-tagGroups).
|
||||||
|
/// Tags should be specified via <see cref="ApiDefinitionAttribute"/> and this filter will map this to the
|
||||||
|
/// <see cref="OpenApiDocument"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class ApiTagsFilter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A class to handle Api Groups (OpenApi tags and x-tagGroups).
|
/// The main operation filter that will map every <see cref="ApiDefinitionAttribute"/>.
|
||||||
/// Tags should be specified via <see cref="ApiDefinitionAttribute"/> and this filter will map this to the
|
|
||||||
/// <see cref="OpenApiDocument"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ApiTagsFilter
|
/// <param name="context">The processor context, this is given by the AddOperationFilter method.</param>
|
||||||
|
/// <returns>This always return <c>true</c> since it should not remove operations.</returns>
|
||||||
|
public static bool OperationFilter(OperationProcessorContext context)
|
||||||
{
|
{
|
||||||
/// <summary>
|
ApiDefinitionAttribute def =
|
||||||
/// The main operation filter that will map every <see cref="ApiDefinitionAttribute"/>.
|
context.ControllerType.GetCustomAttribute<ApiDefinitionAttribute>();
|
||||||
/// </summary>
|
string name = def?.Name ?? context.ControllerType.Name;
|
||||||
/// <param name="context">The processor context, this is given by the AddOperationFilter method.</param>
|
|
||||||
/// <returns>This always return <c>true</c> since it should not remove operations.</returns>
|
ApiDefinitionAttribute methodOverride =
|
||||||
public static bool OperationFilter(OperationProcessorContext context)
|
context.MethodInfo.GetCustomAttribute<ApiDefinitionAttribute>();
|
||||||
|
if (methodOverride != null)
|
||||||
|
name = methodOverride.Name;
|
||||||
|
|
||||||
|
context.OperationDescription.Operation.Tags.Add(name);
|
||||||
|
if (context.Document.Tags.All(x => x.Name != name))
|
||||||
{
|
{
|
||||||
ApiDefinitionAttribute def =
|
context.Document.Tags.Add(
|
||||||
context.ControllerType.GetCustomAttribute<ApiDefinitionAttribute>();
|
new OpenApiTag
|
||||||
string name = def?.Name ?? context.ControllerType.Name;
|
{
|
||||||
|
Name = name,
|
||||||
ApiDefinitionAttribute methodOverride =
|
Description = context.ControllerType.GetXmlDocsSummary()
|
||||||
context.MethodInfo.GetCustomAttribute<ApiDefinitionAttribute>();
|
}
|
||||||
if (methodOverride != null)
|
);
|
||||||
name = methodOverride.Name;
|
}
|
||||||
|
|
||||||
context.OperationDescription.Operation.Tags.Add(name);
|
|
||||||
if (context.Document.Tags.All(x => x.Name != name))
|
|
||||||
{
|
|
||||||
context.Document.Tags.Add(
|
|
||||||
new OpenApiTag
|
|
||||||
{
|
|
||||||
Name = name,
|
|
||||||
Description = context.ControllerType.GetXmlDocsSummary()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (def?.Group == null)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
context.Document.ExtensionData ??= new Dictionary<string, object>();
|
|
||||||
context.Document.ExtensionData.TryAdd("x-tagGroups", new List<TagGroups>());
|
|
||||||
List<TagGroups> obj = (List<TagGroups>)context.Document.ExtensionData["x-tagGroups"];
|
|
||||||
TagGroups existing = obj.FirstOrDefault(x => x.Name == def.Group);
|
|
||||||
if (existing != null)
|
|
||||||
{
|
|
||||||
if (!existing.Tags.Contains(def.Name))
|
|
||||||
existing.Tags.Add(def.Name);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
obj.Add(
|
|
||||||
new TagGroups
|
|
||||||
{
|
|
||||||
Name = def.Group,
|
|
||||||
Tags = new List<string> { def.Name }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (def?.Group == null)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
context.Document.ExtensionData ??= new Dictionary<string, object>();
|
||||||
|
context.Document.ExtensionData.TryAdd("x-tagGroups", new List<TagGroups>());
|
||||||
|
List<TagGroups> obj = (List<TagGroups>)context.Document.ExtensionData["x-tagGroups"];
|
||||||
|
TagGroups existing = obj.FirstOrDefault(x => x.Name == def.Group);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
if (!existing.Tags.Contains(def.Name))
|
||||||
|
existing.Tags.Add(def.Name);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
obj.Add(
|
||||||
|
new TagGroups
|
||||||
|
{
|
||||||
|
Name = def.Group,
|
||||||
|
Tags = new List<string> { def.Name }
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
return true;
|
||||||
/// This add every tags that are not in a x-tagGroups to a new tagGroups named "Other".
|
}
|
||||||
/// Since tags that are not in a tagGroups are not shown, this is necessary if you want them displayed.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="postProcess">
|
|
||||||
/// The document to do this for. This should be done in the PostProcess part of the document or after
|
|
||||||
/// the main operation filter (see <see cref="OperationFilter"/>) has finished.
|
|
||||||
/// </param>
|
|
||||||
public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess)
|
|
||||||
{
|
|
||||||
List<TagGroups> tagGroups = (List<TagGroups>)postProcess.ExtensionData["x-tagGroups"];
|
|
||||||
List<string> tagsWithoutGroup = postProcess
|
|
||||||
.Tags.Select(x => x.Name)
|
|
||||||
.Where(x => tagGroups.SelectMany(y => y.Tags).All(y => y != x))
|
|
||||||
.ToList();
|
|
||||||
if (tagsWithoutGroup.Any())
|
|
||||||
{
|
|
||||||
tagGroups.Add(new TagGroups { Name = "Others", Tags = tagsWithoutGroup });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Use <see cref="ApiDefinitionAttribute"/> to create tags and groups of tags on the resulting swagger
|
/// This add every tags that are not in a x-tagGroups to a new tagGroups named "Other".
|
||||||
/// document.
|
/// Since tags that are not in a tagGroups are not shown, this is necessary if you want them displayed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="options">The settings of the swagger document.</param>
|
/// <param name="postProcess">
|
||||||
public static void UseApiTags(this AspNetCoreOpenApiDocumentGeneratorSettings options)
|
/// The document to do this for. This should be done in the PostProcess part of the document or after
|
||||||
|
/// the main operation filter (see <see cref="OperationFilter"/>) has finished.
|
||||||
|
/// </param>
|
||||||
|
public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess)
|
||||||
|
{
|
||||||
|
List<TagGroups> tagGroups = (List<TagGroups>)postProcess.ExtensionData["x-tagGroups"];
|
||||||
|
List<string> tagsWithoutGroup = postProcess
|
||||||
|
.Tags.Select(x => x.Name)
|
||||||
|
.Where(x => tagGroups.SelectMany(y => y.Tags).All(y => y != x))
|
||||||
|
.ToList();
|
||||||
|
if (tagsWithoutGroup.Any())
|
||||||
{
|
{
|
||||||
options.AddOperationFilter(OperationFilter);
|
tagGroups.Add(new TagGroups { Name = "Others", Tags = tagsWithoutGroup });
|
||||||
options.PostProcess += x => x.AddLeftoversToOthersGroup();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use <see cref="ApiDefinitionAttribute"/> to create tags and groups of tags on the resulting swagger
|
||||||
|
/// document.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The settings of the swagger document.</param>
|
||||||
|
public static void UseApiTags(this AspNetCoreOpenApiDocumentGeneratorSettings options)
|
||||||
|
{
|
||||||
|
options.AddOperationFilter(OperationFilter);
|
||||||
|
options.PostProcess += x => x.AddLeftoversToOthersGroup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,45 +24,44 @@ using Kyoo.Utils;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||||
|
|
||||||
namespace Kyoo.Swagger
|
namespace Kyoo.Swagger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A filter that change <see cref="ProducesResponseTypeAttribute"/>'s
|
||||||
|
/// <see cref="ProducesResponseTypeAttribute.Type"/> that where set to <see cref="ActionResult{T}"/> to the
|
||||||
|
/// return type of the method.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is only useful when the return type of the method is a generics type and that can't be specified in the
|
||||||
|
/// attribute directly (since attributes don't support generics). This should not be used otherwise.
|
||||||
|
/// </remarks>
|
||||||
|
public class GenericResponseProvider : IApplicationModelProvider
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// A filter that change <see cref="ProducesResponseTypeAttribute"/>'s
|
public int Order => -1;
|
||||||
/// <see cref="ProducesResponseTypeAttribute.Type"/> that where set to <see cref="ActionResult{T}"/> to the
|
|
||||||
/// return type of the method.
|
/// <inheritdoc />
|
||||||
/// </summary>
|
public void OnProvidersExecuted(ApplicationModelProviderContext context) { }
|
||||||
/// <remarks>
|
|
||||||
/// This is only useful when the return type of the method is a generics type and that can't be specified in the
|
/// <inheritdoc />
|
||||||
/// attribute directly (since attributes don't support generics). This should not be used otherwise.
|
public void OnProvidersExecuting(ApplicationModelProviderContext context)
|
||||||
/// </remarks>
|
|
||||||
public class GenericResponseProvider : IApplicationModelProvider
|
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions))
|
||||||
public int Order => -1;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void OnProvidersExecuted(ApplicationModelProviderContext context) { }
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void OnProvidersExecuting(ApplicationModelProviderContext context)
|
|
||||||
{
|
{
|
||||||
foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions))
|
IEnumerable<ProducesResponseTypeAttribute> responses = action
|
||||||
|
.Filters.OfType<ProducesResponseTypeAttribute>()
|
||||||
|
.Where(x => x.Type == typeof(ActionResult<>));
|
||||||
|
foreach (ProducesResponseTypeAttribute response in responses)
|
||||||
{
|
{
|
||||||
IEnumerable<ProducesResponseTypeAttribute> responses = action
|
Type type = action.ActionMethod.ReturnType;
|
||||||
.Filters.OfType<ProducesResponseTypeAttribute>()
|
type =
|
||||||
.Where(x => x.Type == typeof(ActionResult<>));
|
Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0]
|
||||||
foreach (ProducesResponseTypeAttribute response in responses)
|
?? type;
|
||||||
{
|
type =
|
||||||
Type type = action.ActionMethod.ReturnType;
|
Utility
|
||||||
type =
|
.GetGenericDefinition(type, typeof(ActionResult<>))
|
||||||
Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0]
|
?.GetGenericArguments()[0] ?? type;
|
||||||
?? type;
|
response.Type = type;
|
||||||
type =
|
|
||||||
Utility
|
|
||||||
.GetGenericDefinition(type, typeof(ActionResult<>))
|
|
||||||
?.GetGenericArguments()[0] ?? type;
|
|
||||||
response.Type = type;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,23 +20,22 @@ using System.Collections.Generic;
|
|||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using NSwag;
|
using NSwag;
|
||||||
|
|
||||||
namespace Kyoo.Swagger.Models
|
namespace Kyoo.Swagger.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A class representing a group of tags in the <see cref="OpenApiDocument"/>
|
||||||
|
/// </summary>
|
||||||
|
public class TagGroups
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A class representing a group of tags in the <see cref="OpenApiDocument"/>
|
/// The name of the tag group.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TagGroups
|
[JsonProperty(PropertyName = "name")]
|
||||||
{
|
public string Name { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// The name of the tag group.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(PropertyName = "name")]
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The list of tags in this group.
|
/// The list of tags in this group.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty(PropertyName = "tags")]
|
[JsonProperty(PropertyName = "tags")]
|
||||||
public List<string> Tags { get; set; }
|
public List<string> Tags { get; set; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -25,80 +25,78 @@ using NSwag;
|
|||||||
using NSwag.Generation.Processors;
|
using NSwag.Generation.Processors;
|
||||||
using NSwag.Generation.Processors.Contexts;
|
using NSwag.Generation.Processors.Contexts;
|
||||||
|
|
||||||
namespace Kyoo.Swagger
|
namespace Kyoo.Swagger;
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// An operation processor that adds permissions information from the <see cref="PermissionAttribute"/> and the
|
|
||||||
/// <see cref="PartialPermissionAttribute"/>.
|
|
||||||
/// </summary>
|
|
||||||
public class OperationPermissionProcessor : IOperationProcessor
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public bool Process(OperationProcessorContext context)
|
|
||||||
{
|
|
||||||
context.OperationDescription.Operation.Security ??=
|
|
||||||
new List<OpenApiSecurityRequirement>();
|
|
||||||
OpenApiSecurityRequirement perms = context
|
|
||||||
.MethodInfo.GetCustomAttributes<UserOnlyAttribute>()
|
|
||||||
.Aggregate(
|
|
||||||
new OpenApiSecurityRequirement(),
|
|
||||||
(agg, _) =>
|
|
||||||
{
|
|
||||||
agg[nameof(Kyoo)] = Array.Empty<string>();
|
|
||||||
return agg;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An operation processor that adds permissions information from the <see cref="PermissionAttribute"/> and the
|
||||||
|
/// <see cref="PartialPermissionAttribute"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class OperationPermissionProcessor : IOperationProcessor
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool Process(OperationProcessorContext context)
|
||||||
|
{
|
||||||
|
context.OperationDescription.Operation.Security ??= new List<OpenApiSecurityRequirement>();
|
||||||
|
OpenApiSecurityRequirement perms = context
|
||||||
|
.MethodInfo.GetCustomAttributes<UserOnlyAttribute>()
|
||||||
|
.Aggregate(
|
||||||
|
new OpenApiSecurityRequirement(),
|
||||||
|
(agg, _) =>
|
||||||
|
{
|
||||||
|
agg[nameof(Kyoo)] = Array.Empty<string>();
|
||||||
|
return agg;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
perms = context
|
||||||
|
.MethodInfo.GetCustomAttributes<PermissionAttribute>()
|
||||||
|
.Aggregate(
|
||||||
|
perms,
|
||||||
|
(agg, cur) =>
|
||||||
|
{
|
||||||
|
ICollection<string> permissions = _GetPermissionsList(agg, cur.Group);
|
||||||
|
permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}");
|
||||||
|
agg[nameof(Kyoo)] = permissions;
|
||||||
|
return agg;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
PartialPermissionAttribute controller =
|
||||||
|
context.ControllerType.GetCustomAttribute<PartialPermissionAttribute>();
|
||||||
|
if (controller != null)
|
||||||
|
{
|
||||||
perms = context
|
perms = context
|
||||||
.MethodInfo.GetCustomAttributes<PermissionAttribute>()
|
.MethodInfo.GetCustomAttributes<PartialPermissionAttribute>()
|
||||||
.Aggregate(
|
.Aggregate(
|
||||||
perms,
|
perms,
|
||||||
(agg, cur) =>
|
(agg, cur) =>
|
||||||
{
|
{
|
||||||
ICollection<string> permissions = _GetPermissionsList(agg, cur.Group);
|
Group? group =
|
||||||
permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}");
|
controller.Group != Group.Overall ? controller.Group : cur.Group;
|
||||||
|
string type = controller.Type ?? cur.Type;
|
||||||
|
Kind? kind = controller.Type == null ? controller.Kind : cur.Kind;
|
||||||
|
ICollection<string> permissions = _GetPermissionsList(
|
||||||
|
agg,
|
||||||
|
group ?? Group.Overall
|
||||||
|
);
|
||||||
|
permissions.Add($"{type}.{kind!.Value.ToString().ToLower()}");
|
||||||
agg[nameof(Kyoo)] = permissions;
|
agg[nameof(Kyoo)] = permissions;
|
||||||
return agg;
|
return agg;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
PartialPermissionAttribute controller =
|
|
||||||
context.ControllerType.GetCustomAttribute<PartialPermissionAttribute>();
|
|
||||||
if (controller != null)
|
|
||||||
{
|
|
||||||
perms = context
|
|
||||||
.MethodInfo.GetCustomAttributes<PartialPermissionAttribute>()
|
|
||||||
.Aggregate(
|
|
||||||
perms,
|
|
||||||
(agg, cur) =>
|
|
||||||
{
|
|
||||||
Group? group =
|
|
||||||
controller.Group != Group.Overall ? controller.Group : cur.Group;
|
|
||||||
string type = controller.Type ?? cur.Type;
|
|
||||||
Kind? kind = controller.Type == null ? controller.Kind : cur.Kind;
|
|
||||||
ICollection<string> permissions = _GetPermissionsList(
|
|
||||||
agg,
|
|
||||||
group ?? Group.Overall
|
|
||||||
);
|
|
||||||
permissions.Add($"{type}.{kind!.Value.ToString().ToLower()}");
|
|
||||||
agg[nameof(Kyoo)] = permissions;
|
|
||||||
return agg;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
context.OperationDescription.Operation.Security.Add(perms);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ICollection<string> _GetPermissionsList(
|
context.OperationDescription.Operation.Security.Add(perms);
|
||||||
OpenApiSecurityRequirement security,
|
return true;
|
||||||
Group group
|
}
|
||||||
)
|
|
||||||
{
|
private static ICollection<string> _GetPermissionsList(
|
||||||
return security.TryGetValue(group.ToString(), out IEnumerable<string> perms)
|
OpenApiSecurityRequirement security,
|
||||||
? perms.ToList()
|
Group group
|
||||||
: new List<string>();
|
)
|
||||||
}
|
{
|
||||||
|
return security.TryGetValue(group.ToString(), out IEnumerable<string> perms)
|
||||||
|
? perms.ToList()
|
||||||
|
: new List<string>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,103 +29,102 @@ using NSwag;
|
|||||||
using NSwag.Generation.AspNetCore;
|
using NSwag.Generation.AspNetCore;
|
||||||
using static Kyoo.Abstractions.Models.Utils.Constants;
|
using static Kyoo.Abstractions.Models.Utils.Constants;
|
||||||
|
|
||||||
namespace Kyoo.Swagger
|
namespace Kyoo.Swagger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A module to enable a swagger interface and an OpenAPI endpoint to document Kyoo.
|
||||||
|
/// </summary>
|
||||||
|
public class SwaggerModule : IPlugin
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// A module to enable a swagger interface and an OpenAPI endpoint to document Kyoo.
|
public string Name => "Swagger";
|
||||||
/// </summary>
|
|
||||||
public class SwaggerModule : IPlugin
|
/// <inheritdoc />
|
||||||
|
public void Configure(IServiceCollection services)
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
services.AddTransient<IApplicationModelProvider, GenericResponseProvider>();
|
||||||
public string Name => "Swagger";
|
services.AddOpenApiDocument(document =>
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Configure(IServiceCollection services)
|
|
||||||
{
|
{
|
||||||
services.AddTransient<IApplicationModelProvider, GenericResponseProvider>();
|
document.Title = "Kyoo API";
|
||||||
services.AddOpenApiDocument(document =>
|
// TODO use a real multi-line description in markdown.
|
||||||
|
document.Description = "The Kyoo's public API";
|
||||||
|
document.Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString(3);
|
||||||
|
document.DocumentName = "v1";
|
||||||
|
document.UseControllerSummaryAsTagDescription = true;
|
||||||
|
document.GenerateExamples = true;
|
||||||
|
document.PostProcess = options =>
|
||||||
{
|
{
|
||||||
document.Title = "Kyoo API";
|
options.Info.Contact = new OpenApiContact
|
||||||
// TODO use a real multi-line description in markdown.
|
|
||||||
document.Description = "The Kyoo's public API";
|
|
||||||
document.Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString(3);
|
|
||||||
document.DocumentName = "v1";
|
|
||||||
document.UseControllerSummaryAsTagDescription = true;
|
|
||||||
document.GenerateExamples = true;
|
|
||||||
document.PostProcess = options =>
|
|
||||||
{
|
{
|
||||||
options.Info.Contact = new OpenApiContact
|
Name = "Kyoo's github",
|
||||||
{
|
Url = "https://github.com/zoriya/Kyoo"
|
||||||
Name = "Kyoo's github",
|
|
||||||
Url = "https://github.com/zoriya/Kyoo"
|
|
||||||
};
|
|
||||||
options.Info.License = new OpenApiLicense
|
|
||||||
{
|
|
||||||
Name = "GPL-3.0-or-later",
|
|
||||||
Url = "https://github.com/zoriya/Kyoo/blob/master/LICENSE"
|
|
||||||
};
|
|
||||||
|
|
||||||
options.Info.ExtensionData ??= new Dictionary<string, object>();
|
|
||||||
options.Info.ExtensionData["x-logo"] = new
|
|
||||||
{
|
|
||||||
url = "/banner.png",
|
|
||||||
backgroundColor = "#FFFFFF",
|
|
||||||
altText = "Kyoo's logo"
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
document.UseApiTags();
|
options.Info.License = new OpenApiLicense
|
||||||
document.SortApis();
|
|
||||||
document.AddOperationFilter(x =>
|
|
||||||
{
|
{
|
||||||
if (x is AspNetCoreOperationProcessorContext ctx)
|
Name = "GPL-3.0-or-later",
|
||||||
return ctx.ApiDescription.ActionDescriptor.AttributeRouteInfo?.Order
|
Url = "https://github.com/zoriya/Kyoo/blob/master/LICENSE"
|
||||||
!= AlternativeRoute;
|
};
|
||||||
return true;
|
|
||||||
});
|
|
||||||
document.SchemaGenerator.Settings.TypeMappers.Add(
|
|
||||||
new PrimitiveTypeMapper(
|
|
||||||
typeof(Identifier),
|
|
||||||
x =>
|
|
||||||
{
|
|
||||||
x.IsNullableRaw = false;
|
|
||||||
x.Type = JsonObjectType.String | JsonObjectType.Integer;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
document.AddSecurity(
|
options.Info.ExtensionData ??= new Dictionary<string, object>();
|
||||||
nameof(Kyoo),
|
options.Info.ExtensionData["x-logo"] = new
|
||||||
new OpenApiSecurityScheme
|
{
|
||||||
{
|
url = "/banner.png",
|
||||||
Type = OpenApiSecuritySchemeType.Http,
|
backgroundColor = "#FFFFFF",
|
||||||
Scheme = "Bearer",
|
altText = "Kyoo's logo"
|
||||||
BearerFormat = "JWT",
|
};
|
||||||
Description = "The user's bearer"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
document.OperationProcessors.Add(new OperationPermissionProcessor());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IEnumerable<IStartupAction> ConfigureSteps =>
|
|
||||||
new IStartupAction[]
|
|
||||||
{
|
|
||||||
SA.New<IApplicationBuilder>(app => app.UseOpenApi(), SA.Before + 1),
|
|
||||||
SA.New<IApplicationBuilder>(
|
|
||||||
app =>
|
|
||||||
app.UseReDoc(x =>
|
|
||||||
{
|
|
||||||
x.Path = "/doc";
|
|
||||||
x.TransformToExternalPath = (internalUiRoute, _) =>
|
|
||||||
"/api" + internalUiRoute;
|
|
||||||
x.AdditionalSettings["theme"] = new
|
|
||||||
{
|
|
||||||
colors = new { primary = new { main = "#e13e13" } }
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
SA.Before
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
document.UseApiTags();
|
||||||
|
document.SortApis();
|
||||||
|
document.AddOperationFilter(x =>
|
||||||
|
{
|
||||||
|
if (x is AspNetCoreOperationProcessorContext ctx)
|
||||||
|
return ctx.ApiDescription.ActionDescriptor.AttributeRouteInfo?.Order
|
||||||
|
!= AlternativeRoute;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
document.SchemaGenerator.Settings.TypeMappers.Add(
|
||||||
|
new PrimitiveTypeMapper(
|
||||||
|
typeof(Identifier),
|
||||||
|
x =>
|
||||||
|
{
|
||||||
|
x.IsNullableRaw = false;
|
||||||
|
x.Type = JsonObjectType.String | JsonObjectType.Integer;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
document.AddSecurity(
|
||||||
|
nameof(Kyoo),
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Type = OpenApiSecuritySchemeType.Http,
|
||||||
|
Scheme = "Bearer",
|
||||||
|
BearerFormat = "JWT",
|
||||||
|
Description = "The user's bearer"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
document.OperationProcessors.Add(new OperationPermissionProcessor());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<IStartupAction> ConfigureSteps =>
|
||||||
|
new IStartupAction[]
|
||||||
|
{
|
||||||
|
SA.New<IApplicationBuilder>(app => app.UseOpenApi(), SA.Before + 1),
|
||||||
|
SA.New<IApplicationBuilder>(
|
||||||
|
app =>
|
||||||
|
app.UseReDoc(x =>
|
||||||
|
{
|
||||||
|
x.Path = "/doc";
|
||||||
|
x.TransformToExternalPath = (internalUiRoute, _) =>
|
||||||
|
"/api" + internalUiRoute;
|
||||||
|
x.AdditionalSettings["theme"] = new
|
||||||
|
{
|
||||||
|
colors = new { primary = new { main = "#e13e13" } }
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
SA.Before
|
||||||
|
)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user