diff --git a/back/src/Kyoo.Abstractions/Controllers/IIssueRepository.cs b/back/src/Kyoo.Abstractions/Controllers/IIssueRepository.cs new file mode 100644 index 00000000..831e50bb --- /dev/null +++ b/back/src/Kyoo.Abstractions/Controllers/IIssueRepository.cs @@ -0,0 +1,35 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; + +namespace Kyoo.Abstractions.Controllers; + +public interface IIssueRepository +{ + Task> GetAll(Filter? filter = default); + + Task GetCount(Filter? filter = default); + + Task Upsert(Issue issue); + + Task DeleteAll(Filter? filter = default); +} diff --git a/back/src/Kyoo.Abstractions/Models/Issues.cs b/back/src/Kyoo.Abstractions/Models/Issues.cs index acc62b76..851dc29f 100644 --- a/back/src/Kyoo.Abstractions/Models/Issues.cs +++ b/back/src/Kyoo.Abstractions/Models/Issues.cs @@ -45,7 +45,7 @@ public class Issue : IAddedDate /// /// Some extra data that could store domain-specific info. /// - public Dictionary Extra { get; set; } + public Dictionary Extra { get; set; } = new(); /// public DateTime AddedDate { get; set; } diff --git a/back/src/Kyoo.Core/Controllers/Repositories/EfHelpers.cs b/back/src/Kyoo.Core/Controllers/Repositories/EfHelpers.cs new file mode 100644 index 00000000..76f0e731 --- /dev/null +++ b/back/src/Kyoo.Core/Controllers/Repositories/EfHelpers.cs @@ -0,0 +1,140 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Kyoo.Utils; + +namespace Kyoo.Core.Controllers; + +public static class EfHelpers +{ + public static Expression> ToEfLambda(this Filter? filter) + { + if (filter == null) + return x => true; + + ParameterExpression x = Expression.Parameter(typeof(T), "x"); + + Expression CmpRandomHandler(string cmp, string seed, Guid refId) + { + MethodInfo concat = typeof(string).GetMethod( + 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 left = Expression.Call( + typeof(DatabaseContext), + nameof(DatabaseContext.MD5), + null, + xrng + ); + Expression right = Expression.Call( + typeof(DatabaseContext), + nameof(DatabaseContext.MD5), + null, + Expression.Constant($"{seed}{refId}") + ); + return cmp switch + { + "=" => Expression.Equal(left, right), + "<" => Expression.GreaterThan(left, right), + ">" => Expression.LessThan(left, right), + _ => throw new NotImplementedException() + }; + } + + BinaryExpression StringCompatibleExpression( + Func operand, + string property, + object value + ) + { + var left = Expression.Property(x, property); + var right = Expression.Constant(value, ((PropertyInfo)left.Member).PropertyType); + if (left.Type != typeof(string)) + return operand(left, right); + MethodCallExpression call = Expression.Call( + typeof(string), + "Compare", + null, + left, + right + ); + return operand(call, Expression.Constant(0)); + } + + Expression Exp( + Func operand, + string property, + object? value + ) + { + var prop = Expression.Property(x, property); + var val = Expression.Constant(value, ((PropertyInfo)prop.Member).PropertyType); + return operand(prop, val); + } + + Expression Parse(Filter f) + { + return f switch + { + Filter.And(var first, var second) + => Expression.AndAlso(Parse(first), Parse(second)), + Filter.Or(var first, var second) + => Expression.OrElse(Parse(first), Parse(second)), + Filter.Not(var inner) => Expression.Not(Parse(inner)), + Filter.Eq(var property, var value) => Exp(Expression.Equal, property, value), + Filter.Ne(var property, var value) => Exp(Expression.NotEqual, property, value), + Filter.Gt(var property, var value) + => StringCompatibleExpression(Expression.GreaterThan, property, value), + Filter.Ge(var property, var value) + => StringCompatibleExpression(Expression.GreaterThanOrEqual, property, value), + Filter.Lt(var property, var value) + => StringCompatibleExpression(Expression.LessThan, property, value), + Filter.Le(var property, var value) + => StringCompatibleExpression(Expression.LessThanOrEqual, property, value), + Filter.Has(var property, var value) + => Expression.Call( + typeof(Enumerable), + "Contains", + new[] { value.GetType() }, + Expression.Property(x, property), + Expression.Constant(value) + ), + Filter.CmpRandom(var op, var seed, var refId) + => CmpRandomHandler(op, seed, refId), + Filter.Lambda(var lambda) + => ExpressionArgumentReplacer.ReplaceParams(lambda.Body, lambda.Parameters, x), + _ => throw new NotImplementedException(), + }; + } + + Expression body = Parse(filter); + return Expression.Lambda>(body, x); + } +} diff --git a/back/src/Kyoo.Core/Controllers/Repositories/IssueRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/IssueRepository.cs new file mode 100644 index 00000000..f4cb64ae --- /dev/null +++ b/back/src/Kyoo.Core/Controllers/Repositories/IssueRepository.cs @@ -0,0 +1,54 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; +using Kyoo.Postgresql; +using Microsoft.EntityFrameworkCore; + +namespace Kyoo.Core.Controllers; + +public class IssueRepository(DatabaseContext database) : IIssueRepository +{ + public async Task> GetAll(Filter? filter = null) + { + return await database.Issues.Where(filter.ToEfLambda()).ToListAsync(); + } + + public Task GetCount(Filter? filter = null) + { + return database.Issues.Where(filter.ToEfLambda()).CountAsync(); + } + + public async Task Upsert(Issue issue) + { + issue.AddedDate = DateTime.UtcNow; + await database.Issues.Upsert(issue).RunAsync(); + return issue; + } + + public Task DeleteAll(Filter? filter = null) + { + return database.Issues.Where(filter.ToEfLambda()).ExecuteDeleteAsync(); + } +} diff --git a/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs b/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs index 38130e24..f502d380 100644 --- a/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs +++ b/back/src/Kyoo.Core/Controllers/Repositories/LocalRepository.cs @@ -118,125 +118,6 @@ namespace Kyoo.Core.Controllers return _Sort(query, sortBy, false).ThenBy(x => x.Id); } - protected Expression> ParseFilter(Filter? filter) - { - if (filter == null) - return x => true; - - ParameterExpression x = Expression.Parameter(typeof(T), "x"); - - Expression CmpRandomHandler(string cmp, string seed, Guid refId) - { - MethodInfo concat = typeof(string).GetMethod( - 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 left = Expression.Call( - typeof(DatabaseContext), - nameof(DatabaseContext.MD5), - null, - xrng - ); - Expression right = Expression.Call( - typeof(DatabaseContext), - nameof(DatabaseContext.MD5), - null, - Expression.Constant($"{seed}{refId}") - ); - return cmp switch - { - "=" => Expression.Equal(left, right), - "<" => Expression.GreaterThan(left, right), - ">" => Expression.LessThan(left, right), - _ => throw new NotImplementedException() - }; - } - - BinaryExpression StringCompatibleExpression( - Func operand, - string property, - object value - ) - { - var left = Expression.Property(x, property); - var right = Expression.Constant(value, ((PropertyInfo)left.Member).PropertyType); - if (left.Type != typeof(string)) - return operand(left, right); - MethodCallExpression call = Expression.Call( - typeof(string), - "Compare", - null, - left, - right - ); - return operand(call, Expression.Constant(0)); - } - - Expression Exp( - Func operand, - string property, - object? value - ) - { - var prop = Expression.Property(x, property); - var val = Expression.Constant(value, ((PropertyInfo)prop.Member).PropertyType); - return operand(prop, val); - } - - Expression Parse(Filter f) - { - return f switch - { - Filter.And(var first, var second) - => Expression.AndAlso(Parse(first), Parse(second)), - Filter.Or(var first, var second) - => Expression.OrElse(Parse(first), Parse(second)), - Filter.Not(var inner) => Expression.Not(Parse(inner)), - Filter.Eq(var property, var value) => Exp(Expression.Equal, property, value), - Filter.Ne(var property, var value) - => Exp(Expression.NotEqual, property, value), - Filter.Gt(var property, var value) - => StringCompatibleExpression(Expression.GreaterThan, property, value), - Filter.Ge(var property, var value) - => StringCompatibleExpression( - Expression.GreaterThanOrEqual, - property, - value - ), - Filter.Lt(var property, var value) - => StringCompatibleExpression(Expression.LessThan, property, value), - Filter.Le(var property, var value) - => StringCompatibleExpression(Expression.LessThanOrEqual, property, value), - Filter.Has(var property, var value) - => Expression.Call( - typeof(Enumerable), - "Contains", - new[] { value.GetType() }, - Expression.Property(x, property), - Expression.Constant(value) - ), - Filter.CmpRandom(var op, var seed, var refId) - => CmpRandomHandler(op, seed, refId), - Filter.Lambda(var lambda) - => ExpressionArgumentReplacer.ReplaceParams( - lambda.Body, - lambda.Parameters, - x - ), - _ => throw new NotImplementedException(), - }; - } - - Expression body = Parse(filter); - return Expression.Lambda>(body, x); - } - protected IQueryable AddIncludes(IQueryable query, Include? include) { if (include == null) @@ -405,7 +286,7 @@ namespace Kyoo.Core.Controllers filter = Filter.And(filter, keysetFilter); } if (filter != null) - query = query.Where(ParseFilter(filter)); + query = query.Where(filter.ToEfLambda()); if (limit.Reverse) query = query.Reverse(); @@ -422,7 +303,7 @@ namespace Kyoo.Core.Controllers { IQueryable query = Database.Set(); if (filter != null) - query = query.Where(ParseFilter(filter)); + query = query.Where(filter.ToEfLambda()); return query.CountAsync(); } diff --git a/back/src/Kyoo.Core/CoreModule.cs b/back/src/Kyoo.Core/CoreModule.cs index 121fe7b1..24138b52 100644 --- a/back/src/Kyoo.Core/CoreModule.cs +++ b/back/src/Kyoo.Core/CoreModule.cs @@ -75,6 +75,11 @@ namespace Kyoo.Core .As() .AsSelf() .InstancePerLifetimeScope(); + builder + .RegisterType() + .As() + .AsSelf() + .InstancePerLifetimeScope(); builder.RegisterType().InstancePerLifetimeScope(); } diff --git a/back/src/Kyoo.Core/Views/Metadata/IssueApi.cs b/back/src/Kyoo.Core/Views/Metadata/IssueApi.cs new file mode 100644 index 00000000..f1d0a83a --- /dev/null +++ b/back/src/Kyoo.Core/Views/Metadata/IssueApi.cs @@ -0,0 +1,119 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Collections.Generic; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Attributes; +using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using static Kyoo.Abstractions.Models.Utils.Constants; + +namespace Kyoo.Core.Api; + +/// +/// Create or list issues on the instance +/// +[Route("issues")] +[Route("issue", Order = AlternativeRoute)] +[ApiController] +[PartialPermission(nameof(Issue), Group = Group.Admin)] +[ApiDefinition("Issue", Group = AdminGroup)] +public class IssueApi(IIssueRepository issues) : Controller +{ + /// + /// Get count + /// + /// + /// Get the number of issues that match the filters. + /// + /// A list of filters to respect. + /// How many issues matched that filter. + /// Invalid filters. + [HttpGet("count")] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task> GetCount([FromQuery] Filter filter) + { + return await issues.GetCount(filter); + } + + /// + /// Get all issues + /// + /// + /// Get all issues that match the given filter. + /// + /// Filter the returned items. + /// A list of issues that match every filters. + /// Invalid filters or sort information. + [HttpGet] + [PartialPermission(Kind.Read)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task>> GetAll([FromQuery] Filter? filter) + { + return Ok(await issues.GetAll(filter)); + } + + /// + /// Upsert issue + /// + /// + /// Create or update an issue. + /// + /// The issue to create. + /// The created issue. + /// The issue in the request body is invalid. + [HttpPost] + [PartialPermission(Kind.Create)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task> Create([FromBody] Issue issue) + { + return await issues.Upsert(issue); + } + + /// + /// Delete issues + /// + /// + /// Delete all issues matching the given filters. + /// + /// The list of filters. + /// The item(s) has successfully been deleted. + /// One or multiple filters are invalid. + [HttpDelete] + [PartialPermission(Kind.Delete)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] + public async Task Delete([FromQuery] Filter filter) + { + if (filter == null) + return BadRequest( + new RequestError("Incule a filter to delete items, all items won't be deleted.") + ); + + await issues.DeleteAll(filter); + return NoContent(); + } +} diff --git a/back/src/Kyoo.Core/Views/Resources/UserApi.cs b/back/src/Kyoo.Core/Views/Resources/UserApi.cs index a3fdb9ef..345037d6 100644 --- a/back/src/Kyoo.Core/Views/Resources/UserApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/UserApi.cs @@ -39,7 +39,7 @@ namespace Kyoo.Core.Api; [PartialPermission(nameof(User), Group = Group.Admin)] [ApiDefinition("Users", Group = ResourcesGroup)] public class UserApi(ILibraryManager libraryManager, IThumbnailsManager thumbs) - : CrudApi(libraryManager.Users) + : CrudApi(libraryManager!.Users) { /// /// Get profile picture diff --git a/back/src/Kyoo.Postgresql/DatabaseContext.cs b/back/src/Kyoo.Postgresql/DatabaseContext.cs index 683a0fc5..2e559989 100644 --- a/back/src/Kyoo.Postgresql/DatabaseContext.cs +++ b/back/src/Kyoo.Postgresql/DatabaseContext.cs @@ -409,9 +409,7 @@ namespace Kyoo.Postgresql modelBuilder.Entity().Ignore(x => x.Links); - - modelBuilder.Entity() - .HasKey(x => new { x.Domain, x.Cause }); + modelBuilder.Entity().HasKey(x => new { x.Domain, x.Cause }); // TODO: Waiting for https://github.com/dotnet/efcore/issues/29825 // modelBuilder.Entity() @@ -419,7 +417,8 @@ namespace Kyoo.Postgresql // { // x.ToJson(); // }); - modelBuilder.Entity() + modelBuilder + .Entity() .Property(x => x.Extra) .HasConversion( v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), diff --git a/back/src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.cs b/back/src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.cs index 8c8a6ae4..f4763cf6 100644 --- a/back/src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.cs +++ b/back/src/Kyoo.Postgresql/Migrations/20240217143306_AddIssues.cs @@ -13,7 +13,8 @@ namespace Kyoo.Postgresql.Migrations { migrationBuilder.DropForeignKey( name: "fk_show_watch_status_episodes_next_episode_id", - table: "show_watch_status"); + table: "show_watch_status" + ); migrationBuilder.CreateTable( name: "issues", @@ -23,12 +24,17 @@ namespace Kyoo.Postgresql.Migrations cause = table.Column(type: "text", nullable: false), reason = table.Column(type: "text", nullable: false), extra = table.Column(type: "json", nullable: false), - added_date = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'") + added_date = table.Column( + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now() at time zone 'utc'" + ) }, constraints: table => { table.PrimaryKey("pk_issues", x => new { x.domain, x.cause }); - }); + } + ); migrationBuilder.AddForeignKey( name: "fk_show_watch_status_episodes_next_episode_id", @@ -36,7 +42,8 @@ namespace Kyoo.Postgresql.Migrations column: "next_episode_id", principalTable: "episodes", principalColumn: "id", - onDelete: ReferentialAction.SetNull); + onDelete: ReferentialAction.SetNull + ); } /// @@ -44,17 +51,18 @@ namespace Kyoo.Postgresql.Migrations { migrationBuilder.DropForeignKey( name: "fk_show_watch_status_episodes_next_episode_id", - table: "show_watch_status"); + table: "show_watch_status" + ); - migrationBuilder.DropTable( - name: "issues"); + migrationBuilder.DropTable(name: "issues"); migrationBuilder.AddForeignKey( name: "fk_show_watch_status_episodes_next_episode_id", table: "show_watch_status", column: "next_episode_id", principalTable: "episodes", - principalColumn: "id"); + principalColumn: "id" + ); } } }