From 97de98b89a0f5514351c9d82a39b01a2605d6034 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 26 Oct 2023 23:47:06 +0200 Subject: [PATCH] Add seed in random queries next url --- back/src/Kyoo.Abstractions/Models/Page.cs | 4 +- .../Kyoo.Abstractions/Models/Utils/Sort.cs | 16 +++-- back/src/Kyoo.Core/CoreModule.cs | 2 + back/src/Kyoo.Core/ExceptionFilter.cs | 4 ++ back/src/Kyoo.Core/Views/Helper/BaseApi.cs | 20 ++++-- back/src/Kyoo.Core/Views/Helper/CrudApi.cs | 4 +- back/src/Kyoo.Core/Views/Helper/SortBinder.cs | 65 +++++++++++++++++++ back/src/Kyoo.Core/Views/Metadata/StaffApi.cs | 7 +- .../src/Kyoo.Core/Views/Metadata/StudioApi.cs | 4 +- .../Views/Resources/CollectionApi.cs | 4 +- .../src/Kyoo.Core/Views/Resources/MovieApi.cs | 4 +- .../Kyoo.Core/Views/Resources/SeasonApi.cs | 4 +- back/src/Kyoo.Core/Views/Resources/ShowApi.cs | 19 +++--- back/src/Kyoo.Postgresql/DatabaseContext.cs | 1 - back/src/Kyoo.Postgresql/PostgresContext.cs | 1 - 15 files changed, 119 insertions(+), 40 deletions(-) create mode 100644 back/src/Kyoo.Core/Views/Helper/SortBinder.cs diff --git a/back/src/Kyoo.Abstractions/Models/Page.cs b/back/src/Kyoo.Abstractions/Models/Page.cs index fff8524e..49c7d7cc 100644 --- a/back/src/Kyoo.Abstractions/Models/Page.cs +++ b/back/src/Kyoo.Abstractions/Models/Page.cs @@ -42,12 +42,12 @@ namespace Kyoo.Abstractions.Models /// /// The link of the previous page. /// - public string Previous { get; } + public string? Previous { get; } /// /// The link of the next page. /// - public string Next { get; } + public string? Next { get; } /// /// The number of items in the current page. diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Sort.cs b/back/src/Kyoo.Abstractions/Models/Utils/Sort.cs index f08720b4..89e1f4fe 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Sort.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Sort.cs @@ -17,6 +17,7 @@ // along with Kyoo. If not, see . using System; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -57,7 +58,7 @@ namespace Kyoo.Abstractions.Controllers public record Conglomerate(params Sort[] List) : Sort; /// Sort randomly items - public record Random(int seed) : Sort; + public record Random(uint seed) : Sort; /// The default sort method for the given type. public record Default : Sort; @@ -66,19 +67,20 @@ namespace Kyoo.Abstractions.Controllers /// Create a new instance from a key's name (case insensitive). /// /// A key name with an optional order specifier. Format: "key:asc", "key:desc" or "key". + /// The random seed. /// An invalid key or sort specifier as been given. /// A for the given string - public static Sort From(string sortBy) + public static Sort From(string? sortBy, uint seed) { if (string.IsNullOrEmpty(sortBy) || sortBy == "default") return new Default(); if (sortBy == "random") - return new Random(new System.Random().Next(int.MinValue, int.MaxValue)); + return new Random(seed); if (sortBy.Contains(',')) - return new Conglomerate(sortBy.Split(',').Select(From).ToArray()); + return new Conglomerate(sortBy.Split(',').Select(x => From(x, seed)).ToArray()); if (sortBy.StartsWith("random:")) - return new Random(int.Parse(sortBy["random:".Length..])); + return new Random(uint.Parse(sortBy["random:".Length..])); string key = sortBy.Contains(':') ? sortBy[..sortBy.IndexOf(':')] : sortBy; string? order = sortBy.Contains(':') ? sortBy[(sortBy.IndexOf(':') + 1)..] : null; @@ -87,11 +89,11 @@ namespace Kyoo.Abstractions.Controllers "desc" => true, "asc" => false, null => false, - _ => throw new ArgumentException($"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}.") }; PropertyInfo? property = typeof(T).GetProperty(key, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); if (property == null) - throw new ArgumentException("The given sort key is not valid."); + throw new ValidationException("The given sort key is not valid."); return new By(property.Name, desendant); } } diff --git a/back/src/Kyoo.Core/CoreModule.cs b/back/src/Kyoo.Core/CoreModule.cs index cd0b6d1f..13a0e5a7 100644 --- a/back/src/Kyoo.Core/CoreModule.cs +++ b/back/src/Kyoo.Core/CoreModule.cs @@ -23,6 +23,7 @@ using Autofac; using Kyoo.Abstractions; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models.Utils; +using Kyoo.Core.Api; using Kyoo.Core.Controllers; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; @@ -69,6 +70,7 @@ namespace Kyoo.Core services.AddMvcCore(options => { options.Filters.Add(); + options.ModelBinderProviders.Insert(0, new SortBinder.Provider()); }) .AddNewtonsoftJson(x => { diff --git a/back/src/Kyoo.Core/ExceptionFilter.cs b/back/src/Kyoo.Core/ExceptionFilter.cs index 48dfea59..c2e5af5a 100644 --- a/back/src/Kyoo.Core/ExceptionFilter.cs +++ b/back/src/Kyoo.Core/ExceptionFilter.cs @@ -17,6 +17,7 @@ // along with Kyoo. If not, see . using System; +using System.ComponentModel.DataAnnotations; using Kyoo.Abstractions.Models.Exceptions; using Kyoo.Abstractions.Models.Utils; using Microsoft.AspNetCore.Http; @@ -50,6 +51,9 @@ namespace Kyoo.Core case ArgumentException ex: context.Result = new BadRequestObjectResult(new RequestError(ex.Message)); break; + case ValidationException ex: + context.Result = new BadRequestObjectResult(new RequestError(ex.Message)); + break; case ItemNotFoundException ex: context.Result = new NotFoundObjectResult(new RequestError(ex.Message)); break; diff --git a/back/src/Kyoo.Core/Views/Helper/BaseApi.cs b/back/src/Kyoo.Core/Views/Helper/BaseApi.cs index 70304719..9b94130c 100644 --- a/back/src/Kyoo.Core/Views/Helper/BaseApi.cs +++ b/back/src/Kyoo.Core/Views/Helper/BaseApi.cs @@ -19,6 +19,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using Kyoo.Abstractions.Models; using Microsoft.AspNetCore.Mvc; @@ -42,14 +43,23 @@ namespace Kyoo.Core.Api protected Page Page(ICollection resources, int limit) where TResult : IResource { + Dictionary query = Request.Query.ToDictionary( + x => x.Key, + x => x.Value.ToString(), + StringComparer.InvariantCultureIgnoreCase + ); + + // If the query was sorted randomly, add the seed to the url to get reproducible links (next,prev,first...) + if (query.ContainsKey("sortBy")) + { + object seed = HttpContext.Items["seed"]!; + + query["sortBy"] = Regex.Replace(query["sortBy"], "random(?!:)", $"random:{seed}"); + } return new Page( resources, Request.Path, - Request.Query.ToDictionary( - x => x.Key, - x => x.Value.ToString(), - StringComparer.InvariantCultureIgnoreCase - ), + query, limit ); } diff --git a/back/src/Kyoo.Core/Views/Helper/CrudApi.cs b/back/src/Kyoo.Core/Views/Helper/CrudApi.cs index c89d8650..488eb44b 100644 --- a/back/src/Kyoo.Core/Views/Helper/CrudApi.cs +++ b/back/src/Kyoo.Core/Views/Helper/CrudApi.cs @@ -113,13 +113,13 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] public async Task>> GetAll( - [FromQuery] string sortBy, + [FromQuery] Sort sortBy, [FromQuery] Dictionary where, [FromQuery] Pagination pagination) { ICollection resources = await Repository.GetAll( ApiHelper.ParseWhere(where), - Sort.From(sortBy), + sortBy, pagination ); diff --git a/back/src/Kyoo.Core/Views/Helper/SortBinder.cs b/back/src/Kyoo.Core/Views/Helper/SortBinder.cs new file mode 100644 index 00000000..3200201f --- /dev/null +++ b/back/src/Kyoo.Core/Views/Helper/SortBinder.cs @@ -0,0 +1,65 @@ +// 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.Reflection; +using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +namespace Kyoo.Core.Api; + +public class SortBinder : IModelBinder +{ + private readonly Random _rng = new(); + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + ValueProviderResult sortBy = bindingContext.ValueProvider.GetValue(bindingContext.FieldName); + uint seed = BitConverter.ToUInt32( + BitConverter.GetBytes(_rng.Next(int.MinValue, int.MaxValue)), + 0 + ); + try + { + object sort = bindingContext.ModelType.GetMethod(nameof(Sort.From))! + .Invoke(null, new object?[] { sortBy.FirstValue, seed })!; + bindingContext.Result = ModelBindingResult.Success(sort); + bindingContext.HttpContext.Items["seed"] = seed; + return Task.CompletedTask; + } + catch (TargetInvocationException ex) + { + throw ex.InnerException!; + } + } + + public class Provider : IModelBinderProvider + { + public IModelBinder GetBinder(ModelBinderProviderContext context) + { + if (context.Metadata.ModelType.Name == "Sort`1") + { + return new BinderTypeModelBinder(typeof(SortBinder)); + } + + return null!; + } + } +} diff --git a/back/src/Kyoo.Core/Views/Metadata/StaffApi.cs b/back/src/Kyoo.Core/Views/Metadata/StaffApi.cs index 7918cf4f..fdce01a1 100644 --- a/back/src/Kyoo.Core/Views/Metadata/StaffApi.cs +++ b/back/src/Kyoo.Core/Views/Metadata/StaffApi.cs @@ -81,16 +81,15 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetRoles(Identifier identifier, - [FromQuery] string sortBy, + [FromQuery] Sort sortBy, [FromQuery] Dictionary where, [FromQuery] Pagination pagination) { Expression>? whereQuery = ApiHelper.ParseWhere(where); - Sort sort = Sort.From(sortBy); ICollection resources = await identifier.Match( - id => _libraryManager.GetRolesFromPeople(id, whereQuery, sort, pagination), - slug => _libraryManager.GetRolesFromPeople(slug, whereQuery, sort, pagination) + id => _libraryManager.GetRolesFromPeople(id, whereQuery, sortBy, pagination), + slug => _libraryManager.GetRolesFromPeople(slug, whereQuery, sortBy, pagination) ); return Page(resources, pagination.Limit); diff --git a/back/src/Kyoo.Core/Views/Metadata/StudioApi.cs b/back/src/Kyoo.Core/Views/Metadata/StudioApi.cs index 6e3776ab..95871123 100644 --- a/back/src/Kyoo.Core/Views/Metadata/StudioApi.cs +++ b/back/src/Kyoo.Core/Views/Metadata/StudioApi.cs @@ -78,13 +78,13 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetShows(Identifier identifier, - [FromQuery] string sortBy, + [FromQuery] Sort sortBy, [FromQuery] Dictionary where, [FromQuery] Pagination pagination) { ICollection resources = await _libraryManager.GetAll( ApiHelper.ParseWhere(where, identifier.Matcher(x => x.StudioId, x => x.Studio!.Slug)), - Sort.From(sortBy), + sortBy, pagination ); diff --git a/back/src/Kyoo.Core/Views/Resources/CollectionApi.cs b/back/src/Kyoo.Core/Views/Resources/CollectionApi.cs index 5005bdf2..9e8a5e7b 100644 --- a/back/src/Kyoo.Core/Views/Resources/CollectionApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/CollectionApi.cs @@ -79,13 +79,13 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetShows(Identifier identifier, - [FromQuery] string sortBy, + [FromQuery] Sort sortBy, [FromQuery] Dictionary where, [FromQuery] Pagination pagination) { ICollection resources = await _libraryManager.GetAll( ApiHelper.ParseWhere(where, identifier.IsContainedIn(x => x.Collections!)), - Sort.From(sortBy), + sortBy, pagination ); diff --git a/back/src/Kyoo.Core/Views/Resources/MovieApi.cs b/back/src/Kyoo.Core/Views/Resources/MovieApi.cs index ab3977ad..8b3058ae 100644 --- a/back/src/Kyoo.Core/Views/Resources/MovieApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/MovieApi.cs @@ -131,13 +131,13 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetCollections(Identifier identifier, - [FromQuery] string sortBy, + [FromQuery] Sort sortBy, [FromQuery] Dictionary where, [FromQuery] Pagination pagination) { ICollection resources = await _libraryManager.GetAll( ApiHelper.ParseWhere(where, identifier.IsContainedIn(x => x.Movies!)), - Sort.From(sortBy), + sortBy, pagination ); diff --git a/back/src/Kyoo.Core/Views/Resources/SeasonApi.cs b/back/src/Kyoo.Core/Views/Resources/SeasonApi.cs index b2dccc81..f00fa9b9 100644 --- a/back/src/Kyoo.Core/Views/Resources/SeasonApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/SeasonApi.cs @@ -79,13 +79,13 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetEpisode(Identifier identifier, - [FromQuery] string sortBy, + [FromQuery] Sort sortBy, [FromQuery] Dictionary where, [FromQuery] Pagination pagination) { ICollection resources = await _libraryManager.GetAll( ApiHelper.ParseWhere(where, identifier.Matcher(x => x.SeasonId, x => x.Season!.Slug)), - Sort.From(sortBy), + sortBy, pagination ); diff --git a/back/src/Kyoo.Core/Views/Resources/ShowApi.cs b/back/src/Kyoo.Core/Views/Resources/ShowApi.cs index 5b55a5d1..eaa0913d 100644 --- a/back/src/Kyoo.Core/Views/Resources/ShowApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/ShowApi.cs @@ -81,13 +81,13 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetSeasons(Identifier identifier, - [FromQuery] string sortBy, + [FromQuery] Sort sortBy, [FromQuery] Dictionary where, [FromQuery] Pagination pagination) { ICollection resources = await _libraryManager.GetAll( ApiHelper.ParseWhere(where, identifier.Matcher(x => x.ShowId, x => x.Show!.Slug)), - Sort.From(sortBy), + sortBy, pagination ); @@ -116,13 +116,13 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetEpisodes(Identifier identifier, - [FromQuery] string sortBy, + [FromQuery] Sort sortBy, [FromQuery] Dictionary where, [FromQuery] Pagination pagination) { ICollection resources = await _libraryManager.GetAll( ApiHelper.ParseWhere(where, identifier.Matcher(x => x.ShowId, x => x.Show!.Slug)), - Sort.From(sortBy), + sortBy, pagination ); @@ -151,16 +151,15 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetPeople(Identifier identifier, - [FromQuery] string sortBy, + [FromQuery] Sort sortBy, [FromQuery] Dictionary where, [FromQuery] Pagination pagination) { Expression>? whereQuery = ApiHelper.ParseWhere(where); - Sort sort = Sort.From(sortBy); ICollection resources = await identifier.Match( - id => _libraryManager.GetPeopleFromShow(id, whereQuery, sort, pagination), - slug => _libraryManager.GetPeopleFromShow(slug, whereQuery, sort, pagination) + id => _libraryManager.GetPeopleFromShow(id, whereQuery, sortBy, pagination), + slug => _libraryManager.GetPeopleFromShow(slug, whereQuery, sortBy, pagination) ); return Page(resources, pagination.Limit); } @@ -203,13 +202,13 @@ namespace Kyoo.Core.Api [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetCollections(Identifier identifier, - [FromQuery] string sortBy, + [FromQuery] Sort sortBy, [FromQuery] Dictionary where, [FromQuery] Pagination pagination) { ICollection resources = await _libraryManager.GetAll( ApiHelper.ParseWhere(where, identifier.IsContainedIn(x => x.Shows!)), - Sort.From(sortBy), + sortBy, pagination ); diff --git a/back/src/Kyoo.Postgresql/DatabaseContext.cs b/back/src/Kyoo.Postgresql/DatabaseContext.cs index bf93273c..3e7232be 100644 --- a/back/src/Kyoo.Postgresql/DatabaseContext.cs +++ b/back/src/Kyoo.Postgresql/DatabaseContext.cs @@ -23,7 +23,6 @@ using System.Linq.Expressions; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models.Exceptions; diff --git a/back/src/Kyoo.Postgresql/PostgresContext.cs b/back/src/Kyoo.Postgresql/PostgresContext.cs index ab185f43..a55e8773 100644 --- a/back/src/Kyoo.Postgresql/PostgresContext.cs +++ b/back/src/Kyoo.Postgresql/PostgresContext.cs @@ -18,7 +18,6 @@ using System; using System.Globalization; -using System.Linq; using System.Linq.Expressions; using System.Reflection; using EFCore.NamingConventions.Internal;