Add csharpier as a code formatter

This commit is contained in:
Zoe Roux 2023-12-07 18:07:07 +01:00
parent baa78b9417
commit 7e6e56a366
101 changed files with 3113 additions and 1982 deletions

View File

@ -7,6 +7,12 @@
"commands": [ "commands": [
"dotnet-ef" "dotnet-ef"
] ]
},
"csharpier": {
"version": "0.26.4",
"commands": [
"dotnet-csharpier"
]
} }
} }
} }

View File

@ -100,11 +100,13 @@ namespace Kyoo.Abstractions.Controllers
/// <param name="reverse">Reverse the sort.</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> /// <param name="afterId">Select the first element after this id if it was in a list.</param>
/// <returns>The resource found</returns> /// <returns>The resource found</returns>
Task<T?> GetOrDefault(Filter<T>? filter, Task<T?> GetOrDefault(
Filter<T>? filter,
Include<T>? include = default, Include<T>? include = default,
Sort<T>? sortBy = default, Sort<T>? sortBy = default,
bool reverse = false, bool reverse = false,
Guid? afterId = default); Guid? afterId = default
);
/// <summary> /// <summary>
/// Search for resources with the database. /// Search for resources with the database.
@ -122,10 +124,12 @@ namespace Kyoo.Abstractions.Controllers
/// <param name="include">The related fields to include.</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> /// <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> /// <returns>A list of resources that match every filters</returns>
Task<ICollection<T>> GetAll(Filter<T>? filter = null, Task<ICollection<T>> GetAll(
Filter<T>? filter = null,
Sort<T>? sort = default, Sort<T>? sort = default,
Include<T>? include = default, Include<T>? include = default,
Pagination? limit = default); Pagination? limit = default
);
/// <summary> /// <summary>
/// Get the number of resources that match the filter's predicate. /// Get the number of resources that match the filter's predicate.
@ -166,8 +170,8 @@ namespace Kyoo.Abstractions.Controllers
/// </summary> /// </summary>
/// <param name="obj">The resource newly created.</param> /// <param name="obj">The resource newly created.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected static Task OnResourceCreated(T obj) protected static Task OnResourceCreated(T obj) =>
=> OnCreated?.Invoke(obj) ?? Task.CompletedTask; OnCreated?.Invoke(obj) ?? Task.CompletedTask;
/// <summary> /// <summary>
/// Edit a resource and replace every property /// Edit a resource and replace every property
@ -199,8 +203,8 @@ namespace Kyoo.Abstractions.Controllers
/// </summary> /// </summary>
/// <param name="obj">The resource newly edited.</param> /// <param name="obj">The resource newly edited.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected static Task OnResourceEdited(T obj) protected static Task OnResourceEdited(T obj) =>
=> OnEdited?.Invoke(obj) ?? Task.CompletedTask; OnEdited?.Invoke(obj) ?? Task.CompletedTask;
/// <summary> /// <summary>
/// Delete a resource by it's ID /// Delete a resource by it's ID
@ -243,8 +247,8 @@ namespace Kyoo.Abstractions.Controllers
/// </summary> /// </summary>
/// <param name="obj">The resource newly deleted.</param> /// <param name="obj">The resource newly deleted.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected static Task OnResourceDeleted(T obj) protected static Task OnResourceDeleted(T obj) =>
=> OnDeleted?.Invoke(obj) ?? Task.CompletedTask; OnDeleted?.Invoke(obj) ?? Task.CompletedTask;
} }
/// <summary> /// <summary>

View File

@ -35,10 +35,12 @@ public interface ISearchManager
/// <param name="pagination">How pagination should be done (where to start and how many to return)</param> /// <param name="pagination">How pagination should be done (where to start and how many to return)</param>
/// <param name="include">The related fields to include.</param> /// <param name="include">The related fields to include.</param>
/// <returns>A list of resources that match every filters</returns> /// <returns>A list of resources that match every filters</returns>
public Task<SearchPage<ILibraryItem>.SearchResult> SearchItems(string? query, public Task<SearchPage<ILibraryItem>.SearchResult> SearchItems(
string? query,
Sort<ILibraryItem> sortBy, Sort<ILibraryItem> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<ILibraryItem>? include = default); Include<ILibraryItem>? include = default
);
/// <summary> /// <summary>
/// Search for movies. /// Search for movies.
@ -48,10 +50,12 @@ public interface ISearchManager
/// <param name="pagination">How pagination should be done (where to start and how many to return)</param> /// <param name="pagination">How pagination should be done (where to start and how many to return)</param>
/// <param name="include">The related fields to include.</param> /// <param name="include">The related fields to include.</param>
/// <returns>A list of resources that match every filters</returns> /// <returns>A list of resources that match every filters</returns>
public Task<SearchPage<Movie>.SearchResult> SearchMovies(string? query, public Task<SearchPage<Movie>.SearchResult> SearchMovies(
string? query,
Sort<Movie> sortBy, Sort<Movie> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<Movie>? include = default); Include<Movie>? include = default
);
/// <summary> /// <summary>
/// Search for shows. /// Search for shows.
@ -61,10 +65,12 @@ public interface ISearchManager
/// <param name="pagination">How pagination should be done (where to start and how many to return)</param> /// <param name="pagination">How pagination should be done (where to start and how many to return)</param>
/// <param name="include">The related fields to include.</param> /// <param name="include">The related fields to include.</param>
/// <returns>A list of resources that match every filters</returns> /// <returns>A list of resources that match every filters</returns>
public Task<SearchPage<Show>.SearchResult> SearchShows(string? query, public Task<SearchPage<Show>.SearchResult> SearchShows(
string? query,
Sort<Show> sortBy, Sort<Show> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<Show>? include = default); Include<Show>? include = default
);
/// <summary> /// <summary>
/// Search for collections. /// Search for collections.
@ -74,10 +80,12 @@ public interface ISearchManager
/// <param name="pagination">How pagination should be done (where to start and how many to return)</param> /// <param name="pagination">How pagination should be done (where to start and how many to return)</param>
/// <param name="include">The related fields to include.</param> /// <param name="include">The related fields to include.</param>
/// <returns>A list of resources that match every filters</returns> /// <returns>A list of resources that match every filters</returns>
public Task<SearchPage<Collection>.SearchResult> SearchCollections(string? query, public Task<SearchPage<Collection>.SearchResult> SearchCollections(
string? query,
Sort<Collection> sortBy, Sort<Collection> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<Collection>? include = default); Include<Collection>? include = default
);
/// <summary> /// <summary>
/// Search for episodes. /// Search for episodes.
@ -87,10 +95,12 @@ public interface ISearchManager
/// <param name="pagination">How pagination should be done (where to start and how many to return)</param> /// <param name="pagination">How pagination should be done (where to start and how many to return)</param>
/// <param name="include">The related fields to include.</param> /// <param name="include">The related fields to include.</param>
/// <returns>A list of resources that match every filters</returns> /// <returns>A list of resources that match every filters</returns>
public Task<SearchPage<Episode>.SearchResult> SearchEpisodes(string? query, public Task<SearchPage<Episode>.SearchResult> SearchEpisodes(
string? query,
Sort<Episode> sortBy, Sort<Episode> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<Episode>? include = default); Include<Episode>? include = default
);
/// <summary> /// <summary>
/// Search for studios. /// Search for studios.
@ -100,8 +110,10 @@ public interface ISearchManager
/// <param name="pagination">How pagination should be done (where to start and how many to return)</param> /// <param name="pagination">How pagination should be done (where to start and how many to return)</param>
/// <param name="include">The related fields to include.</param> /// <param name="include">The related fields to include.</param>
/// <returns>A list of resources that match every filters</returns> /// <returns>A list of resources that match every filters</returns>
public Task<SearchPage<Studio>.SearchResult> SearchStudios(string? query, public Task<SearchPage<Studio>.SearchResult> SearchStudios(
string? query,
Sort<Studio> sortBy, Sort<Studio> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<Studio>? include = default); Include<Studio>? include = default
);
} }

View File

@ -39,11 +39,17 @@ public interface IWatchStatusRepository
Task<ICollection<IWatchlist>> GetAll( Task<ICollection<IWatchlist>> GetAll(
Filter<IWatchlist>? filter = default, Filter<IWatchlist>? filter = default,
Include<IWatchlist>? include = default, Include<IWatchlist>? include = default,
Pagination? limit = default); Pagination? limit = default
);
Task<MovieWatchStatus?> GetMovieStatus(Guid movieId, Guid userId); Task<MovieWatchStatus?> GetMovieStatus(Guid movieId, Guid userId);
Task<MovieWatchStatus?> SetMovieStatus(Guid movieId, Guid userId, WatchStatus status, int? watchedTime); Task<MovieWatchStatus?> SetMovieStatus(
Guid movieId,
Guid userId,
WatchStatus status,
int? watchedTime
);
Task DeleteMovieStatus(Guid movieId, Guid userId); Task DeleteMovieStatus(Guid movieId, Guid userId);
@ -57,7 +63,12 @@ public interface IWatchStatusRepository
/// <param name="watchedTime">Where the user has stopped watching. Only usable if Status /// <param name="watchedTime">Where the user has stopped watching. Only usable if Status
/// is <see cref="WatchStatus.Watching"/></param> /// is <see cref="WatchStatus.Watching"/></param>
Task<EpisodeWatchStatus?> SetEpisodeStatus(Guid episodeId, Guid userId, WatchStatus status, int? watchedTime); Task<EpisodeWatchStatus?> SetEpisodeStatus(
Guid episodeId,
Guid userId,
WatchStatus status,
int? watchedTime
);
Task DeleteEpisodeStatus(Guid episodeId, Guid userId); Task DeleteEpisodeStatus(Guid episodeId, Guid userId);
} }

View File

@ -17,7 +17,6 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System; using System;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Kyoo.Abstractions.Controllers namespace Kyoo.Abstractions.Controllers
@ -26,8 +25,6 @@ namespace Kyoo.Abstractions.Controllers
/// A list of constant priorities used for <see cref="IStartupAction"/>'s <see cref="IStartupAction.Priority"/>. /// 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"/>. /// It also contains helper methods for creating new <see cref="StartupAction"/>.
/// </summary> /// </summary>
[SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name",
Justification = "StartupAction is nested and the name SA is short to improve readability in plugin's startup.")]
public static class SA public static class SA
{ {
/// <summary> /// <summary>
@ -72,8 +69,7 @@ namespace Kyoo.Abstractions.Controllers
/// <param name="action">The action to run</param> /// <param name="action">The action to run</param>
/// <param name="priority">The priority of the new action</param> /// <param name="priority">The priority of the new action</param>
/// <returns>A new <see cref="StartupAction"/></returns> /// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction New(Action action, int priority) public static StartupAction New(Action action, int priority) => new(action, priority);
=> new(action, priority);
/// <summary> /// <summary>
/// Create a new <see cref="StartupAction"/>. /// Create a new <see cref="StartupAction"/>.
@ -83,8 +79,7 @@ namespace Kyoo.Abstractions.Controllers
/// <typeparam name="T">A dependency that this action will use.</typeparam> /// <typeparam name="T">A dependency that this action will use.</typeparam>
/// <returns>A new <see cref="StartupAction"/></returns> /// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction<T> New<T>(Action<T> action, int priority) public static StartupAction<T> New<T>(Action<T> action, int priority)
where T : notnull where T : notnull => new(action, priority);
=> new(action, priority);
/// <summary> /// <summary>
/// Create a new <see cref="StartupAction"/>. /// Create a new <see cref="StartupAction"/>.
@ -96,8 +91,7 @@ namespace Kyoo.Abstractions.Controllers
/// <returns>A new <see cref="StartupAction"/></returns> /// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction<T, T2> New<T, T2>(Action<T, T2> action, int priority) public static StartupAction<T, T2> New<T, T2>(Action<T, T2> action, int priority)
where T : notnull where T : notnull
where T2 : notnull where T2 : notnull => new(action, priority);
=> new(action, priority);
/// <summary> /// <summary>
/// Create a new <see cref="StartupAction"/>. /// Create a new <see cref="StartupAction"/>.
@ -108,11 +102,13 @@ namespace Kyoo.Abstractions.Controllers
/// <typeparam name="T2">A second 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> /// <typeparam name="T3">A third dependency that this action will use.</typeparam>
/// <returns>A new <see cref="StartupAction"/></returns> /// <returns>A new <see cref="StartupAction"/></returns>
public static StartupAction<T, T2, T3> New<T, T2, T3>(Action<T, T2, T3> action, int priority) public static StartupAction<T, T2, T3> New<T, T2, T3>(
Action<T, T2, T3> action,
int priority
)
where T : notnull where T : notnull
where T2 : notnull where T2 : notnull
where T3 : notnull where T3 : notnull => new(action, priority);
=> new(action, priority);
/// <summary> /// <summary>
/// A <see cref="IStartupAction"/> with no dependencies. /// A <see cref="IStartupAction"/> with no dependencies.
@ -209,10 +205,7 @@ namespace Kyoo.Abstractions.Controllers
/// <inheritdoc /> /// <inheritdoc />
public void Run(IServiceProvider provider) public void Run(IServiceProvider provider)
{ {
_action.Invoke( _action.Invoke(provider.GetRequiredService<T>(), provider.GetRequiredService<T2>());
provider.GetRequiredService<T>(),
provider.GetRequiredService<T2>()
);
} }
} }

View File

@ -48,7 +48,6 @@ namespace Kyoo.Abstractions.Models.Exceptions
/// <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 DuplicatedItemException(SerializationInfo info, StreamingContext context) protected DuplicatedItemException(SerializationInfo info, StreamingContext context)
: base(info, context) : base(info, context) { }
{ }
} }
} }

View File

@ -37,8 +37,7 @@ namespace Kyoo.Abstractions.Models.Exceptions
/// </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
@ -46,7 +45,6 @@ namespace Kyoo.Abstractions.Models.Exceptions
/// <param name="info">Serialization infos</param> /// <param name="info">Serialization infos</param>
/// <param name="context">The serialization context</param> /// <param name="context">The serialization context</param>
protected ItemNotFoundException(SerializationInfo info, StreamingContext context) protected ItemNotFoundException(SerializationInfo info, StreamingContext context)
: base(info, context) : base(info, context) { }
{ }
} }
} }

View File

@ -25,15 +25,12 @@ namespace Kyoo.Abstractions.Models.Exceptions
public class UnauthorizedException : Exception public class UnauthorizedException : Exception
{ {
public UnauthorizedException() public UnauthorizedException()
: base("User not authenticated or token invalid.") : base("User not authenticated or token invalid.") { }
{ }
public UnauthorizedException(string message) public UnauthorizedException(string message)
: base(message) : base(message) { }
{ }
protected UnauthorizedException(SerializationInfo info, StreamingContext context) protected UnauthorizedException(SerializationInfo info, StreamingContext context)
: base(info, context) : base(info, context) { }
{ }
} }
} }

View File

@ -24,5 +24,4 @@ namespace Kyoo.Abstractions.Models;
/// A watch list item. /// A watch list item.
/// </summary> /// </summary>
[OneOf(Types = new[] { typeof(Show), typeof(Movie) })] [OneOf(Types = new[] { typeof(Show), typeof(Movie) })]
public interface IWatchlist : IResource, IThumbnails, IMetadata, IAddedDate public interface IWatchlist : IResource, IThumbnails, IMetadata, IAddedDate { }
{ }

View File

@ -67,7 +67,13 @@ namespace Kyoo.Abstractions.Models
/// <param name="previous">The link of the previous page.</param> /// <param name="previous">The link of the previous page.</param>
/// <param name="next">The link of the next page.</param> /// <param name="next">The link of the next page.</param>
/// <param name="first">The link of the first 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) public Page(
ICollection<T> items,
string @this,
string? previous,
string? next,
string first
)
{ {
Items = items; Items = items;
This = @this; This = @this;
@ -83,10 +89,7 @@ namespace Kyoo.Abstractions.Models
/// <param name="url">The base url of the resources available from this 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="query">The list of query strings of the current page</param>
/// <param name="limit">The number of items requested for the current page.</param> /// <param name="limit">The number of items requested for the current page.</param>
public Page(ICollection<T> items, public Page(ICollection<T> items, string url, Dictionary<string, string> query, int limit)
string url,
Dictionary<string, string> query,
int limit)
{ {
Items = items; Items = items;
This = url + query.ToQueryString(); This = url + query.ToQueryString();

View File

@ -37,7 +37,8 @@ namespace Kyoo.Abstractions.Models
public Guid Id { get; set; } public Guid Id { get; set; }
/// <inheritdoc /> /// <inheritdoc />
[MaxLength(256)] public string Slug { get; set; } [MaxLength(256)]
public string Slug { get; set; }
/// <summary> /// <summary>
/// The name of this collection. /// The name of this collection.
@ -64,12 +65,14 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The list of movies contained in this collection. /// The list of movies contained in this collection.
/// </summary> /// </summary>
[SerializeIgnore] public ICollection<Movie>? Movies { get; set; } [SerializeIgnore]
public ICollection<Movie>? Movies { get; set; }
/// <summary> /// <summary>
/// The list of shows contained in this collection. /// The list of shows contained in this collection.
/// </summary> /// </summary>
[SerializeIgnore] public ICollection<Show>? Shows { get; set; } [SerializeIgnore]
public ICollection<Show>? Shows { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); public Dictionary<string, MetadataId> ExternalId { get; set; } = new();

View File

@ -34,7 +34,8 @@ namespace Kyoo.Abstractions.Models
public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews public class Episode : IQuery, IResource, IMetadata, IThumbnails, IAddedDate, INews
{ {
// Use absolute numbers by default and fallback to season/episodes if it does not exists. // Use absolute numbers by default and fallback to season/episodes if it does not exists.
public static Sort DefaultSort => new Sort<Episode>.Conglomerate( public static Sort DefaultSort =>
new Sort<Episode>.Conglomerate(
new Sort<Episode>.By(x => x.AbsoluteNumber), new Sort<Episode>.By(x => x.AbsoluteNumber),
new Sort<Episode>.By(x => x.SeasonNumber), new Sort<Episode>.By(x => x.SeasonNumber),
new Sort<Episode>.By(x => x.EpisodeNumber) new Sort<Episode>.By(x => x.EpisodeNumber)
@ -51,10 +52,14 @@ namespace Kyoo.Abstractions.Models
get get
{ {
if (ShowSlug != null || Show?.Slug != null) if (ShowSlug != null || Show?.Slug != null)
return GetSlug(ShowSlug ?? Show!.Slug, SeasonNumber, EpisodeNumber, AbsoluteNumber); return GetSlug(
ShowSlug ?? Show!.Slug,
SeasonNumber,
EpisodeNumber,
AbsoluteNumber
);
return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber); return GetSlug(ShowId.ToString(), SeasonNumber, EpisodeNumber, AbsoluteNumber);
} }
[UsedImplicitly] [UsedImplicitly]
private set private set
{ {
@ -85,7 +90,8 @@ namespace Kyoo.Abstractions.Models
/// <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>
[SerializeIgnore] public string? ShowSlug { private get; set; } [SerializeIgnore]
public string? ShowSlug { private get; set; }
/// <summary> /// <summary>
/// The ID of the Show containing this episode. /// The ID of the Show containing this episode.
@ -95,7 +101,8 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The show that contains this episode. /// The show that contains this episode.
/// </summary> /// </summary>
[LoadableRelation(nameof(ShowId))] public Show? Show { get; set; } [LoadableRelation(nameof(ShowId))]
public Show? Show { get; set; }
/// <summary> /// <summary>
/// The ID of the Season containing this episode. /// The ID of the Season containing this episode.
@ -109,7 +116,8 @@ namespace Kyoo.Abstractions.Models
/// 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))] public Season? Season { get; set; } [LoadableRelation(nameof(SeasonId))]
public Season? Season { get; set; }
/// <summary> /// <summary>
/// The season in witch this episode is in. /// The season in witch this episode is in.
@ -192,12 +200,15 @@ namespace Kyoo.Abstractions.Models
)] )]
public Episode? PreviousEpisode { get; set; } public Episode? PreviousEpisode { get; set; }
private Episode? _PreviousEpisode => Show!.Episodes! private Episode? _PreviousEpisode =>
Show!
.Episodes!
.OrderBy(x => x.AbsoluteNumber == null) .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)
@ -229,17 +240,21 @@ namespace Kyoo.Abstractions.Models
)] )]
public Episode? NextEpisode { get; set; } public Episode? NextEpisode { get; set; }
private Episode? _NextEpisode => Show!.Episodes! private Episode? _NextEpisode =>
Show!
.Episodes!
.OrderBy(x => x.AbsoluteNumber) .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)
); );
[SerializeIgnore] public ICollection<EpisodeWatchStatus>? Watched { get; set; } [SerializeIgnore]
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.
@ -257,7 +272,8 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// Links to watch this episode. /// Links to watch this episode.
/// </summary> /// </summary>
public VideoLinks Links => new() public VideoLinks Links =>
new()
{ {
Direct = $"/video/episode/{Slug}/direct", Direct = $"/video/episode/{Slug}/direct",
Hls = $"/video/episode/{Slug}/master.m3u8", Hls = $"/video/episode/{Slug}/master.m3u8",
@ -280,10 +296,12 @@ namespace Kyoo.Abstractions.Models
/// 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(string showSlug, public static string GetSlug(
string showSlug,
int? seasonNumber, int? seasonNumber,
int? episodeNumber, int? episodeNumber,
int? absoluteNumber = null) int? absoluteNumber = null
)
{ {
return seasonNumber switch return seasonNumber switch
{ {

View File

@ -82,7 +82,11 @@ namespace Kyoo.Abstractions.Models
} }
/// <inheritdoc /> /// <inheritdoc />
public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) public override object ConvertFrom(
ITypeDescriptorContext? context,
CultureInfo? culture,
object value
)
{ {
if (value is not string source) if (value is not string source)
return base.ConvertFrom(context, culture, value)!; return base.ConvertFrom(context, culture, value)!;
@ -90,7 +94,10 @@ namespace Kyoo.Abstractions.Models
} }
/// <inheritdoc /> /// <inheritdoc />
public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) public override bool CanConvertTo(
ITypeDescriptorContext? context,
Type? destinationType
)
{ {
return false; return false;
} }

View File

@ -31,7 +31,16 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// A series or a movie. /// A series or a movie.
/// </summary> /// </summary>
public class Movie : IQuery, IResource, IMetadata, IOnMerge, IThumbnails, IAddedDate, ILibraryItem, INews, IWatchlist public class Movie
: IQuery,
IResource,
IMetadata,
IOnMerge,
IThumbnails,
IAddedDate,
ILibraryItem,
INews,
IWatchlist
{ {
public static Sort DefaultSort => new Sort<Movie>.By(x => x.Name); public static Sort DefaultSort => new Sort<Movie>.By(x => x.Name);
@ -120,12 +129,14 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The ID of the Studio that made this show. /// The ID of the Studio that made this show.
/// </summary> /// </summary>
[SerializeIgnore] public Guid? StudioId { get; set; } [SerializeIgnore]
public Guid? StudioId { get; set; }
/// <summary> /// <summary>
/// The Studio that made this show. /// The Studio that made this show.
/// </summary> /// </summary>
[LoadableRelation(nameof(StudioId))] public Studio? Studio { get; set; } [LoadableRelation(nameof(StudioId))]
public Studio? Studio { get; set; }
// /// <summary> // /// <summary>
// /// The list of people that made this show. // /// The list of people that made this show.
@ -135,18 +146,21 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The list of collections that contains this show. /// The list of collections that contains this show.
/// </summary> /// </summary>
[SerializeIgnore] public ICollection<Collection>? Collections { get; set; } [SerializeIgnore]
public ICollection<Collection>? Collections { get; set; }
/// <summary> /// <summary>
/// Links to watch this movie. /// Links to watch this movie.
/// </summary> /// </summary>
public VideoLinks Links => new() public VideoLinks Links =>
new()
{ {
Direct = $"/video/movie/{Slug}/direct", Direct = $"/video/movie/{Slug}/direct",
Hls = $"/video/movie/{Slug}/master.m3u8", Hls = $"/video/movie/{Slug}/master.m3u8",
}; };
[SerializeIgnore] public ICollection<MovieWatchStatus>? Watched { get; set; } [SerializeIgnore]
public ICollection<MovieWatchStatus>? 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.

View File

@ -62,7 +62,8 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The list of roles this person has played in. See <see cref="PeopleRole"/> for more information. /// The list of roles this person has played in. See <see cref="PeopleRole"/> for more information.
/// </summary> /// </summary>
[SerializeIgnore] public ICollection<PeopleRole>? Roles { get; set; } [SerializeIgnore]
public ICollection<PeopleRole>? Roles { get; set; }
public People() { } public People() { }

View File

@ -49,7 +49,6 @@ namespace Kyoo.Abstractions.Models
return $"{ShowId}-s{SeasonNumber}"; return $"{ShowId}-s{SeasonNumber}";
return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}"; return $"{ShowSlug ?? Show?.Slug}-s{SeasonNumber}";
} }
[UsedImplicitly] [UsedImplicitly]
[NotNull] [NotNull]
private set private set
@ -57,7 +56,9 @@ namespace Kyoo.Abstractions.Models
Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)"); Match match = Regex.Match(value, @"(?<show>.+)-s(?<season>\d+)");
if (!match.Success) if (!match.Success)
throw new ArgumentException("Invalid season slug. Format: {showSlug}-s{seasonNumber}"); throw new ArgumentException(
"Invalid season slug. Format: {showSlug}-s{seasonNumber}"
);
ShowSlug = match.Groups["show"].Value; ShowSlug = match.Groups["show"].Value;
SeasonNumber = int.Parse(match.Groups["season"].Value); SeasonNumber = int.Parse(match.Groups["season"].Value);
} }
@ -66,7 +67,8 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The slug of the Show that contain this episode. If this is not set, this season is ill-formed. /// The slug of the Show that contain this episode. If this is not set, this season is ill-formed.
/// </summary> /// </summary>
[SerializeIgnore] public string? ShowSlug { private get; set; } [SerializeIgnore]
public string? ShowSlug { private get; set; }
/// <summary> /// <summary>
/// The ID of the Show containing this season. /// The ID of the Show containing this season.
@ -76,7 +78,8 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The show that contains this season. /// The show that contains this season.
/// </summary> /// </summary>
[LoadableRelation(nameof(ShowId))] public Show? Show { get; set; } [LoadableRelation(nameof(ShowId))]
public Show? Show { get; set; }
/// <summary> /// <summary>
/// The number of this season. This can be set to 0 to indicate specials. /// The number of this season. This can be set to 0 to indicate specials.
@ -121,7 +124,8 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The list of episodes that this season contains. /// The list of episodes that this season contains.
/// </summary> /// </summary>
[SerializeIgnore] public ICollection<Episode>? Episodes { get; set; } [SerializeIgnore]
public ICollection<Episode>? Episodes { get; set; }
/// <summary> /// <summary>
/// The number of episodes in this season. /// The number of episodes in this season.

View File

@ -32,7 +32,15 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// A series or a movie. /// A series or a movie.
/// </summary> /// </summary>
public class Show : IQuery, IResource, IMetadata, IOnMerge, IThumbnails, IAddedDate, ILibraryItem, IWatchlist public class Show
: IQuery,
IResource,
IMetadata,
IOnMerge,
IThumbnails,
IAddedDate,
ILibraryItem,
IWatchlist
{ {
public static Sort DefaultSort => new Sort<Show>.By(x => x.Name); public static Sort DefaultSort => new Sort<Show>.By(x => x.Name);
@ -121,12 +129,14 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The ID of the Studio that made this show. /// The ID of the Studio that made this show.
/// </summary> /// </summary>
[SerializeIgnore] public Guid? StudioId { get; set; } [SerializeIgnore]
public Guid? StudioId { get; set; }
/// <summary> /// <summary>
/// The Studio that made this show. /// The Studio that made this show.
/// </summary> /// </summary>
[LoadableRelation(nameof(StudioId))] public Studio? Studio { get; set; } [LoadableRelation(nameof(StudioId))]
public Studio? Studio { get; set; }
// /// <summary> // /// <summary>
// /// The list of people that made this show. // /// The list of people that made this show.
@ -136,19 +146,22 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The different seasons in this show. If this is a movie, this list is always null or empty. /// The different seasons in this show. If this is a movie, this list is always null or empty.
/// </summary> /// </summary>
[SerializeIgnore] public ICollection<Season>? Seasons { get; set; } [SerializeIgnore]
public ICollection<Season>? Seasons { get; set; }
/// <summary> /// <summary>
/// The list of episodes in this show. /// The list of episodes in this show.
/// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null). /// If this is a movie, there will be a unique episode (with the seasonNumber and episodeNumber set to null).
/// Having an episode is necessary to store metadata and tracks. /// Having an episode is necessary to store metadata and tracks.
/// </summary> /// </summary>
[SerializeIgnore] public ICollection<Episode>? Episodes { get; set; } [SerializeIgnore]
public ICollection<Episode>? Episodes { get; set; }
/// <summary> /// <summary>
/// The list of collections that contains this show. /// The list of collections that contains this show.
/// </summary> /// </summary>
[SerializeIgnore] public ICollection<Collection>? Collections { get; set; } [SerializeIgnore]
public ICollection<Collection>? Collections { get; set; }
/// <summary> /// <summary>
/// The first episode of this show. /// The first episode of this show.
@ -172,7 +185,8 @@ namespace Kyoo.Abstractions.Models
)] )]
public Episode? FirstEpisode { get; set; } public Episode? FirstEpisode { get; set; }
private Episode? _FirstEpisode => Episodes! private Episode? _FirstEpisode =>
Episodes!
.OrderBy(x => x.AbsoluteNumber) .OrderBy(x => x.AbsoluteNumber)
.ThenBy(x => x.SeasonNumber) .ThenBy(x => x.SeasonNumber)
.ThenBy(x => x.EpisodeNumber) .ThenBy(x => x.EpisodeNumber)
@ -199,7 +213,8 @@ namespace Kyoo.Abstractions.Models
private int _EpisodesCount => Episodes!.Count; private int _EpisodesCount => Episodes!.Count;
[SerializeIgnore] public ICollection<ShowWatchStatus>? Watched { get; set; } [SerializeIgnore]
public ICollection<ShowWatchStatus>? 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.

View File

@ -48,12 +48,14 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The list of shows that are made by this studio. /// The list of shows that are made by this studio.
/// </summary> /// </summary>
[SerializeIgnore] public ICollection<Show>? Shows { get; set; } [SerializeIgnore]
public ICollection<Show>? Shows { get; set; }
/// <summary> /// <summary>
/// The list of movies that are made by this studio. /// The list of movies that are made by this studio.
/// </summary> /// </summary>
[SerializeIgnore] public ICollection<Movie>? Movies { get; set; } [SerializeIgnore]
public ICollection<Movie>? Movies { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public Dictionary<string, MetadataId> ExternalId { get; set; } = new(); public Dictionary<string, MetadataId> ExternalId { get; set; } = new();

View File

@ -56,22 +56,26 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The ID of the user that started watching this episode. /// The ID of the user that started watching this episode.
/// </summary> /// </summary>
[SerializeIgnore] public Guid UserId { get; set; } [SerializeIgnore]
public Guid UserId { get; set; }
/// <summary> /// <summary>
/// The user that started watching this episode. /// The user that started watching this episode.
/// </summary> /// </summary>
[SerializeIgnore] public User User { get; set; } [SerializeIgnore]
public User User { get; set; }
/// <summary> /// <summary>
/// The ID of the movie started. /// The ID of the movie started.
/// </summary> /// </summary>
[SerializeIgnore] public Guid MovieId { get; set; } [SerializeIgnore]
public Guid MovieId { get; set; }
/// <summary> /// <summary>
/// The <see cref="Movie"/> started. /// The <see cref="Movie"/> started.
/// </summary> /// </summary>
[SerializeIgnore] public Movie Movie { get; set; } [SerializeIgnore]
public Movie Movie { get; set; }
/// <inheritdoc/> /// <inheritdoc/>
public DateTime AddedDate { get; set; } public DateTime AddedDate { get; set; }
@ -109,22 +113,26 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The ID of the user that started watching this episode. /// The ID of the user that started watching this episode.
/// </summary> /// </summary>
[SerializeIgnore] public Guid UserId { get; set; } [SerializeIgnore]
public Guid UserId { get; set; }
/// <summary> /// <summary>
/// The user that started watching this episode. /// The user that started watching this episode.
/// </summary> /// </summary>
[SerializeIgnore] public User User { get; set; } [SerializeIgnore]
public User User { get; set; }
/// <summary> /// <summary>
/// The ID of the episode started. /// The ID of the episode started.
/// </summary> /// </summary>
[SerializeIgnore] public Guid? EpisodeId { get; set; } [SerializeIgnore]
public Guid? EpisodeId { get; set; }
/// <summary> /// <summary>
/// The <see cref="Episode"/> started. /// The <see cref="Episode"/> started.
/// </summary> /// </summary>
[SerializeIgnore] public Episode Episode { get; set; } [SerializeIgnore]
public Episode Episode { get; set; }
/// <inheritdoc/> /// <inheritdoc/>
public DateTime AddedDate { get; set; } public DateTime AddedDate { get; set; }
@ -162,22 +170,26 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The ID of the user that started watching this episode. /// The ID of the user that started watching this episode.
/// </summary> /// </summary>
[SerializeIgnore] public Guid UserId { get; set; } [SerializeIgnore]
public Guid UserId { get; set; }
/// <summary> /// <summary>
/// The user that started watching this episode. /// The user that started watching this episode.
/// </summary> /// </summary>
[SerializeIgnore] public User User { get; set; } [SerializeIgnore]
public User User { get; set; }
/// <summary> /// <summary>
/// The ID of the show started. /// The ID of the show started.
/// </summary> /// </summary>
[SerializeIgnore] public Guid ShowId { get; set; } [SerializeIgnore]
public Guid ShowId { get; set; }
/// <summary> /// <summary>
/// The <see cref="Show"/> started. /// The <see cref="Show"/> started.
/// </summary> /// </summary>
[SerializeIgnore] public Show Show { get; set; } [SerializeIgnore]
public Show Show { get; set; }
/// <inheritdoc/> /// <inheritdoc/>
public DateTime AddedDate { get; set; } public DateTime AddedDate { get; set; }
@ -200,7 +212,8 @@ namespace Kyoo.Abstractions.Models
/// <summary> /// <summary>
/// The ID of the episode started. /// The ID of the episode started.
/// </summary> /// </summary>
[SerializeIgnore] public Guid? NextEpisodeId { get; set; } [SerializeIgnore]
public Guid? NextEpisodeId { get; set; }
/// <summary> /// <summary>
/// The next <see cref="Episode"/> to watch. /// The next <see cref="Episode"/> to watch.

View File

@ -32,7 +32,8 @@ namespace Kyoo.Abstractions.Models
string @this, string @this,
string? previous, string? previous,
string? next, string? next,
string first) string first
)
: base(result.Items, @this, previous, next, first) : base(result.Items, @this, previous, next, first)
{ {
Query = result.Query; Query = result.Query;

View File

@ -53,24 +53,30 @@ public abstract record Filter
{ {
return filters return filters
.Where(x => x != null) .Where(x => x != null)
.Aggregate((Filter<T>?)null, (acc, filter) => .Aggregate(
(Filter<T>?)null,
(acc, filter) =>
{ {
if (acc == null) if (acc == null)
return filter; return filter;
return new Filter<T>.And(acc, filter!); return new Filter<T>.And(acc, filter!);
}); }
);
} }
public static Filter<T>? Or<T>(params Filter<T>?[] filters) public static Filter<T>? Or<T>(params Filter<T>?[] filters)
{ {
return filters return filters
.Where(x => x != null) .Where(x => x != null)
.Aggregate((Filter<T>?)null, (acc, filter) => .Aggregate(
(Filter<T>?)null,
(acc, filter) =>
{ {
if (acc == null) if (acc == null)
return filter; return filter;
return new Filter<T>.Or(acc, filter!); return new Filter<T>.Or(acc, filter!);
}); }
);
} }
} }
@ -109,8 +115,8 @@ public abstract record Filter<T> : Filter
public static class FilterParsers public static class FilterParsers
{ {
public static readonly Parser<Filter<T>> Filter = public static readonly Parser<Filter<T>> Filter = Parse
Parse.Ref(() => Bracket) .Ref(() => Bracket)
.Or(Parse.Ref(() => Not)) .Or(Parse.Ref(() => Not))
.Or(Parse.Ref(() => Eq)) .Or(Parse.Ref(() => Eq))
.Or(Parse.Ref(() => Ne)) .Or(Parse.Ref(() => Ne))
@ -120,8 +126,8 @@ public abstract record Filter<T> : Filter
.Or(Parse.Ref(() => Le)) .Or(Parse.Ref(() => Le))
.Or(Parse.Ref(() => Has)); .Or(Parse.Ref(() => Has));
public static readonly Parser<Filter<T>> CompleteFilter = public static readonly Parser<Filter<T>> CompleteFilter = Parse
Parse.Ref(() => Or) .Ref(() => Or)
.Or(Parse.Ref(() => And)) .Or(Parse.Ref(() => And))
.Or(Filter); .Or(Filter);
@ -131,22 +137,30 @@ public abstract record Filter<T> : Filter
from close in Parse.Char(')').Token() from close in Parse.Char(')').Token()
select filter; select filter;
public static readonly Parser<IEnumerable<char>> AndOperator = Parse.IgnoreCase("and") public static readonly Parser<IEnumerable<char>> AndOperator = Parse
.IgnoreCase("and")
.Or(Parse.String("&&")) .Or(Parse.String("&&"))
.Token(); .Token();
public static readonly Parser<IEnumerable<char>> OrOperator = Parse.IgnoreCase("or") public static readonly Parser<IEnumerable<char>> OrOperator = Parse
.IgnoreCase("or")
.Or(Parse.String("||")) .Or(Parse.String("||"))
.Token(); .Token();
public static readonly Parser<Filter<T>> And = Parse.ChainOperator(AndOperator, Filter, (_, a, b) => new And(a, b)); public static readonly Parser<Filter<T>> And = Parse.ChainOperator(
AndOperator,
Filter,
(_, a, b) => new And(a, b)
);
public static readonly Parser<Filter<T>> Or = Parse.ChainOperator(OrOperator, And.Or(Filter), (_, a, b) => new Or(a, b)); public static readonly Parser<Filter<T>> Or = Parse.ChainOperator(
OrOperator,
And.Or(Filter),
(_, a, b) => new Or(a, b)
);
public static readonly Parser<Filter<T>> Not = public static readonly Parser<Filter<T>> Not =
from not in Parse.IgnoreCase("not") from not in Parse.IgnoreCase("not").Or(Parse.String("!")).Token()
.Or(Parse.String("!"))
.Token()
from filter in CompleteFilter from filter in CompleteFilter
select new Not(filter); select new Not(filter);
@ -155,9 +169,7 @@ public abstract record Filter<T> : Filter
Type? nullable = Nullable.GetUnderlyingType(type); Type? nullable = Nullable.GetUnderlyingType(type);
if (nullable != null) if (nullable != null)
{ {
return return from value in _GetValueParser(nullable) select value;
from value in _GetValueParser(nullable)
select value;
} }
if (type == typeof(int)) if (type == typeof(int))
@ -165,8 +177,7 @@ public abstract record Filter<T> : Filter
if (type == typeof(float)) if (type == typeof(float))
{ {
return return from a in Parse.Number
from a in Parse.Number
from dot in Parse.Char('.') from dot in Parse.Char('.')
from b in Parse.Number from b in Parse.Number
select float.Parse($"{a}.{b}") as object; select float.Parse($"{a}.{b}") as object;
@ -174,8 +185,10 @@ public abstract record Filter<T> : Filter
if (type == typeof(Guid)) if (type == typeof(Guid))
{ {
return return from guid in Parse.Regex(
from guid in Parse.Regex(@"[({]?[a-fA-F0-9]{8}[-]?([a-fA-F0-9]{4}[-]?){3}[a-fA-F0-9]{12}[})]?", "Guid") @"[({]?[a-fA-F0-9]{8}[-]?([a-fA-F0-9]{4}[-]?){3}[a-fA-F0-9]{12}[})]?",
"Guid"
)
select Guid.Parse(guid) as object; select Guid.Parse(guid) as object;
} }
@ -191,7 +204,11 @@ public abstract record Filter<T> : Filter
if (type.IsEnum) if (type.IsEnum)
{ {
return Parse.LetterOrDigit.Many().Text().Then(x => return Parse
.LetterOrDigit
.Many()
.Text()
.Then(x =>
{ {
if (Enum.TryParse(type, x, true, out object? value)) if (Enum.TryParse(type, x, true, out object? value))
return Parse.Return(value); return Parse.Return(value);
@ -201,8 +218,7 @@ public abstract record Filter<T> : Filter
if (type == typeof(DateTime)) if (type == typeof(DateTime))
{ {
return return from year in Parse.Digit.Repeat(4).Text().Select(int.Parse)
from year in Parse.Digit.Repeat(4).Text().Select(int.Parse)
from yd in Parse.Char('-') from yd in Parse.Char('-')
from mouth in Parse.Digit.Repeat(2).Text().Select(int.Parse) from mouth in Parse.Digit.Repeat(2).Text().Select(int.Parse)
from md in Parse.Char('-') from md in Parse.Char('-')
@ -211,43 +227,57 @@ public abstract record Filter<T> : Filter
} }
if (typeof(IEnumerable).IsAssignableFrom(type)) if (typeof(IEnumerable).IsAssignableFrom(type))
return ParseHelper.Error<object>("Can't filter a list with a default comparator, use the 'has' filter."); return ParseHelper.Error<object>(
"Can't filter a list with a default comparator, use the 'has' filter."
);
return ParseHelper.Error<object>("Unfilterable field found"); return ParseHelper.Error<object>("Unfilterable field found");
} }
private static Parser<Filter<T>> _GetOperationParser( private static Parser<Filter<T>> _GetOperationParser(
Parser<object> op, Parser<object> op,
Func<string, object, Filter<T>> apply, Func<string, object, Filter<T>> apply,
Func<Type, Parser<object?>>? customTypeParser = null) Func<Type, Parser<object?>>? customTypeParser = null
)
{ {
Parser<string> property = Parse.LetterOrDigit.AtLeastOnce().Text(); Parser<string> property = Parse.LetterOrDigit.AtLeastOnce().Text();
return property.Then(prop => return property.Then(prop =>
{ {
Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) }; Type[] types =
typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) };
if (string.Equals(prop, "kind", StringComparison.OrdinalIgnoreCase)) if (string.Equals(prop, "kind", StringComparison.OrdinalIgnoreCase))
{ {
return return from eq in op
from eq in op
from val in types from val in types
.Select(x => Parse.IgnoreCase(x.Name).Text()) .Select(x => Parse.IgnoreCase(x.Name).Text())
.Aggregate(null as Parser<string>, (acc, x) => acc == null ? x : Parse.Or(acc, x)) .Aggregate(
null as Parser<string>,
(acc, x) => acc == null ? x : Parse.Or(acc, x)
)
select apply("kind", val); select apply("kind", val);
} }
PropertyInfo? propInfo = types PropertyInfo? propInfo = types
.Select(x => x.GetProperty(prop, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)) .Select(
x =>
x.GetProperty(
prop,
BindingFlags.IgnoreCase
| BindingFlags.Public
| BindingFlags.Instance
)
)
.FirstOrDefault(); .FirstOrDefault();
if (propInfo == null) if (propInfo == null)
return ParseHelper.Error<Filter<T>>($"The given filter '{prop}' is invalid."); return ParseHelper.Error<Filter<T>>($"The given filter '{prop}' is invalid.");
Parser<object?> value = customTypeParser != null Parser<object?> value =
customTypeParser != null
? customTypeParser(propInfo.PropertyType) ? customTypeParser(propInfo.PropertyType)
: _GetValueParser(propInfo.PropertyType); : _GetValueParser(propInfo.PropertyType);
return return from eq in op
from eq in op
from val in value from val in value
select apply(propInfo.Name, val); select apply(propInfo.Name, val);
}); });
@ -261,7 +291,10 @@ public abstract record Filter<T> : Filter
Type? inner = Nullable.GetUnderlyingType(type); Type? inner = Nullable.GetUnderlyingType(type);
if (inner == null) if (inner == null)
return _GetValueParser(type); return _GetValueParser(type);
return Parse.String("null").Token().Return((object?)null) return Parse
.String("null")
.Token()
.Return((object?)null)
.Or(_GetValueParser(inner)); .Or(_GetValueParser(inner));
} }
); );
@ -274,7 +307,10 @@ public abstract record Filter<T> : Filter
Type? inner = Nullable.GetUnderlyingType(type); Type? inner = Nullable.GetUnderlyingType(type);
if (inner == null) if (inner == null)
return _GetValueParser(type); return _GetValueParser(type);
return Parse.String("null").Token().Return((object?)null) return Parse
.String("null")
.Token()
.Return((object?)null)
.Or(_GetValueParser(inner)); .Or(_GetValueParser(inner));
} }
); );
@ -305,7 +341,9 @@ public abstract record Filter<T> : Filter
(Type type) => (Type type) =>
{ {
if (typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string)) if (typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string))
return _GetValueParser(type.GetElementType() ?? type.GenericTypeArguments.First()); return _GetValueParser(
type.GetElementType() ?? type.GenericTypeArguments.First()
);
return ParseHelper.Error<object>("Can't use 'has' on a non-list."); return ParseHelper.Error<object>("Can't use 'has' on a non-list.");
} }
); );
@ -321,7 +359,9 @@ public abstract record Filter<T> : Filter
IResult<Filter<T>> ret = FilterParsers.CompleteFilter.End().TryParse(filter); IResult<Filter<T>> ret = FilterParsers.CompleteFilter.End().TryParse(filter);
if (ret.WasSuccessful) if (ret.WasSuccessful)
return ret.Value; return ret.Value;
throw new ValidationException($"Could not parse filter argument: {ret.Message}. Not parsed: {filter[ret.Remainder.Position..]}"); throw new ValidationException(
$"Could not parse filter argument: {ret.Message}. Not parsed: {filter[ret.Remainder.Position..]}"
);
} }
catch (ParseException ex) catch (ParseException ex)
{ {

View File

@ -82,9 +82,7 @@ namespace Kyoo.Abstractions.Models.Utils
/// </example> /// </example>
public T Match<T>(Func<Guid, T> idFunc, Func<string, T> slugFunc) public T Match<T>(Func<Guid, T> idFunc, Func<string, T> slugFunc)
{ {
return _id.HasValue return _id.HasValue ? idFunc(_id.Value) : slugFunc(_slug!);
? idFunc(_id.Value)
: slugFunc(_slug!);
} }
/// <summary> /// <summary>
@ -99,12 +97,19 @@ namespace Kyoo.Abstractions.Models.Utils
/// identifier.Matcher&lt;Season&gt;(x => x.ShowID, x => x.Show.Slug) /// identifier.Matcher&lt;Season&gt;(x => x.ShowID, x => x.Show.Slug)
/// </code> /// </code>
/// </example> /// </example>
public Filter<T> Matcher<T>(Expression<Func<T, Guid>> idGetter, public Filter<T> Matcher<T>(
Expression<Func<T, string>> slugGetter) Expression<Func<T, Guid>> idGetter,
Expression<Func<T, string>> slugGetter
)
{ {
ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug);
BinaryExpression equal = Expression.Equal(_id.HasValue ? idGetter.Body : slugGetter.Body, self); BinaryExpression equal = Expression.Equal(
ICollection<ParameterExpression> parameters = _id.HasValue ? idGetter.Parameters : slugGetter.Parameters; _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); Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters);
return new Filter<T>.Lambda(lambda); return new Filter<T>.Lambda(lambda);
} }
@ -118,12 +123,19 @@ namespace Kyoo.Abstractions.Models.Utils
/// <param name="slugGetter">An expression to retrieve a slug 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> /// <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> /// <returns>An expression to match the type <typeparamref name="T"/> to this identifier.</returns>
public Filter<T> Matcher<T>(Expression<Func<T, Guid?>> idGetter, public Filter<T> Matcher<T>(
Expression<Func<T, string>> slugGetter) Expression<Func<T, Guid?>> idGetter,
Expression<Func<T, string>> slugGetter
)
{ {
ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug); ConstantExpression self = Expression.Constant(_id.HasValue ? _id.Value : _slug);
BinaryExpression equal = Expression.Equal(_id.HasValue ? idGetter.Body : slugGetter.Body, self); BinaryExpression equal = Expression.Equal(
ICollection<ParameterExpression> parameters = _id.HasValue ? idGetter.Parameters : slugGetter.Parameters; _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); Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(equal, parameters);
return new Filter<T>.Lambda(lambda); return new Filter<T>.Lambda(lambda);
} }
@ -137,10 +149,7 @@ namespace Kyoo.Abstractions.Models.Utils
/// </returns> /// </returns>
public bool IsSame(IResource resource) public bool IsSame(IResource resource)
{ {
return Match( return Match(id => resource.Id == id, slug => resource.Slug == slug);
id => resource.Id == id,
slug => resource.Slug == slug
);
} }
/// <summary> /// <summary>
@ -161,9 +170,7 @@ namespace Kyoo.Abstractions.Models.Utils
private Expression<Func<T, bool>> _IsSameExpression<T>() private Expression<Func<T, bool>> _IsSameExpression<T>()
where T : IResource where T : IResource
{ {
return _id.HasValue return _id.HasValue ? x => x.Id == _id.Value : x => x.Slug == _slug;
? x => x.Id == _id.Value
: x => x.Slug == _slug;
} }
/// <summary> /// <summary>
@ -181,17 +188,23 @@ namespace Kyoo.Abstractions.Models.Utils
.Where(x => x.Name == nameof(Enumerable.Any)) .Where(x => x.Name == nameof(Enumerable.Any))
.FirstOrDefault(x => x.GetParameters().Length == 2)! .FirstOrDefault(x => x.GetParameters().Length == 2)!
.MakeGenericMethod(typeof(T2)); .MakeGenericMethod(typeof(T2));
MethodCallExpression call = Expression.Call(null, method, listGetter.Body, _IsSameExpression<T2>()); MethodCallExpression call = Expression.Call(
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(call, listGetter.Parameters); 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); return new Filter<T>.Lambda(lambda);
} }
/// <inheritdoc /> /// <inheritdoc />
public override string ToString() public override string ToString()
{ {
return _id.HasValue return _id.HasValue ? _id.Value.ToString() : _slug!;
? _id.Value.ToString()
: _slug!;
} }
/// <summary> /// <summary>
@ -208,15 +221,17 @@ namespace Kyoo.Abstractions.Models.Utils
} }
/// <inheritdoc /> /// <inheritdoc />
public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) public override object ConvertFrom(
ITypeDescriptorContext? context,
CultureInfo? culture,
object value
)
{ {
if (value is Guid id) if (value is Guid id)
return new Identifier(id); return new Identifier(id);
if (value is not string slug) if (value is not string slug)
return base.ConvertFrom(context, culture, value)!; return base.ConvertFrom(context, culture, value)!;
return Guid.TryParse(slug, out id) return Guid.TryParse(slug, out id) ? new Identifier(id) : new Identifier(slug);
? new Identifier(id)
: new Identifier(slug);
} }
} }
} }

View File

@ -36,7 +36,8 @@ public class Include
public record SingleRelation(string Name, Type type, string RelationIdName) : Metadata(Name); public record SingleRelation(string Name, Type type, string RelationIdName) : Metadata(Name);
public record CustomRelation(string Name, Type type, string Sql, string? On, Type Declaring) : Metadata(Name); public record CustomRelation(string Name, Type type, string Sql, string? On, Type Declaring)
: Metadata(Name);
public record ProjectedRelation(string Name, string Sql) : Metadata(Name); public record ProjectedRelation(string Name, string Sql) : Metadata(Name);
} }
@ -57,11 +58,22 @@ public class Include<T> : Include
public Include(params string[] fields) public Include(params string[] fields)
{ {
Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) }; Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) };
Metadatas = fields.SelectMany(key => Metadatas = fields
.SelectMany(key =>
{ {
var relations = types var relations = types
.Select(x => x.GetProperty(key, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)!) .Select(
.Select(prop => (prop, attr: prop?.GetCustomAttribute<LoadableRelationAttribute>()!)) x =>
x.GetProperty(
key,
BindingFlags.IgnoreCase
| BindingFlags.Public
| BindingFlags.Instance
)!
)
.Select(
prop => (prop, attr: prop?.GetCustomAttribute<LoadableRelationAttribute>()!)
)
.Where(x => x.prop != null && x.attr != null) .Where(x => x.prop != null && x.attr != null)
.ToList(); .ToList();
if (!relations.Any()) if (!relations.Any())
@ -72,15 +84,23 @@ public class Include<T> : Include
(PropertyInfo prop, LoadableRelationAttribute attr) = x; (PropertyInfo prop, LoadableRelationAttribute attr) = x;
if (attr.RelationID != null) if (attr.RelationID != null)
return new SingleRelation(prop.Name, prop.PropertyType, attr.RelationID) as Metadata; return new SingleRelation(prop.Name, prop.PropertyType, attr.RelationID)
as Metadata;
if (attr.Sql != null) if (attr.Sql != null)
return new CustomRelation(prop.Name, prop.PropertyType, attr.Sql, attr.On, prop.DeclaringType!); return new CustomRelation(
prop.Name,
prop.PropertyType,
attr.Sql,
attr.On,
prop.DeclaringType!
);
if (attr.Projected != null) if (attr.Projected != null)
return new ProjectedRelation(prop.Name, attr.Projected); return new ProjectedRelation(prop.Name, attr.Projected);
throw new NotImplementedException(); throw new NotImplementedException();
}) })
.Distinct(); .Distinct();
}).ToArray(); })
.ToArray();
} }
public static Include<T> From(string? fields) public static Include<T> From(string? fields)

View File

@ -51,7 +51,10 @@ namespace Kyoo.Abstractions.Models.Utils
public RequestError(string[] errors) public RequestError(string[] errors)
{ {
if (errors == null || !errors.Any()) if (errors == null || !errors.Any())
throw new ArgumentException("Errors must be non null and not empty", nameof(errors)); throw new ArgumentException(
"Errors must be non null and not empty",
nameof(errors)
);
Errors = errors; Errors = errors;
} }
} }

View File

@ -111,12 +111,22 @@ namespace Kyoo.Abstractions.Controllers
"desc" => true, "desc" => true,
"asc" => false, "asc" => false,
null => false, null => false,
_ => throw new ValidationException($"The sort order, if set, should be :asc or :desc but it was :{order}.") _
=> 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) }; Type[] types =
typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) };
PropertyInfo? property = types PropertyInfo? property = types
.Select(x => x.GetProperty(key, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)) .Select(
x =>
x.GetProperty(
key,
BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance
)
)
.FirstOrDefault(x => x != null); .FirstOrDefault(x => x != null);
if (property == null) if (property == null)
throw new ValidationException("The given sort key is not valid."); throw new ValidationException("The given sort key is not valid.");

View File

@ -37,11 +37,15 @@ namespace Kyoo.Abstractions
/// If your repository implements a special interface, please use <see cref="RegisterRepository{T,T2}"/> /// If your repository implements a special interface, please use <see cref="RegisterRepository{T,T2}"/>
/// </remarks> /// </remarks>
/// <returns>The initial container.</returns> /// <returns>The initial container.</returns>
public static IRegistrationBuilder<T, ConcreteReflectionActivatorData, SingleRegistrationStyle> public static IRegistrationBuilder<
RegisterRepository<T>(this ContainerBuilder builder) T,
ConcreteReflectionActivatorData,
SingleRegistrationStyle
> RegisterRepository<T>(this ContainerBuilder builder)
where T : IBaseRepository where T : IBaseRepository
{ {
return builder.RegisterType<T>() return builder
.RegisterType<T>()
.AsSelf() .AsSelf()
.As<IBaseRepository>() .As<IBaseRepository>()
.As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))!) .As(Utility.GetGenericDefinition(typeof(T), typeof(IRepository<>))!)
@ -58,8 +62,11 @@ namespace Kyoo.Abstractions
/// 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<T2, ConcreteReflectionActivatorData, SingleRegistrationStyle> public static IRegistrationBuilder<
RegisterRepository<T, T2>(this ContainerBuilder builder) T2,
ConcreteReflectionActivatorData,
SingleRegistrationStyle
> RegisterRepository<T, T2>(this ContainerBuilder builder)
where T : notnull where T : notnull
where T2 : IBaseRepository, T where T2 : IBaseRepository, T
{ {

View File

@ -50,8 +50,7 @@ namespace Kyoo.Utils
do do
{ {
yield return enumerator.Current; yield return enumerator.Current;
} } while (enumerator.MoveNext());
while (enumerator.MoveNext());
} }
return Generator(self, action); return Generator(self, action);

View File

@ -38,13 +38,14 @@ public sealed class ExpressionArgumentReplacer : ExpressionVisitor
return base.VisitParameter(node); return base.VisitParameter(node);
} }
public static Expression ReplaceParams(Expression expression, IEnumerable<ParameterExpression> epxParams, params ParameterExpression[] param) public static Expression ReplaceParams(
Expression expression,
IEnumerable<ParameterExpression> epxParams,
params ParameterExpression[] param
)
{ {
ExpressionArgumentReplacer replacer = new( ExpressionArgumentReplacer replacer =
epxParams new(epxParams.Zip(param).ToDictionary(x => x.First, x => x.Second as Expression));
.Zip(param)
.ToDictionary(x => x.First, x => x.Second as Expression)
);
return replacer.Visit(expression); return replacer.Visit(expression);
} }
} }

View File

@ -45,9 +45,11 @@ namespace Kyoo.Utils
/// set to those of <paramref name="first"/>. /// set to those of <paramref name="first"/>.
/// </returns> /// </returns>
[ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)] [ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)]
public static IDictionary<T, T2>? CompleteDictionaries<T, T2>(IDictionary<T, T2>? first, public static IDictionary<T, T2>? CompleteDictionaries<T, T2>(
IDictionary<T, T2>? first,
IDictionary<T, T2>? second, IDictionary<T, T2>? second,
out bool hasChanged) out bool hasChanged
)
{ {
if (first == null) if (first == null)
{ {
@ -58,7 +60,9 @@ namespace Kyoo.Utils
hasChanged = false; hasChanged = false;
if (second == null) if (second == null)
return first; return first;
hasChanged = second.Any(x => !first.ContainsKey(x.Key) || x.Value?.Equals(first[x.Key]) == false); hasChanged = second.Any(
x => !first.ContainsKey(x.Key) || x.Value?.Equals(first[x.Key]) == false
);
foreach ((T key, T2 value) in first) foreach ((T key, T2 value) in first)
second.TryAdd(key, value); second.TryAdd(key, value);
return second; return second;
@ -83,17 +87,22 @@ namespace Kyoo.Utils
/// </param> /// </param>
/// <typeparam name="T">Fields of T will be completed</typeparam> /// <typeparam name="T">Fields of T will be completed</typeparam>
/// <returns><paramref name="first"/></returns> /// <returns><paramref name="first"/></returns>
public static T Complete<T>(T first, public static T Complete<T>(
T first,
T? second, T? second,
[InstantHandle] Func<PropertyInfo, bool>? where = null) [InstantHandle] Func<PropertyInfo, bool>? where = null
)
{ {
if (second == null) if (second == null)
return first; return first;
Type type = typeof(T); Type type = typeof(T);
IEnumerable<PropertyInfo> properties = type.GetProperties() IEnumerable<PropertyInfo> properties = type.GetProperties()
.Where(x => x is { CanRead: true, CanWrite: true } .Where(
&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null); x =>
x is { CanRead: true, CanWrite: true }
&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null
);
if (where != null) if (where != null)
properties = properties.Where(where); properties = properties.Where(where);
@ -107,19 +116,16 @@ namespace Kyoo.Utils
if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>))) if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>)))
{ {
Type[] dictionaryTypes = Utility.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))! Type[] dictionaryTypes = Utility
.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))!
.GenericTypeArguments; .GenericTypeArguments;
object?[] parameters = object?[] parameters = { property.GetValue(first), value, false };
{
property.GetValue(first),
value,
false
};
object newDictionary = Utility.RunGenericMethod<object>( object newDictionary = Utility.RunGenericMethod<object>(
typeof(Merger), typeof(Merger),
nameof(CompleteDictionaries), nameof(CompleteDictionaries),
dictionaryTypes, dictionaryTypes,
parameters)!; parameters
)!;
if ((bool)parameters[2]!) if ((bool)parameters[2]!)
property.SetValue(first, newDictionary); property.SetValue(first, newDictionary);
} }

View File

@ -60,13 +60,17 @@ namespace Kyoo.Utils
{ {
case UnicodeCategory.UppercaseLetter: case UnicodeCategory.UppercaseLetter:
case UnicodeCategory.TitlecaseLetter: case UnicodeCategory.TitlecaseLetter:
if (previousCategory == UnicodeCategory.SpaceSeparator || if (
previousCategory == UnicodeCategory.LowercaseLetter || previousCategory == UnicodeCategory.SpaceSeparator
(previousCategory != UnicodeCategory.DecimalDigitNumber && || previousCategory == UnicodeCategory.LowercaseLetter
previousCategory != null && || (
currentIndex > 0 && previousCategory != UnicodeCategory.DecimalDigitNumber
currentIndex + 1 < name.Length && && previousCategory != null
char.IsLower(name[currentIndex + 1]))) && currentIndex > 0
&& currentIndex + 1 < name.Length
&& char.IsLower(name[currentIndex + 1])
)
)
{ {
builder.Append('_'); builder.Append('_');
} }
@ -105,7 +109,10 @@ namespace Kyoo.Utils
public static bool IsPropertyExpression(LambdaExpression ex) public static bool IsPropertyExpression(LambdaExpression ex)
{ {
return ex.Body is MemberExpression return ex.Body is MemberExpression
|| (ex.Body.NodeType == ExpressionType.Convert && ((UnaryExpression)ex.Body).Operand is MemberExpression); || (
ex.Body.NodeType == ExpressionType.Convert
&& ((UnaryExpression)ex.Body).Operand is MemberExpression
);
} }
/// <summary> /// <summary>
@ -118,7 +125,8 @@ namespace Kyoo.Utils
{ {
if (!IsPropertyExpression(ex)) if (!IsPropertyExpression(ex))
throw new ArgumentException($"{ex} is not a property expression."); throw new ArgumentException($"{ex} is not a property expression.");
MemberExpression? member = ex.Body.NodeType == ExpressionType.Convert MemberExpression? member =
ex.Body.NodeType == ExpressionType.Convert
? ((UnaryExpression)ex.Body).Operand as MemberExpression ? ((UnaryExpression)ex.Body).Operand as MemberExpression
: ex.Body as MemberExpression; : ex.Body as MemberExpression;
return member!.Member.Name; return member!.Member.Name;
@ -175,7 +183,8 @@ namespace Kyoo.Utils
IEnumerable<Type> types = genericType.IsInterface IEnumerable<Type> types = genericType.IsInterface
? type.GetInterfaces() ? type.GetInterfaces()
: type.GetInheritanceTree(); : type.GetInheritanceTree();
return types.Prepend(type) return types
.Prepend(type)
.Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); .Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType);
} }
@ -195,8 +204,11 @@ namespace Kyoo.Utils
IEnumerable<Type> types = genericType.IsInterface IEnumerable<Type> types = genericType.IsInterface
? type.GetInterfaces() ? type.GetInterfaces()
: type.GetInheritanceTree(); : type.GetInheritanceTree();
return types.Prepend(type) return types
.FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType); .Prepend(type)
.FirstOrDefault(
x => x.IsGenericType && x.GetGenericTypeDefinition() == genericType
);
} }
/// <summary> /// <summary>
@ -221,11 +233,13 @@ namespace Kyoo.Utils
/// <exception cref="ArgumentException">No method match the given constraints.</exception> /// <exception cref="ArgumentException">No method match the given constraints.</exception>
/// <returns>The method handle of the matching method.</returns> /// <returns>The method handle of the matching method.</returns>
[PublicAPI] [PublicAPI]
public static MethodInfo GetMethod(Type type, public static MethodInfo GetMethod(
Type type,
BindingFlags flag, BindingFlags flag,
string name, string name,
Type[] generics, Type[] generics,
object?[] args) object?[] args
)
{ {
MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public) MethodInfo[] methods = type.GetMethods(flag | BindingFlags.Public)
.Where(x => x.Name == name) .Where(x => x.Name == name)
@ -233,9 +247,11 @@ namespace Kyoo.Utils
.Where(x => x.GetParameters().Length == args.Length) .Where(x => x.GetParameters().Length == args.Length)
.IfEmpty(() => .IfEmpty(() =>
{ {
throw new ArgumentException($"A method named {name} with " + throw new ArgumentException(
$"{args.Length} arguments and {generics.Length} generic " + $"A method named {name} with "
$"types could not be found on {type.Name}."); + $"{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. // TODO this won't work but I don't know why.
// .Where(x => // .Where(x =>
@ -257,7 +273,9 @@ namespace Kyoo.Utils
if (methods.Length == 1) if (methods.Length == 1)
return methods[0]; return methods[0];
throw new ArgumentException($"Multiple methods named {name} match the generics and parameters constraints."); throw new ArgumentException(
$"Multiple methods named {name} match the generics and parameters constraints."
);
} }
/// <summary> /// <summary>
@ -288,7 +306,8 @@ namespace Kyoo.Utils
Type owner, Type owner,
string methodName, string methodName,
Type type, Type type,
params object[] args) params object[] args
)
{ {
return RunGenericMethod<T>(owner, methodName, new[] { type }, args); return RunGenericMethod<T>(owner, methodName, new[] { type }, args);
} }
@ -323,10 +342,13 @@ namespace Kyoo.Utils
Type owner, Type owner,
string methodName, string methodName,
Type[] types, Type[] types,
params object?[] args) params object?[] args
)
{ {
if (types.Length < 1) if (types.Length < 1)
throw new ArgumentException($"The {nameof(types)} array is empty. At least one type is needed."); throw new ArgumentException(
$"The {nameof(types)} array is empty. At least one type is needed."
);
MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args); MethodInfo method = GetMethod(owner, BindingFlags.Static, methodName, types, args);
return (T?)method.MakeGenericMethod(types).Invoke(null, args); return (T?)method.MakeGenericMethod(types).Invoke(null, args);
} }

View File

@ -61,22 +61,29 @@ namespace Kyoo.Authentication
/// <inheritdoc /> /// <inheritdoc />
public void Configure(IServiceCollection services) public void Configure(IServiceCollection services)
{ {
string secret = _configuration.GetValue("AUTHENTICATION_SECRET", AuthenticationOption.DefaultSecret)!; string secret = _configuration.GetValue(
PermissionOption permissions = new() "AUTHENTICATION_SECRET",
AuthenticationOption.DefaultSecret
)!;
PermissionOption permissions =
new()
{ {
Default = _configuration.GetValue("UNLOGGED_PERMISSIONS", "overall.read")!.Split(','), Default = _configuration
NewUser = _configuration.GetValue("DEFAULT_PERMISSIONS", "overall.read")!.Split(','), .GetValue("UNLOGGED_PERMISSIONS", "overall.read")!
.Split(','),
NewUser = _configuration
.GetValue("DEFAULT_PERMISSIONS", "overall.read")!
.Split(','),
ApiKeys = _configuration.GetValue("KYOO_APIKEYS", string.Empty)!.Split(','), ApiKeys = _configuration.GetValue("KYOO_APIKEYS", string.Empty)!.Split(','),
}; };
services.AddSingleton(permissions); services.AddSingleton(permissions);
services.AddSingleton(new AuthenticationOption() services.AddSingleton(
{ new AuthenticationOption() { Secret = secret, Permissions = permissions, }
Secret = secret, );
Permissions = permissions,
});
// TODO handle direct-videos with bearers (probably add a cookie and a app.Use to translate that for videos) // TODO handle direct-videos with bearers (probably add a cookie and a app.Use to translate that for videos)
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
@ -91,7 +98,8 @@ namespace Kyoo.Authentication
} }
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps => new IStartupAction[] public IEnumerable<IStartupAction> ConfigureSteps =>
new IStartupAction[]
{ {
SA.New<IApplicationBuilder>(app => app.UseAuthentication(), SA.Authentication), SA.New<IApplicationBuilder>(app => app.UseAuthentication(), SA.Authentication),
}; };

View File

@ -57,13 +57,22 @@ namespace Kyoo.Authentication
/// <inheritdoc /> /// <inheritdoc />
public IFilterMetadata Create(PermissionAttribute attribute) public IFilterMetadata Create(PermissionAttribute attribute)
{ {
return new PermissionValidatorFilter(attribute.Type, attribute.Kind, attribute.Group, _options); return new PermissionValidatorFilter(
attribute.Type,
attribute.Kind,
attribute.Group,
_options
);
} }
/// <inheritdoc /> /// <inheritdoc />
public IFilterMetadata Create(PartialPermissionAttribute attribute) public IFilterMetadata Create(PartialPermissionAttribute attribute)
{ {
return new PermissionValidatorFilter(((object?)attribute.Type ?? attribute.Kind)!, attribute.Group, _options); return new PermissionValidatorFilter(
((object?)attribute.Type ?? attribute.Kind)!,
attribute.Group,
_options
);
} }
/// <summary> /// <summary>
@ -102,7 +111,8 @@ namespace Kyoo.Authentication
string permission, string permission,
Kind kind, Kind kind,
Group group, Group group,
PermissionOption options) PermissionOption options
)
{ {
_permission = permission; _permission = permission;
_kind = kind; _kind = kind;
@ -116,7 +126,11 @@ namespace Kyoo.Authentication
/// <param name="partialInfo">The partial permission to validate.</param> /// <param name="partialInfo">The partial permission to validate.</param>
/// <param name="group">The group of the permission.</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 PermissionValidatorFilter(object partialInfo, Group? group, PermissionOption options) public PermissionValidatorFilter(
object partialInfo,
Group? group,
PermissionOption options
)
{ {
switch (partialInfo) switch (partialInfo)
{ {
@ -127,7 +141,9 @@ namespace Kyoo.Authentication
_permission = perm; _permission = perm;
break; break;
default: default:
throw new ArgumentException($"{nameof(partialInfo)} can only be a permission string or a kind."); throw new ArgumentException(
$"{nameof(partialInfo)} can only be a permission string or a kind."
);
} }
if (group != null) if (group != null)
@ -158,13 +174,17 @@ namespace Kyoo.Authentication
context.HttpContext.Items["PermissionType"] = permission; context.HttpContext.Items["PermissionType"] = permission;
return; return;
default: default:
throw new ArgumentException("Multiple non-matching partial permission attribute " + throw new ArgumentException(
"are not supported."); "Multiple non-matching partial permission attribute "
+ "are not supported."
);
} }
if (permission == null || kind == null) if (permission == null || kind == null)
{ {
throw new ArgumentException("The permission type or kind is still missing after two partial " + throw new ArgumentException(
"permission attributes, this is unsupported."); "The permission type or kind is still missing after two partial "
+ "permission attributes, this is unsupported."
);
} }
} }
@ -178,25 +198,43 @@ namespace Kyoo.Authentication
{ {
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); context.Result = _ErrorResult(
$"Missing permission {permStr} or {overallStr}",
StatusCodes.Status403Forbidden
);
} }
else if (res.None) else if (res.None)
{ {
ICollection<string> permissions = _options.Default ?? Array.Empty<string>(); ICollection<string> permissions = _options.Default ?? Array.Empty<string>();
if (permissions.All(x => x != permStr && x != overallStr)) if (permissions.All(x => x != permStr && x != overallStr))
{ {
context.Result = _ErrorResult($"Unlogged user does not have permission {permStr} or {overallStr}", StatusCodes.Status401Unauthorized); context.Result = _ErrorResult(
$"Unlogged user does not have permission {permStr} or {overallStr}",
StatusCodes.Status401Unauthorized
);
} }
} }
else if (res.Failure != null) else if (res.Failure != null)
context.Result = _ErrorResult(res.Failure.Message, StatusCodes.Status403Forbidden); context.Result = _ErrorResult(
res.Failure.Message,
StatusCodes.Status403Forbidden
);
else else
context.Result = _ErrorResult("Authentication panic", StatusCodes.Status500InternalServerError); context.Result = _ErrorResult(
"Authentication panic",
StatusCodes.Status500InternalServerError
);
} }
private AuthenticateResult _ApiKeyCheck(ActionContext context) private AuthenticateResult _ApiKeyCheck(ActionContext context)
{ {
if (!context.HttpContext.Request.Headers.TryGetValue("X-API-Key", out StringValues apiKey)) if (
!context
.HttpContext
.Request
.Headers
.TryGetValue("X-API-Key", out StringValues apiKey)
)
return AuthenticateResult.NoResult(); return AuthenticateResult.NoResult();
if (!_options.ApiKeys.Contains<string>(apiKey!)) if (!_options.ApiKeys.Contains<string>(apiKey!))
return AuthenticateResult.Fail("Invalid API-Key."); return AuthenticateResult.Fail("Invalid API-Key.");
@ -205,11 +243,16 @@ namespace Kyoo.Authentication
new ClaimsPrincipal( new ClaimsPrincipal(
new[] new[]
{ {
new ClaimsIdentity(new[] new ClaimsIdentity(
new[]
{ {
// TODO: Make permission configurable, for now every APIKEY as all permissions. // TODO: Make permission configurable, for now every APIKEY as all permissions.
new Claim(Claims.Permissions, string.Join(',', PermissionOption.Admin)) new Claim(
}) Claims.Permissions,
string.Join(',', PermissionOption.Admin)
)
}
)
} }
), ),
"apikey" "apikey"
@ -219,10 +262,14 @@ namespace Kyoo.Authentication
private async Task<AuthenticateResult> _JwtCheck(ActionContext context) private async Task<AuthenticateResult> _JwtCheck(ActionContext context)
{ {
AuthenticateResult ret = await context.HttpContext.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme); AuthenticateResult ret = await context
.HttpContext
.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);
// Change the failure message to make the API nice to use. // Change the failure message to make the API nice to use.
if (ret.Failure != null) if (ret.Failure != null)
return AuthenticateResult.Fail("Invalid JWT token. The token may have expired."); return AuthenticateResult.Fail(
"Invalid JWT token. The token may have expired."
);
return ret; return ret;
} }
} }

View File

@ -55,10 +55,10 @@ namespace Kyoo.Authentication
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
string permissions = user.Permissions != null string permissions =
? string.Join(',', user.Permissions) user.Permissions != null ? string.Join(',', user.Permissions) : string.Empty;
: string.Empty; List<Claim> claims =
List<Claim> claims = new() new()
{ {
new Claim(Claims.Id, user.Id.ToString()), new Claim(Claims.Id, user.Id.ToString()),
new Claim(Claims.Name, user.Username), new Claim(Claims.Name, user.Username),
@ -67,7 +67,8 @@ namespace Kyoo.Authentication
}; };
if (user.Email != null) if (user.Email != null)
claims.Add(new Claim(Claims.Email, user.Email)); claims.Add(new Claim(Claims.Email, user.Email));
JwtSecurityToken token = new( JwtSecurityToken token =
new(
signingCredentials: credential, signingCredentials: credential,
claims: claims, claims: claims,
expires: DateTime.UtcNow.Add(expireIn) expires: DateTime.UtcNow.Add(expireIn)
@ -80,7 +81,8 @@ namespace Kyoo.Authentication
{ {
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret)); SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.Secret));
SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature); SigningCredentials credential = new(key, SecurityAlgorithms.HmacSha256Signature);
JwtSecurityToken token = new( JwtSecurityToken token =
new(
signingCredentials: credential, signingCredentials: credential,
claims: new[] claims: new[]
{ {
@ -102,14 +104,18 @@ namespace Kyoo.Authentication
ClaimsPrincipal principal; ClaimsPrincipal principal;
try try
{ {
principal = tokenHandler.ValidateToken(refreshToken, new TokenValidationParameters principal = tokenHandler.ValidateToken(
refreshToken,
new TokenValidationParameters
{ {
ValidateIssuer = false, ValidateIssuer = false,
ValidateAudience = false, ValidateAudience = false,
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
ValidateLifetime = true, ValidateLifetime = true,
IssuerSigningKey = key IssuerSigningKey = key
}, out SecurityToken _); },
out SecurityToken _
);
} }
catch (Exception) catch (Exception)
{ {
@ -117,7 +123,9 @@ namespace Kyoo.Authentication
} }
if (principal.Claims.First(x => x.Type == Claims.Type).Value != "refresh") if (principal.Claims.First(x => x.Type == Claims.Type).Value != "refresh")
throw new SecurityTokenException("Invalid token type. The token should be a refresh token."); throw new SecurityTokenException(
"Invalid token type. The token should be a refresh token."
);
Claim identifier = principal.Claims.First(x => x.Type == Claims.Id); Claim identifier = principal.Claims.First(x => x.Type == Claims.Id);
if (Guid.TryParse(identifier.Value, out Guid id)) if (Guid.TryParse(identifier.Value, out Guid id))
return id; return id;

View File

@ -40,9 +40,12 @@ namespace Kyoo.Authentication.Models
get get
{ {
return Enum.GetNames<Group>() return Enum.GetNames<Group>()
.SelectMany(group => Enum.GetNames<Kind>() .SelectMany(
group =>
Enum.GetNames<Kind>()
.Select(kind => $"{group}.{kind}".ToLowerInvariant()) .Select(kind => $"{group}.{kind}".ToLowerInvariant())
).ToArray(); )
.ToArray();
} }
} }

View File

@ -66,7 +66,11 @@ namespace Kyoo.Authentication.Views
/// <param name="users">The repository used to check if the user exists.</param> /// <param name="users">The repository used to check if the user exists.</param>
/// <param name="token">The token generator.</param> /// <param name="token">The token generator.</param>
/// <param name="permissions">The permission opitons.</param> /// <param name="permissions">The permission opitons.</param>
public AuthApi(IRepository<User> users, ITokenController token, PermissionOption permissions) public AuthApi(
IRepository<User> users,
ITokenController token,
PermissionOption permissions
)
{ {
_users = users; _users = users;
_token = token; _token = token;
@ -97,7 +101,9 @@ namespace Kyoo.Authentication.Views
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
public async Task<ActionResult<JwtToken>> Login([FromBody] LoginRequest request) 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)); 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)) if (user == null || !BCryptNet.Verify(request.Password, user.Password))
return Forbid(new RequestError("The user and password does not match.")); return Forbid(new RequestError("The user and password does not match."));

View File

@ -28,11 +28,13 @@ namespace Kyoo.Core.Controllers
public class IdentifierRouteConstraint : IRouteConstraint public class IdentifierRouteConstraint : IRouteConstraint
{ {
/// <inheritdoc /> /// <inheritdoc />
public bool Match(HttpContext? httpContext, public bool Match(
HttpContext? httpContext,
IRouter? route, IRouter? route,
string routeKey, string routeKey,
RouteValueDictionary values, RouteValueDictionary values,
RouteDirection routeDirection) RouteDirection routeDirection
)
{ {
return values.ContainsKey(routeKey); return values.ContainsKey(routeKey);
} }

View File

@ -40,7 +40,8 @@ namespace Kyoo.Core.Controllers
IRepository<Episode> episodeRepository, IRepository<Episode> episodeRepository,
IRepository<People> peopleRepository, IRepository<People> peopleRepository,
IRepository<Studio> studioRepository, IRepository<Studio> studioRepository,
IRepository<User> userRepository) IRepository<User> userRepository
)
{ {
LibraryItems = libraryItemRepository; LibraryItems = libraryItemRepository;
News = newsRepository; News = newsRepository;

View File

@ -50,7 +50,10 @@ namespace Kyoo.Core.Controllers
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<ICollection<Collection>> Search(string query, Include<Collection>? include = default) public override async Task<ICollection<Collection>> Search(
string query,
Include<Collection>? include = default
)
{ {
return await AddIncludes(_database.Collections, include) return await AddIncludes(_database.Collections, include)
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))

View File

@ -65,22 +65,33 @@ public static class DapperHelper
.Where(x => !x.Key.StartsWith('_')) .Where(x => !x.Key.StartsWith('_'))
// If first char is lower, assume manual sql instead of reflection. // If first char is lower, assume manual sql instead of reflection.
.Where(x => char.IsLower(key.First()) || x.Value.GetProperty(key) != null) .Where(x => char.IsLower(key.First()) || x.Value.GetProperty(key) != null)
.Select(x => $"{x.Key}.{x.Value.GetProperty(key)?.GetCustomAttribute<ColumnAttribute>()?.Name ?? key.ToSnakeCase()}") .Select(
x =>
$"{x.Key}.{x.Value.GetProperty(key)?.GetCustomAttribute<ColumnAttribute>()?.Name ?? key.ToSnakeCase()}"
)
.ToArray(); .ToArray();
if (keys.Length == 1) if (keys.Length == 1)
return keys.First(); return keys.First();
return $"coalesce({string.Join(", ", keys)})"; return $"coalesce({string.Join(", ", keys)})";
} }
public static string ProcessSort<T>(Sort<T> sort, bool reverse, Dictionary<string, Type> config, bool recurse = false) public static string ProcessSort<T>(
Sort<T> sort,
bool reverse,
Dictionary<string, Type> config,
bool recurse = false
)
where T : IQuery where T : IQuery
{ {
string ret = sort switch string ret = sort switch
{ {
Sort<T>.Default(var value) => ProcessSort(value, reverse, config, true), Sort<T>.Default(var value) => ProcessSort(value, reverse, config, true),
Sort<T>.By(string key, bool desc) => $"{Property(key, config)} {(desc ^ reverse ? "desc" : "asc")}", Sort<T>.By(string key, bool desc)
Sort<T>.Random(var seed) => $"md5('{seed}' || {Property("id", config)}) {(reverse ? "desc" : "asc")}", => $"{Property(key, config)} {(desc ^ reverse ? "desc" : "asc")}",
Sort<T>.Conglomerate(var list) => string.Join(", ", list.Select(x => ProcessSort(x, reverse, config, true))), Sort<T>.Random(var seed)
=> $"md5('{seed}' || {Property("id", config)}) {(reverse ? "desc" : "asc")}",
Sort<T>.Conglomerate(var list)
=> string.Join(", ", list.Select(x => ProcessSort(x, reverse, config, true))),
_ => throw new SwitchExpressionException(), _ => throw new SwitchExpressionException(),
}; };
if (recurse) if (recurse)
@ -108,10 +119,14 @@ public static class DapperHelper
switch (metadata) switch (metadata)
{ {
case Include.SingleRelation(var name, var type, var rid): case Include.SingleRelation(var name, var type, var rid):
string tableName = type.GetCustomAttribute<TableAttribute>()?.Name ?? $"{type.Name.ToSnakeCase()}s"; string tableName =
type.GetCustomAttribute<TableAttribute>()?.Name
?? $"{type.Name.ToSnakeCase()}s";
types.Add(type); types.Add(type);
projection.AppendLine($", r{relation}.* -- {type.Name} as r{relation}"); projection.AppendLine($", r{relation}.* -- {type.Name} as r{relation}");
join.Append($"\nleft join {tableName} as r{relation} on r{relation}.id = {Property(rid, config)}"); join.Append(
$"\nleft join {tableName} as r{relation} on r{relation}.id = {Property(rid, config)}"
);
break; break;
case Include.CustomRelation(var name, var type, var sql, var on, var declaring): case Include.CustomRelation(var name, var type, var sql, var on, var declaring):
string owner = config.First(x => x.Value == declaring).Key; string owner = config.First(x => x.Value == declaring).Key;
@ -133,7 +148,8 @@ public static class DapperHelper
T Map(T item, IEnumerable<object?> relations) T Map(T item, IEnumerable<object?> relations)
{ {
IEnumerable<string> metadatas = include.Metadatas IEnumerable<string> metadatas = include
.Metadatas
.Where(x => x is not Include.ProjectedRelation) .Where(x => x is not Include.ProjectedRelation)
.Select(x => x.Name); .Select(x => x.Name);
foreach ((string name, object? value) in metadatas.Zip(relations)) foreach ((string name, object? value) in metadatas.Zip(relations))
@ -150,15 +166,23 @@ public static class DapperHelper
return (projection.ToString(), join.ToString(), types, Map); return (projection.ToString(), join.ToString(), types, Map);
} }
public static FormattableString ProcessFilter<T>(Filter<T> filter, Dictionary<string, Type> config) public static FormattableString ProcessFilter<T>(
Filter<T> filter,
Dictionary<string, Type> config
)
{ {
FormattableString Format(string key, FormattableString op) FormattableString Format(string key, FormattableString op)
{ {
if (key == "kind") if (key == "kind")
{ {
string cases = string.Join('\n', config string cases = string.Join(
'\n',
config
.Skip(1) .Skip(1)
.Select(x => $"when {x.Key}.id is not null then '{x.Value.Name.ToLowerInvariant()}'") .Select(
x =>
$"when {x.Key}.id is not null then '{x.Value.Name.ToLowerInvariant()}'"
)
); );
return $""" return $"""
case case
@ -172,7 +196,10 @@ public static class DapperHelper
.Where(x => !x.Key.StartsWith('_')) .Where(x => !x.Key.StartsWith('_'))
// If first char is lower, assume manual sql instead of reflection. // If first char is lower, assume manual sql instead of reflection.
.Where(x => char.IsLower(key.First()) || x.Value.GetProperty(key) != null) .Where(x => char.IsLower(key.First()) || x.Value.GetProperty(key) != null)
.Select(x => $"{x.Key}.{x.Value.GetProperty(key)?.GetCustomAttribute<ColumnAttribute>()?.Name ?? key.ToSnakeCase()}"); .Select(
x =>
$"{x.Key}.{x.Value.GetProperty(key)?.GetCustomAttribute<ColumnAttribute>()?.Name ?? key.ToSnakeCase()}"
);
FormattableString ret = $"{properties.First():raw} {op}"; FormattableString ret = $"{properties.First():raw} {op}";
foreach (string property in properties.Skip(1)) foreach (string property in properties.Skip(1))
@ -194,16 +221,20 @@ public static class DapperHelper
Filter<T>.And(var first, var second) => $"({Process(first)} and {Process(second)})", Filter<T>.And(var first, var second) => $"({Process(first)} and {Process(second)})",
Filter<T>.Or(var first, var second) => $"({Process(first)} or {Process(second)})", Filter<T>.Or(var first, var second) => $"({Process(first)} or {Process(second)})",
Filter<T>.Not(var inner) => $"(not {Process(inner)})", Filter<T>.Not(var inner) => $"(not {Process(inner)})",
Filter<T>.Eq(var property, var value) when value is null => Format(property, $"is null"), Filter<T>.Eq(var property, var value) when value is null
Filter<T>.Ne(var property, var value) when value is null => Format(property, $"is not null"), => Format(property, $"is null"),
Filter<T>.Ne(var property, var value) when value is null
=> Format(property, $"is not null"),
Filter<T>.Eq(var property, var value) => Format(property, $"= {P(value!)}"), Filter<T>.Eq(var property, var value) => Format(property, $"= {P(value!)}"),
Filter<T>.Ne(var property, var value) => Format(property, $"!= {P(value!)}"), Filter<T>.Ne(var property, var value) => Format(property, $"!= {P(value!)}"),
Filter<T>.Gt(var property, var value) => Format(property, $"> {P(value)}"), Filter<T>.Gt(var property, var value) => Format(property, $"> {P(value)}"),
Filter<T>.Ge(var property, var value) => Format(property, $">= {P(value)}"), Filter<T>.Ge(var property, var value) => Format(property, $">= {P(value)}"),
Filter<T>.Lt(var property, var value) => Format(property, $"< {P(value)}"), Filter<T>.Lt(var property, var value) => Format(property, $"< {P(value)}"),
Filter<T>.Le(var property, var value) => Format(property, $"> {P(value)}"), Filter<T>.Le(var property, var value) => Format(property, $"> {P(value)}"),
Filter<T>.Has(var property, var value) => $"{P(value)} = any({Property(property, config):raw})", Filter<T>.Has(var property, var value)
Filter<T>.CmpRandom(var op, var seed, var id) => $"md5({seed} || coalesce({string.Join(", ", config.Select(x => $"{x.Key}.id")):raw})) {op:raw} md5({seed} || {id.ToString()})", => $"{P(value)} = any({Property(property, config):raw})",
Filter<T>.CmpRandom(var op, var seed, var id)
=> $"md5({seed} || coalesce({string.Join(", ", config.Select(x => $"{x.Key}.id")):raw})) {op:raw} md5({seed} || {id.ToString()})",
Filter<T>.Lambda(var lambda) => throw new NotSupportedException(), Filter<T>.Lambda(var lambda) => throw new NotSupportedException(),
_ => throw new NotImplementedException(), _ => throw new NotImplementedException(),
}; };
@ -213,7 +244,8 @@ public static class DapperHelper
public static string ExpendProjections(Type type, string? prefix, Include include) public static string ExpendProjections(Type type, string? prefix, Include include)
{ {
IEnumerable<string> projections = include.Metadatas IEnumerable<string> projections = include
.Metadatas
.Select(x => (x as Include.ProjectedRelation)!) .Select(x => (x as Include.ProjectedRelation)!)
.Where(x => x != null) .Where(x => x != null)
.Where(x => type.GetProperty(x.Name) != null) .Where(x => type.GetProperty(x.Name) != null)
@ -231,26 +263,36 @@ public static class DapperHelper
Include<T>? include, Include<T>? include,
Filter<T>? filter, Filter<T>? filter,
Sort<T>? sort, Sort<T>? sort,
Pagination? limit) Pagination? limit
)
where T : class, IResource, IQuery where T : class, IResource, IQuery
{ {
SqlBuilder query = new(db, command); SqlBuilder query = new(db, command);
// Include handling // Include handling
include ??= new(); include ??= new();
var (includeProjection, includeJoin, includeTypes, mapIncludes) = ProcessInclude(include, config); var (includeProjection, includeJoin, includeTypes, mapIncludes) = ProcessInclude(
include,
config
);
query.Replace("/* includesJoin */", $"{includeJoin:raw}", out bool replaced); query.Replace("/* includesJoin */", $"{includeJoin:raw}", out bool replaced);
if (!replaced) if (!replaced)
query.AppendLiteral(includeJoin); query.AppendLiteral(includeJoin);
query.Replace("/* includes */", $"{includeProjection:raw}", out replaced); query.Replace("/* includes */", $"{includeProjection:raw}", out replaced);
if (!replaced) if (!replaced)
throw new ArgumentException("Missing '/* includes */' placeholder in top level sql select to support includes."); throw new ArgumentException(
"Missing '/* includes */' placeholder in top level sql select to support includes."
);
// Handle pagination, orders and filter. // Handle pagination, orders and filter.
if (limit?.AfterID != null) if (limit?.AfterID != null)
{ {
T reference = await get(limit.AfterID.Value); T reference = await get(limit.AfterID.Value);
Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate(sort, reference, !limit.Reverse); Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate(
sort,
reference,
!limit.Reverse
);
filter = Filter.And(filter, keysetFilter); filter = Filter.And(filter, keysetFilter);
} }
if (filter != null) if (filter != null)
@ -273,7 +315,10 @@ public static class DapperHelper
List<Type> types = config.Select(x => x.Value).Concat(includeTypes).ToList(); List<Type> types = config.Select(x => x.Value).Concat(includeTypes).ToList();
// Expand projections on every types received. // Expand projections on every types received.
sql = Regex.Replace(sql, @"(,?) -- (\w+)( as (\w+))?", (match) => sql = Regex.Replace(
sql,
@"(,?) -- (\w+)( as (\w+))?",
(match) =>
{ {
string leadingComa = match.Groups[1].Value; string leadingComa = match.Groups[1].Value;
string type = match.Groups[2].Value; string type = match.Groups[2].Value;
@ -289,18 +334,26 @@ public static class DapperHelper
if (typeV.IsAssignableTo(typeof(IThumbnails))) if (typeV.IsAssignableTo(typeof(IThumbnails)))
{ {
string posterProj = string.Join(", ", new[] { "poster", "thumbnail", "logo" } string posterProj = string.Join(
.Select(x => $"{prefix}{x}_source as source, {prefix}{x}_blurhash as blurhash")); ", ",
new[] { "poster", "thumbnail", "logo" }.Select(
x => $"{prefix}{x}_source as source, {prefix}{x}_blurhash as blurhash"
)
);
projection = string.IsNullOrEmpty(projection) projection = string.IsNullOrEmpty(projection)
? posterProj ? posterProj
: $"{projection}, {posterProj}"; : $"{projection}, {posterProj}";
types.InsertRange(types.IndexOf(typeV) + 1, Enumerable.Repeat(typeof(Image), 3)); types.InsertRange(
types.IndexOf(typeV) + 1,
Enumerable.Repeat(typeof(Image), 3)
);
} }
if (string.IsNullOrEmpty(projection)) if (string.IsNullOrEmpty(projection))
return leadingComa; return leadingComa;
return $", {projection}{leadingComa}"; return $", {projection}{leadingComa}";
}); }
);
IEnumerable<T> data = await db.QueryAsync<T>( IEnumerable<T> data = await db.QueryAsync<T>(
sql, sql,
@ -322,7 +375,10 @@ public static class DapperHelper
return mapIncludes(mapper(nItems), nItems.Skip(config.Count)); return mapIncludes(mapper(nItems), nItems.Skip(config.Count));
}, },
ParametersDictionary.LoadFrom(cmd), ParametersDictionary.LoadFrom(cmd),
splitOn: string.Join(',', types.Select(x => x.GetCustomAttribute<SqlFirstColumnAttribute>()?.Name ?? "id")) splitOn: string.Join(
',',
types.Select(x => x.GetCustomAttribute<SqlFirstColumnAttribute>()?.Name ?? "id")
)
); );
if (limit?.Reverse == true) if (limit?.Reverse == true)
data = data.Reverse(); data = data.Reverse();
@ -339,7 +395,8 @@ public static class DapperHelper
Filter<T>? filter, Filter<T>? filter,
Sort<T>? sort = null, Sort<T>? sort = null,
bool reverse = false, bool reverse = false,
Guid? afterId = default) Guid? afterId = default
)
where T : class, IResource, IQuery where T : class, IResource, IQuery
{ {
ICollection<T> ret = await db.Query<T>( ICollection<T> ret = await db.Query<T>(
@ -361,7 +418,8 @@ public static class DapperHelper
FormattableString command, FormattableString command,
Dictionary<string, Type> config, Dictionary<string, Type> config,
SqlVariableContext context, SqlVariableContext context,
Filter<T>? filter) Filter<T>? filter
)
where T : class, IResource where T : class, IResource
{ {
SqlBuilder query = new(db, command); SqlBuilder query = new(db, command);
@ -374,10 +432,7 @@ public static class DapperHelper
// language=postgreSQL // language=postgreSQL
string sql = $"select count(*) from ({cmd.Sql}) as query"; string sql = $"select count(*) from ({cmd.Sql}) as query";
return await db.QuerySingleAsync<int>( return await db.QuerySingleAsync<int>(sql, ParametersDictionary.LoadFrom(cmd));
sql,
ParametersDictionary.LoadFrom(cmd)
);
} }
} }

View File

@ -43,7 +43,6 @@ public abstract class DapperRepository<T> : IRepository<T>
protected SqlVariableContext Context { get; init; } protected SqlVariableContext Context { get; init; }
public DapperRepository(DbConnection database, SqlVariableContext context) public DapperRepository(DbConnection database, SqlVariableContext context)
{ {
Database = database; Database = database;
@ -69,11 +68,13 @@ public abstract class DapperRepository<T> : IRepository<T>
} }
/// <inheritdoc/> /// <inheritdoc/>
public virtual async Task<T> Get(Filter<T>? filter, public virtual async Task<T> Get(
Filter<T>? filter,
Include<T>? include = default, Include<T>? include = default,
Sort<T>? sortBy = default, Sort<T>? sortBy = default,
bool reverse = false, bool reverse = false,
Guid? afterId = default) Guid? afterId = default
)
{ {
T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId); T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId);
if (ret == null) if (ret == null)
@ -84,7 +85,8 @@ public abstract class DapperRepository<T> : IRepository<T>
/// <inheritdoc /> /// <inheritdoc />
public async Task<ICollection<T>> FromIds(IList<Guid> ids, Include<T>? include = null) public async Task<ICollection<T>> FromIds(IList<Guid> ids, Include<T>? include = null)
{ {
return (await Database.Query<T>( return (
await Database.Query<T>(
Sql, Sql,
Config, Config,
Mapper, Mapper,
@ -94,7 +96,8 @@ public abstract class DapperRepository<T> : IRepository<T>
Filter.Or(ids.Select(x => new Filter<T>.Eq("id", x)).ToArray()), Filter.Or(ids.Select(x => new Filter<T>.Eq("id", x)).ToArray()),
sort: null, sort: null,
limit: null limit: null
)) )
)
.OrderBy(x => ids.IndexOf(x.Id)) .OrderBy(x => ids.IndexOf(x.Id))
.ToList(); .ToList();
} }
@ -138,11 +141,13 @@ public abstract class DapperRepository<T> : IRepository<T>
} }
/// <inheritdoc /> /// <inheritdoc />
public virtual Task<T?> GetOrDefault(Filter<T>? filter, public virtual Task<T?> GetOrDefault(
Filter<T>? filter,
Include<T>? include = default, Include<T>? include = default,
Sort<T>? sortBy = default, Sort<T>? sortBy = default,
bool reverse = false, bool reverse = false,
Guid? afterId = default) Guid? afterId = default
)
{ {
return Database.QuerySingle<T>( return Database.QuerySingle<T>(
Sql, Sql,
@ -158,10 +163,12 @@ public abstract class DapperRepository<T> : IRepository<T>
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<ICollection<T>> GetAll(Filter<T>? filter = default, public Task<ICollection<T>> GetAll(
Filter<T>? filter = default,
Sort<T>? sort = default, Sort<T>? sort = default,
Include<T>? include = default, Include<T>? include = default,
Pagination? limit = default) Pagination? limit = default
)
{ {
return Database.Query<T>( return Database.Query<T>(
Sql, Sql,
@ -179,16 +186,12 @@ public abstract class DapperRepository<T> : IRepository<T>
/// <inheritdoc /> /// <inheritdoc />
public Task<int> GetCount(Filter<T>? filter = null) public Task<int> GetCount(Filter<T>? filter = null)
{ {
return Database.Count( return Database.Count(Sql, Config, Context, filter);
Sql,
Config,
Context,
filter
);
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<ICollection<T>> Search(string query, Include<T>? include = null) => throw new NotImplementedException(); public Task<ICollection<T>> Search(string query, Include<T>? include = null) =>
throw new NotImplementedException();
/// <inheritdoc /> /// <inheritdoc />
public Task<T> Create(T obj) => throw new NotImplementedException(); public Task<T> Create(T obj) => throw new NotImplementedException();

View File

@ -47,8 +47,12 @@ namespace Kyoo.Core.Controllers
IRepository<Show>.OnEdited += async (show) => IRepository<Show>.OnEdited += async (show) =>
{ {
await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope();
DatabaseContext database = scope.ServiceProvider.GetRequiredService<DatabaseContext>(); DatabaseContext database = scope
List<Episode> episodes = await database.Episodes.AsTracking() .ServiceProvider
.GetRequiredService<DatabaseContext>();
List<Episode> episodes = await database
.Episodes
.AsTracking()
.Where(x => x.ShowId == show.Id) .Where(x => x.ShowId == show.Id)
.ToListAsync(); .ToListAsync();
foreach (Episode ep in episodes) foreach (Episode ep in episodes)
@ -66,9 +70,11 @@ namespace Kyoo.Core.Controllers
/// <param name="database">The database handle to use.</param> /// <param name="database">The database handle to use.</param>
/// <param name="shows">A show repository</param> /// <param name="shows">A show repository</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param> /// <param name="thumbs">The thumbnail manager used to store images.</param>
public EpisodeRepository(DatabaseContext database, public EpisodeRepository(
DatabaseContext database,
IRepository<Show> shows, IRepository<Show> shows,
IThumbnailsManager thumbs) IThumbnailsManager thumbs
)
: base(database, thumbs) : base(database, thumbs)
{ {
_database = database; _database = database;
@ -76,7 +82,10 @@ namespace Kyoo.Core.Controllers
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<ICollection<Episode>> Search(string query, Include<Episode>? include = default) public override async Task<ICollection<Episode>> Search(
string query,
Include<Episode>? include = default
)
{ {
return await AddIncludes(_database.Episodes, include) return await AddIncludes(_database.Episodes, include)
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
@ -87,14 +96,26 @@ namespace Kyoo.Core.Controllers
protected override Task<Episode?> GetDuplicated(Episode item) protected override Task<Episode?> GetDuplicated(Episode item)
{ {
if (item is { SeasonNumber: not null, EpisodeNumber: not null }) if (item is { SeasonNumber: not null, EpisodeNumber: not null })
return _database.Episodes.FirstOrDefaultAsync(x => x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber && x.EpisodeNumber == item.EpisodeNumber); return _database
return _database.Episodes.FirstOrDefaultAsync(x => x.ShowId == item.ShowId && x.AbsoluteNumber == item.AbsoluteNumber); .Episodes
.FirstOrDefaultAsync(
x =>
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 /> /// <inheritdoc />
public override async Task<Episode> Create(Episode obj) public override async Task<Episode> Create(Episode obj)
{ {
obj.ShowSlug = obj.Show?.Slug ?? (await _database.Shows.FirstAsync(x => x.Id == obj.ShowId)).Slug; obj.ShowSlug =
obj.Show?.Slug ?? (await _database.Shows.FirstAsync(x => x.Id == obj.ShowId)).Slug;
await base.Create(obj); await base.Create(obj);
_database.Entry(obj).State = EntityState.Added; _database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => GetDuplicated(obj)); await _database.SaveChangesAsync(() => GetDuplicated(obj));
@ -110,22 +131,31 @@ namespace Kyoo.Core.Controllers
{ {
if (resource.Show == null) if (resource.Show == null)
{ {
throw new ArgumentException($"Can't store an episode not related " + throw new ArgumentException(
$"to any show (showID: {resource.ShowId})."); $"Can't store an episode not related "
+ $"to any show (showID: {resource.ShowId})."
);
} }
resource.ShowId = resource.Show.Id; resource.ShowId = resource.Show.Id;
} }
if (resource.SeasonId == null && resource.SeasonNumber != null) if (resource.SeasonId == null && resource.SeasonNumber != null)
{ {
resource.Season = await _database.Seasons.FirstOrDefaultAsync(x => x.ShowId == resource.ShowId resource.Season = await _database
&& x.SeasonNumber == resource.SeasonNumber); .Seasons
.FirstOrDefaultAsync(
x => x.ShowId == resource.ShowId && x.SeasonNumber == resource.SeasonNumber
);
} }
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task Delete(Episode obj) public override async Task Delete(Episode obj)
{ {
int epCount = await _database.Episodes.Where(x => x.ShowId == obj.ShowId).Take(2).CountAsync(); int epCount = await _database
.Episodes
.Where(x => x.ShowId == obj.ShowId)
.Take(2)
.CountAsync();
_database.Entry(obj).State = EntityState.Deleted; _database.Entry(obj).State = EntityState.Deleted;
await _database.SaveChangesAsync(); await _database.SaveChangesAsync();
await base.Delete(obj); await base.Delete(obj);

View File

@ -33,7 +33,8 @@ namespace Kyoo.Core.Controllers
public class LibraryItemRepository : DapperRepository<ILibraryItem> public class LibraryItemRepository : DapperRepository<ILibraryItem>
{ {
// language=PostgreSQL // language=PostgreSQL
protected override FormattableString Sql => $""" protected override FormattableString Sql =>
$"""
select select
s.*, -- Show as s s.*, -- Show as s
m.*, m.*,
@ -58,7 +59,8 @@ namespace Kyoo.Core.Controllers
) as c on false ) as c on false
"""; """;
protected override Dictionary<string, Type> Config => new() protected override Dictionary<string, Type> Config =>
new()
{ {
{ "s", typeof(Show) }, { "s", typeof(Show) },
{ "m", typeof(Movie) }, { "m", typeof(Movie) },
@ -77,15 +79,15 @@ namespace Kyoo.Core.Controllers
} }
public LibraryItemRepository(DbConnection database, SqlVariableContext context) public LibraryItemRepository(DbConnection database, SqlVariableContext context)
: base(database, context) : base(database, context) { }
{ }
public async Task<ICollection<ILibraryItem>> GetAllOfCollection( public async Task<ICollection<ILibraryItem>> GetAllOfCollection(
Guid collectionId, Guid collectionId,
Filter<ILibraryItem>? filter = default, Filter<ILibraryItem>? filter = default,
Sort<ILibraryItem>? sort = default, Sort<ILibraryItem>? sort = default,
Include<ILibraryItem>? include = default, Include<ILibraryItem>? include = default,
Pagination? limit = default) Pagination? limit = default
)
{ {
// language=PostgreSQL // language=PostgreSQL
FormattableString sql = $""" FormattableString sql = $"""
@ -111,11 +113,7 @@ namespace Kyoo.Core.Controllers
return await Database.Query<ILibraryItem>( return await Database.Query<ILibraryItem>(
sql, sql,
new() new() { { "s", typeof(Show) }, { "m", typeof(Movie) }, },
{
{ "s", typeof(Show) },
{ "m", typeof(Movie) },
},
Mapper, Mapper,
(id) => Get(id), (id) => Get(id),
Context, Context,

View File

@ -75,17 +75,18 @@ namespace Kyoo.Core.Controllers
{ {
sortBy ??= new Sort<T>.Default(); sortBy ??= new Sort<T>.Default();
IOrderedQueryable<T> _SortBy(IQueryable<T> qr, Expression<Func<T, object>> sort, bool desc, bool then) IOrderedQueryable<T> _SortBy(
IQueryable<T> qr,
Expression<Func<T, object>> sort,
bool desc,
bool then
)
{ {
if (then && qr is IOrderedQueryable<T> qro) if (then && qr is IOrderedQueryable<T> qro)
{ {
return desc return desc ? qro.ThenByDescending(sort) : qro.ThenBy(sort);
? qro.ThenByDescending(sort)
: qro.ThenBy(sort);
} }
return desc return desc ? qr.OrderByDescending(sort) : qr.OrderBy(sort);
? qr.OrderByDescending(sort)
: qr.OrderBy(sort);
} }
IOrderedQueryable<T> _Sort(IQueryable<T> query, Sort<T> sortBy, bool then) IOrderedQueryable<T> _Sort(IQueryable<T> query, Sort<T> sortBy, bool then)
@ -98,7 +99,12 @@ namespace Kyoo.Core.Controllers
return _SortBy(query, x => EF.Property<T>(x, key), desc, then); return _SortBy(query, x => EF.Property<T>(x, key), desc, then);
case Sort<T>.Random(var seed): case Sort<T>.Random(var seed):
// NOTE: To edit this, don't forget to edit the random handiling inside the KeysetPaginate function // 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); return _SortBy(
query,
x => DatabaseContext.MD5(seed + x.Id.ToString()),
false,
then
);
case Sort<T>.Conglomerate(var sorts): case Sort<T>.Conglomerate(var sorts):
IOrderedQueryable<T> nQuery = _Sort(query, sorts.First(), false); IOrderedQueryable<T> nQuery = _Sort(query, sorts.First(), false);
foreach (Sort<T> sort in sorts.Skip(1)) foreach (Sort<T> sort in sorts.Skip(1))
@ -121,11 +127,28 @@ namespace Kyoo.Core.Controllers
Expression CmpRandomHandler(string cmp, string seed, Guid refId) Expression CmpRandomHandler(string cmp, string seed, Guid refId)
{ {
MethodInfo concat = typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!; MethodInfo concat = typeof(string).GetMethod(
Expression id = Expression.Call(Expression.Property(x, "ID"), nameof(Guid.ToString), null); nameof(string.Concat),
new[] { typeof(string), typeof(string) }
)!;
Expression id = Expression.Call(
Expression.Property(x, "ID"),
nameof(Guid.ToString),
null
);
Expression xrng = Expression.Call(concat, Expression.Constant(seed), id); Expression xrng = Expression.Call(concat, Expression.Constant(seed), id);
Expression left = Expression.Call(typeof(DatabaseContext), nameof(DatabaseContext.MD5), null, xrng); Expression left = Expression.Call(
Expression right = Expression.Call(typeof(DatabaseContext), nameof(DatabaseContext.MD5), null, Expression.Constant($"{seed}{refId}")); typeof(DatabaseContext),
nameof(DatabaseContext.MD5),
null,
xrng
);
Expression right = Expression.Call(
typeof(DatabaseContext),
nameof(DatabaseContext.MD5),
null,
Expression.Constant($"{seed}{refId}")
);
return cmp switch return cmp switch
{ {
"=" => Expression.Equal(left, right), "=" => Expression.Equal(left, right),
@ -138,17 +161,28 @@ namespace Kyoo.Core.Controllers
BinaryExpression StringCompatibleExpression( BinaryExpression StringCompatibleExpression(
Func<Expression, Expression, BinaryExpression> operand, Func<Expression, Expression, BinaryExpression> operand,
string property, string property,
object value) object value
)
{ {
var left = Expression.Property(x, property); var left = Expression.Property(x, property);
var right = Expression.Constant(value, ((PropertyInfo)left.Member).PropertyType); var right = Expression.Constant(value, ((PropertyInfo)left.Member).PropertyType);
if (left.Type != typeof(string)) if (left.Type != typeof(string))
return operand(left, right); return operand(left, right);
MethodCallExpression call = Expression.Call(typeof(string), "Compare", null, left, right); MethodCallExpression call = Expression.Call(
typeof(string),
"Compare",
null,
left,
right
);
return operand(call, Expression.Constant(0)); return operand(call, Expression.Constant(0));
} }
Expression Exp(Func<Expression, Expression, BinaryExpression> operand, string property, object? value) Expression Exp(
Func<Expression, Expression, BinaryExpression> operand,
string property,
object? value
)
{ {
var prop = Expression.Property(x, property); var prop = Expression.Property(x, property);
var val = Expression.Constant(value, ((PropertyInfo)prop.Member).PropertyType); var val = Expression.Constant(value, ((PropertyInfo)prop.Member).PropertyType);
@ -159,18 +193,42 @@ namespace Kyoo.Core.Controllers
{ {
return f switch return f switch
{ {
Filter<T>.And(var first, var second) => Expression.AndAlso(Parse(first), Parse(second)), Filter<T>.And(var first, var second)
Filter<T>.Or(var first, var second) => Expression.OrElse(Parse(first), Parse(second)), => Expression.AndAlso(Parse(first), Parse(second)),
Filter<T>.Or(var first, var second)
=> Expression.OrElse(Parse(first), Parse(second)),
Filter<T>.Not(var inner) => Expression.Not(Parse(inner)), Filter<T>.Not(var inner) => Expression.Not(Parse(inner)),
Filter<T>.Eq(var property, var value) => Exp(Expression.Equal, property, value), Filter<T>.Eq(var property, var value) => Exp(Expression.Equal, property, value),
Filter<T>.Ne(var property, var value) => Exp(Expression.NotEqual, property, value), Filter<T>.Ne(var property, var value)
Filter<T>.Gt(var property, var value) => StringCompatibleExpression(Expression.GreaterThan, property, value), => Exp(Expression.NotEqual, property, value),
Filter<T>.Ge(var property, var value) => StringCompatibleExpression(Expression.GreaterThanOrEqual, property, value), Filter<T>.Gt(var property, var value)
Filter<T>.Lt(var property, var value) => StringCompatibleExpression(Expression.LessThan, property, value), => StringCompatibleExpression(Expression.GreaterThan, property, value),
Filter<T>.Le(var property, var value) => StringCompatibleExpression(Expression.LessThanOrEqual, property, value), Filter<T>.Ge(var property, var value)
Filter<T>.Has(var property, var value) => Expression.Call(typeof(Enumerable), "Contains", new[] { value.GetType() }, Expression.Property(x, property), Expression.Constant(value)), => StringCompatibleExpression(
Filter<T>.CmpRandom(var op, var seed, var refId) => CmpRandomHandler(op, seed, refId), Expression.GreaterThanOrEqual,
Filter<T>.Lambda(var lambda) => ExpressionArgumentReplacer.ReplaceParams(lambda.Body, lambda.Parameters, x), property,
value
),
Filter<T>.Lt(var property, var value)
=> StringCompatibleExpression(Expression.LessThan, property, value),
Filter<T>.Le(var property, var value)
=> StringCompatibleExpression(Expression.LessThanOrEqual, property, value),
Filter<T>.Has(var property, var value)
=> Expression.Call(
typeof(Enumerable),
"Contains",
new[] { value.GetType() },
Expression.Property(x, property),
Expression.Constant(value)
),
Filter<T>.CmpRandom(var op, var seed, var refId)
=> CmpRandomHandler(op, seed, refId),
Filter<T>.Lambda(var lambda)
=> ExpressionArgumentReplacer.ReplaceParams(
lambda.Body,
lambda.Parameters,
x
),
_ => throw new NotImplementedException(), _ => throw new NotImplementedException(),
}; };
} }
@ -231,7 +289,9 @@ namespace Kyoo.Core.Controllers
{ {
T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId); T? ret = await GetOrDefault(filter, include, sortBy, reverse, afterId);
if (ret == null) if (ret == null)
throw new ItemNotFoundException($"No {typeof(T).Name} found with the given predicate."); throw new ItemNotFoundException(
$"No {typeof(T).Name} found with the given predicate."
);
return ret; return ret;
} }
@ -243,8 +303,7 @@ namespace Kyoo.Core.Controllers
/// <inheritdoc /> /// <inheritdoc />
public virtual Task<T?> GetOrDefault(Guid id, Include<T>? include = default) public virtual Task<T?> GetOrDefault(Guid id, Include<T>? include = default)
{ {
return AddIncludes(Database.Set<T>(), include) return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Id == id);
.FirstOrDefaultAsync(x => x.Id == id);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -256,16 +315,17 @@ namespace Kyoo.Core.Controllers
.OrderBy(x => EF.Functions.Random()) .OrderBy(x => EF.Functions.Random())
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
return AddIncludes(Database.Set<T>(), include) return AddIncludes(Database.Set<T>(), include).FirstOrDefaultAsync(x => x.Slug == slug);
.FirstOrDefaultAsync(x => x.Slug == slug);
} }
/// <inheritdoc /> /// <inheritdoc />
public virtual async Task<T?> GetOrDefault(Filter<T>? filter, public virtual async Task<T?> GetOrDefault(
Filter<T>? filter,
Include<T>? include = default, Include<T>? include = default,
Sort<T>? sortBy = default, Sort<T>? sortBy = default,
bool reverse = false, bool reverse = false,
Guid? afterId = default) Guid? afterId = default
)
{ {
IQueryable<T> query = await ApplyFilters( IQueryable<T> query = await ApplyFilters(
Database.Set<T>(), Database.Set<T>(),
@ -278,7 +338,10 @@ namespace Kyoo.Core.Controllers
} }
/// <inheritdoc/> /// <inheritdoc/>
public virtual async Task<ICollection<T>> FromIds(IList<Guid> ids, Include<T>? include = default) public virtual async Task<ICollection<T>> FromIds(
IList<Guid> ids,
Include<T>? include = default
)
{ {
return ( return (
await AddIncludes(Database.Set<T>(), include) await AddIncludes(Database.Set<T>(), include)
@ -293,12 +356,20 @@ namespace Kyoo.Core.Controllers
public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default); public abstract Task<ICollection<T>> Search(string query, Include<T>? include = default);
/// <inheritdoc/> /// <inheritdoc/>
public virtual async Task<ICollection<T>> GetAll(Filter<T>? filter = null, public virtual async Task<ICollection<T>> GetAll(
Filter<T>? filter = null,
Sort<T>? sort = default, Sort<T>? sort = default,
Include<T>? include = default, Include<T>? include = default,
Pagination? limit = default) Pagination? limit = default
)
{ {
IQueryable<T> query = await ApplyFilters(Database.Set<T>(), filter, sort, limit, include); IQueryable<T> query = await ApplyFilters(
Database.Set<T>(),
filter,
sort,
limit,
include
);
return await query.ToListAsync(); return await query.ToListAsync();
} }
@ -311,11 +382,13 @@ namespace Kyoo.Core.Controllers
/// <param name="limit">Pagination information (where to start and how many to get)</param> /// <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> /// <param name="include">Related fields to also load with this query.</param>
/// <returns>The filtered query</returns> /// <returns>The filtered query</returns>
protected async Task<IQueryable<T>> ApplyFilters(IQueryable<T> query, protected async Task<IQueryable<T>> ApplyFilters(
IQueryable<T> query,
Filter<T>? filter = null, Filter<T>? filter = null,
Sort<T>? sort = default, Sort<T>? sort = default,
Pagination? limit = default, Pagination? limit = default,
Include<T>? include = default) Include<T>? include = default
)
{ {
query = AddIncludes(query, include); query = AddIncludes(query, include);
query = Sort(query, sort); query = Sort(query, sort);
@ -324,7 +397,11 @@ namespace Kyoo.Core.Controllers
if (limit.AfterID != null) if (limit.AfterID != null)
{ {
T reference = await Get(limit.AfterID.Value); T reference = await Get(limit.AfterID.Value);
Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate(sort, reference, !limit.Reverse); Filter<T>? keysetFilter = RepositoryHelper.KeysetPaginate(
sort,
reference,
!limit.Reverse
);
filter = Filter.And(filter, keysetFilter); filter = Filter.And(filter, keysetFilter);
} }
if (filter != null) if (filter != null)
@ -364,11 +441,14 @@ namespace Kyoo.Core.Controllers
throw new DuplicatedItemException(await GetDuplicated(obj)); throw new DuplicatedItemException(await GetDuplicated(obj));
} }
if (thumbs.Poster != null) if (thumbs.Poster != null)
Database.Entry(thumbs).Reference(x => x.Poster).TargetEntry!.State = EntityState.Added; Database.Entry(thumbs).Reference(x => x.Poster).TargetEntry!.State =
EntityState.Added;
if (thumbs.Thumbnail != null) if (thumbs.Thumbnail != null)
Database.Entry(thumbs).Reference(x => x.Thumbnail).TargetEntry!.State = EntityState.Added; Database.Entry(thumbs).Reference(x => x.Thumbnail).TargetEntry!.State =
EntityState.Added;
if (thumbs.Logo != null) if (thumbs.Logo != null)
Database.Entry(thumbs).Reference(x => x.Logo).TargetEntry!.State = EntityState.Added; Database.Entry(thumbs).Reference(x => x.Logo).TargetEntry!.State =
EntityState.Added;
} }
return obj; return obj;
} }
@ -399,7 +479,11 @@ namespace Kyoo.Core.Controllers
{ {
T old = await GetWithTracking(edited.Id); T old = await GetWithTracking(edited.Id);
Merger.Complete(old, edited, x => x.GetCustomAttribute<LoadableRelationAttribute>() == null); Merger.Complete(
old,
edited,
x => x.GetCustomAttribute<LoadableRelationAttribute>() == null
);
await EditRelations(old, edited); await EditRelations(old, edited);
await Database.SaveChangesAsync(); await Database.SaveChangesAsync();
await IRepository<T>.OnResourceEdited(old); await IRepository<T>.OnResourceEdited(old);
@ -450,8 +534,10 @@ namespace Kyoo.Core.Controllers
{ {
if (resource is IThumbnails thumbs && changed is IThumbnails chng) 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.Poster).IsModified =
Database.Entry(thumbs).Reference(x => x.Thumbnail).IsModified = thumbs.Thumbnail != chng.Thumbnail; 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; Database.Entry(thumbs).Reference(x => x.Logo).IsModified = thumbs.Logo != chng.Logo;
} }
return Validate(resource); return Validate(resource);
@ -468,7 +554,11 @@ namespace Kyoo.Core.Controllers
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
protected virtual Task Validate(T resource) protected virtual Task Validate(T resource)
{ {
if (typeof(T).GetProperty(nameof(resource.Slug))!.GetCustomAttribute<ComputedAttribute>() != null) if (
typeof(T)
.GetProperty(nameof(resource.Slug))!
.GetCustomAttribute<ComputedAttribute>() != null
)
return Task.CompletedTask; return Task.CompletedTask;
if (string.IsNullOrEmpty(resource.Slug)) if (string.IsNullOrEmpty(resource.Slug))
throw new ArgumentException("Resource can't have null as a slug."); throw new ArgumentException("Resource can't have null as a slug.");
@ -476,15 +566,21 @@ namespace Kyoo.Core.Controllers
{ {
try try
{ {
MethodInfo? setter = typeof(T).GetProperty(nameof(resource.Slug))!.GetSetMethod(); MethodInfo? setter = typeof(T)
.GetProperty(nameof(resource.Slug))!
.GetSetMethod();
if (setter != null) if (setter != null)
setter.Invoke(resource, new object[] { resource.Slug + '!' }); setter.Invoke(resource, new object[] { resource.Slug + '!' });
else else
throw new ArgumentException("Resources slug can't be number only or the literal \"random\"."); throw new ArgumentException(
"Resources slug can't be number only or the literal \"random\"."
);
} }
catch catch
{ {
throw new ArgumentException("Resources slug can't be number only or the literal \"random\"."); throw new ArgumentException(
"Resources slug can't be number only or the literal \"random\"."
);
} }
} }
return Task.CompletedTask; return Task.CompletedTask;

View File

@ -54,10 +54,12 @@ namespace Kyoo.Core.Controllers
/// <param name="studios">A studio repository</param> /// <param name="studios">A studio repository</param>
/// <param name="people">A people repository</param> /// <param name="people">A people repository</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param> /// <param name="thumbs">The thumbnail manager used to store images.</param>
public MovieRepository(DatabaseContext database, public MovieRepository(
DatabaseContext database,
IRepository<Studio> studios, IRepository<Studio> studios,
IRepository<People> people, IRepository<People> people,
IThumbnailsManager thumbs) IThumbnailsManager thumbs
)
: base(database, thumbs) : base(database, thumbs)
{ {
_database = database; _database = database;
@ -66,7 +68,10 @@ namespace Kyoo.Core.Controllers
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<ICollection<Movie>> Search(string query, Include<Movie>? include = default) public override async Task<ICollection<Movie>> Search(
string query,
Include<Movie>? include = default
)
{ {
return await AddIncludes(_database.Movies, include) return await AddIncludes(_database.Movies, include)
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))

View File

@ -30,7 +30,8 @@ namespace Kyoo.Core.Controllers
public class NewsRepository : DapperRepository<INews> public class NewsRepository : DapperRepository<INews>
{ {
// language=PostgreSQL // language=PostgreSQL
protected override FormattableString Sql => $""" protected override FormattableString Sql =>
$"""
select select
e.*, -- Episode as e e.*, -- Episode as e
m.* m.*
@ -45,11 +46,8 @@ namespace Kyoo.Core.Controllers
) as m on false ) as m on false
"""; """;
protected override Dictionary<string, Type> Config => new() protected override Dictionary<string, Type> Config =>
{ new() { { "e", typeof(Episode) }, { "m", typeof(Movie) }, };
{ "e", typeof(Episode) },
{ "m", typeof(Movie) },
};
protected override INews Mapper(List<object?> items) protected override INews Mapper(List<object?> items)
{ {
@ -61,7 +59,6 @@ namespace Kyoo.Core.Controllers
} }
public NewsRepository(DbConnection database, SqlVariableContext context) public NewsRepository(DbConnection database, SqlVariableContext context)
: base(database, context) : base(database, context) { }
{ }
} }
} }

View File

@ -50,9 +50,11 @@ namespace Kyoo.Core.Controllers
/// <param name="database">The database handle</param> /// <param name="database">The database handle</param>
/// <param name="shows">A lazy loaded show repository</param> /// <param name="shows">A lazy loaded show repository</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param> /// <param name="thumbs">The thumbnail manager used to store images.</param>
public PeopleRepository(DatabaseContext database, public PeopleRepository(
DatabaseContext database,
Lazy<IRepository<Show>> shows, Lazy<IRepository<Show>> shows,
IThumbnailsManager thumbs) IThumbnailsManager thumbs
)
: base(database, thumbs) : base(database, thumbs)
{ {
_database = database; _database = database;
@ -60,7 +62,10 @@ namespace Kyoo.Core.Controllers
} }
/// <inheritdoc /> /// <inheritdoc />
public override Task<ICollection<People>> Search(string query, Include<People>? include = default) public override Task<ICollection<People>> Search(
string query,
Include<People>? include = default
)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
// return await AddIncludes(_database.People, include) // return await AddIncludes(_database.People, include)
@ -88,7 +93,8 @@ namespace Kyoo.Core.Controllers
{ {
foreach (PeopleRole role in resource.Roles) foreach (PeopleRole role in resource.Roles)
{ {
role.Show = _database.LocalEntity<Show>(role.Show!.Slug) role.Show =
_database.LocalEntity<Show>(role.Show!.Slug)
?? await _shows.Value.CreateIfNotExists(role.Show); ?? await _shows.Value.CreateIfNotExists(role.Show);
role.ShowID = role.Show.Id; role.ShowID = role.Show.Id;
_database.Entry(role).State = EntityState.Added; _database.Entry(role).State = EntityState.Added;

View File

@ -59,9 +59,11 @@ public class RepositoryHelper
return sort switch return sort switch
{ {
Sort<T>.Default(var value) => GetSortsBy(value), Sort<T>.Default(var value) => GetSortsBy(value),
Sort<T>.By @sortBy => new[] { new SortIndicator(sortBy.Key, sortBy.Desendant, null) }, Sort<T>.By @sortBy
=> new[] { new SortIndicator(sortBy.Key, sortBy.Desendant, null) },
Sort<T>.Conglomerate(var list) => list.SelectMany(GetSortsBy), Sort<T>.Conglomerate(var list) => list.SelectMany(GetSortsBy),
Sort<T>.Random(var seed) => new[] { new SortIndicator("random", false, seed.ToString()) }, Sort<T>.Random(var seed)
=> new[] { new SortIndicator("random", false, seed.ToString()) },
_ => Array.Empty<SortIndicator>(), _ => Array.Empty<SortIndicator>(),
}; };
} }
@ -88,8 +90,12 @@ public class RepositoryHelper
Filter<T>? equals = null; Filter<T>? equals = null;
foreach ((string pKey, bool pDesc, string? pSeed) in previousSteps) foreach ((string pKey, bool pDesc, string? pSeed) in previousSteps)
{ {
Filter<T> pEquals = pSeed == null Filter<T> pEquals =
? new Filter<T>.Eq(pKey, reference.GetType().GetProperty(pKey)?.GetValue(reference)) pSeed == null
? new Filter<T>.Eq(
pKey,
reference.GetType().GetProperty(pKey)?.GetValue(reference)
)
: new Filter<T>.CmpRandom("=", pSeed, reference.Id); : new Filter<T>.CmpRandom("=", pSeed, reference.Id);
equals = Filter.And(equals, pEquals); equals = Filter.And(equals, pEquals);
} }
@ -98,14 +104,18 @@ public class RepositoryHelper
Func<string, object, Filter<T>> comparer = greaterThan Func<string, object, Filter<T>> comparer = greaterThan
? (prop, val) => new Filter<T>.Gt(prop, val) ? (prop, val) => new Filter<T>.Gt(prop, val)
: (prop, val) => new Filter<T>.Lt(prop, val); : (prop, val) => new Filter<T>.Lt(prop, val);
Filter<T> last = seed == null Filter<T> last =
seed == null
? comparer(key, value!) ? comparer(key, value!)
: new Filter<T>.CmpRandom(greaterThan ? ">" : "<", seed, reference.Id); : new Filter<T>.CmpRandom(greaterThan ? ">" : "<", seed, reference.Id);
if (key != "random") if (key != "random")
{ {
Type[] types = typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) }; Type[] types =
PropertyInfo property = types.Select(x => x.GetProperty(key)!).First(x => x != null); typeof(T).GetCustomAttribute<OneOfAttribute>()?.Types ?? new[] { typeof(T) };
PropertyInfo property = types
.Select(x => x.GetProperty(key)!)
.First(x => x != null);
if (Nullable.GetUnderlyingType(property.PropertyType) != null) if (Nullable.GetUnderlyingType(property.PropertyType) != null)
last = new Filter<T>.Or(last, new Filter<T>.Eq(key, null)); last = new Filter<T>.Or(last, new Filter<T>.Eq(key, null));
} }

View File

@ -47,8 +47,12 @@ namespace Kyoo.Core.Controllers
IRepository<Show>.OnEdited += async (show) => IRepository<Show>.OnEdited += async (show) =>
{ {
await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope();
DatabaseContext database = scope.ServiceProvider.GetRequiredService<DatabaseContext>(); DatabaseContext database = scope
List<Season> seasons = await database.Seasons.AsTracking() .ServiceProvider
.GetRequiredService<DatabaseContext>();
List<Season> seasons = await database
.Seasons
.AsTracking()
.Where(x => x.ShowId == show.Id) .Where(x => x.ShowId == show.Id)
.ToListAsync(); .ToListAsync();
foreach (Season season in seasons) foreach (Season season in seasons)
@ -65,8 +69,7 @@ namespace Kyoo.Core.Controllers
/// </summary> /// </summary>
/// <param name="database">The database handle that will be used</param> /// <param name="database">The database handle that will be used</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param> /// <param name="thumbs">The thumbnail manager used to store images.</param>
public SeasonRepository(DatabaseContext database, public SeasonRepository(DatabaseContext database, IThumbnailsManager thumbs)
IThumbnailsManager thumbs)
: base(database, thumbs) : base(database, thumbs)
{ {
_database = database; _database = database;
@ -74,11 +77,18 @@ namespace Kyoo.Core.Controllers
protected override Task<Season?> GetDuplicated(Season item) protected override Task<Season?> GetDuplicated(Season item)
{ {
return _database.Seasons.FirstOrDefaultAsync(x => x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber); return _database
.Seasons
.FirstOrDefaultAsync(
x => x.ShowId == item.ShowId && x.SeasonNumber == item.SeasonNumber
);
} }
/// <inheritdoc/> /// <inheritdoc/>
public override async Task<ICollection<Season>> Search(string query, Include<Season>? include = default) public override async Task<ICollection<Season>> Search(
string query,
Include<Season>? include = default
)
{ {
return await AddIncludes(_database.Seasons, include) return await AddIncludes(_database.Seasons, include)
.Where(x => EF.Functions.ILike(x.Name!, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Name!, $"%{query}%"))
@ -90,7 +100,8 @@ namespace Kyoo.Core.Controllers
public override async Task<Season> Create(Season obj) public override async Task<Season> Create(Season obj)
{ {
await base.Create(obj); await base.Create(obj);
obj.ShowSlug = (await _database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug obj.ShowSlug =
(await _database.Shows.FirstOrDefaultAsync(x => x.Id == obj.ShowId))?.Slug
?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}"); ?? throw new ItemNotFoundException($"No show found with ID {obj.ShowId}");
_database.Entry(obj).State = EntityState.Added; _database.Entry(obj).State = EntityState.Added;
await _database.SaveChangesAsync(() => GetDuplicated(obj)); await _database.SaveChangesAsync(() => GetDuplicated(obj));
@ -106,8 +117,10 @@ namespace Kyoo.Core.Controllers
{ {
if (resource.Show == null) if (resource.Show == null)
{ {
throw new ValidationException($"Can't store a season not related to any show " + throw new ValidationException(
$"(showID: {resource.ShowId})."); $"Can't store a season not related to any show "
+ $"(showID: {resource.ShowId})."
);
} }
resource.ShowId = resource.Show.Id; resource.ShowId = resource.Show.Id;
} }

View File

@ -55,10 +55,12 @@ namespace Kyoo.Core.Controllers
/// <param name="studios">A studio repository</param> /// <param name="studios">A studio repository</param>
/// <param name="people">A people repository</param> /// <param name="people">A people repository</param>
/// <param name="thumbs">The thumbnail manager used to store images.</param> /// <param name="thumbs">The thumbnail manager used to store images.</param>
public ShowRepository(DatabaseContext database, public ShowRepository(
DatabaseContext database,
IRepository<Studio> studios, IRepository<Studio> studios,
IRepository<People> people, IRepository<People> people,
IThumbnailsManager thumbs) IThumbnailsManager thumbs
)
: base(database, thumbs) : base(database, thumbs)
{ {
_database = database; _database = database;
@ -67,7 +69,10 @@ namespace Kyoo.Core.Controllers
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<ICollection<Show>> Search(string query, Include<Show>? include = default) public override async Task<ICollection<Show>> Search(
string query,
Include<Show>? include = default
)
{ {
return await AddIncludes(_database.Shows, include) return await AddIncludes(_database.Shows, include)
.Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Name + " " + x.Slug, $"%{query}%"))

View File

@ -50,7 +50,10 @@ namespace Kyoo.Core.Controllers
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<ICollection<Studio>> Search(string query, Include<Studio>? include = default) public override async Task<ICollection<Studio>> Search(
string query,
Include<Studio>? include = default
)
{ {
return await AddIncludes(_database.Studios, include) return await AddIncludes(_database.Studios, include)
.Where(x => EF.Functions.ILike(x.Name, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Name, $"%{query}%"))

View File

@ -49,7 +49,10 @@ namespace Kyoo.Core.Controllers
} }
/// <inheritdoc /> /// <inheritdoc />
public override async Task<ICollection<User>> Search(string query, Include<User>? include = default) public override async Task<ICollection<User>> Search(
string query,
Include<User>? include = default
)
{ {
return await AddIncludes(_database.Users, include) return await AddIncludes(_database.Users, include)
.Where(x => EF.Functions.ILike(x.Username, $"%{query}%")) .Where(x => EF.Functions.ILike(x.Username, $"%{query}%"))

View File

@ -66,7 +66,9 @@ public class WatchStatusRepository : IWatchStatusRepository
{ {
await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope(); await using AsyncServiceScope scope = CoreModule.Services.CreateAsyncScope();
DatabaseContext db = scope.ServiceProvider.GetRequiredService<DatabaseContext>(); DatabaseContext db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
WatchStatusRepository repo = scope.ServiceProvider.GetRequiredService<WatchStatusRepository>(); WatchStatusRepository repo = scope
.ServiceProvider
.GetRequiredService<WatchStatusRepository>();
List<Guid> users = await db.ShowWatchStatus List<Guid> users = await db.ShowWatchStatus
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(x => x.ShowId == ep.ShowId && x.Status == WatchStatus.Completed) .Where(x => x.ShowId == ep.ShowId && x.Status == WatchStatus.Completed)
@ -77,10 +79,12 @@ public class WatchStatusRepository : IWatchStatusRepository
}; };
} }
public WatchStatusRepository(DatabaseContext database, public WatchStatusRepository(
DatabaseContext database,
IRepository<Movie> movies, IRepository<Movie> movies,
DbConnection db, DbConnection db,
SqlVariableContext context) SqlVariableContext context
)
{ {
_database = database; _database = database;
_movies = movies; _movies = movies;
@ -89,7 +93,8 @@ public class WatchStatusRepository : IWatchStatusRepository
} }
// language=PostgreSQL // language=PostgreSQL
protected FormattableString Sql => $""" protected FormattableString Sql =>
$"""
select select
s.*, s.*,
swe.*, -- Episode as swe swe.*, -- Episode as swe
@ -126,7 +131,8 @@ public class WatchStatusRepository : IWatchStatusRepository
coalesce(s.id, m.id) asc coalesce(s.id, m.id) asc
"""; """;
protected Dictionary<string, Type> Config => new() protected Dictionary<string, Type> Config =>
new()
{ {
{ "s", typeof(Show) }, { "s", typeof(Show) },
{ "_sw", typeof(ShowWatchStatus) }, { "_sw", typeof(ShowWatchStatus) },
@ -178,10 +184,14 @@ public class WatchStatusRepository : IWatchStatusRepository
public async Task<ICollection<IWatchlist>> GetAll( public async Task<ICollection<IWatchlist>> GetAll(
Filter<IWatchlist>? filter = default, Filter<IWatchlist>? filter = default,
Include<IWatchlist>? include = default, Include<IWatchlist>? include = default,
Pagination? limit = default) Pagination? limit = default
)
{ {
if (include != null) if (include != null)
include.Metadatas = include.Metadatas.Where(x => x.Name != nameof(Show.WatchStatus)).ToList(); include.Metadatas = include
.Metadatas
.Where(x => x.Name != nameof(Show.WatchStatus))
.ToList();
// We can't use the generic after id hanler since the sort depends on a relation. // We can't use the generic after id hanler since the sort depends on a relation.
if (limit?.AfterID != null) if (limit?.AfterID != null)
@ -216,7 +226,9 @@ public class WatchStatusRepository : IWatchStatusRepository
/// <inheritdoc /> /// <inheritdoc />
public Task<MovieWatchStatus?> GetMovieStatus(Guid movieId, Guid userId) public Task<MovieWatchStatus?> GetMovieStatus(Guid movieId, Guid userId)
{ {
return _database.MovieWatchStatus.FirstOrDefaultAsync(x => x.MovieId == movieId && x.UserId == userId); return _database
.MovieWatchStatus
.FirstOrDefaultAsync(x => x.MovieId == movieId && x.UserId == userId);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -224,10 +236,12 @@ public class WatchStatusRepository : IWatchStatusRepository
Guid movieId, Guid movieId,
Guid userId, Guid userId,
WatchStatus status, WatchStatus status,
int? watchedTime) int? watchedTime
)
{ {
Movie movie = await _movies.Get(movieId); Movie movie = await _movies.Get(movieId);
int? percent = watchedTime != null && movie.Runtime > 0 int? percent =
watchedTime != null && movie.Runtime > 0
? (int)Math.Round(watchedTime.Value / (movie.Runtime * 60f) * 100f) ? (int)Math.Round(watchedTime.Value / (movie.Runtime * 60f) * 100f)
: null; : null;
@ -241,9 +255,12 @@ public class WatchStatusRepository : IWatchStatusRepository
} }
if (watchedTime.HasValue && status != WatchStatus.Watching) if (watchedTime.HasValue && status != WatchStatus.Watching)
throw new ValidationException("Can't have a watched time if the status is not watching."); throw new ValidationException(
"Can't have a watched time if the status is not watching."
);
MovieWatchStatus ret = new() MovieWatchStatus ret =
new()
{ {
UserId = userId, UserId = userId,
MovieId = movieId, MovieId = movieId,
@ -253,18 +270,19 @@ public class WatchStatusRepository : IWatchStatusRepository
AddedDate = DateTime.UtcNow, AddedDate = DateTime.UtcNow,
PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null,
}; };
await _database.MovieWatchStatus.Upsert(ret) await _database
.MovieWatchStatus
.Upsert(ret)
.UpdateIf(x => status != Watching || x.Status != Completed) .UpdateIf(x => status != Watching || x.Status != Completed)
.RunAsync(); .RunAsync();
return ret; return ret;
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task DeleteMovieStatus( public async Task DeleteMovieStatus(Guid movieId, Guid userId)
Guid movieId,
Guid userId)
{ {
await _database.MovieWatchStatus await _database
.MovieWatchStatus
.Where(x => x.MovieId == movieId && x.UserId == userId) .Where(x => x.MovieId == movieId && x.UserId == userId)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
} }
@ -272,26 +290,32 @@ public class WatchStatusRepository : IWatchStatusRepository
/// <inheritdoc /> /// <inheritdoc />
public Task<ShowWatchStatus?> GetShowStatus(Guid showId, Guid userId) public Task<ShowWatchStatus?> GetShowStatus(Guid showId, Guid userId)
{ {
return _database.ShowWatchStatus.FirstOrDefaultAsync(x => x.ShowId == showId && x.UserId == userId); return _database
.ShowWatchStatus
.FirstOrDefaultAsync(x => x.ShowId == showId && x.UserId == userId);
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<ShowWatchStatus?> SetShowStatus( public Task<ShowWatchStatus?> SetShowStatus(Guid showId, Guid userId, WatchStatus status) =>
Guid showId, _SetShowStatus(showId, userId, status);
Guid userId,
WatchStatus status
) => _SetShowStatus(showId, userId, status);
private async Task<ShowWatchStatus?> _SetShowStatus( private async Task<ShowWatchStatus?> _SetShowStatus(
Guid showId, Guid showId,
Guid userId, Guid userId,
WatchStatus status, WatchStatus status,
bool newEpisode = false) bool newEpisode = false
)
{ {
int unseenEpisodeCount = status != WatchStatus.Completed int unseenEpisodeCount =
? await _database.Episodes status != WatchStatus.Completed
? await _database
.Episodes
.Where(x => x.ShowId == showId) .Where(x => x.ShowId == showId)
.Where(x => x.Watched!.First(x => x.UserId == userId)!.Status != WatchStatus.Completed) .Where(
x =>
x.Watched!.First(x => x.UserId == userId)!.Status
!= WatchStatus.Completed
)
.CountAsync() .CountAsync()
: 0; : 0;
if (unseenEpisodeCount == 0) if (unseenEpisodeCount == 0)
@ -301,79 +325,105 @@ public class WatchStatusRepository : IWatchStatusRepository
Guid? nextEpisodeId = null; Guid? nextEpisodeId = null;
if (status == WatchStatus.Watching) if (status == WatchStatus.Watching)
{ {
var cursor = await _database.Episodes var cursor = await _database
.Episodes
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(x => x.ShowId == showId) .Where(x => x.ShowId == showId)
.OrderByDescending(x => x.AbsoluteNumber) .OrderByDescending(x => x.AbsoluteNumber)
.OrderByDescending(x => x.SeasonNumber) .OrderByDescending(x => x.SeasonNumber)
.OrderByDescending(x => x.EpisodeNumber) .OrderByDescending(x => x.EpisodeNumber)
.Select(x => new { x.Id, Status = x.Watched!.First(x => x.UserId == userId) }) .Select(x => new { x.Id, Status = x.Watched!.First(x => x.UserId == userId) })
.FirstOrDefaultAsync(x => x.Status.Status == WatchStatus.Completed || x.Status.Status == WatchStatus.Watching); .FirstOrDefaultAsync(
x =>
x.Status.Status == WatchStatus.Completed
|| x.Status.Status == WatchStatus.Watching
);
cursorWatchStatus = cursor?.Status; cursorWatchStatus = cursor?.Status;
nextEpisodeId = cursor?.Status.Status == WatchStatus.Watching nextEpisodeId =
cursor?.Status.Status == WatchStatus.Watching
? cursor.Id ? cursor.Id
: await _database.Episodes : await _database
.Episodes
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(x => x.ShowId == showId) .Where(x => x.ShowId == showId)
.OrderByDescending(x => x.AbsoluteNumber) .OrderByDescending(x => x.AbsoluteNumber)
.OrderByDescending(x => x.SeasonNumber) .OrderByDescending(x => x.SeasonNumber)
.OrderByDescending(x => x.EpisodeNumber) .OrderByDescending(x => x.EpisodeNumber)
.Select(x => new { x.Id, Status = x.Watched!.FirstOrDefault(x => x.UserId == userId) }) .Select(
x =>
new
{
x.Id,
Status = x.Watched!.FirstOrDefault(x => x.UserId == userId)
}
)
.Where(x => x.Status == null || x.Status.Status != WatchStatus.Completed) .Where(x => x.Status == null || x.Status.Status != WatchStatus.Completed)
.Select(x => x.Id) .Select(x => x.Id)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
else if (status == WatchStatus.Completed) else if (status == WatchStatus.Completed)
{ {
List<Guid> episodes = await _database.Episodes List<Guid> episodes = await _database
.Episodes
.Where(x => x.ShowId == showId) .Where(x => x.ShowId == showId)
.Select(x => x.Id) .Select(x => x.Id)
.ToListAsync(); .ToListAsync();
await _database.EpisodeWatchStatus await _database
.UpsertRange(episodes.Select(episodeId => new EpisodeWatchStatus .EpisodeWatchStatus
.UpsertRange(
episodes.Select(
episodeId =>
new EpisodeWatchStatus
{ {
UserId = userId, UserId = userId,
EpisodeId = episodeId, EpisodeId = episodeId,
Status = WatchStatus.Completed, Status = WatchStatus.Completed,
AddedDate = DateTime.UtcNow, AddedDate = DateTime.UtcNow,
PlayedDate = DateTime.UtcNow PlayedDate = DateTime.UtcNow
})) }
)
)
.UpdateIf(x => x.Status == Watching || x.Status == Planned) .UpdateIf(x => x.Status == Watching || x.Status == Planned)
.RunAsync(); .RunAsync();
} }
ShowWatchStatus ret = new() ShowWatchStatus ret =
new()
{ {
UserId = userId, UserId = userId,
ShowId = showId, ShowId = showId,
Status = status, Status = status,
AddedDate = DateTime.UtcNow, AddedDate = DateTime.UtcNow,
NextEpisodeId = nextEpisodeId, NextEpisodeId = nextEpisodeId,
WatchedTime = cursorWatchStatus?.Status == WatchStatus.Watching WatchedTime =
cursorWatchStatus?.Status == WatchStatus.Watching
? cursorWatchStatus.WatchedTime ? cursorWatchStatus.WatchedTime
: null, : null,
WatchedPercent = cursorWatchStatus?.Status == WatchStatus.Watching WatchedPercent =
cursorWatchStatus?.Status == WatchStatus.Watching
? cursorWatchStatus.WatchedPercent ? cursorWatchStatus.WatchedPercent
: null, : null,
UnseenEpisodesCount = unseenEpisodeCount, UnseenEpisodesCount = unseenEpisodeCount,
PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null,
}; };
await _database.ShowWatchStatus.Upsert(ret) await _database
.ShowWatchStatus
.Upsert(ret)
.UpdateIf(x => status != Watching || x.Status != Completed || newEpisode) .UpdateIf(x => status != Watching || x.Status != Completed || newEpisode)
.RunAsync(); .RunAsync();
return ret; return ret;
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task DeleteShowStatus( public async Task DeleteShowStatus(Guid showId, Guid userId)
Guid showId,
Guid userId)
{ {
await _database.ShowWatchStatus await _database
.ShowWatchStatus
.IgnoreAutoIncludes() .IgnoreAutoIncludes()
.Where(x => x.ShowId == showId && x.UserId == userId) .Where(x => x.ShowId == showId && x.UserId == userId)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
await _database.EpisodeWatchStatus await _database
.EpisodeWatchStatus
.Where(x => x.Episode.ShowId == showId && x.UserId == userId) .Where(x => x.Episode.ShowId == showId && x.UserId == userId)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
} }
@ -381,7 +431,9 @@ public class WatchStatusRepository : IWatchStatusRepository
/// <inheritdoc /> /// <inheritdoc />
public Task<EpisodeWatchStatus?> GetEpisodeStatus(Guid episodeId, Guid userId) public Task<EpisodeWatchStatus?> GetEpisodeStatus(Guid episodeId, Guid userId)
{ {
return _database.EpisodeWatchStatus.FirstOrDefaultAsync(x => x.EpisodeId == episodeId && x.UserId == userId); return _database
.EpisodeWatchStatus
.FirstOrDefaultAsync(x => x.EpisodeId == episodeId && x.UserId == userId);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -389,10 +441,12 @@ public class WatchStatusRepository : IWatchStatusRepository
Guid episodeId, Guid episodeId,
Guid userId, Guid userId,
WatchStatus status, WatchStatus status,
int? watchedTime) int? watchedTime
)
{ {
Episode episode = await _database.Episodes.FirstAsync(x => x.Id == episodeId); Episode episode = await _database.Episodes.FirstAsync(x => x.Id == episodeId);
int? percent = watchedTime != null && episode.Runtime > 0 int? percent =
watchedTime != null && episode.Runtime > 0
? (int)Math.Round(watchedTime.Value / (episode.Runtime * 60f) * 100f) ? (int)Math.Round(watchedTime.Value / (episode.Runtime * 60f) * 100f)
: null; : null;
@ -406,9 +460,12 @@ public class WatchStatusRepository : IWatchStatusRepository
} }
if (watchedTime.HasValue && status != WatchStatus.Watching) if (watchedTime.HasValue && status != WatchStatus.Watching)
throw new ValidationException("Can't have a watched time if the status is not watching."); throw new ValidationException(
"Can't have a watched time if the status is not watching."
);
EpisodeWatchStatus ret = new() EpisodeWatchStatus ret =
new()
{ {
UserId = userId, UserId = userId,
EpisodeId = episodeId, EpisodeId = episodeId,
@ -418,7 +475,9 @@ public class WatchStatusRepository : IWatchStatusRepository
AddedDate = DateTime.UtcNow, AddedDate = DateTime.UtcNow,
PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null, PlayedDate = status == WatchStatus.Completed ? DateTime.UtcNow : null,
}; };
await _database.EpisodeWatchStatus.Upsert(ret) await _database
.EpisodeWatchStatus
.Upsert(ret)
.UpdateIf(x => status != Watching || x.Status != Completed) .UpdateIf(x => status != Watching || x.Status != Completed)
.RunAsync(); .RunAsync();
await SetShowStatus(episode.ShowId, userId, WatchStatus.Watching); await SetShowStatus(episode.ShowId, userId, WatchStatus.Watching);
@ -426,11 +485,10 @@ public class WatchStatusRepository : IWatchStatusRepository
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task DeleteEpisodeStatus( public async Task DeleteEpisodeStatus(Guid episodeId, Guid userId)
Guid episodeId,
Guid userId)
{ {
await _database.EpisodeWatchStatus await _database
.EpisodeWatchStatus
.Where(x => x.EpisodeId == episodeId && x.UserId == userId) .Where(x => x.EpisodeId == episodeId && x.UserId == userId)
.ExecuteDeleteAsync(); .ExecuteDeleteAsync();
} }

View File

@ -36,7 +36,8 @@ namespace Kyoo.Core.Controllers
/// </summary> /// </summary>
public class ThumbnailsManager : IThumbnailsManager public class ThumbnailsManager : IThumbnailsManager
{ {
private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading = new(); private static readonly Dictionary<string, TaskCompletionSource<object>> _downloading =
new();
private readonly ILogger<ThumbnailsManager> _logger; private readonly ILogger<ThumbnailsManager> _logger;
@ -47,8 +48,10 @@ namespace Kyoo.Core.Controllers
/// </summary> /// </summary>
/// <param name="clientFactory">Client factory</param> /// <param name="clientFactory">Client factory</param>
/// <param name="logger">A logger to report errors</param> /// <param name="logger">A logger to report errors</param>
public ThumbnailsManager(IHttpClientFactory clientFactory, public ThumbnailsManager(
ILogger<ThumbnailsManager> logger) IHttpClientFactory clientFactory,
ILogger<ThumbnailsManager> logger
)
{ {
_clientFactory = clientFactory; _clientFactory = clientFactory;
_logger = logger; _logger = logger;
@ -85,14 +88,35 @@ namespace Kyoo.Core.Controllers
info.ColorType = SKColorType.Rgba8888; info.ColorType = SKColorType.Rgba8888;
using SKBitmap original = SKBitmap.Decode(codec, info); using SKBitmap original = SKBitmap.Decode(codec, info);
using SKBitmap high = original.Resize(new SKSizeI(original.Width, original.Height), SKFilterQuality.High); using SKBitmap high = original.Resize(
await _WriteTo(original, $"{localPath}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp", 90); 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); using SKBitmap medium = high.Resize(
await _WriteTo(medium, $"{localPath}.{ImageQuality.Medium.ToString().ToLowerInvariant()}.webp", 75); 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); using SKBitmap low = medium.Resize(
await _WriteTo(low, $"{localPath}.{ImageQuality.Low.ToString().ToLowerInvariant()}.webp", 50); 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); image.Blurhash = Blurhasher.Encode(low, 4, 3);
} }
@ -108,7 +132,8 @@ namespace Kyoo.Core.Controllers
{ {
string name = item is IResource res ? res.Slug : "???"; string name = item is IResource res ? res.Slug : "???";
string posterPath = $"{_GetBaseImagePath(item, "poster")}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp"; string posterPath =
$"{_GetBaseImagePath(item, "poster")}.{ImageQuality.High.ToString().ToLowerInvariant()}.webp";
bool duplicated = false; bool duplicated = false;
TaskCompletionSource<object>? sync = null; TaskCompletionSource<object>? sync = null;
try try
@ -128,15 +153,25 @@ namespace Kyoo.Core.Controllers
} }
if (duplicated) if (duplicated)
{ {
object? dup = sync != null object? dup = sync != null ? await sync.Task : null;
? await sync.Task
: null;
throw new DuplicatedItemException(dup); throw new DuplicatedItemException(dup);
} }
await _DownloadImage(item.Poster, _GetBaseImagePath(item, "poster"), $"The poster of {name}"); await _DownloadImage(
await _DownloadImage(item.Thumbnail, _GetBaseImagePath(item, "thumbnail"), $"The poster of {name}"); item.Poster,
await _DownloadImage(item.Logo, _GetBaseImagePath(item, "logo"), $"The poster of {name}"); _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 finally
{ {
@ -155,7 +190,8 @@ namespace Kyoo.Core.Controllers
{ {
string directory = item switch string directory = item switch
{ {
IResource res => Path.Combine("./metadata", item.GetType().Name.ToLowerInvariant(), res.Slug), IResource res
=> Path.Combine("./metadata", item.GetType().Name.ToLowerInvariant(), res.Slug),
_ => Path.Combine("./metadata", typeof(T).Name.ToLowerInvariant()) _ => Path.Combine("./metadata", typeof(T).Name.ToLowerInvariant())
}; };
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
@ -175,7 +211,9 @@ namespace Kyoo.Core.Controllers
{ {
IEnumerable<string> images = new[] { "poster", "thumbnail", "logo" } IEnumerable<string> images = new[] { "poster", "thumbnail", "logo" }
.SelectMany(x => _GetBaseImagePath(item, x)) .SelectMany(x => _GetBaseImagePath(item, x))
.SelectMany(x => new[] .SelectMany(
x =>
new[]
{ {
ImageQuality.High.ToString().ToLowerInvariant(), ImageQuality.High.ToString().ToLowerInvariant(),
ImageQuality.Medium.ToString().ToLowerInvariant(), ImageQuality.Medium.ToString().ToLowerInvariant(),

View File

@ -54,7 +54,10 @@ namespace Kyoo.Core
/// <inheritdoc /> /// <inheritdoc />
public void Configure(ContainerBuilder builder) public void Configure(ContainerBuilder builder)
{ {
builder.RegisterType<ThumbnailsManager>().As<IThumbnailsManager>().InstancePerLifetimeScope(); builder
.RegisterType<ThumbnailsManager>()
.As<IThumbnailsManager>()
.InstancePerLifetimeScope();
builder.RegisterType<LibraryManager>().As<ILibraryManager>().InstancePerLifetimeScope(); builder.RegisterType<LibraryManager>().As<ILibraryManager>().InstancePerLifetimeScope();
builder.RegisterRepository<LibraryItemRepository>(); builder.RegisterRepository<LibraryItemRepository>();
@ -67,7 +70,11 @@ namespace Kyoo.Core
builder.RegisterRepository<StudioRepository>(); builder.RegisterRepository<StudioRepository>();
builder.RegisterRepository<UserRepository>(); builder.RegisterRepository<UserRepository>();
builder.RegisterRepository<NewsRepository>(); builder.RegisterRepository<NewsRepository>();
builder.RegisterType<WatchStatusRepository>().As<IWatchStatusRepository>().AsSelf().InstancePerLifetimeScope(); builder
.RegisterType<WatchStatusRepository>()
.As<IWatchStatusRepository>()
.AsSelf()
.InstancePerLifetimeScope();
builder.RegisterType<SqlVariableContext>().InstancePerLifetimeScope(); builder.RegisterType<SqlVariableContext>().InstancePerLifetimeScope();
} }
@ -77,7 +84,8 @@ namespace Kyoo.Core
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddTransient<IConfigureOptions<MvcNewtonsoftJsonOptions>, JsonOptions>(); services.AddTransient<IConfigureOptions<MvcNewtonsoftJsonOptions>, JsonOptions>();
services.AddMvcCore(options => services
.AddMvcCore(options =>
{ {
options.Filters.Add<ExceptionFilter>(); options.Filters.Add<ExceptionFilter>();
options.ModelBinderProviders.Insert(0, new SortBinder.Provider()); options.ModelBinderProviders.Insert(0, new SortBinder.Provider());
@ -120,12 +128,16 @@ namespace Kyoo.Core
} }
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps => new IStartupAction[] public IEnumerable<IStartupAction> ConfigureSteps =>
new IStartupAction[]
{ {
SA.New<IApplicationBuilder>(app => app.UseHsts(), SA.Before), SA.New<IApplicationBuilder>(app => app.UseHsts(), SA.Before),
SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1), SA.New<IApplicationBuilder>(app => app.UseResponseCompression(), SA.Routing + 1),
SA.New<IApplicationBuilder>(app => app.UseRouting(), SA.Routing), SA.New<IApplicationBuilder>(app => app.UseRouting(), SA.Routing),
SA.New<IApplicationBuilder>(app => app.UseEndpoints(x => x.MapControllers()), SA.Endpoint) SA.New<IApplicationBuilder>(
app => app.UseEndpoints(x => x.MapControllers()),
SA.Endpoint
)
}; };
} }
} }

View File

@ -66,7 +66,9 @@ namespace Kyoo.Core
break; break;
case Exception ex: case Exception ex:
_logger.LogError(ex, "Unhandled error"); _logger.LogError(ex, "Unhandled error");
context.Result = new ServerErrorObjectResult(new RequestError("Internal Server Error")); context.Result = new ServerErrorObjectResult(
new RequestError("Internal Server Error")
);
break; break;
} }
} }

View File

@ -45,7 +45,9 @@ namespace Kyoo.Core.Api
protected Page<TResult> Page<TResult>(ICollection<TResult> resources, int limit) protected Page<TResult> Page<TResult>(ICollection<TResult> resources, int limit)
where TResult : IResource where TResult : IResource
{ {
Dictionary<string, string> query = Request.Query.ToDictionary( Dictionary<string, string> query = Request
.Query
.ToDictionary(
x => x.Key, x => x.Key,
x => x.Value.ToString(), x => x.Value.ToString(),
StringComparer.InvariantCultureIgnoreCase StringComparer.InvariantCultureIgnoreCase
@ -58,18 +60,15 @@ namespace Kyoo.Core.Api
query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}"); query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}");
} }
return new Page<TResult>( return new Page<TResult>(resources, Request.Path, query, limit);
resources,
Request.Path,
query,
limit
);
} }
protected SearchPage<TResult> SearchPage<TResult>(SearchPage<TResult>.SearchResult result) protected SearchPage<TResult> SearchPage<TResult>(SearchPage<TResult>.SearchResult result)
where TResult : IResource where TResult : IResource
{ {
Dictionary<string, string> query = Request.Query.ToDictionary( Dictionary<string, string> query = Request
.Query
.ToDictionary(
x => x.Key, x => x.Key,
x => x.Value.ToString(), x => x.Value.ToString(),
StringComparer.InvariantCultureIgnoreCase StringComparer.InvariantCultureIgnoreCase
@ -79,7 +78,9 @@ namespace Kyoo.Core.Api
string? previous = null; string? previous = null;
string? next = null; string? next = null;
string first; string first;
int limit = query.TryGetValue("limit", out string? limitStr) ? int.Parse(limitStr) : new SearchPagination().Limit; 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; int? skip = query.TryGetValue("skip", out string? skipStr) ? int.Parse(skipStr) : null;
if (skip != null) if (skip != null)
@ -97,13 +98,7 @@ namespace Kyoo.Core.Api
query.Remove("skip"); query.Remove("skip");
first = Request.Path + query.ToQueryString(); first = Request.Path + query.ToQueryString();
return new SearchPage<TResult>( return new SearchPage<TResult>(result, self, previous, next, first);
result,
self,
previous,
next,
first
);
} }
} }
} }

View File

@ -67,7 +67,10 @@ namespace Kyoo.Core.Api
[PartialPermission(Kind.Read)] [PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<T>> Get(Identifier identifier, [FromQuery] Include<T>? fields) public async Task<ActionResult<T>> Get(
Identifier identifier,
[FromQuery] Include<T>? fields
)
{ {
T? ret = await identifier.Match( T? ret = await identifier.Match(
id => Repository.GetOrDefault(id, fields), id => Repository.GetOrDefault(id, fields),
@ -116,14 +119,10 @@ namespace Kyoo.Core.Api
[FromQuery] Sort<T> sortBy, [FromQuery] Sort<T> sortBy,
[FromQuery] Filter<T>? filter, [FromQuery] Filter<T>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<T>? fields) [FromQuery] Include<T>? fields
)
{ {
ICollection<T> resources = await Repository.GetAll( ICollection<T> resources = await Repository.GetAll(filter, sortBy, fields, pagination);
filter,
sortBy,
fields,
pagination
);
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
} }
@ -195,7 +194,9 @@ namespace Kyoo.Core.Api
if (resource.Id.HasValue) if (resource.Id.HasValue)
return await Repository.Patch(resource.Id.Value, TryUpdateModelAsync); return await Repository.Patch(resource.Id.Value, TryUpdateModelAsync);
if (resource.Slug == null) if (resource.Slug == null)
throw new ArgumentException("Either the Id or the slug of the resource has to be defined to edit it."); throw new ArgumentException(
"Either the Id or the slug of the resource has to be defined to edit it."
);
T old = await Repository.Get(resource.Slug); T old = await Repository.Get(resource.Slug);
return await Repository.Patch(old.Id, TryUpdateModelAsync); return await Repository.Patch(old.Id, TryUpdateModelAsync);
@ -216,10 +217,7 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Identifier identifier) public async Task<IActionResult> Delete(Identifier identifier)
{ {
await identifier.Match( await identifier.Match(id => Repository.Delete(id), slug => Repository.Delete(slug));
id => Repository.Delete(id),
slug => Repository.Delete(slug)
);
return NoContent(); return NoContent();
} }
@ -239,7 +237,9 @@ namespace Kyoo.Core.Api
public async Task<IActionResult> Delete([FromQuery] Filter<T> filter) public async Task<IActionResult> Delete([FromQuery] Filter<T> filter)
{ {
if (filter == null) if (filter == null)
return BadRequest(new RequestError("Incule a filter to delete items, all items won't be deleted.")); return BadRequest(
new RequestError("Incule a filter to delete items, all items won't be deleted.")
);
await Repository.DeleteAll(filter); await Repository.DeleteAll(filter);
return NoContent(); return NoContent();

View File

@ -49,14 +49,17 @@ namespace Kyoo.Core.Api
/// The repository to use as a baking store for the type <typeparamref name="T"/>. /// The repository to use as a baking store for the type <typeparamref name="T"/>.
/// </param> /// </param>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> /// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
public CrudThumbsApi(IRepository<T> repository, public CrudThumbsApi(IRepository<T> repository, IThumbnailsManager thumbs)
IThumbnailsManager thumbs)
: base(repository) : base(repository)
{ {
_thumbs = thumbs; _thumbs = thumbs;
} }
private async Task<IActionResult> _GetImage(Identifier identifier, string image, ImageQuality? quality) private async Task<IActionResult> _GetImage(
Identifier identifier,
string image,
ImageQuality? quality
)
{ {
T? resource = await identifier.Match( T? resource = await identifier.Match(
id => Repository.GetOrDefault(id), id => Repository.GetOrDefault(id),
@ -94,7 +97,10 @@ namespace Kyoo.Core.Api
[PartialPermission(Kind.Read)] [PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetPoster(Identifier identifier, [FromQuery] ImageQuality? quality) public Task<IActionResult> GetPoster(
Identifier identifier,
[FromQuery] ImageQuality? quality
)
{ {
return _GetImage(identifier, "poster", quality); return _GetImage(identifier, "poster", quality);
} }
@ -134,7 +140,10 @@ namespace Kyoo.Core.Api
/// </response> /// </response>
[HttpGet("{identifier:id}/thumbnail")] [HttpGet("{identifier:id}/thumbnail")]
[HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)] [HttpGet("{identifier:id}/backdrop", Order = AlternativeRoute)]
public Task<IActionResult> GetBackdrop(Identifier identifier, [FromQuery] ImageQuality? quality) public Task<IActionResult> GetBackdrop(
Identifier identifier,
[FromQuery] ImageQuality? quality
)
{ {
return _GetImage(identifier, "thumbnail", quality); return _GetImage(identifier, "thumbnail", quality);
} }

View File

@ -28,10 +28,14 @@ public class FilterBinder : IModelBinder
{ {
public Task BindModelAsync(ModelBindingContext bindingContext) public Task BindModelAsync(ModelBindingContext bindingContext)
{ {
ValueProviderResult fields = bindingContext.ValueProvider.GetValue(bindingContext.FieldName); ValueProviderResult fields = bindingContext
.ValueProvider
.GetValue(bindingContext.FieldName);
try try
{ {
object? filter = bindingContext.ModelType.GetMethod(nameof(Filter<object>.From))! object? filter = bindingContext
.ModelType
.GetMethod(nameof(Filter<object>.From))!
.Invoke(null, new object?[] { fields.FirstValue }); .Invoke(null, new object?[] { fields.FirstValue });
bindingContext.Result = ModelBindingResult.Success(filter); bindingContext.Result = ModelBindingResult.Success(filter);
return Task.CompletedTask; return Task.CompletedTask;

View File

@ -31,10 +31,14 @@ public class IncludeBinder : IModelBinder
public Task BindModelAsync(ModelBindingContext bindingContext) public Task BindModelAsync(ModelBindingContext bindingContext)
{ {
ValueProviderResult fields = bindingContext.ValueProvider.GetValue(bindingContext.FieldName); ValueProviderResult fields = bindingContext
.ValueProvider
.GetValue(bindingContext.FieldName);
try try
{ {
object include = bindingContext.ModelType.GetMethod(nameof(Include<object>.From))! object include = bindingContext
.ModelType
.GetMethod(nameof(Include<object>.From))!
.Invoke(null, new object?[] { fields.FirstValue })!; .Invoke(null, new object?[] { fields.FirstValue })!;
bindingContext.Result = ModelBindingResult.Success(include); bindingContext.Result = ModelBindingResult.Success(include);
bindingContext.HttpContext.Items["fields"] = ((dynamic)include).Fields; bindingContext.HttpContext.Items["fields"] = ((dynamic)include).Fields;

View File

@ -47,7 +47,9 @@ namespace Kyoo.Core.Api
/// <inheritdoc /> /// <inheritdoc />
public void Configure(MvcNewtonsoftJsonOptions options) public void Configure(MvcNewtonsoftJsonOptions options)
{ {
options.SerializerSettings.ContractResolver = new JsonSerializerContract(_httpContextAccessor); options.SerializerSettings.ContractResolver = new JsonSerializerContract(
_httpContextAccessor
);
options.SerializerSettings.Converters.Add(new PeopleRoleConverter()); options.SerializerSettings.Converters.Add(new PeopleRoleConverter());
} }
} }

View File

@ -51,16 +51,23 @@ namespace Kyoo.Core.Api
} }
/// <inheritdoc /> /// <inheritdoc />
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) protected override JsonProperty CreateProperty(
MemberInfo member,
MemberSerialization memberSerialization
)
{ {
JsonProperty property = base.CreateProperty(member, memberSerialization); JsonProperty property = base.CreateProperty(member, memberSerialization);
LoadableRelationAttribute? relation = member.GetCustomAttribute<LoadableRelationAttribute>(); LoadableRelationAttribute? relation =
member.GetCustomAttribute<LoadableRelationAttribute>();
if (relation != null) if (relation != null)
{ {
property.ShouldSerialize = _ => property.ShouldSerialize = _ =>
{ {
if (_httpContextAccessor.HttpContext!.Items["fields"] is not ICollection<string> fields) if (
_httpContextAccessor.HttpContext!.Items["fields"]
is not ICollection<string> fields
)
return false; return false;
return fields.Contains(member.Name); return fields.Contains(member.Name);
}; };
@ -73,13 +80,20 @@ namespace Kyoo.Core.Api
return property; return property;
} }
protected override IList<JsonProperty> CreateProperties(Type type, MemberSerialization memberSerialization) protected override IList<JsonProperty> CreateProperties(
Type type,
MemberSerialization memberSerialization
)
{ {
IList<JsonProperty> properties = base.CreateProperties(type, memberSerialization); IList<JsonProperty> properties = base.CreateProperties(type, memberSerialization);
if (properties.All(x => x.PropertyName != "kind") && type.IsAssignableTo(typeof(IResource))) if (
properties.All(x => x.PropertyName != "kind")
&& type.IsAssignableTo(typeof(IResource))
)
{ {
properties.Add(new JsonProperty() properties.Add(
new JsonProperty()
{ {
DeclaringType = type, DeclaringType = type,
PropertyName = "kind", PropertyName = "kind",
@ -89,7 +103,8 @@ namespace Kyoo.Core.Api
Readable = true, Readable = true,
Writable = false, Writable = false,
TypeNameHandling = TypeNameHandling.None, TypeNameHandling = TypeNameHandling.None,
}); }
);
} }
return properties; return properties;
@ -104,11 +119,10 @@ namespace Kyoo.Core.Api
_value = value; _value = value;
} }
public object GetValue(object target) public object GetValue(object target) => _value;
=> _value;
public void SetValue(object target, object? value) public void SetValue(object target, object? value) =>
=> throw new NotImplementedException(); throw new NotImplementedException();
} }
} }
} }

View File

@ -31,7 +31,11 @@ namespace Kyoo.Core.Api
public class PeopleRoleConverter : JsonConverter<PeopleRole> public class PeopleRoleConverter : JsonConverter<PeopleRole>
{ {
/// <inheritdoc /> /// <inheritdoc />
public override void WriteJson(JsonWriter writer, PeopleRole? value, JsonSerializer serializer) public override void WriteJson(
JsonWriter writer,
PeopleRole? value,
JsonSerializer serializer
)
{ {
// if (value == null) // if (value == null)
// { // {
@ -58,11 +62,13 @@ namespace Kyoo.Core.Api
} }
/// <inheritdoc /> /// <inheritdoc />
public override PeopleRole ReadJson(JsonReader reader, public override PeopleRole ReadJson(
JsonReader reader,
Type objectType, Type objectType,
PeopleRole? existingValue, PeopleRole? existingValue,
bool hasExistingValue, bool hasExistingValue,
JsonSerializer serializer) JsonSerializer serializer
)
{ {
throw new NotImplementedException(); throw new NotImplementedException();
} }

View File

@ -32,14 +32,18 @@ public class SortBinder : IModelBinder
public Task BindModelAsync(ModelBindingContext bindingContext) public Task BindModelAsync(ModelBindingContext bindingContext)
{ {
ValueProviderResult sortBy = bindingContext.ValueProvider.GetValue(bindingContext.FieldName); ValueProviderResult sortBy = bindingContext
.ValueProvider
.GetValue(bindingContext.FieldName);
uint seed = BitConverter.ToUInt32( uint seed = BitConverter.ToUInt32(
BitConverter.GetBytes(_rng.Next(int.MinValue, int.MaxValue)), BitConverter.GetBytes(_rng.Next(int.MinValue, int.MaxValue)),
0 0
); );
try try
{ {
object sort = bindingContext.ModelType.GetMethod(nameof(Sort<Movie>.From))! object sort = bindingContext
.ModelType
.GetMethod(nameof(Sort<Movie>.From))!
.Invoke(null, new object?[] { sortBy.FirstValue, seed })!; .Invoke(null, new object?[] { sortBy.FirstValue, seed })!;
bindingContext.Result = ModelBindingResult.Success(sort); bindingContext.Result = ModelBindingResult.Success(sort);
bindingContext.HttpContext.Items["seed"] = seed; bindingContext.HttpContext.Items["seed"] = seed;

View File

@ -47,8 +47,7 @@ namespace Kyoo.Core.Api
/// The library manager used to modify or retrieve information about the data store. /// The library manager used to modify or retrieve information about the data store.
/// </param> /// </param>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> /// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
public StaffApi(ILibraryManager libraryManager, public StaffApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
IThumbnailsManager thumbs)
: base(libraryManager.People, thumbs) : base(libraryManager.People, thumbs)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;

View File

@ -77,20 +77,30 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Show>>> GetShows(Identifier identifier, public async Task<ActionResult<Page<Show>>> GetShows(
Identifier identifier,
[FromQuery] Sort<Show> sortBy, [FromQuery] Sort<Show> sortBy,
[FromQuery] Filter<Show>? filter, [FromQuery] Filter<Show>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<Show> fields) [FromQuery] Include<Show> fields
)
{ {
ICollection<Show> resources = await _libraryManager.Shows.GetAll( ICollection<Show> resources = await _libraryManager
Filter.And(filter, identifier.Matcher<Show>(x => x.StudioId, x => x.Studio!.Slug)), .Shows
.GetAll(
Filter.And(
filter,
identifier.Matcher<Show>(x => x.StudioId, x => x.Studio!.Slug)
),
sortBy, sortBy,
fields, fields,
pagination pagination
); );
if (!resources.Any() && await _libraryManager.Studios.GetOrDefault(identifier.IsSame<Studio>()) == null) if (
!resources.Any()
&& await _libraryManager.Studios.GetOrDefault(identifier.IsSame<Studio>()) == null
)
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
} }

View File

@ -46,10 +46,12 @@ namespace Kyoo.Core.Api
private readonly CollectionRepository _collections; private readonly CollectionRepository _collections;
private readonly LibraryItemRepository _items; private readonly LibraryItemRepository _items;
public CollectionApi(ILibraryManager libraryManager, public CollectionApi(
ILibraryManager libraryManager,
CollectionRepository collections, CollectionRepository collections,
LibraryItemRepository items, LibraryItemRepository items,
IThumbnailsManager thumbs) IThumbnailsManager thumbs
)
: base(libraryManager.Collections, thumbs) : base(libraryManager.Collections, thumbs)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
@ -139,11 +141,13 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<ILibraryItem>>> GetItems(Identifier identifier, public async Task<ActionResult<Page<ILibraryItem>>> GetItems(
Identifier identifier,
[FromQuery] Sort<ILibraryItem> sortBy, [FromQuery] Sort<ILibraryItem> sortBy,
[FromQuery] Filter<ILibraryItem>? filter, [FromQuery] Filter<ILibraryItem>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<ILibraryItem>? fields) [FromQuery] Include<ILibraryItem>? fields
)
{ {
Guid collectionId = await identifier.Match( Guid collectionId = await identifier.Match(
id => Task.FromResult(id), id => Task.FromResult(id),
@ -152,12 +156,18 @@ namespace Kyoo.Core.Api
ICollection<ILibraryItem> resources = await _items.GetAllOfCollection( ICollection<ILibraryItem> resources = await _items.GetAllOfCollection(
collectionId, collectionId,
filter, filter,
sortBy == new Sort<ILibraryItem>.Default() ? new Sort<ILibraryItem>.By(nameof(Movie.AirDate)) : sortBy, sortBy == new Sort<ILibraryItem>.Default()
? new Sort<ILibraryItem>.By(nameof(Movie.AirDate))
: sortBy,
fields, fields,
pagination pagination
); );
if (!resources.Any() && await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>()) == null) if (
!resources.Any()
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
== null
)
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
} }
@ -182,20 +192,31 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Show>>> GetShows(Identifier identifier, public async Task<ActionResult<Page<Show>>> GetShows(
Identifier identifier,
[FromQuery] Sort<Show> sortBy, [FromQuery] Sort<Show> sortBy,
[FromQuery] Filter<Show>? filter, [FromQuery] Filter<Show>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<Show>? fields) [FromQuery] Include<Show>? fields
)
{ {
ICollection<Show> resources = await _libraryManager.Shows.GetAll( ICollection<Show> resources = await _libraryManager
Filter.And(filter, identifier.IsContainedIn<Show, Collection>(x => x.Collections)), .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, sortBy == new Sort<Show>.Default() ? new Sort<Show>.By(x => x.AirDate) : sortBy,
fields, fields,
pagination pagination
); );
if (!resources.Any() && await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>()) == null) if (
!resources.Any()
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
== null
)
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
} }
@ -220,20 +241,33 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Movie>>> GetMovies(Identifier identifier, public async Task<ActionResult<Page<Movie>>> GetMovies(
Identifier identifier,
[FromQuery] Sort<Movie> sortBy, [FromQuery] Sort<Movie> sortBy,
[FromQuery] Filter<Movie>? filter, [FromQuery] Filter<Movie>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<Movie>? fields) [FromQuery] Include<Movie>? fields
)
{ {
ICollection<Movie> resources = await _libraryManager.Movies.GetAll( ICollection<Movie> resources = await _libraryManager
Filter.And(filter, identifier.IsContainedIn<Movie, Collection>(x => x.Collections)), .Movies
sortBy == new Sort<Movie>.Default() ? new Sort<Movie>.By(x => x.AirDate) : sortBy, .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, fields,
pagination pagination
); );
if (!resources.Any() && await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>()) == null) if (
!resources.Any()
&& await _libraryManager.Collections.GetOrDefault(identifier.IsSame<Collection>())
== null
)
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
} }

View File

@ -52,8 +52,7 @@ namespace Kyoo.Core.Api
/// The library manager used to modify or retrieve information in the data store. /// The library manager used to modify or retrieve information in the data store.
/// </param> /// </param>
/// <param name="thumbnails">The thumbnail manager used to retrieve images paths.</param> /// <param name="thumbnails">The thumbnail manager used to retrieve images paths.</param>
public EpisodeApi(ILibraryManager libraryManager, public EpisodeApi(ILibraryManager libraryManager, IThumbnailsManager thumbnails)
IThumbnailsManager thumbnails)
: base(libraryManager.Episodes, thumbnails) : base(libraryManager.Episodes, thumbnails)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
@ -73,9 +72,14 @@ namespace Kyoo.Core.Api
[PartialPermission(Kind.Read)] [PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Show>> GetShow(Identifier identifier, [FromQuery] Include<Show> fields) 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); return await _libraryManager
.Shows
.Get(identifier.IsContainedIn<Show, Episode>(x => x.Episodes!), fields);
} }
/// <summary> /// <summary>
@ -94,21 +98,21 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Season>> GetSeason(Identifier identifier, [FromQuery] Include<Season> fields) public async Task<ActionResult<Season>> GetSeason(
Identifier identifier,
[FromQuery] Include<Season> fields
)
{ {
Season? ret = await _libraryManager.Seasons.GetOrDefault( Season? ret = await _libraryManager
identifier.IsContainedIn<Season, Episode>(x => x.Episodes!), .Seasons
fields .GetOrDefault(identifier.IsContainedIn<Season, Episode>(x => x.Episodes!), 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 return episode == null ? NotFound() : NoContent();
? NotFound()
: NoContent();
} }
/// <summary> /// <summary>
@ -154,18 +158,19 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<EpisodeWatchStatus?> SetWatchStatus(Identifier identifier, WatchStatus status, int? watchedTime) public async Task<EpisodeWatchStatus?> SetWatchStatus(
Identifier identifier,
WatchStatus status,
int? watchedTime
)
{ {
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
id, .WatchStatus
User.GetIdOrThrow(), .SetEpisodeStatus(id, User.GetIdOrThrow(), status, watchedTime);
status,
watchedTime
);
} }
/// <summary> /// <summary>

View File

@ -54,8 +54,7 @@ namespace Kyoo.Core.Api
/// The library manager used to modify or retrieve information about the data store. /// The library manager used to modify or retrieve information about the data store.
/// </param> /// </param>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> /// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
public MovieApi(ILibraryManager libraryManager, public MovieApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
IThumbnailsManager thumbs)
: base(libraryManager.Movies, thumbs) : base(libraryManager.Movies, thumbs)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
@ -109,9 +108,14 @@ namespace Kyoo.Core.Api
[PartialPermission(Kind.Read)] [PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Studio>> GetStudio(Identifier identifier, [FromQuery] Include<Studio> fields) public async Task<ActionResult<Studio>> GetStudio(
Identifier identifier,
[FromQuery] Include<Studio> fields
)
{ {
return await _libraryManager.Studios.Get(identifier.IsContainedIn<Studio, Movie>(x => x.Movies!), fields); return await _libraryManager
.Studios
.Get(identifier.IsContainedIn<Studio, Movie>(x => x.Movies!), fields);
} }
/// <summary> /// <summary>
@ -134,20 +138,27 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Collection>>> GetCollections(Identifier identifier, public async Task<ActionResult<Page<Collection>>> GetCollections(
Identifier identifier,
[FromQuery] Sort<Collection> sortBy, [FromQuery] Sort<Collection> sortBy,
[FromQuery] Filter<Collection>? filter, [FromQuery] Filter<Collection>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<Collection> fields) [FromQuery] Include<Collection> fields
)
{ {
ICollection<Collection> resources = await _libraryManager.Collections.GetAll( ICollection<Collection> resources = await _libraryManager
.Collections
.GetAll(
Filter.And(filter, identifier.IsContainedIn<Collection, Movie>(x => x.Movies)), Filter.And(filter, identifier.IsContainedIn<Collection, Movie>(x => x.Movies)),
sortBy, sortBy,
fields, fields,
pagination pagination
); );
if (!resources.Any() && await _libraryManager.Movies.GetOrDefault(identifier.IsSame<Movie>()) == null) if (
!resources.Any()
&& await _libraryManager.Movies.GetOrDefault(identifier.IsSame<Movie>()) == null
)
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
} }
@ -196,18 +207,19 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<MovieWatchStatus?> SetWatchStatus(Identifier identifier, WatchStatus status, int? watchedTime) public async Task<MovieWatchStatus?> SetWatchStatus(
Identifier identifier,
WatchStatus status,
int? watchedTime
)
{ {
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
); );
return await _libraryManager.WatchStatus.SetMovieStatus( return await _libraryManager
id, .WatchStatus
User.GetIdOrThrow(), .SetMovieStatus(id, User.GetIdOrThrow(), status, watchedTime);
status,
watchedTime
);
} }
/// <summary> /// <summary>

View File

@ -36,7 +36,6 @@ namespace Kyoo.Core.Api
public class NewsApi : CrudThumbsApi<INews> public class NewsApi : CrudThumbsApi<INews>
{ {
public NewsApi(IRepository<INews> news, IThumbnailsManager thumbs) public NewsApi(IRepository<INews> news, IThumbnailsManager thumbs)
: base(news, thumbs) : base(news, thumbs) { }
{ }
} }
} }

View File

@ -66,9 +66,12 @@ namespace Kyoo.Core.Api
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<Collection> sortBy, [FromQuery] Sort<Collection> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<Collection> fields) [FromQuery] Include<Collection> fields
)
{ {
return SearchPage(await _searchManager.SearchCollections(q, sortBy, pagination, fields)); return SearchPage(
await _searchManager.SearchCollections(q, sortBy, pagination, fields)
);
} }
/// <summary> /// <summary>
@ -91,7 +94,8 @@ namespace Kyoo.Core.Api
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<Show> sortBy, [FromQuery] Sort<Show> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<Show> fields) [FromQuery] Include<Show> fields
)
{ {
return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields)); return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields));
} }
@ -116,7 +120,8 @@ namespace Kyoo.Core.Api
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<Movie> sortBy, [FromQuery] Sort<Movie> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<Movie> fields) [FromQuery] Include<Movie> fields
)
{ {
return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields)); return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields));
} }
@ -141,7 +146,8 @@ namespace Kyoo.Core.Api
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<ILibraryItem> sortBy, [FromQuery] Sort<ILibraryItem> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<ILibraryItem> fields) [FromQuery] Include<ILibraryItem> fields
)
{ {
return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields)); return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields));
} }
@ -166,7 +172,8 @@ namespace Kyoo.Core.Api
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<Episode> sortBy, [FromQuery] Sort<Episode> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<Episode> fields) [FromQuery] Include<Episode> fields
)
{ {
return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields)); return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields));
} }
@ -191,7 +198,8 @@ namespace Kyoo.Core.Api
[FromQuery] string? q, [FromQuery] string? q,
[FromQuery] Sort<Studio> sortBy, [FromQuery] Sort<Studio> sortBy,
[FromQuery] SearchPagination pagination, [FromQuery] SearchPagination pagination,
[FromQuery] Include<Studio> fields) [FromQuery] Include<Studio> fields
)
{ {
return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields)); return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields));
} }

View File

@ -52,8 +52,7 @@ namespace Kyoo.Core.Api
/// The library manager used to modify or retrieve information in the data store. /// The library manager used to modify or retrieve information in the data store.
/// </param> /// </param>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> /// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
public SeasonApi(ILibraryManager libraryManager, public SeasonApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
IThumbnailsManager thumbs)
: base(libraryManager.Seasons, thumbs) : base(libraryManager.Seasons, thumbs)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
@ -79,20 +78,30 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Episode>>> GetEpisode(Identifier identifier, public async Task<ActionResult<Page<Episode>>> GetEpisode(
Identifier identifier,
[FromQuery] Sort<Episode> sortBy, [FromQuery] Sort<Episode> sortBy,
[FromQuery] Filter<Episode>? filter, [FromQuery] Filter<Episode>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<Episode> fields) [FromQuery] Include<Episode> fields
)
{ {
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll( ICollection<Episode> resources = await _libraryManager
Filter.And(filter, identifier.Matcher<Episode>(x => x.SeasonId, x => x.Season!.Slug)), .Episodes
.GetAll(
Filter.And(
filter,
identifier.Matcher<Episode>(x => x.SeasonId, x => x.Season!.Slug)
),
sortBy, sortBy,
fields, fields,
pagination pagination
); );
if (!resources.Any() && await _libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null) if (
!resources.Any()
&& await _libraryManager.Seasons.GetOrDefault(identifier.IsSame<Season>()) == null
)
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
} }
@ -111,12 +120,14 @@ namespace Kyoo.Core.Api
[PartialPermission(Kind.Read)] [PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Show>> GetShow(Identifier identifier, [FromQuery] Include<Show> fields) public async Task<ActionResult<Show>> GetShow(
Identifier identifier,
[FromQuery] Include<Show> fields
)
{ {
Show? ret = await _libraryManager.Shows.GetOrDefault( Show? ret = await _libraryManager
identifier.IsContainedIn<Show, Season>(x => x.Seasons!), .Shows
fields .GetOrDefault(identifier.IsContainedIn<Show, Season>(x => x.Seasons!), fields);
);
if (ret == null) if (ret == null)
return NotFound(); return NotFound();
return ret; return ret;

View File

@ -54,8 +54,7 @@ namespace Kyoo.Core.Api
/// The library manager used to modify or retrieve information about the data store. /// The library manager used to modify or retrieve information about the data store.
/// </param> /// </param>
/// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param> /// <param name="thumbs">The thumbnail manager used to retrieve images paths.</param>
public ShowApi(ILibraryManager libraryManager, public ShowApi(ILibraryManager libraryManager, IThumbnailsManager thumbs)
IThumbnailsManager thumbs)
: base(libraryManager.Shows, thumbs) : base(libraryManager.Shows, thumbs)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
@ -81,20 +80,30 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Season>>> GetSeasons(Identifier identifier, public async Task<ActionResult<Page<Season>>> GetSeasons(
Identifier identifier,
[FromQuery] Sort<Season> sortBy, [FromQuery] Sort<Season> sortBy,
[FromQuery] Filter<Season>? filter, [FromQuery] Filter<Season>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<Season> fields) [FromQuery] Include<Season> fields
)
{ {
ICollection<Season> resources = await _libraryManager.Seasons.GetAll( ICollection<Season> resources = await _libraryManager
Filter.And(filter, identifier.Matcher<Season>(x => x.ShowId, x => x.Show!.Slug)), .Seasons
.GetAll(
Filter.And(
filter,
identifier.Matcher<Season>(x => x.ShowId, x => x.Show!.Slug)
),
sortBy, sortBy,
fields, fields,
pagination pagination
); );
if (!resources.Any() && await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null) if (
!resources.Any()
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
)
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
} }
@ -119,20 +128,30 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Episode>>> GetEpisodes(Identifier identifier, public async Task<ActionResult<Page<Episode>>> GetEpisodes(
Identifier identifier,
[FromQuery] Sort<Episode> sortBy, [FromQuery] Sort<Episode> sortBy,
[FromQuery] Filter<Episode>? filter, [FromQuery] Filter<Episode>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<Episode> fields) [FromQuery] Include<Episode> fields
)
{ {
ICollection<Episode> resources = await _libraryManager.Episodes.GetAll( ICollection<Episode> resources = await _libraryManager
Filter.And(filter, identifier.Matcher<Episode>(x => x.ShowId, x => x.Show!.Slug)), .Episodes
.GetAll(
Filter.And(
filter,
identifier.Matcher<Episode>(x => x.ShowId, x => x.Show!.Slug)
),
sortBy, sortBy,
fields, fields,
pagination pagination
); );
if (!resources.Any() && await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null) if (
!resources.Any()
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
)
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
} }
@ -186,9 +205,14 @@ namespace Kyoo.Core.Api
[PartialPermission(Kind.Read)] [PartialPermission(Kind.Read)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Studio>> GetStudio(Identifier identifier, [FromQuery] Include<Studio> fields) 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); return await _libraryManager
.Studios
.Get(identifier.IsContainedIn<Studio, Show>(x => x.Shows!), fields);
} }
/// <summary> /// <summary>
@ -211,20 +235,27 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Page<Collection>>> GetCollections(Identifier identifier, public async Task<ActionResult<Page<Collection>>> GetCollections(
Identifier identifier,
[FromQuery] Sort<Collection> sortBy, [FromQuery] Sort<Collection> sortBy,
[FromQuery] Filter<Collection>? filter, [FromQuery] Filter<Collection>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<Collection> fields) [FromQuery] Include<Collection> fields
)
{ {
ICollection<Collection> resources = await _libraryManager.Collections.GetAll( ICollection<Collection> resources = await _libraryManager
.Collections
.GetAll(
Filter.And(filter, identifier.IsContainedIn<Collection, Show>(x => x.Shows!)), Filter.And(filter, identifier.IsContainedIn<Collection, Show>(x => x.Shows!)),
sortBy, sortBy,
fields, fields,
pagination pagination
); );
if (!resources.Any() && await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null) if (
!resources.Any()
&& await _libraryManager.Shows.GetOrDefault(identifier.IsSame<Show>()) == null
)
return NotFound(); return NotFound();
return Page(resources, pagination.Limit); return Page(resources, pagination.Limit);
} }
@ -271,17 +302,16 @@ namespace Kyoo.Core.Api
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ShowWatchStatus?> SetWatchStatus(Identifier identifier, WatchStatus status) public async Task<ShowWatchStatus?> SetWatchStatus(
Identifier identifier,
WatchStatus status
)
{ {
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
); );
return await _libraryManager.WatchStatus.SetShowStatus( return await _libraryManager.WatchStatus.SetShowStatus(id, User.GetIdOrThrow(), status);
id,
User.GetIdOrThrow(),
status
);
} }
/// <summary> /// <summary>

View File

@ -63,7 +63,8 @@ namespace Kyoo.Core.Api
public async Task<ActionResult<Page<IWatchlist>>> GetAll( public async Task<ActionResult<Page<IWatchlist>>> GetAll(
[FromQuery] Filter<IWatchlist>? filter, [FromQuery] Filter<IWatchlist>? filter,
[FromQuery] Pagination pagination, [FromQuery] Pagination pagination,
[FromQuery] Include<IWatchlist>? fields) [FromQuery] Include<IWatchlist>? fields
)
{ {
ICollection<IWatchlist> resources = await _repository.GetAll( ICollection<IWatchlist> resources = await _repository.GetAll(
filter, filter,

View File

@ -96,12 +96,10 @@ namespace Kyoo.Host
_logger = Log.Logger.ForContext<Application>(); _logger = Log.Logger.ForContext<Application>();
AppDomain.CurrentDomain.ProcessExit += (_, _) => Log.CloseAndFlush(); AppDomain.CurrentDomain.ProcessExit += (_, _) => Log.CloseAndFlush();
AppDomain.CurrentDomain.UnhandledException += (_, ex) AppDomain.CurrentDomain.UnhandledException += (_, ex) =>
=> Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception"); Log.Fatal(ex.ExceptionObject as Exception, "Unhandled exception");
IHost host = _CreateWebHostBuilder(args) IHost host = _CreateWebHostBuilder(args).ConfigureContainer(configure).Build();
.ConfigureContainer(configure)
.Build();
await using (AsyncServiceScope scope = host.Services.CreateAsyncScope()) await using (AsyncServiceScope scope = host.Services.CreateAsyncScope())
{ {
@ -122,8 +120,14 @@ namespace Kyoo.Host
try try
{ {
CoreModule.Services = host.Services; CoreModule.Services = host.Services;
_logger.Information("Version: {Version}", Assembly.GetExecutingAssembly().GetName().Version.ToString(3)); _logger.Information(
_logger.Information("Data directory: {DataDirectory}", Environment.CurrentDirectory); "Version: {Version}",
Assembly.GetExecutingAssembly().GetName().Version.ToString(3)
);
_logger.Information(
"Data directory: {DataDirectory}",
Environment.CurrentDirectory
);
await host.RunAsync(cancellationToken); await host.RunAsync(cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
@ -146,12 +150,25 @@ namespace Kyoo.Host
.ConfigureAppConfiguration(x => _SetupConfig(x, args)) .ConfigureAppConfiguration(x => _SetupConfig(x, args))
.UseSerilog((host, services, builder) => _ConfigureLogging(builder)) .UseSerilog((host, services, builder) => _ConfigureLogging(builder))
.ConfigureServices(x => x.AddRouting()) .ConfigureServices(x => x.AddRouting())
.ConfigureWebHost(x => x .ConfigureWebHost(
.UseKestrel(options => { options.AddServerHeader = false; }) x =>
x.UseKestrel(options =>
{
options.AddServerHeader = false;
})
.UseIIS() .UseIIS()
.UseIISIntegration() .UseIISIntegration()
.UseUrls(Environment.GetEnvironmentVariable("KYOO_BIND_URL") ?? "http://*:5000") .UseUrls(
.UseStartup(host => PluginsStartup.FromWebHost(host, new LoggerFactory().AddSerilog())) Environment.GetEnvironmentVariable("KYOO_BIND_URL")
?? "http://*:5000"
)
.UseStartup(
host =>
PluginsStartup.FromWebHost(
host,
new LoggerFactory().AddSerilog()
)
)
); );
} }
@ -179,13 +196,20 @@ namespace Kyoo.Host
"[{@t:HH:mm:ss} {@l:u3} {Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), 25} " "[{@t:HH:mm:ss} {@l:u3} {Substring(SourceContext, LastIndexOf(SourceContext, '.') + 1), 25} "
+ "({@i:D10})] {@m}{#if not EndsWith(@m, '\n')}\n{#end}{@x}"; + "({@i:D10})] {@m}{#if not EndsWith(@m, '\n')}\n{#end}{@x}";
builder builder
.MinimumLevel.Warning() .MinimumLevel
.MinimumLevel.Override("Kyoo", LogEventLevel.Verbose) .Warning()
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Verbose) .MinimumLevel
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Fatal) .Override("Kyoo", LogEventLevel.Verbose)
.WriteTo.Console(new ExpressionTemplate(template, theme: TemplateTheme.Code)) .MinimumLevel
.Enrich.WithThreadId() .Override("Microsoft.Hosting.Lifetime", LogEventLevel.Verbose)
.Enrich.FromLogContext(); .MinimumLevel
.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Fatal)
.WriteTo
.Console(new ExpressionTemplate(template, theme: TemplateTheme.Code))
.Enrich
.WithThreadId()
.Enrich
.FromLogContext();
} }
} }
} }

View File

@ -51,8 +51,7 @@ namespace Kyoo.Host.Controllers
/// </summary> /// </summary>
/// <param name="provider">A service container to allow initialization of plugins</param> /// <param name="provider">A service container to allow initialization of plugins</param>
/// <param name="logger">The logger used by this class.</param> /// <param name="logger">The logger used by this class.</param>
public PluginManager(IServiceProvider provider, public PluginManager(IServiceProvider provider, ILogger<PluginManager> logger)
ILogger<PluginManager> logger)
{ {
_provider = provider; _provider = provider;
_logger = logger; _logger = logger;
@ -86,7 +85,8 @@ namespace Kyoo.Host.Controllers
/// <inheritdoc /> /// <inheritdoc />
public void LoadPlugins(params Type[] plugins) public void LoadPlugins(params Type[] plugins)
{ {
LoadPlugins(plugins LoadPlugins(
plugins
.Select(x => (IPlugin)ActivatorUtilities.CreateInstance(_provider, x)) .Select(x => (IPlugin)ActivatorUtilities.CreateInstance(_provider, x))
.ToArray() .ToArray()
); );

View File

@ -55,9 +55,7 @@ namespace Kyoo.Host
} }
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps => new[] public IEnumerable<IStartupAction> ConfigureSteps =>
{ new[] { SA.New<IApplicationBuilder>(app => app.UseSerilogRequestLogging(), SA.Before) };
SA.New<IApplicationBuilder>(app => app.UseSerilogRequestLogging(), SA.Before)
};
} }
} }

View File

@ -79,14 +79,11 @@ namespace Kyoo.Host
/// The logger factory used to log while the application is setting itself up. /// The logger factory used to log while the application is setting itself up.
/// </param> /// </param>
/// <returns>A new <see cref="PluginsStartup"/>.</returns> /// <returns>A new <see cref="PluginsStartup"/>.</returns>
public static PluginsStartup FromWebHost(WebHostBuilderContext host, public static PluginsStartup FromWebHost(WebHostBuilderContext host, ILoggerFactory logger)
ILoggerFactory logger)
{ {
HostServiceProvider hostProvider = new(host.HostingEnvironment, host.Configuration, logger); HostServiceProvider hostProvider =
PluginManager plugins = new( new(host.HostingEnvironment, host.Configuration, logger);
hostProvider, PluginManager plugins = new(hostProvider, logger.CreateLogger<PluginManager>());
logger.CreateLogger<PluginManager>()
);
return new PluginsStartup(plugins); return new PluginsStartup(plugins);
} }
@ -96,7 +93,9 @@ namespace Kyoo.Host
/// <param name="services">The service collection to fill.</param> /// <param name="services">The service collection to fill.</param>
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
foreach (Assembly assembly in _plugins.GetAllPlugins().Select(x => x.GetType().Assembly)) foreach (
Assembly assembly in _plugins.GetAllPlugins().Select(x => x.GetType().Assembly)
)
services.AddMvcCore().AddApplicationPart(assembly); services.AddMvcCore().AddApplicationPart(assembly);
_hostModule.Configure(services); _hostModule.Configure(services);
@ -122,13 +121,14 @@ namespace Kyoo.Host
/// <param name="container">An autofac container used to create a new scope to configure asp-net.</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) public void Configure(IApplicationBuilder app, ILifetimeScope container)
{ {
IEnumerable<IStartupAction> steps = _plugins.GetAllPlugins() IEnumerable<IStartupAction> steps = _plugins
.GetAllPlugins()
.Append(_hostModule) .Append(_hostModule)
.SelectMany(x => x.ConfigureSteps) .SelectMany(x => x.ConfigureSteps)
.OrderByDescending(x => x.Priority); .OrderByDescending(x => x.Priority);
using ILifetimeScope scope = container.BeginLifetimeScope(x => using ILifetimeScope scope = container.BeginLifetimeScope(
x.RegisterInstance(app).SingleInstance().ExternallyOwned() x => x.RegisterInstance(app).SingleInstance().ExternallyOwned()
); );
IServiceProvider provider = scope.Resolve<IServiceProvider>(); IServiceProvider provider = scope.Resolve<IServiceProvider>();
foreach (IStartupAction step in steps) foreach (IStartupAction step in steps)
@ -164,9 +164,11 @@ namespace Kyoo.Host
/// </param> /// </param>
/// <param name="configuration">The configuration context</param> /// <param name="configuration">The configuration context</param>
/// <param name="loggerFactory">A logger factory used to create a logger for the plugin manager.</param> /// <param name="loggerFactory">A logger factory used to create a logger for the plugin manager.</param>
public HostServiceProvider(IWebHostEnvironment hostEnvironment, public HostServiceProvider(
IWebHostEnvironment hostEnvironment,
IConfiguration configuration, IConfiguration configuration,
ILoggerFactory loggerFactory) ILoggerFactory loggerFactory
)
{ {
_hostEnvironment = hostEnvironment; _hostEnvironment = hostEnvironment;
_configuration = configuration; _configuration = configuration;
@ -176,7 +178,10 @@ namespace Kyoo.Host
/// <inheritdoc /> /// <inheritdoc />
public object GetService(Type serviceType) public object GetService(Type serviceType)
{ {
if (serviceType == typeof(IWebHostEnvironment) || serviceType == typeof(IHostEnvironment)) if (
serviceType == typeof(IWebHostEnvironment)
|| serviceType == typeof(IHostEnvironment)
)
return _hostEnvironment; return _hostEnvironment;
if (serviceType == typeof(IConfiguration)) if (serviceType == typeof(IConfiguration))
return _configuration; return _configuration;

View File

@ -33,7 +33,8 @@ namespace Kyoo.Meiliseach
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
public static Dictionary<string, Settings> IndexSettings => new() public static Dictionary<string, Settings> IndexSettings =>
new()
{ {
{ {
"items", "items",
@ -104,10 +105,7 @@ namespace Kyoo.Meiliseach
CamelCase.ConvertName(nameof(Episode.EpisodeNumber)), CamelCase.ConvertName(nameof(Episode.EpisodeNumber)),
CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)), CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)),
}, },
DisplayedAttributes = new[] DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Episode.Id)), },
{
CamelCase.ConvertName(nameof(Episode.Id)),
},
// TODO: Add stopwords // TODO: Add stopwords
} }
}, },
@ -122,10 +120,7 @@ namespace Kyoo.Meiliseach
}, },
FilterableAttributes = Array.Empty<string>(), FilterableAttributes = Array.Empty<string>(),
SortableAttributes = Array.Empty<string>(), SortableAttributes = Array.Empty<string>(),
DisplayedAttributes = new[] DisplayedAttributes = new[] { CamelCase.ConvertName(nameof(Studio.Id)), },
{
CamelCase.ConvertName(nameof(Studio.Id)),
},
// TODO: Add stopwords // TODO: Add stopwords
} }
}, },
@ -173,7 +168,10 @@ namespace Kyoo.Meiliseach
private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind) private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind)
{ {
TaskInfo task = await client.CreateIndexAsync(index, hasKind ? "ref" : CamelCase.ConvertName(nameof(IResource.Id))); TaskInfo task = await client.CreateIndexAsync(
index,
hasKind ? "ref" : CamelCase.ConvertName(nameof(IResource.Id))
);
await client.WaitForTaskAsync(task.TaskUid); await client.WaitForTaskAsync(task.TaskUid);
await client.Index(index).UpdateSettingsAsync(IndexSettings[index]); await client.Index(index).UpdateSettingsAsync(IndexSettings[index]);
} }
@ -181,12 +179,15 @@ namespace Kyoo.Meiliseach
/// <inheritdoc /> /// <inheritdoc />
public void Configure(ContainerBuilder builder) public void Configure(ContainerBuilder builder)
{ {
builder.RegisterInstance(new MeilisearchClient( builder
.RegisterInstance(
new MeilisearchClient(
_configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"), _configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"),
_configuration.GetValue<string?>("MEILI_MASTER_KEY") _configuration.GetValue<string?>("MEILI_MASTER_KEY")
)).SingleInstance(); )
builder.RegisterType<MeiliSync>().AsSelf().SingleInstance() )
.AutoActivate(); .SingleInstance();
builder.RegisterType<MeiliSync>().AsSelf().SingleInstance().AutoActivate();
builder.RegisterType<SearchManager>().As<ISearchManager>().InstancePerLifetimeScope(); builder.RegisterType<SearchManager>().As<ISearchManager>().InstancePerLifetimeScope();
} }
} }

View File

@ -36,11 +36,21 @@ public class SearchManager : ISearchManager
return sort switch return sort switch
{ {
Sort<T>.Default => Array.Empty<string>(), Sort<T>.Default => Array.Empty<string>(),
Sort<T>.By @sortBy => MeilisearchModule.IndexSettings[index].SortableAttributes.Contains(sortBy.Key, StringComparer.InvariantCultureIgnoreCase) Sort<T>.By @sortBy
? new[] { $"{CamelCase.ConvertName(sortBy.Key)}:{(sortBy.Desendant ? "desc" : "asc")}" } => MeilisearchModule
.IndexSettings[index]
.SortableAttributes
.Contains(sortBy.Key, StringComparer.InvariantCultureIgnoreCase)
? new[]
{
$"{CamelCase.ConvertName(sortBy.Key)}:{(sortBy.Desendant ? "desc" : "asc")}"
}
: throw new ValidationException($"Invalid sorting mode: {sortBy.Key}"), : throw new ValidationException($"Invalid sorting mode: {sortBy.Key}"),
Sort<T>.Conglomerate(var list) => list.SelectMany(x => _GetSortsBy(index, x)), Sort<T>.Conglomerate(var list) => list.SelectMany(x => _GetSortsBy(index, x)),
Sort<T>.Random => throw new ValidationException("Random sorting is not supported while searching."), Sort<T>.Random
=> throw new ValidationException(
"Random sorting is not supported while searching."
),
_ => Array.Empty<string>(), _ => Array.Empty<string>(),
}; };
} }
@ -51,79 +61,100 @@ public class SearchManager : ISearchManager
_libraryManager = libraryManager; _libraryManager = libraryManager;
} }
private async Task<SearchPage<T>.SearchResult> _Search<T>(string index, string? query, private async Task<SearchPage<T>.SearchResult> _Search<T>(
string index,
string? query,
string? where = null, string? where = null,
Sort<T>? sortBy = default, Sort<T>? sortBy = default,
SearchPagination? pagination = default, SearchPagination? pagination = default,
Include<T>? include = default) Include<T>? include = default
)
where T : class, IResource, IQuery where T : class, IResource, IQuery
{ {
// TODO: add filters and facets // TODO: add filters and facets
ISearchable<IdResource> res = await _client.Index(index).SearchAsync<IdResource>(query, new SearchQuery() ISearchable<IdResource> res = await _client
.Index(index)
.SearchAsync<IdResource>(
query,
new SearchQuery()
{ {
Filter = where, Filter = where,
Sort = _GetSortsBy(index, sortBy), Sort = _GetSortsBy(index, sortBy),
Limit = pagination?.Limit ?? 50, Limit = pagination?.Limit ?? 50,
Offset = pagination?.Skip ?? 0, Offset = pagination?.Skip ?? 0,
}); }
);
return new SearchPage<T>.SearchResult return new SearchPage<T>.SearchResult
{ {
Query = query, Query = query,
Items = await _libraryManager.Repository<T>() Items = await _libraryManager
.Repository<T>()
.FromIds(res.Hits.Select(x => x.Id).ToList(), include), .FromIds(res.Hits.Select(x => x.Id).ToList(), include),
}; };
} }
/// <inheritdoc/> /// <inheritdoc/>
public Task<SearchPage<ILibraryItem>.SearchResult> SearchItems(string? query, public Task<SearchPage<ILibraryItem>.SearchResult> SearchItems(
string? query,
Sort<ILibraryItem> sortBy, Sort<ILibraryItem> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<ILibraryItem>? include = default) Include<ILibraryItem>? include = default
)
{ {
return _Search("items", query, null, sortBy, pagination, include); return _Search("items", query, null, sortBy, pagination, include);
} }
/// <inheritdoc/> /// <inheritdoc/>
public Task<SearchPage<Movie>.SearchResult> SearchMovies(string? query, public Task<SearchPage<Movie>.SearchResult> SearchMovies(
string? query,
Sort<Movie> sortBy, Sort<Movie> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<Movie>? include = default) Include<Movie>? include = default
)
{ {
return _Search("items", query, $"kind = {nameof(Movie)}", sortBy, pagination, include); return _Search("items", query, $"kind = {nameof(Movie)}", sortBy, pagination, include);
} }
/// <inheritdoc/> /// <inheritdoc/>
public Task<SearchPage<Show>.SearchResult> SearchShows(string? query, public Task<SearchPage<Show>.SearchResult> SearchShows(
string? query,
Sort<Show> sortBy, Sort<Show> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<Show>? include = default) Include<Show>? include = default
)
{ {
return _Search("items", query, $"kind = {nameof(Show)}", sortBy, pagination, include); return _Search("items", query, $"kind = {nameof(Show)}", sortBy, pagination, include);
} }
/// <inheritdoc/> /// <inheritdoc/>
public Task<SearchPage<Collection>.SearchResult> SearchCollections(string? query, public Task<SearchPage<Collection>.SearchResult> SearchCollections(
string? query,
Sort<Collection> sortBy, Sort<Collection> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<Collection>? include = default) Include<Collection>? include = default
)
{ {
return _Search("items", query, $"kind = {nameof(Collection)}", sortBy, pagination, include); return _Search("items", query, $"kind = {nameof(Collection)}", sortBy, pagination, include);
} }
/// <inheritdoc/> /// <inheritdoc/>
public Task<SearchPage<Episode>.SearchResult> SearchEpisodes(string? query, public Task<SearchPage<Episode>.SearchResult> SearchEpisodes(
string? query,
Sort<Episode> sortBy, Sort<Episode> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<Episode>? include = default) Include<Episode>? include = default
)
{ {
return _Search(nameof(Episode), query, null, sortBy, pagination, include); return _Search(nameof(Episode), query, null, sortBy, pagination, include);
} }
/// <inheritdoc/> /// <inheritdoc/>
public Task<SearchPage<Studio>.SearchResult> SearchStudios(string? query, public Task<SearchPage<Studio>.SearchResult> SearchStudios(
string? query,
Sort<Studio> sortBy, Sort<Studio> sortBy,
SearchPagination pagination, SearchPagination pagination,
Include<Studio>? include = default) Include<Studio>? include = default
)
{ {
return _Search(nameof(Studio), query, null, sortBy, pagination, include); return _Search(nameof(Studio), query, null, sortBy, pagination, include);
} }

View File

@ -117,11 +117,13 @@ namespace Kyoo.Postgresql
where T2 : class, IResource where T2 : class, IResource
{ {
Set<Dictionary<string, object>>(LinkName<T1, T2>()) Set<Dictionary<string, object>>(LinkName<T1, T2>())
.Add(new Dictionary<string, object> .Add(
new Dictionary<string, object>
{ {
[LinkNameFk<T1>()] = first, [LinkNameFk<T1>()] = first,
[LinkNameFk<T2>()] = second [LinkNameFk<T2>()] = second
}); }
);
} }
protected DatabaseContext(IHttpContextAccessor accessor) protected DatabaseContext(IHttpContextAccessor accessor)
@ -177,11 +179,16 @@ namespace Kyoo.Postgresql
// { // {
// x.ToJson(); // x.ToJson();
// }); // });
modelBuilder.Entity<T>() modelBuilder
.Entity<T>()
.Property(x => x.ExternalId) .Property(x => x.ExternalId)
.HasConversion( .HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => JsonSerializer.Deserialize<Dictionary<string, MetadataId>>(v, (JsonSerializerOptions?)null)! v =>
JsonSerializer.Deserialize<Dictionary<string, MetadataId>>(
v,
(JsonSerializerOptions?)null
)!
) )
.HasColumnType("json"); .HasColumnType("json");
} }
@ -189,18 +196,16 @@ namespace Kyoo.Postgresql
private static void _HasImages<T>(ModelBuilder modelBuilder) private static void _HasImages<T>(ModelBuilder modelBuilder)
where T : class, IThumbnails where T : class, IThumbnails
{ {
modelBuilder.Entity<T>() modelBuilder.Entity<T>().OwnsOne(x => x.Poster);
.OwnsOne(x => x.Poster); modelBuilder.Entity<T>().OwnsOne(x => x.Thumbnail);
modelBuilder.Entity<T>() modelBuilder.Entity<T>().OwnsOne(x => x.Logo);
.OwnsOne(x => x.Thumbnail);
modelBuilder.Entity<T>()
.OwnsOne(x => x.Logo);
} }
private static void _HasAddedDate<T>(ModelBuilder modelBuilder) private static void _HasAddedDate<T>(ModelBuilder modelBuilder)
where T : class, IAddedDate where T : class, IAddedDate
{ {
modelBuilder.Entity<T>() modelBuilder
.Entity<T>()
.Property(x => x.AddedDate) .Property(x => x.AddedDate)
.HasDefaultValueSql("now() at time zone 'utc'") .HasDefaultValueSql("now() at time zone 'utc'")
.ValueGeneratedOnAdd(); .ValueGeneratedOnAdd();
@ -215,24 +220,27 @@ namespace Kyoo.Postgresql
/// <param name="secondNavigation">The second navigation expression from T2 to T</param> /// <param name="secondNavigation">The second navigation expression from T2 to T</param>
/// <typeparam name="T">The owning type of the relationship</typeparam> /// <typeparam name="T">The owning type of the relationship</typeparam>
/// <typeparam name="T2">The owned type of the relationship</typeparam> /// <typeparam name="T2">The owned type of the relationship</typeparam>
private void _HasManyToMany<T, T2>(ModelBuilder modelBuilder, private void _HasManyToMany<T, T2>(
ModelBuilder modelBuilder,
Expression<Func<T, IEnumerable<T2>?>> firstNavigation, Expression<Func<T, IEnumerable<T2>?>> firstNavigation,
Expression<Func<T2, IEnumerable<T>?>> secondNavigation) Expression<Func<T2, IEnumerable<T>?>> secondNavigation
)
where T : class, IResource where T : class, IResource
where T2 : class, IResource where T2 : class, IResource
{ {
modelBuilder.Entity<T2>() modelBuilder
.Entity<T2>()
.HasMany(secondNavigation) .HasMany(secondNavigation)
.WithMany(firstNavigation) .WithMany(firstNavigation)
.UsingEntity<Dictionary<string, object>>( .UsingEntity<Dictionary<string, object>>(
LinkName<T, T2>(), LinkName<T, T2>(),
x => x x =>
.HasOne<T>() x.HasOne<T>()
.WithMany() .WithMany()
.HasForeignKey(LinkNameFk<T>()) .HasForeignKey(LinkNameFk<T>())
.OnDelete(DeleteBehavior.Cascade), .OnDelete(DeleteBehavior.Cascade),
x => x x =>
.HasOne<T2>() x.HasOne<T2>()
.WithMany() .WithMany()
.HasForeignKey(LinkNameFk<T2>()) .HasForeignKey(LinkNameFk<T2>())
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
@ -247,33 +255,37 @@ namespace Kyoo.Postgresql
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Show>() modelBuilder.Entity<Show>().Ignore(x => x.FirstEpisode).Ignore(x => x.AirDate);
.Ignore(x => x.FirstEpisode) modelBuilder
.Ignore(x => x.AirDate); .Entity<Episode>()
modelBuilder.Entity<Episode>()
.Ignore(x => x.PreviousEpisode) .Ignore(x => x.PreviousEpisode)
.Ignore(x => x.NextEpisode); .Ignore(x => x.NextEpisode);
// modelBuilder.Entity<PeopleRole>() // modelBuilder.Entity<PeopleRole>()
// .Ignore(x => x.ForPeople); // .Ignore(x => x.ForPeople);
modelBuilder.Entity<Show>() modelBuilder
.Entity<Show>()
.HasMany(x => x.Seasons) .HasMany(x => x.Seasons)
.WithOne(x => x.Show) .WithOne(x => x.Show)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Show>() modelBuilder
.Entity<Show>()
.HasMany(x => x.Episodes) .HasMany(x => x.Episodes)
.WithOne(x => x.Show) .WithOne(x => x.Show)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Season>() modelBuilder
.Entity<Season>()
.HasMany(x => x.Episodes) .HasMany(x => x.Episodes)
.WithOne(x => x.Season) .WithOne(x => x.Season)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Movie>() modelBuilder
.Entity<Movie>()
.HasOne(x => x.Studio) .HasOne(x => x.Studio)
.WithMany(x => x.Movies) .WithMany(x => x.Movies)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<Show>() modelBuilder
.Entity<Show>()
.HasOne(x => x.Studio) .HasOne(x => x.Studio)
.WithMany(x => x.Shows) .WithMany(x => x.Shows)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
@ -305,16 +317,21 @@ namespace Kyoo.Postgresql
modelBuilder.Entity<User>().OwnsOne(x => x.Logo); modelBuilder.Entity<User>().OwnsOne(x => x.Logo);
modelBuilder.Entity<MovieWatchStatus>() modelBuilder
.Entity<MovieWatchStatus>()
.HasKey(x => new { User = x.UserId, Movie = x.MovieId }); .HasKey(x => new { User = x.UserId, Movie = x.MovieId });
modelBuilder.Entity<ShowWatchStatus>() modelBuilder
.Entity<ShowWatchStatus>()
.HasKey(x => new { User = x.UserId, Show = x.ShowId }); .HasKey(x => new { User = x.UserId, Show = x.ShowId });
modelBuilder.Entity<EpisodeWatchStatus>() modelBuilder
.Entity<EpisodeWatchStatus>()
.HasKey(x => new { User = x.UserId, Episode = x.EpisodeId }); .HasKey(x => new { User = x.UserId, Episode = x.EpisodeId });
modelBuilder.Entity<MovieWatchStatus>().HasQueryFilter(x => x.UserId == CurrentUserId); modelBuilder.Entity<MovieWatchStatus>().HasQueryFilter(x => x.UserId == CurrentUserId);
modelBuilder.Entity<ShowWatchStatus>().HasQueryFilter(x => x.UserId == CurrentUserId); modelBuilder.Entity<ShowWatchStatus>().HasQueryFilter(x => x.UserId == CurrentUserId);
modelBuilder.Entity<EpisodeWatchStatus>().HasQueryFilter(x => x.UserId == CurrentUserId); modelBuilder
.Entity<EpisodeWatchStatus>()
.HasQueryFilter(x => x.UserId == CurrentUserId);
modelBuilder.Entity<ShowWatchStatus>().Navigation(x => x.NextEpisode).AutoInclude(); modelBuilder.Entity<ShowWatchStatus>().Navigation(x => x.NextEpisode).AutoInclude();
@ -326,39 +343,35 @@ namespace Kyoo.Postgresql
modelBuilder.Entity<Show>().Ignore(x => x.WatchStatus); modelBuilder.Entity<Show>().Ignore(x => x.WatchStatus);
modelBuilder.Entity<Episode>().Ignore(x => x.WatchStatus); modelBuilder.Entity<Episode>().Ignore(x => x.WatchStatus);
modelBuilder.Entity<Collection>() modelBuilder.Entity<Collection>().HasIndex(x => x.Slug).IsUnique();
.HasIndex(x => x.Slug)
.IsUnique();
// modelBuilder.Entity<People>() // modelBuilder.Entity<People>()
// .HasIndex(x => x.Slug) // .HasIndex(x => x.Slug)
// .IsUnique(); // .IsUnique();
modelBuilder.Entity<Movie>() modelBuilder.Entity<Movie>().HasIndex(x => x.Slug).IsUnique();
.HasIndex(x => x.Slug) modelBuilder.Entity<Show>().HasIndex(x => x.Slug).IsUnique();
.IsUnique(); modelBuilder.Entity<Studio>().HasIndex(x => x.Slug).IsUnique();
modelBuilder.Entity<Show>() modelBuilder
.HasIndex(x => x.Slug) .Entity<Season>()
.IsUnique();
modelBuilder.Entity<Studio>()
.HasIndex(x => x.Slug)
.IsUnique();
modelBuilder.Entity<Season>()
.HasIndex(x => new { ShowID = x.ShowId, x.SeasonNumber }) .HasIndex(x => new { ShowID = x.ShowId, x.SeasonNumber })
.IsUnique(); .IsUnique();
modelBuilder.Entity<Season>() modelBuilder.Entity<Season>().HasIndex(x => x.Slug).IsUnique();
.HasIndex(x => x.Slug) modelBuilder
.IsUnique(); .Entity<Episode>()
modelBuilder.Entity<Episode>() .HasIndex(
.HasIndex(x => new { ShowID = x.ShowId, x.SeasonNumber, x.EpisodeNumber, x.AbsoluteNumber }) x =>
.IsUnique(); new
modelBuilder.Entity<Episode>() {
.HasIndex(x => x.Slug) ShowID = x.ShowId,
.IsUnique(); x.SeasonNumber,
modelBuilder.Entity<User>() x.EpisodeNumber,
.HasIndex(x => x.Slug) x.AbsoluteNumber
}
)
.IsUnique(); .IsUnique();
modelBuilder.Entity<Episode>().HasIndex(x => x.Slug).IsUnique();
modelBuilder.Entity<User>().HasIndex(x => x.Slug).IsUnique();
modelBuilder.Entity<Movie>() modelBuilder.Entity<Movie>().Ignore(x => x.Links);
.Ignore(x => x.Links);
} }
/// <summary> /// <summary>
@ -428,8 +441,10 @@ namespace Kyoo.Postgresql
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete</param> /// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete</param>
/// <exception cref="DuplicatedItemException">A duplicated item has been found.</exception> /// <exception cref="DuplicatedItemException">A duplicated item has been found.</exception>
/// <returns>The number of state entries written to the database.</returns> /// <returns>The number of state entries written to the database.</returns>
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, public override async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default) bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default
)
{ {
try try
{ {
@ -450,7 +465,9 @@ namespace Kyoo.Postgresql
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete</param> /// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete</param>
/// <exception cref="DuplicatedItemException">A duplicated item has been found.</exception> /// <exception cref="DuplicatedItemException">A duplicated item has been found.</exception>
/// <returns>The number of state entries written to the database.</returns> /// <returns>The number of state entries written to the database.</returns>
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) public override async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default
)
{ {
try try
{ {
@ -475,7 +492,8 @@ namespace Kyoo.Postgresql
/// <returns>The number of state entries written to the database.</returns> /// <returns>The number of state entries written to the database.</returns>
public async Task<int> SaveChangesAsync<T>( public async Task<int> SaveChangesAsync<T>(
Func<Task<T>> getExisting, Func<Task<T>> getExisting,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
try try
{ {
@ -523,9 +541,7 @@ namespace Kyoo.Postgresql
public T? LocalEntity<T>(string slug) public T? LocalEntity<T>(string slug)
where T : class, IResource where T : class, IResource
{ {
return ChangeTracker.Entries<T>() return ChangeTracker.Entries<T>().FirstOrDefault(x => x.Entity.Slug == slug)?.Entity;
.FirstOrDefault(x => x.Entity.Slug == slug)
?.Entity;
} }
/// <summary> /// <summary>
@ -540,7 +556,11 @@ namespace Kyoo.Postgresql
/// </summary> /// </summary>
public void DiscardChanges() public void DiscardChanges()
{ {
foreach (EntityEntry entry in ChangeTracker.Entries().Where(x => x.State != EntityState.Detached)) foreach (
EntityEntry entry in ChangeTracker
.Entries()
.Where(x => x.State != EntityState.Detached)
)
{ {
entry.State = EntityState.Detached; entry.State = EntityState.Detached;
} }

View File

@ -31,71 +31,122 @@ namespace Kyoo.Postgresql.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterDatabase() migrationBuilder
.Annotation("Npgsql:Enum:genre", "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western") .AlterDatabase()
.Annotation(
"Npgsql:Enum:genre",
"action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western"
)
.Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned"); .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned");
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "collections", name: "collections",
columns: table => new columns: table =>
new
{ {
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), slug = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
name = table.Column<string>(type: "text", nullable: false), name = table.Column<string>(type: "text", nullable: false),
overview = table.Column<string>(type: "text", nullable: true), overview = table.Column<string>(type: "text", nullable: true),
added_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), added_date = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() at time zone 'utc'"
),
poster_source = table.Column<string>(type: "text", nullable: true), poster_source = table.Column<string>(type: "text", nullable: true),
poster_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), poster_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
thumbnail_source = table.Column<string>(type: "text", nullable: true), thumbnail_source = table.Column<string>(type: "text", nullable: true),
thumbnail_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), thumbnail_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
logo_source = table.Column<string>(type: "text", nullable: true), logo_source = table.Column<string>(type: "text", nullable: true),
logo_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), logo_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
external_id = table.Column<string>(type: "json", nullable: false) external_id = table.Column<string>(type: "json", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_collections", x => x.id); table.PrimaryKey("pk_collections", x => x.id);
}); }
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "studios", name: "studios",
columns: table => new columns: table =>
new
{ {
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), slug = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
name = table.Column<string>(type: "text", nullable: false), name = table.Column<string>(type: "text", nullable: false),
external_id = table.Column<string>(type: "json", nullable: false) external_id = table.Column<string>(type: "json", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_studios", x => x.id); table.PrimaryKey("pk_studios", x => x.id);
}); }
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "users", name: "users",
columns: table => new columns: table =>
new
{ {
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), slug = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
username = table.Column<string>(type: "text", nullable: false), username = table.Column<string>(type: "text", nullable: false),
email = table.Column<string>(type: "text", nullable: false), email = table.Column<string>(type: "text", nullable: false),
password = table.Column<string>(type: "text", nullable: false), password = table.Column<string>(type: "text", nullable: false),
permissions = table.Column<string[]>(type: "text[]", nullable: false), permissions = table.Column<string[]>(type: "text[]", nullable: false),
added_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), added_date = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() at time zone 'utc'"
),
logo_source = table.Column<string>(type: "text", nullable: true), logo_source = table.Column<string>(type: "text", nullable: true),
logo_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true) logo_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_users", x => x.id); table.PrimaryKey("pk_users", x => x.id);
}); }
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "movies", name: "movies",
columns: table => new columns: table =>
new
{ {
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), slug = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
name = table.Column<string>(type: "text", nullable: false), name = table.Column<string>(type: "text", nullable: false),
tagline = table.Column<string>(type: "text", nullable: true), tagline = table.Column<string>(type: "text", nullable: true),
aliases = table.Column<string[]>(type: "text[]", nullable: false), aliases = table.Column<string[]>(type: "text[]", nullable: false),
@ -106,14 +157,33 @@ namespace Kyoo.Postgresql.Migrations
status = table.Column<Status>(type: "status", nullable: false), status = table.Column<Status>(type: "status", nullable: false),
rating = table.Column<int>(type: "integer", nullable: false), rating = table.Column<int>(type: "integer", nullable: false),
runtime = table.Column<int>(type: "integer", nullable: false), runtime = table.Column<int>(type: "integer", nullable: false),
air_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), air_date = table.Column<DateTime>(
added_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), type: "timestamp with time zone",
nullable: true
),
added_date = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() at time zone 'utc'"
),
poster_source = table.Column<string>(type: "text", nullable: true), poster_source = table.Column<string>(type: "text", nullable: true),
poster_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), poster_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
thumbnail_source = table.Column<string>(type: "text", nullable: true), thumbnail_source = table.Column<string>(type: "text", nullable: true),
thumbnail_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), thumbnail_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
logo_source = table.Column<string>(type: "text", nullable: true), logo_source = table.Column<string>(type: "text", nullable: true),
logo_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), logo_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
trailer = table.Column<string>(type: "text", nullable: true), trailer = table.Column<string>(type: "text", nullable: true),
external_id = table.Column<string>(type: "json", nullable: false), external_id = table.Column<string>(type: "json", nullable: false),
studio_id = table.Column<Guid>(type: "uuid", nullable: true) studio_id = table.Column<Guid>(type: "uuid", nullable: true)
@ -126,15 +196,22 @@ namespace Kyoo.Postgresql.Migrations
column: x => x.studio_id, column: x => x.studio_id,
principalTable: "studios", principalTable: "studios",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.SetNull); onDelete: ReferentialAction.SetNull
}); );
}
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "shows", name: "shows",
columns: table => new columns: table =>
new
{ {
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), slug = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
name = table.Column<string>(type: "text", nullable: false), name = table.Column<string>(type: "text", nullable: false),
tagline = table.Column<string>(type: "text", nullable: true), tagline = table.Column<string>(type: "text", nullable: true),
aliases = table.Column<List<string>>(type: "text[]", nullable: false), aliases = table.Column<List<string>>(type: "text[]", nullable: false),
@ -143,15 +220,37 @@ namespace Kyoo.Postgresql.Migrations
genres = table.Column<List<Genre>>(type: "genre[]", nullable: false), genres = table.Column<List<Genre>>(type: "genre[]", nullable: false),
status = table.Column<Status>(type: "status", nullable: false), status = table.Column<Status>(type: "status", nullable: false),
rating = table.Column<int>(type: "integer", nullable: false), rating = table.Column<int>(type: "integer", nullable: false),
start_air = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), start_air = table.Column<DateTime>(
end_air = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), type: "timestamp with time zone",
added_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), nullable: true
),
end_air = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: true
),
added_date = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() at time zone 'utc'"
),
poster_source = table.Column<string>(type: "text", nullable: true), poster_source = table.Column<string>(type: "text", nullable: true),
poster_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), poster_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
thumbnail_source = table.Column<string>(type: "text", nullable: true), thumbnail_source = table.Column<string>(type: "text", nullable: true),
thumbnail_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), thumbnail_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
logo_source = table.Column<string>(type: "text", nullable: true), logo_source = table.Column<string>(type: "text", nullable: true),
logo_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), logo_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
trailer = table.Column<string>(type: "text", nullable: true), trailer = table.Column<string>(type: "text", nullable: true),
external_id = table.Column<string>(type: "json", nullable: false), external_id = table.Column<string>(type: "json", nullable: false),
studio_id = table.Column<Guid>(type: "uuid", nullable: true) studio_id = table.Column<Guid>(type: "uuid", nullable: true)
@ -164,76 +263,119 @@ namespace Kyoo.Postgresql.Migrations
column: x => x.studio_id, column: x => x.studio_id,
principalTable: "studios", principalTable: "studios",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.SetNull); onDelete: ReferentialAction.SetNull
}); );
}
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "link_collection_movie", name: "link_collection_movie",
columns: table => new columns: table =>
new
{ {
collection_id = table.Column<Guid>(type: "uuid", nullable: false), collection_id = table.Column<Guid>(type: "uuid", nullable: false),
movie_id = table.Column<Guid>(type: "uuid", nullable: false) movie_id = table.Column<Guid>(type: "uuid", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_link_collection_movie", x => new { x.collection_id, x.movie_id }); table.PrimaryKey(
"pk_link_collection_movie",
x => new { x.collection_id, x.movie_id }
);
table.ForeignKey( table.ForeignKey(
name: "fk_link_collection_movie_collections_collection_id", name: "fk_link_collection_movie_collections_collection_id",
column: x => x.collection_id, column: x => x.collection_id,
principalTable: "collections", principalTable: "collections",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
);
table.ForeignKey( table.ForeignKey(
name: "fk_link_collection_movie_movies_movie_id", name: "fk_link_collection_movie_movies_movie_id",
column: x => x.movie_id, column: x => x.movie_id,
principalTable: "movies", principalTable: "movies",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
}); );
}
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "link_collection_show", name: "link_collection_show",
columns: table => new columns: table =>
new
{ {
collection_id = table.Column<Guid>(type: "uuid", nullable: false), collection_id = table.Column<Guid>(type: "uuid", nullable: false),
show_id = table.Column<Guid>(type: "uuid", nullable: false) show_id = table.Column<Guid>(type: "uuid", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_link_collection_show", x => new { x.collection_id, x.show_id }); table.PrimaryKey(
"pk_link_collection_show",
x => new { x.collection_id, x.show_id }
);
table.ForeignKey( table.ForeignKey(
name: "fk_link_collection_show_collections_collection_id", name: "fk_link_collection_show_collections_collection_id",
column: x => x.collection_id, column: x => x.collection_id,
principalTable: "collections", principalTable: "collections",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
);
table.ForeignKey( table.ForeignKey(
name: "fk_link_collection_show_shows_show_id", name: "fk_link_collection_show_shows_show_id",
column: x => x.show_id, column: x => x.show_id,
principalTable: "shows", principalTable: "shows",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
}); );
}
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "seasons", name: "seasons",
columns: table => new columns: table =>
new
{ {
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), slug = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
show_id = table.Column<Guid>(type: "uuid", nullable: false), show_id = table.Column<Guid>(type: "uuid", nullable: false),
season_number = table.Column<int>(type: "integer", nullable: false), season_number = table.Column<int>(type: "integer", nullable: false),
name = table.Column<string>(type: "text", nullable: true), name = table.Column<string>(type: "text", nullable: true),
overview = table.Column<string>(type: "text", nullable: true), overview = table.Column<string>(type: "text", nullable: true),
start_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), start_date = table.Column<DateTime>(
added_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), type: "timestamp with time zone",
end_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), nullable: true
),
added_date = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() at time zone 'utc'"
),
end_date = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: true
),
poster_source = table.Column<string>(type: "text", nullable: true), poster_source = table.Column<string>(type: "text", nullable: true),
poster_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), poster_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
thumbnail_source = table.Column<string>(type: "text", nullable: true), thumbnail_source = table.Column<string>(type: "text", nullable: true),
thumbnail_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), thumbnail_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
logo_source = table.Column<string>(type: "text", nullable: true), logo_source = table.Column<string>(type: "text", nullable: true),
logo_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), logo_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
external_id = table.Column<string>(type: "json", nullable: false) external_id = table.Column<string>(type: "json", nullable: false)
}, },
constraints: table => constraints: table =>
@ -244,15 +386,22 @@ namespace Kyoo.Postgresql.Migrations
column: x => x.show_id, column: x => x.show_id,
principalTable: "shows", principalTable: "shows",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
}); );
}
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "episodes", name: "episodes",
columns: table => new columns: table =>
new
{ {
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), slug = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
show_id = table.Column<Guid>(type: "uuid", nullable: false), show_id = table.Column<Guid>(type: "uuid", nullable: false),
season_id = table.Column<Guid>(type: "uuid", nullable: true), season_id = table.Column<Guid>(type: "uuid", nullable: true),
season_number = table.Column<int>(type: "integer", nullable: true), season_number = table.Column<int>(type: "integer", nullable: true),
@ -262,14 +411,33 @@ namespace Kyoo.Postgresql.Migrations
name = table.Column<string>(type: "text", nullable: true), name = table.Column<string>(type: "text", nullable: true),
overview = table.Column<string>(type: "text", nullable: true), overview = table.Column<string>(type: "text", nullable: true),
runtime = table.Column<int>(type: "integer", nullable: false), runtime = table.Column<int>(type: "integer", nullable: false),
release_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), release_date = table.Column<DateTime>(
added_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), type: "timestamp with time zone",
nullable: true
),
added_date = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() at time zone 'utc'"
),
poster_source = table.Column<string>(type: "text", nullable: true), poster_source = table.Column<string>(type: "text", nullable: true),
poster_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), poster_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
thumbnail_source = table.Column<string>(type: "text", nullable: true), thumbnail_source = table.Column<string>(type: "text", nullable: true),
thumbnail_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), thumbnail_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
logo_source = table.Column<string>(type: "text", nullable: true), logo_source = table.Column<string>(type: "text", nullable: true),
logo_blurhash = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true), logo_blurhash = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
external_id = table.Column<string>(type: "json", nullable: false) external_id = table.Column<string>(type: "json", nullable: false)
}, },
constraints: table => constraints: table =>
@ -280,124 +448,132 @@ namespace Kyoo.Postgresql.Migrations
column: x => x.season_id, column: x => x.season_id,
principalTable: "seasons", principalTable: "seasons",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
);
table.ForeignKey( table.ForeignKey(
name: "fk_episodes_shows_show_id", name: "fk_episodes_shows_show_id",
column: x => x.show_id, column: x => x.show_id,
principalTable: "shows", principalTable: "shows",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
}); );
}
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_collections_slug", name: "ix_collections_slug",
table: "collections", table: "collections",
column: "slug", column: "slug",
unique: true); unique: true
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_episodes_season_id", name: "ix_episodes_season_id",
table: "episodes", table: "episodes",
column: "season_id"); column: "season_id"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_episodes_show_id_season_number_episode_number_absolute_numb", name: "ix_episodes_show_id_season_number_episode_number_absolute_numb",
table: "episodes", table: "episodes",
columns: new[] { "show_id", "season_number", "episode_number", "absolute_number" }, columns: new[] { "show_id", "season_number", "episode_number", "absolute_number" },
unique: true); unique: true
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_episodes_slug", name: "ix_episodes_slug",
table: "episodes", table: "episodes",
column: "slug", column: "slug",
unique: true); unique: true
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_link_collection_movie_movie_id", name: "ix_link_collection_movie_movie_id",
table: "link_collection_movie", table: "link_collection_movie",
column: "movie_id"); column: "movie_id"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_link_collection_show_show_id", name: "ix_link_collection_show_show_id",
table: "link_collection_show", table: "link_collection_show",
column: "show_id"); column: "show_id"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_movies_slug", name: "ix_movies_slug",
table: "movies", table: "movies",
column: "slug", column: "slug",
unique: true); unique: true
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_movies_studio_id", name: "ix_movies_studio_id",
table: "movies", table: "movies",
column: "studio_id"); column: "studio_id"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_seasons_show_id_season_number", name: "ix_seasons_show_id_season_number",
table: "seasons", table: "seasons",
columns: new[] { "show_id", "season_number" }, columns: new[] { "show_id", "season_number" },
unique: true); unique: true
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_seasons_slug", name: "ix_seasons_slug",
table: "seasons", table: "seasons",
column: "slug", column: "slug",
unique: true); unique: true
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_shows_slug", name: "ix_shows_slug",
table: "shows", table: "shows",
column: "slug", column: "slug",
unique: true); unique: true
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_shows_studio_id", name: "ix_shows_studio_id",
table: "shows", table: "shows",
column: "studio_id"); column: "studio_id"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_studios_slug", name: "ix_studios_slug",
table: "studios", table: "studios",
column: "slug", column: "slug",
unique: true); unique: true
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_users_slug", name: "ix_users_slug",
table: "users", table: "users",
column: "slug", column: "slug",
unique: true); unique: true
);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "episodes");
name: "episodes");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "link_collection_movie");
name: "link_collection_movie");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "link_collection_show");
name: "link_collection_show");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "users");
name: "users");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "seasons");
name: "seasons");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "movies");
name: "movies");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "collections");
name: "collections");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "shows");
name: "shows");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "studios");
name: "studios");
} }
} }
} }

View File

@ -30,50 +30,79 @@ namespace Kyoo.Postgresql.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.AlterDatabase() migrationBuilder
.Annotation("Npgsql:Enum:genre", "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western") .AlterDatabase()
.Annotation(
"Npgsql:Enum:genre",
"action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western"
)
.Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned") .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned")
.Annotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned") .Annotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned")
.OldAnnotation("Npgsql:Enum:genre", "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western") .OldAnnotation(
"Npgsql:Enum:genre",
"action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western"
)
.OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned"); .OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned");
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "episode_watch_status", name: "episode_watch_status",
columns: table => new columns: table =>
new
{ {
user_id = table.Column<Guid>(type: "uuid", nullable: false), user_id = table.Column<Guid>(type: "uuid", nullable: false),
episode_id = table.Column<Guid>(type: "uuid", nullable: false), episode_id = table.Column<Guid>(type: "uuid", nullable: false),
added_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), added_date = table.Column<DateTime>(
played_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() at time zone 'utc'"
),
played_date = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: true
),
status = table.Column<WatchStatus>(type: "watch_status", nullable: false), status = table.Column<WatchStatus>(type: "watch_status", nullable: false),
watched_time = table.Column<int>(type: "integer", nullable: true), watched_time = table.Column<int>(type: "integer", nullable: true),
watched_percent = table.Column<int>(type: "integer", nullable: true) watched_percent = table.Column<int>(type: "integer", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_episode_watch_status", x => new { x.user_id, x.episode_id }); table.PrimaryKey(
"pk_episode_watch_status",
x => new { x.user_id, x.episode_id }
);
table.ForeignKey( table.ForeignKey(
name: "fk_episode_watch_status_episodes_episode_id", name: "fk_episode_watch_status_episodes_episode_id",
column: x => x.episode_id, column: x => x.episode_id,
principalTable: "episodes", principalTable: "episodes",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
);
table.ForeignKey( table.ForeignKey(
name: "fk_episode_watch_status_users_user_id", name: "fk_episode_watch_status_users_user_id",
column: x => x.user_id, column: x => x.user_id,
principalTable: "users", principalTable: "users",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
}); );
}
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "movie_watch_status", name: "movie_watch_status",
columns: table => new columns: table =>
new
{ {
user_id = table.Column<Guid>(type: "uuid", nullable: false), user_id = table.Column<Guid>(type: "uuid", nullable: false),
movie_id = table.Column<Guid>(type: "uuid", nullable: false), movie_id = table.Column<Guid>(type: "uuid", nullable: false),
added_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), added_date = table.Column<DateTime>(
played_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() at time zone 'utc'"
),
played_date = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: true
),
status = table.Column<WatchStatus>(type: "watch_status", nullable: false), status = table.Column<WatchStatus>(type: "watch_status", nullable: false),
watched_time = table.Column<int>(type: "integer", nullable: true), watched_time = table.Column<int>(type: "integer", nullable: true),
watched_percent = table.Column<int>(type: "integer", nullable: true) watched_percent = table.Column<int>(type: "integer", nullable: true)
@ -86,23 +115,34 @@ namespace Kyoo.Postgresql.Migrations
column: x => x.movie_id, column: x => x.movie_id,
principalTable: "movies", principalTable: "movies",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
);
table.ForeignKey( table.ForeignKey(
name: "fk_movie_watch_status_users_user_id", name: "fk_movie_watch_status_users_user_id",
column: x => x.user_id, column: x => x.user_id,
principalTable: "users", principalTable: "users",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
}); );
}
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "show_watch_status", name: "show_watch_status",
columns: table => new columns: table =>
new
{ {
user_id = table.Column<Guid>(type: "uuid", nullable: false), user_id = table.Column<Guid>(type: "uuid", nullable: false),
show_id = table.Column<Guid>(type: "uuid", nullable: false), show_id = table.Column<Guid>(type: "uuid", nullable: false),
added_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), added_date = table.Column<DateTime>(
played_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() at time zone 'utc'"
),
played_date = table.Column<DateTime>(
type: "timestamp with time zone",
nullable: true
),
status = table.Column<WatchStatus>(type: "watch_status", nullable: false), status = table.Column<WatchStatus>(type: "watch_status", nullable: false),
unseen_episodes_count = table.Column<int>(type: "integer", nullable: false), unseen_episodes_count = table.Column<int>(type: "integer", nullable: false),
next_episode_id = table.Column<Guid>(type: "uuid", nullable: true), next_episode_id = table.Column<Guid>(type: "uuid", nullable: true),
@ -116,58 +156,70 @@ namespace Kyoo.Postgresql.Migrations
name: "fk_show_watch_status_episodes_next_episode_id", name: "fk_show_watch_status_episodes_next_episode_id",
column: x => x.next_episode_id, column: x => x.next_episode_id,
principalTable: "episodes", principalTable: "episodes",
principalColumn: "id"); principalColumn: "id"
);
table.ForeignKey( table.ForeignKey(
name: "fk_show_watch_status_shows_show_id", name: "fk_show_watch_status_shows_show_id",
column: x => x.show_id, column: x => x.show_id,
principalTable: "shows", principalTable: "shows",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
);
table.ForeignKey( table.ForeignKey(
name: "fk_show_watch_status_users_user_id", name: "fk_show_watch_status_users_user_id",
column: x => x.user_id, column: x => x.user_id,
principalTable: "users", principalTable: "users",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
}); );
}
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_episode_watch_status_episode_id", name: "ix_episode_watch_status_episode_id",
table: "episode_watch_status", table: "episode_watch_status",
column: "episode_id"); column: "episode_id"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_movie_watch_status_movie_id", name: "ix_movie_watch_status_movie_id",
table: "movie_watch_status", table: "movie_watch_status",
column: "movie_id"); column: "movie_id"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_show_watch_status_next_episode_id", name: "ix_show_watch_status_next_episode_id",
table: "show_watch_status", table: "show_watch_status",
column: "next_episode_id"); column: "next_episode_id"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_show_watch_status_show_id", name: "ix_show_watch_status_show_id",
table: "show_watch_status", table: "show_watch_status",
column: "show_id"); column: "show_id"
);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "episode_watch_status");
name: "episode_watch_status");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "movie_watch_status");
name: "movie_watch_status");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "show_watch_status");
name: "show_watch_status");
migrationBuilder.AlterDatabase() migrationBuilder
.Annotation("Npgsql:Enum:genre", "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western") .AlterDatabase()
.Annotation(
"Npgsql:Enum:genre",
"action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western"
)
.Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned") .Annotation("Npgsql:Enum:status", "unknown,finished,airing,planned")
.OldAnnotation("Npgsql:Enum:genre", "action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western") .OldAnnotation(
"Npgsql:Enum:genre",
"action,adventure,animation,comedy,crime,documentary,drama,family,fantasy,history,horror,music,mystery,romance,science_fiction,thriller,war,western"
)
.OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned") .OldAnnotation("Npgsql:Enum:status", "unknown,finished,airing,planned")
.OldAnnotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned"); .OldAnnotation("Npgsql:Enum:watch_status", "completed,watching,droped,planned");
} }

View File

@ -56,8 +56,7 @@ namespace Kyoo.Postgresql
/// Design time constructor (dotnet ef migrations add). Do not use /// Design time constructor (dotnet ef migrations add). Do not use
/// </summary> /// </summary>
public PostgresContext() public PostgresContext()
: base(null!) : base(null!) { }
{ }
public PostgresContext(DbContextOptions options, IHttpContextAccessor accessor) public PostgresContext(DbContextOptions options, IHttpContextAccessor accessor)
: base(options, accessor) : base(options, accessor)
@ -98,8 +97,10 @@ namespace Kyoo.Postgresql
modelBuilder.HasPostgresEnum<Genre>(); modelBuilder.HasPostgresEnum<Genre>();
modelBuilder.HasPostgresEnum<WatchStatus>(); modelBuilder.HasPostgresEnum<WatchStatus>();
modelBuilder.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!) modelBuilder
.HasTranslation(args => .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(MD5))!)
.HasTranslation(
args =>
new SqlFunctionExpression( new SqlFunctionExpression(
"md5", "md5",
args, args,
@ -130,7 +131,12 @@ namespace Kyoo.Postgresql
/// <inheritdoc /> /// <inheritdoc />
protected override bool IsDuplicateException(Exception ex) protected override bool IsDuplicateException(Exception ex)
{ {
return ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation or PostgresErrorCodes.ForeignKeyViolation }; return ex.InnerException
is PostgresException
{
SqlState: PostgresErrorCodes.UniqueViolation
or PostgresErrorCodes.ForeignKeyViolation
};
} }
} }
} }

View File

@ -79,14 +79,24 @@ namespace Kyoo.Postgresql
SqlMapper.TypeMapProvider = (type) => SqlMapper.TypeMapProvider = (type) =>
{ {
return new CustomPropertyTypeMap(type, (type, name) => return new CustomPropertyTypeMap(
type,
(type, name) =>
{ {
string newName = Regex.Replace(name, "(^|_)([a-z])", (match) => match.Groups[2].Value.ToUpperInvariant()); 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 // TODO: Add images handling here (name: poster_source, newName: PosterSource) should set Poster.Source
return type.GetProperty(newName)!; return type.GetProperty(newName)!;
}); }
);
}; };
SqlMapper.AddTypeHandler(typeof(Dictionary<string, MetadataId>), new JsonTypeHandler<Dictionary<string, MetadataId>>()); SqlMapper.AddTypeHandler(
typeof(Dictionary<string, MetadataId>),
new JsonTypeHandler<Dictionary<string, MetadataId>>()
);
SqlMapper.AddTypeHandler(typeof(List<string>), new ListTypeHandler<string>()); SqlMapper.AddTypeHandler(typeof(List<string>), new ListTypeHandler<string>());
SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>()); SqlMapper.AddTypeHandler(typeof(List<Genre>), new ListTypeHandler<Genre>());
SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler()); SqlMapper.AddTypeHandler(typeof(Wrapper), new Wrapper.Handler());
@ -97,7 +107,8 @@ namespace Kyoo.Postgresql
/// <inheritdoc /> /// <inheritdoc />
public void Configure(IServiceCollection services) public void Configure(IServiceCollection services)
{ {
DbConnectionStringBuilder builder = new() DbConnectionStringBuilder builder =
new()
{ {
["USER ID"] = _configuration.GetValue("POSTGRES_USER", "KyooUser"), ["USER ID"] = _configuration.GetValue("POSTGRES_USER", "KyooUser"),
["PASSWORD"] = _configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"), ["PASSWORD"] = _configuration.GetValue("POSTGRES_PASSWORD", "KyooPassword"),
@ -109,14 +120,18 @@ namespace Kyoo.Postgresql
["TIMEOUT"] = "30" ["TIMEOUT"] = "30"
}; };
services.AddDbContext<DatabaseContext, PostgresContext>(x => services.AddDbContext<DatabaseContext, PostgresContext>(
x =>
{ {
x.UseNpgsql(builder.ConnectionString) x.UseNpgsql(builder.ConnectionString).UseProjectables();
.UseProjectables();
if (_environment.IsDevelopment()) if (_environment.IsDevelopment())
x.EnableDetailedErrors().EnableSensitiveDataLogging(); x.EnableDetailedErrors().EnableSensitiveDataLogging();
}, ServiceLifetime.Transient); },
services.AddTransient<DbConnection>((_) => new NpgsqlConnection(builder.ConnectionString)); ServiceLifetime.Transient
);
services.AddTransient<DbConnection>(
(_) => new NpgsqlConnection(builder.ConnectionString)
);
services.AddHealthChecks().AddDbContextCheck<DatabaseContext>(); services.AddHealthChecks().AddDbContextCheck<DatabaseContext>();
} }

View File

@ -38,7 +38,8 @@ namespace Kyoo.Swagger
options.PostProcess += postProcess => options.PostProcess += postProcess =>
{ {
// We can't reorder items by assigning the sorted value to the Paths variable since it has no setter. // 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.Paths List<KeyValuePair<string, OpenApiPathItem>> sorted = postProcess
.Paths
.OrderBy(x => x.Key) .OrderBy(x => x.Key)
.ToList(); .ToList();
postProcess.Paths.Clear(); postProcess.Paths.Clear();
@ -56,9 +57,7 @@ namespace Kyoo.Swagger
.Select(x => .Select(x =>
{ {
x.Name = x.Name[(x.Name.IndexOf(':') + 1)..]; x.Name = x.Name[(x.Name.IndexOf(':') + 1)..];
x.Tags = x.Tags x.Tags = x.Tags.OrderBy(y => y).ToList();
.OrderBy(y => y)
.ToList();
return x; return x;
}) })
.ToList(); .ToList();

View File

@ -21,10 +21,10 @@ using System.Linq;
using System.Reflection; using System.Reflection;
using Kyoo.Abstractions.Models.Attributes; using Kyoo.Abstractions.Models.Attributes;
using Kyoo.Swagger.Models; using Kyoo.Swagger.Models;
using Namotion.Reflection;
using NSwag; using NSwag;
using NSwag.Generation.AspNetCore; using NSwag.Generation.AspNetCore;
using NSwag.Generation.Processors.Contexts; using NSwag.Generation.Processors.Contexts;
using Namotion.Reflection;
namespace Kyoo.Swagger namespace Kyoo.Swagger
{ {
@ -42,21 +42,30 @@ namespace Kyoo.Swagger
/// <returns>This always return <c>true</c> since it should not remove operations.</returns> /// <returns>This always return <c>true</c> since it should not remove operations.</returns>
public static bool OperationFilter(OperationProcessorContext context) public static bool OperationFilter(OperationProcessorContext context)
{ {
ApiDefinitionAttribute def = context.ControllerType.GetCustomAttribute<ApiDefinitionAttribute>(); ApiDefinitionAttribute def = context
.ControllerType
.GetCustomAttribute<ApiDefinitionAttribute>();
string name = def?.Name ?? context.ControllerType.Name; string name = def?.Name ?? context.ControllerType.Name;
ApiDefinitionAttribute methodOverride = context.MethodInfo.GetCustomAttribute<ApiDefinitionAttribute>(); ApiDefinitionAttribute methodOverride = context
.MethodInfo
.GetCustomAttribute<ApiDefinitionAttribute>();
if (methodOverride != null) if (methodOverride != null)
name = methodOverride.Name; name = methodOverride.Name;
context.OperationDescription.Operation.Tags.Add(name); context.OperationDescription.Operation.Tags.Add(name);
if (context.Document.Tags.All(x => x.Name != name)) if (context.Document.Tags.All(x => x.Name != name))
{ {
context.Document.Tags.Add(new OpenApiTag context
.Document
.Tags
.Add(
new OpenApiTag
{ {
Name = name, Name = name,
Description = context.ControllerType.GetXmlDocsSummary() Description = context.ControllerType.GetXmlDocsSummary()
}); }
);
} }
if (def?.Group == null) if (def?.Group == null)
@ -73,11 +82,13 @@ namespace Kyoo.Swagger
} }
else else
{ {
obj.Add(new TagGroups obj.Add(
new TagGroups
{ {
Name = def.Group, Name = def.Group,
Tags = new List<string> { def.Name } Tags = new List<string> { def.Name }
}); }
);
} }
return true; return true;
@ -94,19 +105,14 @@ namespace Kyoo.Swagger
public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess) public static void AddLeftoversToOthersGroup(this OpenApiDocument postProcess)
{ {
List<TagGroups> tagGroups = (List<TagGroups>)postProcess.ExtensionData["x-tagGroups"]; List<TagGroups> tagGroups = (List<TagGroups>)postProcess.ExtensionData["x-tagGroups"];
List<string> tagsWithoutGroup = postProcess.Tags List<string> tagsWithoutGroup = postProcess
.Tags
.Select(x => x.Name) .Select(x => x.Name)
.Where(x => tagGroups .Where(x => tagGroups.SelectMany(y => y.Tags).All(y => y != x))
.SelectMany(y => y.Tags)
.All(y => y != x))
.ToList(); .ToList();
if (tagsWithoutGroup.Any()) if (tagsWithoutGroup.Any())
{ {
tagGroups.Add(new TagGroups tagGroups.Add(new TagGroups { Name = "Others", Tags = tagsWithoutGroup });
{
Name = "Others",
Tags = tagsWithoutGroup
});
} }
} }

View File

@ -41,22 +41,27 @@ namespace Kyoo.Swagger
public int Order => -1; public int Order => -1;
/// <inheritdoc /> /// <inheritdoc />
public void OnProvidersExecuted(ApplicationModelProviderContext context) public void OnProvidersExecuted(ApplicationModelProviderContext context) { }
{ }
/// <inheritdoc /> /// <inheritdoc />
public void OnProvidersExecuting(ApplicationModelProviderContext context) public void OnProvidersExecuting(ApplicationModelProviderContext context)
{ {
foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions)) foreach (ActionModel action in context.Result.Controllers.SelectMany(x => x.Actions))
{ {
IEnumerable<ProducesResponseTypeAttribute> responses = action.Filters IEnumerable<ProducesResponseTypeAttribute> responses = action
.Filters
.OfType<ProducesResponseTypeAttribute>() .OfType<ProducesResponseTypeAttribute>()
.Where(x => x.Type == typeof(ActionResult<>)); .Where(x => x.Type == typeof(ActionResult<>));
foreach (ProducesResponseTypeAttribute response in responses) foreach (ProducesResponseTypeAttribute response in responses)
{ {
Type type = action.ActionMethod.ReturnType; Type type = action.ActionMethod.ReturnType;
type = Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0] ?? type; type =
type = Utility.GetGenericDefinition(type, typeof(ActionResult<>))?.GetGenericArguments()[0] ?? type; Utility.GetGenericDefinition(type, typeof(Task<>))?.GetGenericArguments()[0]
?? type;
type =
Utility
.GetGenericDefinition(type, typeof(ActionResult<>))
?.GetGenericArguments()[0] ?? type;
response.Type = type; response.Type = type;
} }
} }

View File

@ -17,8 +17,8 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json;
using NSwag; using NSwag;
using Newtonsoft.Json;
namespace Kyoo.Swagger.Models namespace Kyoo.Swagger.Models
{ {

View File

@ -36,49 +36,69 @@ namespace Kyoo.Swagger
/// <inheritdoc /> /// <inheritdoc />
public bool Process(OperationProcessorContext context) public bool Process(OperationProcessorContext context)
{ {
context.OperationDescription.Operation.Security ??= new List<OpenApiSecurityRequirement>(); context.OperationDescription.Operation.Security ??=
OpenApiSecurityRequirement perms = context.MethodInfo.GetCustomAttributes<UserOnlyAttribute>() new List<OpenApiSecurityRequirement>();
.Aggregate(new OpenApiSecurityRequirement(), (agg, _) => OpenApiSecurityRequirement perms = context
.MethodInfo
.GetCustomAttributes<UserOnlyAttribute>()
.Aggregate(
new OpenApiSecurityRequirement(),
(agg, _) =>
{ {
agg[nameof(Kyoo)] = Array.Empty<string>(); agg[nameof(Kyoo)] = Array.Empty<string>();
return agg; return agg;
}); }
);
perms = context.MethodInfo.GetCustomAttributes<PermissionAttribute>() perms = context
.Aggregate(perms, (agg, cur) => .MethodInfo
.GetCustomAttributes<PermissionAttribute>()
.Aggregate(
perms,
(agg, cur) =>
{ {
ICollection<string> permissions = _GetPermissionsList(agg, cur.Group); ICollection<string> permissions = _GetPermissionsList(agg, cur.Group);
permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}"); permissions.Add($"{cur.Type}.{cur.Kind.ToString().ToLower()}");
agg[nameof(Kyoo)] = permissions; agg[nameof(Kyoo)] = permissions;
return agg; return agg;
}); }
);
PartialPermissionAttribute controller = context.ControllerType PartialPermissionAttribute controller = context
.ControllerType
.GetCustomAttribute<PartialPermissionAttribute>(); .GetCustomAttribute<PartialPermissionAttribute>();
if (controller != null) if (controller != null)
{ {
perms = context.MethodInfo.GetCustomAttributes<PartialPermissionAttribute>() perms = context
.Aggregate(perms, (agg, cur) => .MethodInfo
.GetCustomAttributes<PartialPermissionAttribute>()
.Aggregate(
perms,
(agg, cur) =>
{ {
Group? group = controller.Group != Group.Overall Group? group =
? controller.Group controller.Group != Group.Overall ? controller.Group : cur.Group;
: cur.Group;
string type = controller.Type ?? cur.Type; string type = controller.Type ?? cur.Type;
Kind? kind = controller.Type == null Kind? kind = controller.Type == null ? controller.Kind : cur.Kind;
? controller.Kind ICollection<string> permissions = _GetPermissionsList(
: cur.Kind; agg,
ICollection<string> permissions = _GetPermissionsList(agg, group ?? Group.Overall); group ?? Group.Overall
);
permissions.Add($"{type}.{kind!.Value.ToString().ToLower()}"); permissions.Add($"{type}.{kind!.Value.ToString().ToLower()}");
agg[nameof(Kyoo)] = permissions; agg[nameof(Kyoo)] = permissions;
return agg; return agg;
}); }
);
} }
context.OperationDescription.Operation.Security.Add(perms); context.OperationDescription.Operation.Security.Add(perms);
return true; return true;
} }
private static ICollection<string> _GetPermissionsList(OpenApiSecurityRequirement security, Group group) private static ICollection<string> _GetPermissionsList(
OpenApiSecurityRequirement security,
Group group
)
{ {
return security.TryGetValue(group.ToString(), out IEnumerable<string> perms) return security.TryGetValue(group.ToString(), out IEnumerable<string> perms)
? perms.ToList() ? perms.ToList()

View File

@ -78,39 +78,58 @@ namespace Kyoo.Swagger
document.AddOperationFilter(x => document.AddOperationFilter(x =>
{ {
if (x is AspNetCoreOperationProcessorContext ctx) if (x is AspNetCoreOperationProcessorContext ctx)
return ctx.ApiDescription.ActionDescriptor.AttributeRouteInfo?.Order != AlternativeRoute; return ctx.ApiDescription.ActionDescriptor.AttributeRouteInfo?.Order
!= AlternativeRoute;
return true; return true;
}); });
document.SchemaGenerator.Settings.TypeMappers.Add(new PrimitiveTypeMapper(typeof(Identifier), x => document
.SchemaGenerator
.Settings
.TypeMappers
.Add(
new PrimitiveTypeMapper(
typeof(Identifier),
x =>
{ {
x.IsNullableRaw = false; x.IsNullableRaw = false;
x.Type = JsonObjectType.String | JsonObjectType.Integer; x.Type = JsonObjectType.String | JsonObjectType.Integer;
})); }
)
);
document.AddSecurity(nameof(Kyoo), new OpenApiSecurityScheme document.AddSecurity(
nameof(Kyoo),
new OpenApiSecurityScheme
{ {
Type = OpenApiSecuritySchemeType.Http, Type = OpenApiSecuritySchemeType.Http,
Scheme = "Bearer", Scheme = "Bearer",
BearerFormat = "JWT", BearerFormat = "JWT",
Description = "The user's bearer" Description = "The user's bearer"
}); }
);
document.OperationProcessors.Add(new OperationPermissionProcessor()); document.OperationProcessors.Add(new OperationPermissionProcessor());
}); });
} }
/// <inheritdoc /> /// <inheritdoc />
public IEnumerable<IStartupAction> ConfigureSteps => new IStartupAction[] public IEnumerable<IStartupAction> ConfigureSteps =>
new IStartupAction[]
{ {
SA.New<IApplicationBuilder>(app => app.UseOpenApi(), SA.Before + 1), SA.New<IApplicationBuilder>(app => app.UseOpenApi(), SA.Before + 1),
SA.New<IApplicationBuilder>(app => app.UseReDoc(x => SA.New<IApplicationBuilder>(
app =>
app.UseReDoc(x =>
{ {
x.Path = "/doc"; x.Path = "/doc";
x.TransformToExternalPath = (internalUiRoute, _) => "/api" + internalUiRoute; x.TransformToExternalPath = (internalUiRoute, _) =>
"/api" + internalUiRoute;
x.AdditionalSettings["theme"] = new x.AdditionalSettings["theme"] = new
{ {
colors = new { primary = new { main = "#e13e13" } } colors = new { primary = new { main = "#e13e13" } }
}; };
}), SA.Before) }),
SA.Before
)
}; };
} }
} }

View File

@ -48,9 +48,12 @@ namespace Kyoo.Tests.Database
Mock<IThumbnailsManager> thumbs = new(); Mock<IThumbnailsManager> thumbs = new();
CollectionRepository collection = new(_NewContext(), thumbs.Object); CollectionRepository collection = new(_NewContext(), thumbs.Object);
StudioRepository studio = new(_NewContext(), thumbs.Object); StudioRepository studio = new(_NewContext(), thumbs.Object);
PeopleRepository people = new(_NewContext(), PeopleRepository people =
new(
_NewContext(),
new Lazy<IRepository<Show>>(() => LibraryManager.Shows), new Lazy<IRepository<Show>>(() => LibraryManager.Shows),
thumbs.Object); thumbs.Object
);
MovieRepository movies = new(_NewContext(), studio, people, thumbs.Object); MovieRepository movies = new(_NewContext(), studio, people, thumbs.Object);
ShowRepository show = new(_NewContext(), studio, people, thumbs.Object); ShowRepository show = new(_NewContext(), studio, people, thumbs.Object);
SeasonRepository season = new(_NewContext(), thumbs.Object); SeasonRepository season = new(_NewContext(), thumbs.Object);

View File

@ -72,16 +72,8 @@ namespace Kyoo.Tests.Database
Collection collection = TestSample.GetNew<Collection>(); Collection collection = TestSample.GetNew<Collection>();
collection.ExternalId = new Dictionary<string, MetadataId> collection.ExternalId = new Dictionary<string, MetadataId>
{ {
["1"] = new() ["1"] = new() { Link = "link", DataId = "id" },
{ ["2"] = new() { Link = "new-provider-link", DataId = "new-id" }
Link = "link",
DataId = "id"
},
["2"] = new()
{
Link = "new-provider-link",
DataId = "new-id"
}
}; };
await _repository.Create(collection); await _repository.Create(collection);
@ -111,11 +103,7 @@ namespace Kyoo.Tests.Database
Collection value = await _repository.Get(TestSample.Get<Collection>().Slug); Collection value = await _repository.Get(TestSample.Get<Collection>().Slug);
value.ExternalId = new Dictionary<string, MetadataId> value.ExternalId = new Dictionary<string, MetadataId>
{ {
["test"] = new() ["test"] = new() { Link = "link", DataId = "id" },
{
Link = "link",
DataId = "id"
},
}; };
await _repository.Edit(value); await _repository.Edit(value);
@ -131,11 +119,7 @@ namespace Kyoo.Tests.Database
Collection value = await _repository.Get(TestSample.Get<Collection>().Slug); Collection value = await _repository.Get(TestSample.Get<Collection>().Slug);
value.ExternalId = new Dictionary<string, MetadataId> value.ExternalId = new Dictionary<string, MetadataId>
{ {
["toto"] = new() ["toto"] = new() { Link = "link", DataId = "id" },
{
Link = "link",
DataId = "id"
},
}; };
await _repository.Edit(value); await _repository.Edit(value);
@ -146,11 +130,7 @@ namespace Kyoo.Tests.Database
KAssert.DeepEqual(value, retrieved); KAssert.DeepEqual(value, retrieved);
} }
value.ExternalId.Add("test", new MetadataId value.ExternalId.Add("test", new MetadataId { Link = "link", DataId = "id" });
{
Link = "link",
DataId = "id"
});
await _repository.Edit(value); await _repository.Edit(value);
{ {
@ -169,11 +149,7 @@ namespace Kyoo.Tests.Database
[InlineData("SuPeR")] [InlineData("SuPeR")]
public async Task SearchTest(string query) public async Task SearchTest(string query)
{ {
Collection value = new() Collection value = new() { Slug = "super-test", Name = "This is a test title", };
{
Slug = "super-test",
Name = "This is a test title",
};
await _repository.Create(value); await _repository.Create(value);
ICollection<Collection> ret = await _repository.Search(query); ICollection<Collection> ret = await _repository.Search(query);
KAssert.DeepEqual(value, ret.First()); KAssert.DeepEqual(value, ret.First());

View File

@ -46,7 +46,6 @@ namespace Kyoo.Tests.Database
protected AEpisodeTests(RepositoryActivator repositories) protected AEpisodeTests(RepositoryActivator repositories)
: base(repositories) : base(repositories)
{ {
_repository = repositories.LibraryManager.Episodes; _repository = repositories.LibraryManager.Episodes;
} }
@ -55,11 +54,17 @@ namespace Kyoo.Tests.Database
{ {
Episode episode = await _repository.Get(1.AsGuid()); Episode episode = await _repository.Get(1.AsGuid());
Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e1", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e1", episode.Slug);
await Repositories.LibraryManager.Shows.Patch(episode.ShowId, (x) => await Repositories
.LibraryManager
.Shows
.Patch(
episode.ShowId,
(x) =>
{ {
x.Slug = "new-slug"; x.Slug = "new-slug";
return Task.FromResult(true); return Task.FromResult(true);
}); }
);
episode = await _repository.Get(1.AsGuid()); episode = await _repository.Get(1.AsGuid());
Assert.Equal("new-slug-s1e1", episode.Slug); Assert.Equal("new-slug-s1e1", episode.Slug);
} }
@ -69,11 +74,14 @@ namespace Kyoo.Tests.Database
{ {
Episode episode = await _repository.Get(1.AsGuid()); Episode episode = await _repository.Get(1.AsGuid());
Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e1", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e1", episode.Slug);
episode = await _repository.Patch(1.AsGuid(), (x) => episode = await _repository.Patch(
1.AsGuid(),
(x) =>
{ {
x.SeasonNumber = 2; x.SeasonNumber = 2;
return Task.FromResult(true); return Task.FromResult(true);
}); }
);
Assert.Equal($"{TestSample.Get<Show>().Slug}-s2e1", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-s2e1", episode.Slug);
episode = await _repository.Get(1.AsGuid()); episode = await _repository.Get(1.AsGuid());
Assert.Equal($"{TestSample.Get<Show>().Slug}-s2e1", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-s2e1", episode.Slug);
@ -84,11 +92,17 @@ namespace Kyoo.Tests.Database
{ {
Episode episode = await _repository.Get(1.AsGuid()); Episode episode = await _repository.Get(1.AsGuid());
Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e1", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e1", episode.Slug);
episode = await Repositories.LibraryManager.Episodes.Patch(episode.Id, (x) => episode = await Repositories
.LibraryManager
.Episodes
.Patch(
episode.Id,
(x) =>
{ {
x.EpisodeNumber = 2; x.EpisodeNumber = 2;
return Task.FromResult(true); return Task.FromResult(true);
}); }
);
Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e2", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e2", episode.Slug);
episode = await _repository.Get(1.AsGuid()); episode = await _repository.Get(1.AsGuid());
Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e2", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e2", episode.Slug);
@ -109,26 +123,37 @@ namespace Kyoo.Tests.Database
[Fact] [Fact]
public void AbsoluteSlugTest() public void AbsoluteSlugTest()
{ {
Assert.Equal($"{TestSample.Get<Show>().Slug}-{TestSample.GetAbsoluteEpisode().AbsoluteNumber}", Assert.Equal(
TestSample.GetAbsoluteEpisode().Slug); $"{TestSample.Get<Show>().Slug}-{TestSample.GetAbsoluteEpisode().AbsoluteNumber}",
TestSample.GetAbsoluteEpisode().Slug
);
} }
[Fact] [Fact]
public async Task EpisodeCreationAbsoluteSlugTest() public async Task EpisodeCreationAbsoluteSlugTest()
{ {
Episode episode = await _repository.Create(TestSample.GetAbsoluteEpisode()); Episode episode = await _repository.Create(TestSample.GetAbsoluteEpisode());
Assert.Equal($"{TestSample.Get<Show>().Slug}-{TestSample.GetAbsoluteEpisode().AbsoluteNumber}", episode.Slug); Assert.Equal(
$"{TestSample.Get<Show>().Slug}-{TestSample.GetAbsoluteEpisode().AbsoluteNumber}",
episode.Slug
);
} }
[Fact] [Fact]
public async Task SlugEditAbsoluteTest() public async Task SlugEditAbsoluteTest()
{ {
Episode episode = await _repository.Create(TestSample.GetAbsoluteEpisode()); Episode episode = await _repository.Create(TestSample.GetAbsoluteEpisode());
await Repositories.LibraryManager.Shows.Patch(episode.ShowId, (x) => await Repositories
.LibraryManager
.Shows
.Patch(
episode.ShowId,
(x) =>
{ {
x.Slug = "new-slug"; x.Slug = "new-slug";
return Task.FromResult(true); return Task.FromResult(true);
}); }
);
episode = await _repository.Get(2.AsGuid()); episode = await _repository.Get(2.AsGuid());
Assert.Equal($"new-slug-3", episode.Slug); Assert.Equal($"new-slug-3", episode.Slug);
} }
@ -137,11 +162,14 @@ namespace Kyoo.Tests.Database
public async Task AbsoluteNumberEditTest() public async Task AbsoluteNumberEditTest()
{ {
await _repository.Create(TestSample.GetAbsoluteEpisode()); await _repository.Create(TestSample.GetAbsoluteEpisode());
Episode episode = await _repository.Patch(2.AsGuid(), (x) => Episode episode = await _repository.Patch(
2.AsGuid(),
(x) =>
{ {
x.AbsoluteNumber = 56; x.AbsoluteNumber = 56;
return Task.FromResult(true); return Task.FromResult(true);
}); }
);
Assert.Equal($"{TestSample.Get<Show>().Slug}-56", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-56", episode.Slug);
episode = await _repository.Get(2.AsGuid()); episode = await _repository.Get(2.AsGuid());
Assert.Equal($"{TestSample.Get<Show>().Slug}-56", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-56", episode.Slug);
@ -151,12 +179,15 @@ namespace Kyoo.Tests.Database
public async Task AbsoluteToNormalEditTest() public async Task AbsoluteToNormalEditTest()
{ {
await _repository.Create(TestSample.GetAbsoluteEpisode()); await _repository.Create(TestSample.GetAbsoluteEpisode());
Episode episode = await _repository.Patch(2.AsGuid(), (x) => Episode episode = await _repository.Patch(
2.AsGuid(),
(x) =>
{ {
x.SeasonNumber = 1; x.SeasonNumber = 1;
x.EpisodeNumber = 2; x.EpisodeNumber = 2;
return Task.FromResult(true); return Task.FromResult(true);
}); }
);
Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e2", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e2", episode.Slug);
episode = await _repository.Get(2.AsGuid()); episode = await _repository.Get(2.AsGuid());
Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e2", episode.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-s1e2", episode.Slug);
@ -180,16 +211,8 @@ namespace Kyoo.Tests.Database
Episode value = TestSample.GetNew<Episode>(); Episode value = TestSample.GetNew<Episode>();
value.ExternalId = new Dictionary<string, MetadataId> value.ExternalId = new Dictionary<string, MetadataId>
{ {
["2"] = new() ["2"] = new() { Link = "link", DataId = "id" },
{ ["3"] = new() { Link = "new-provider-link", DataId = "new-id" }
Link = "link",
DataId = "id"
},
["3"] = new()
{
Link = "new-provider-link",
DataId = "new-id"
}
}; };
await _repository.Create(value); await _repository.Create(value);
@ -219,11 +242,7 @@ namespace Kyoo.Tests.Database
Episode value = await _repository.Get(TestSample.Get<Episode>().Slug); Episode value = await _repository.Get(TestSample.Get<Episode>().Slug);
value.ExternalId = new Dictionary<string, MetadataId> value.ExternalId = new Dictionary<string, MetadataId>
{ {
["1"] = new() ["1"] = new() { Link = "link", DataId = "id" },
{
Link = "link",
DataId = "id"
},
}; };
await _repository.Edit(value); await _repository.Edit(value);
@ -239,11 +258,7 @@ namespace Kyoo.Tests.Database
Episode value = await _repository.Get(TestSample.Get<Episode>().Slug); Episode value = await _repository.Get(TestSample.Get<Episode>().Slug);
value.ExternalId = new Dictionary<string, MetadataId> value.ExternalId = new Dictionary<string, MetadataId>
{ {
["toto"] = new() ["toto"] = new() { Link = "link", DataId = "id" },
{
Link = "link",
DataId = "id"
},
}; };
await _repository.Edit(value); await _repository.Edit(value);
@ -254,11 +269,7 @@ namespace Kyoo.Tests.Database
KAssert.DeepEqual(value, retrieved); KAssert.DeepEqual(value, retrieved);
} }
value.ExternalId.Add("test", new MetadataId value.ExternalId.Add("test", new MetadataId { Link = "link", DataId = "id" });
{
Link = "link",
DataId = "id"
});
await _repository.Edit(value); await _repository.Edit(value);
{ {
@ -289,13 +300,19 @@ namespace Kyoo.Tests.Database
[Fact] [Fact]
public async Task CreateTest() public async Task CreateTest()
{ {
await Assert.ThrowsAsync<DuplicatedItemException>(() => _repository.Create(TestSample.Get<Episode>())); await Assert.ThrowsAsync<DuplicatedItemException>(
() => _repository.Create(TestSample.Get<Episode>())
);
await _repository.Delete(TestSample.Get<Episode>()); await _repository.Delete(TestSample.Get<Episode>());
Episode expected = TestSample.Get<Episode>(); Episode expected = TestSample.Get<Episode>();
expected.Id = 0.AsGuid(); expected.Id = 0.AsGuid();
expected.ShowId = (await Repositories.LibraryManager.Shows.Create(TestSample.Get<Show>())).Id; expected.ShowId = (
expected.SeasonId = (await Repositories.LibraryManager.Seasons.Create(TestSample.Get<Season>())).Id; await Repositories.LibraryManager.Shows.Create(TestSample.Get<Show>())
).Id;
expected.SeasonId = (
await Repositories.LibraryManager.Seasons.Create(TestSample.Get<Season>())
).Id;
await _repository.Create(expected); await _repository.Create(expected);
KAssert.DeepEqual(expected, await _repository.Get(expected.Slug)); KAssert.DeepEqual(expected, await _repository.Get(expected.Slug));
} }
@ -304,10 +321,17 @@ namespace Kyoo.Tests.Database
public override async Task CreateIfNotExistTest() public override async Task CreateIfNotExistTest()
{ {
Episode expected = TestSample.Get<Episode>(); Episode expected = TestSample.Get<Episode>();
KAssert.DeepEqual(expected, await _repository.CreateIfNotExists(TestSample.Get<Episode>())); KAssert.DeepEqual(
expected,
await _repository.CreateIfNotExists(TestSample.Get<Episode>())
);
await _repository.Delete(TestSample.Get<Episode>()); await _repository.Delete(TestSample.Get<Episode>());
expected.ShowId = (await Repositories.LibraryManager.Shows.Create(TestSample.Get<Show>())).Id; expected.ShowId = (
expected.SeasonId = (await Repositories.LibraryManager.Seasons.Create(TestSample.Get<Season>())).Id; await Repositories.LibraryManager.Shows.Create(TestSample.Get<Show>())
).Id;
expected.SeasonId = (
await Repositories.LibraryManager.Seasons.Create(TestSample.Get<Season>())
).Id;
KAssert.DeepEqual(expected, await _repository.CreateIfNotExists(expected)); KAssert.DeepEqual(expected, await _repository.CreateIfNotExists(expected));
} }
} }

View File

@ -53,11 +53,17 @@ namespace Kyoo.Tests.Database
{ {
Season season = await _repository.Get(1.AsGuid()); Season season = await _repository.Get(1.AsGuid());
Assert.Equal("anohana-s1", season.Slug); Assert.Equal("anohana-s1", season.Slug);
await Repositories.LibraryManager.Shows.Patch(season.ShowId, (x) => await Repositories
.LibraryManager
.Shows
.Patch(
season.ShowId,
(x) =>
{ {
x.Slug = "new-slug"; x.Slug = "new-slug";
return Task.FromResult(true); return Task.FromResult(true);
}); }
);
season = await _repository.Get(1.AsGuid()); season = await _repository.Get(1.AsGuid());
Assert.Equal("new-slug-s1", season.Slug); Assert.Equal("new-slug-s1", season.Slug);
} }
@ -67,7 +73,9 @@ namespace Kyoo.Tests.Database
{ {
Season season = await _repository.Get(1.AsGuid()); Season season = await _repository.Get(1.AsGuid());
Assert.Equal("anohana-s1", season.Slug); Assert.Equal("anohana-s1", season.Slug);
await _repository.Patch(season.Id, (x) => await _repository.Patch(
season.Id,
(x) =>
{ {
x.SeasonNumber = 2; x.SeasonNumber = 2;
return Task.FromResult(true); return Task.FromResult(true);
@ -80,11 +88,9 @@ namespace Kyoo.Tests.Database
[Fact] [Fact]
public async Task SeasonCreationSlugTest() public async Task SeasonCreationSlugTest()
{ {
Season season = await _repository.Create(new Season Season season = await _repository.Create(
{ new Season { ShowId = TestSample.Get<Show>().Id, SeasonNumber = 2 }
ShowId = TestSample.Get<Show>().Id, );
SeasonNumber = 2
});
Assert.Equal($"{TestSample.Get<Show>().Slug}-s2", season.Slug); Assert.Equal($"{TestSample.Get<Show>().Slug}-s2", season.Slug);
} }
@ -94,16 +100,8 @@ namespace Kyoo.Tests.Database
Season season = TestSample.GetNew<Season>(); Season season = TestSample.GetNew<Season>();
season.ExternalId = new Dictionary<string, MetadataId> season.ExternalId = new Dictionary<string, MetadataId>
{ {
["2"] = new() ["2"] = new() { Link = "link", DataId = "id" },
{ ["1"] = new() { Link = "new-provider-link", DataId = "new-id" }
Link = "link",
DataId = "id"
},
["1"] = new()
{
Link = "new-provider-link",
DataId = "new-id"
}
}; };
await _repository.Create(season); await _repository.Create(season);
@ -133,11 +131,7 @@ namespace Kyoo.Tests.Database
Season value = await _repository.Get(TestSample.Get<Season>().Slug); Season value = await _repository.Get(TestSample.Get<Season>().Slug);
value.ExternalId = new Dictionary<string, MetadataId> value.ExternalId = new Dictionary<string, MetadataId>
{ {
["toto"] = new() ["toto"] = new() { Link = "link", DataId = "id" },
{
Link = "link",
DataId = "id"
},
}; };
await _repository.Edit(value); await _repository.Edit(value);
@ -153,11 +147,7 @@ namespace Kyoo.Tests.Database
Season value = await _repository.Get(TestSample.Get<Season>().Slug); Season value = await _repository.Get(TestSample.Get<Season>().Slug);
value.ExternalId = new Dictionary<string, MetadataId> value.ExternalId = new Dictionary<string, MetadataId>
{ {
["1"] = new() ["1"] = new() { Link = "link", DataId = "id" },
{
Link = "link",
DataId = "id"
},
}; };
await _repository.Edit(value); await _repository.Edit(value);
@ -168,11 +158,7 @@ namespace Kyoo.Tests.Database
KAssert.DeepEqual(value, retrieved); KAssert.DeepEqual(value, retrieved);
} }
value.ExternalId.Add("toto", new MetadataId value.ExternalId.Add("toto", new MetadataId { Link = "link", DataId = "id" });
{
Link = "link",
DataId = "id"
});
await _repository.Edit(value); await _repository.Edit(value);
{ {
@ -191,11 +177,7 @@ namespace Kyoo.Tests.Database
[InlineData("SuPeR")] [InlineData("SuPeR")]
public async Task SearchTest(string query) public async Task SearchTest(string query)
{ {
Season value = new() Season value = new() { Name = "This is a test super title", ShowId = 1.AsGuid() };
{
Name = "This is a test super title",
ShowId = 1.AsGuid()
};
await _repository.Create(value); await _repository.Create(value);
ICollection<Season> ret = await _repository.Search(query); ICollection<Season> ret = await _repository.Search(query);
KAssert.DeepEqual(value, ret.First()); KAssert.DeepEqual(value, ret.First());

View File

@ -172,10 +172,7 @@ namespace Kyoo.Tests.Database
Show value = await _repository.Get(TestSample.Get<Show>().Slug); Show value = await _repository.Get(TestSample.Get<Show>().Slug);
value.ExternalId = new Dictionary<string, MetadataId>() value.ExternalId = new Dictionary<string, MetadataId>()
{ {
["test"] = new() ["test"] = new() { DataId = "1234" }
{
DataId = "1234"
}
}; };
Show edited = await _repository.Edit(value); Show edited = await _repository.Edit(value);
@ -197,10 +194,7 @@ namespace Kyoo.Tests.Database
expected.Slug = "created-relation-test"; expected.Slug = "created-relation-test";
expected.ExternalId = new Dictionary<string, MetadataId> expected.ExternalId = new Dictionary<string, MetadataId>
{ {
["test"] = new() ["test"] = new() { DataId = "ID" }
{
DataId = "ID"
}
}; };
expected.Genres = new List<Genre>() { Genre.Action }; expected.Genres = new List<Genre>() { Genre.Action };
// expected.People = new[] // expected.People = new[]
@ -219,7 +213,8 @@ namespace Kyoo.Tests.Database
KAssert.DeepEqual(expected, created); KAssert.DeepEqual(expected, created);
await using DatabaseContext context = Repositories.Context.New(); await using DatabaseContext context = Repositories.Context.New();
Show retrieved = await context.Shows Show retrieved = await context
.Shows
// .Include(x => x.People) // .Include(x => x.People)
// .ThenInclude(x => x.People) // .ThenInclude(x => x.People)
.Include(x => x.Studio) .Include(x => x.Studio)
@ -253,10 +248,7 @@ namespace Kyoo.Tests.Database
expected.Slug = "created-relation-test"; expected.Slug = "created-relation-test";
expected.ExternalId = new Dictionary<string, MetadataId> expected.ExternalId = new Dictionary<string, MetadataId>
{ {
["test"] = new() ["test"] = new() { DataId = "ID" }
{
DataId = "ID"
}
}; };
Show created = await _repository.Create(expected); Show created = await _repository.Create(expected);
KAssert.DeepEqual(expected, created); KAssert.DeepEqual(expected, created);
@ -285,11 +277,7 @@ namespace Kyoo.Tests.Database
[InlineData("SuPeR")] [InlineData("SuPeR")]
public async Task SearchTest(string query) public async Task SearchTest(string query)
{ {
Show value = new() Show value = new() { Slug = "super-test", Name = "This is a test title?" };
{
Slug = "super-test",
Name = "This is a test title?"
};
await _repository.Create(value); await _repository.Create(value);
ICollection<Show> ret = await _repository.Search(query); ICollection<Show> ret = await _repository.Search(query);
KAssert.DeepEqual(value, ret.First()); KAssert.DeepEqual(value, ret.First());

View File

@ -29,8 +29,7 @@ using Xunit.Abstractions;
namespace Kyoo.Tests namespace Kyoo.Tests
{ {
[CollectionDefinition(nameof(Postgresql))] [CollectionDefinition(nameof(Postgresql))]
public class PostgresCollection : ICollectionFixture<PostgresFixture> public class PostgresCollection : ICollectionFixture<PostgresFixture> { }
{ }
public sealed class PostgresFixture : IDisposable public sealed class PostgresFixture : IDisposable
{ {
@ -45,9 +44,7 @@ namespace Kyoo.Tests
string id = Guid.NewGuid().ToString().Replace('-', '_'); string id = Guid.NewGuid().ToString().Replace('-', '_');
Template = $"kyoo_template_{id}"; Template = $"kyoo_template_{id}";
_options = new DbContextOptionsBuilder<DatabaseContext>() _options = new DbContextOptionsBuilder<DatabaseContext>().UseNpgsql(Connection).Options;
.UseNpgsql(Connection)
.Options;
using PostgresContext context = new(_options, null); using PostgresContext context = new(_options, null);
context.Database.Migrate(); context.Database.Migrate();
@ -80,17 +77,23 @@ namespace Kyoo.Tests
using (NpgsqlConnection connection = new(template.Connection)) using (NpgsqlConnection connection = new(template.Connection))
{ {
connection.Open(); connection.Open();
using NpgsqlCommand cmd = new($"CREATE DATABASE {_database} WITH TEMPLATE {template.Template}", connection); using NpgsqlCommand cmd =
new(
$"CREATE DATABASE {_database} WITH TEMPLATE {template.Template}",
connection
);
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
_context = new DbContextOptionsBuilder<DatabaseContext>() _context = new DbContextOptionsBuilder<DatabaseContext>()
.UseNpgsql(GetConnectionString(_database)) .UseNpgsql(GetConnectionString(_database))
.UseLoggerFactory(LoggerFactory.Create(x => .UseLoggerFactory(
LoggerFactory.Create(x =>
{ {
x.ClearProviders(); x.ClearProviders();
x.AddXunit(output); x.AddXunit(output);
})) })
)
.EnableSensitiveDataLogging() .EnableSensitiveDataLogging()
.EnableDetailedErrors() .EnableDetailedErrors()
.Options; .Options;
@ -101,7 +104,8 @@ namespace Kyoo.Tests
string server = Environment.GetEnvironmentVariable("POSTGRES_HOST") ?? "127.0.0.1"; string server = Environment.GetEnvironmentVariable("POSTGRES_HOST") ?? "127.0.0.1";
string port = Environment.GetEnvironmentVariable("POSTGRES_PORT") ?? "5432"; string port = Environment.GetEnvironmentVariable("POSTGRES_PORT") ?? "5432";
string username = Environment.GetEnvironmentVariable("POSTGRES_USER") ?? "KyooUser"; string username = Environment.GetEnvironmentVariable("POSTGRES_USER") ?? "KyooUser";
string password = Environment.GetEnvironmentVariable("POSTGRES_PASSWORD") ?? "KyooPassword"; string password =
Environment.GetEnvironmentVariable("POSTGRES_PASSWORD") ?? "KyooPassword";
return $"Server={server};Port={port};Database={database};User ID={username};Password={password};Include Error Detail=true"; return $"Server={server};Port={port};Database={database};User ID={username};Password={password};Include Error Detail=true";
} }

View File

@ -25,11 +25,13 @@ namespace Kyoo.Tests
{ {
public static class TestSample public static class TestSample
{ {
private static readonly Dictionary<Type, Func<object>> NewSamples = new() private static readonly Dictionary<Type, Func<object>> NewSamples =
new()
{ {
{ {
typeof(Collection), typeof(Collection),
() => new Collection () =>
new Collection
{ {
Id = 2.AsGuid(), Id = 2.AsGuid(),
Slug = "new-collection", Slug = "new-collection",
@ -40,7 +42,8 @@ namespace Kyoo.Tests
}, },
{ {
typeof(Show), typeof(Show),
() => new Show () =>
new Show
{ {
Id = 2.AsGuid(), Id = 2.AsGuid(),
Slug = "new-show", Slug = "new-show",
@ -57,7 +60,8 @@ namespace Kyoo.Tests
}, },
{ {
typeof(Season), typeof(Season),
() => new Season () =>
new Season
{ {
Id = 2.AsGuid(), Id = 2.AsGuid(),
ShowId = 1.AsGuid(), ShowId = 1.AsGuid(),
@ -72,7 +76,8 @@ namespace Kyoo.Tests
}, },
{ {
typeof(Episode), typeof(Episode),
() => new Episode () =>
new Episode
{ {
Id = 2.AsGuid(), Id = 2.AsGuid(),
ShowId = 1.AsGuid(), ShowId = 1.AsGuid(),
@ -90,7 +95,8 @@ namespace Kyoo.Tests
}, },
{ {
typeof(People), typeof(People),
() => new People () =>
new People
{ {
Id = 2.AsGuid(), Id = 2.AsGuid(),
Slug = "new-person-name", Slug = "new-person-name",
@ -101,11 +107,13 @@ namespace Kyoo.Tests
} }
}; };
private static readonly Dictionary<Type, Func<object>> Samples = new() private static readonly Dictionary<Type, Func<object>> Samples =
new()
{ {
{ {
typeof(Collection), typeof(Collection),
() => new Collection () =>
new Collection
{ {
Id = 1.AsGuid(), Id = 1.AsGuid(),
Slug = "collection", Slug = "collection",
@ -116,7 +124,8 @@ namespace Kyoo.Tests
}, },
{ {
typeof(Show), typeof(Show),
() => new Show () =>
new Show
{ {
Id = 1.AsGuid(), Id = 1.AsGuid(),
Slug = "anohana", Slug = "anohana",
@ -127,9 +136,10 @@ namespace Kyoo.Tests
"AnoHana", "AnoHana",
"We Still Don't Know the Name of the Flower We Saw That Day." "We Still Don't Know the Name of the Flower We Saw That Day."
}, },
Overview = "When Yadomi Jinta was a child, he was a central piece in a group of close friends. " + Overview =
"In time, however, these childhood friends drifted apart, and when they became high " + "When Yadomi Jinta was a child, he was a central piece in a group of close friends. "
"school students, they had long ceased to think of each other as friends.", + "In time, however, these childhood friends drifted apart, and when they became high "
+ "school students, they had long ceased to think of each other as friends.",
Status = Status.Finished, Status = Status.Finished,
StudioId = 1.AsGuid(), StudioId = 1.AsGuid(),
StartAir = new DateTime(2011, 1, 1).ToUniversalTime(), StartAir = new DateTime(2011, 1, 1).ToUniversalTime(),
@ -142,7 +152,8 @@ namespace Kyoo.Tests
}, },
{ {
typeof(Season), typeof(Season),
() => new Season () =>
new Season
{ {
Id = 1.AsGuid(), Id = 1.AsGuid(),
ShowSlug = "anohana", ShowSlug = "anohana",
@ -159,7 +170,8 @@ namespace Kyoo.Tests
}, },
{ {
typeof(Episode), typeof(Episode),
() => new Episode () =>
new Episode
{ {
Id = 1.AsGuid(), Id = 1.AsGuid(),
ShowSlug = "anohana", ShowSlug = "anohana",
@ -179,7 +191,8 @@ namespace Kyoo.Tests
}, },
{ {
typeof(People), typeof(People),
() => new People () =>
new People
{ {
Id = 1.AsGuid(), Id = 1.AsGuid(),
Slug = "the-actor", Slug = "the-actor",
@ -191,7 +204,8 @@ namespace Kyoo.Tests
}, },
{ {
typeof(Studio), typeof(Studio),
() => new Studio () =>
new Studio
{ {
Id = 1.AsGuid(), Id = 1.AsGuid(),
Slug = "hyper-studio", Slug = "hyper-studio",
@ -200,7 +214,8 @@ namespace Kyoo.Tests
}, },
{ {
typeof(User), typeof(User),
() => new User () =>
new User
{ {
Id = 1.AsGuid(), Id = 1.AsGuid(),
Slug = "user", Slug = "user",

View File

@ -29,9 +29,12 @@ namespace Kyoo.Tests.Utility
public void IfEmptyTest() public void IfEmptyTest()
{ {
int[] list = { 1, 2, 3, 4 }; int[] list = { 1, 2, 3, 4 };
list = list.IfEmpty(() => KAssert.Fail("Empty action should not be triggered.")).ToArray(); list = list.IfEmpty(() => KAssert.Fail("Empty action should not be triggered."))
.ToArray();
list = Array.Empty<int>(); list = Array.Empty<int>();
Assert.Throws<ArgumentException>(() => list.IfEmpty(() => throw new ArgumentException()).ToList()); Assert.Throws<ArgumentException>(
() => list.IfEmpty(() => throw new ArgumentException()).ToList()
);
Assert.Empty(list.IfEmpty(() => { })); Assert.Empty(list.IfEmpty(() => { }));
} }
} }

View File

@ -29,15 +29,8 @@ namespace Kyoo.Tests.Utility
[Fact] [Fact]
public void CompleteTest() public void CompleteTest()
{ {
Studio genre = new() Studio genre = new() { Name = "merged" };
{ Studio genre2 = new() { Name = "test", Id = 5.AsGuid(), };
Name = "merged"
};
Studio genre2 = new()
{
Name = "test",
Id = 5.AsGuid(),
};
Studio ret = Merger.Complete(genre, genre2); Studio ret = Merger.Complete(genre, genre2);
Assert.True(ReferenceEquals(genre, ret)); Assert.True(ReferenceEquals(genre, ret));
Assert.Equal(5.AsGuid(), ret.Id); Assert.Equal(5.AsGuid(), ret.Id);
@ -48,15 +41,8 @@ namespace Kyoo.Tests.Utility
[Fact] [Fact]
public void CompleteDictionaryTest() public void CompleteDictionaryTest()
{ {
Collection collection = new() Collection collection = new() { Name = "merged", };
{ Collection collection2 = new() { Id = 5.AsGuid(), Name = "test", };
Name = "merged",
};
Collection collection2 = new()
{
Id = 5.AsGuid(),
Name = "test",
};
Collection ret = Merger.Complete(collection, collection2); Collection ret = Merger.Complete(collection, collection2);
Assert.True(ReferenceEquals(collection, ret)); Assert.True(ReferenceEquals(collection, ret));
Assert.Equal(5.AsGuid(), ret.Id); Assert.Equal(5.AsGuid(), ret.Id);
@ -67,17 +53,14 @@ namespace Kyoo.Tests.Utility
[Fact] [Fact]
public void CompleteDictionaryOutParam() public void CompleteDictionaryOutParam()
{ {
Dictionary<string, string> first = new() Dictionary<string, string> first = new() { ["logo"] = "logo", ["poster"] = "poster" };
{ Dictionary<string, string> second =
["logo"] = "logo", new() { ["poster"] = "new-poster", ["thumbnail"] = "thumbnails" };
["poster"] = "poster" IDictionary<string, string> ret = Merger.CompleteDictionaries(
}; first,
Dictionary<string, string> second = new() second,
{ out bool changed
["poster"] = "new-poster", );
["thumbnail"] = "thumbnails"
};
IDictionary<string, string> ret = Merger.CompleteDictionaries(first, second, out bool changed);
Assert.True(changed); Assert.True(changed);
Assert.Equal(3, ret.Count); Assert.Equal(3, ret.Count);
Assert.Equal("new-poster", ret["poster"]); Assert.Equal("new-poster", ret["poster"]);
@ -88,15 +71,13 @@ namespace Kyoo.Tests.Utility
[Fact] [Fact]
public void CompleteDictionaryEqualTest() public void CompleteDictionaryEqualTest()
{ {
Dictionary<string, string> first = new() Dictionary<string, string> first = new() { ["poster"] = "poster" };
{ Dictionary<string, string> second = new() { ["poster"] = "new-poster", };
["poster"] = "poster" IDictionary<string, string> ret = Merger.CompleteDictionaries(
}; first,
Dictionary<string, string> second = new() second,
{ out bool changed
["poster"] = "new-poster", );
};
IDictionary<string, string> ret = Merger.CompleteDictionaries(first, second, out bool changed);
Assert.True(changed); Assert.True(changed);
Assert.Single(ret); Assert.Single(ret);
Assert.Equal("new-poster", ret["poster"]); Assert.Equal("new-poster", ret["poster"]);
@ -121,17 +102,8 @@ namespace Kyoo.Tests.Utility
[Fact] [Fact]
public void CompleteDictionaryNoChangeNoSetTest() public void CompleteDictionaryNoChangeNoSetTest()
{ {
TestMergeSetter first = new() TestMergeSetter first = new() { Backing = new Dictionary<int, int> { [2] = 3 } };
{ TestMergeSetter second = new() { Backing = new Dictionary<int, int>() };
Backing = new Dictionary<int, int>
{
[2] = 3
}
};
TestMergeSetter second = new()
{
Backing = new Dictionary<int, int>()
};
Merger.Complete(first, second); Merger.Complete(first, second);
// This should no call the setter of first so the test should pass. // This should no call the setter of first so the test should pass.
} }
@ -139,17 +111,14 @@ namespace Kyoo.Tests.Utility
[Fact] [Fact]
public void CompleteDictionaryNullValue() public void CompleteDictionaryNullValue()
{ {
Dictionary<string, string> first = new() Dictionary<string, string> first = new() { ["logo"] = "logo", ["poster"] = null };
{ Dictionary<string, string> second =
["logo"] = "logo", new() { ["poster"] = "new-poster", ["thumbnail"] = "thumbnails" };
["poster"] = null IDictionary<string, string> ret = Merger.CompleteDictionaries(
}; first,
Dictionary<string, string> second = new() second,
{ out bool changed
["poster"] = "new-poster", );
["thumbnail"] = "thumbnails"
};
IDictionary<string, string> ret = Merger.CompleteDictionaries(first, second, out bool changed);
Assert.True(changed); Assert.True(changed);
Assert.Equal(3, ret.Count); Assert.Equal(3, ret.Count);
Assert.Equal("new-poster", ret["poster"]); Assert.Equal("new-poster", ret["poster"]);
@ -160,16 +129,13 @@ namespace Kyoo.Tests.Utility
[Fact] [Fact]
public void CompleteDictionaryNullValueNoChange() public void CompleteDictionaryNullValueNoChange()
{ {
Dictionary<string, string> first = new() Dictionary<string, string> first = new() { ["logo"] = "logo", ["poster"] = null };
{ Dictionary<string, string> second = new() { ["poster"] = null, };
["logo"] = "logo", IDictionary<string, string> ret = Merger.CompleteDictionaries(
["poster"] = null first,
}; second,
Dictionary<string, string> second = new() out bool changed
{ );
["poster"] = null,
};
IDictionary<string, string> ret = Merger.CompleteDictionaries(first, second, out bool changed);
Assert.False(changed); Assert.False(changed);
Assert.Equal(2, ret.Count); Assert.Equal(2, ret.Count);
Assert.Null(ret["poster"]); Assert.Null(ret["poster"]);

Some files were not shown because too many files have changed in this diff Show More