diff --git a/back/Dockerfile b/back/Dockerfile index fd30450e..a0a6cfd3 100644 --- a/back/Dockerfile +++ b/back/Dockerfile @@ -10,6 +10,7 @@ COPY src/Kyoo.Abstractions/Kyoo.Abstractions.csproj src/Kyoo.Abstractions/Kyoo.A COPY src/Kyoo.Core/Kyoo.Core.csproj src/Kyoo.Core/Kyoo.Core.csproj COPY src/Kyoo.Host/Kyoo.Host.csproj src/Kyoo.Host/Kyoo.Host.csproj COPY src/Kyoo.Postgresql/Kyoo.Postgresql.csproj src/Kyoo.Postgresql/Kyoo.Postgresql.csproj +COPY src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj COPY src/Kyoo.Swagger/Kyoo.Swagger.csproj src/Kyoo.Swagger/Kyoo.Swagger.csproj COPY tests/Kyoo.Tests/Kyoo.Tests.csproj tests/Kyoo.Tests/Kyoo.Tests.csproj RUN dotnet restore -a $TARGETARCH diff --git a/back/Dockerfile.dev b/back/Dockerfile.dev index 71a2f5bd..dc71fe07 100644 --- a/back/Dockerfile.dev +++ b/back/Dockerfile.dev @@ -10,6 +10,7 @@ COPY src/Kyoo.Abstractions/Kyoo.Abstractions.csproj src/Kyoo.Abstractions/Kyoo.A COPY src/Kyoo.Core/Kyoo.Core.csproj src/Kyoo.Core/Kyoo.Core.csproj COPY src/Kyoo.Host/Kyoo.Host.csproj src/Kyoo.Host/Kyoo.Host.csproj COPY src/Kyoo.Postgresql/Kyoo.Postgresql.csproj src/Kyoo.Postgresql/Kyoo.Postgresql.csproj +COPY src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj src/Kyoo.Meilisearch/Kyoo.Meilisearch.csproj COPY src/Kyoo.Swagger/Kyoo.Swagger.csproj src/Kyoo.Swagger/Kyoo.Swagger.csproj COPY tests/Kyoo.Tests/Kyoo.Tests.csproj tests/Kyoo.Tests/Kyoo.Tests.csproj RUN dotnet restore diff --git a/back/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs b/back/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs index 6e84c43a..e17d68e2 100644 --- a/back/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs +++ b/back/src/Kyoo.Abstractions/Controllers/ILibraryManager.cs @@ -25,6 +25,9 @@ namespace Kyoo.Abstractions.Controllers /// public interface ILibraryManager { + IRepository Repository() + where T : class, IResource; + /// /// The repository that handle libraries items (a wrapper around shows and collections). /// diff --git a/back/src/Kyoo.Abstractions/Controllers/ISearchManager.cs b/back/src/Kyoo.Abstractions/Controllers/ISearchManager.cs new file mode 100644 index 00000000..68c23b2a --- /dev/null +++ b/back/src/Kyoo.Abstractions/Controllers/ISearchManager.cs @@ -0,0 +1,81 @@ +// 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.Threading.Tasks; +using Kyoo.Abstractions.Models; +using Kyoo.Abstractions.Models.Utils; + +namespace Kyoo.Abstractions.Controllers; + +/// +/// The service to search items. +/// +public interface ISearchManager +{ + /// + /// Search for items. + /// + /// The seach query. + /// Sort information about the query (sort by, sort order) + /// How pagination should be done (where to start and how many to return) + /// The related fields to include. + /// A list of resources that match every filters + public Task.SearchResult> SearchItems(string? query, + Sort sortBy, + SearchPagination pagination, + Include? include = default); + + /// + /// Search for movies. + /// + /// The seach query. + /// Sort information about the query (sort by, sort order) + /// How pagination should be done (where to start and how many to return) + /// The related fields to include. + /// A list of resources that match every filters + public Task.SearchResult> SearchMovies(string? query, + Sort sortBy, + SearchPagination pagination, + Include? include = default); + + /// + /// Search for shows. + /// + /// The seach query. + /// Sort information about the query (sort by, sort order) + /// How pagination should be done (where to start and how many to return) + /// The related fields to include. + /// A list of resources that match every filters + public Task.SearchResult> SearchShows(string? query, + Sort sortBy, + SearchPagination pagination, + Include? include = default); + + /// + /// Search for collections. + /// + /// The seach query. + /// Sort information about the query (sort by, sort order) + /// How pagination should be done (where to start and how many to return) + /// The related fields to include. + /// A list of resources that match every filters + public Task.SearchResult> SearchCollections(string? query, + Sort sortBy, + SearchPagination pagination, + Include? include = default); +} diff --git a/back/src/Kyoo.Abstractions/Models/Page.cs b/back/src/Kyoo.Abstractions/Models/Page.cs index 49c7d7cc..f624370e 100644 --- a/back/src/Kyoo.Abstractions/Models/Page.cs +++ b/back/src/Kyoo.Abstractions/Models/Page.cs @@ -67,7 +67,7 @@ namespace Kyoo.Abstractions.Models /// The link of the previous page. /// The link of the next page. /// The link of the first page. - public Page(ICollection items, string @this, string previous, string next, string first) + public Page(ICollection items, string @this, string? previous, string? next, string first) { Items = items; This = @this; diff --git a/back/src/Kyoo.Abstractions/Models/SearchPage.cs b/back/src/Kyoo.Abstractions/Models/SearchPage.cs new file mode 100644 index 00000000..27a29c0d --- /dev/null +++ b/back/src/Kyoo.Abstractions/Models/SearchPage.cs @@ -0,0 +1,53 @@ +// 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; + +namespace Kyoo.Abstractions.Models +{ + /// + /// Results of a search request. + /// + /// The search item's type. + public class SearchPage : Page + where T : IResource + { + public SearchPage( + SearchResult result, + string @this, + string? previous, + string? next, + string first) + : base(result.Items, @this, previous, next, first) + { + Query = result.Query; + } + + /// + /// The query of the search request. + /// + public string? Query { get; init; } + + public class SearchResult + { + public string? Query { get; set; } + + public ICollection Items { get; set; } + } + } +} diff --git a/back/src/Kyoo.Abstractions/Models/SearchResult.cs b/back/src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs similarity index 70% rename from back/src/Kyoo.Abstractions/Models/SearchResult.cs rename to back/src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs index b47a4ad0..173d4eee 100644 --- a/back/src/Kyoo.Abstractions/Models/SearchResult.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/SearchPagination.cs @@ -16,19 +16,21 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . -using System.Collections.Generic; - -namespace Kyoo.Abstractions.Models +namespace Kyoo.Abstractions.Controllers { /// - /// Results of a search request. + /// Information about the pagination. How many items should be displayed and where to start. /// - public class SearchPage : Page - where T : class, IResource + public class SearchPagination { /// - /// The query of the search request. + /// The count of items to return. /// - public string Query { get; init; } + public int Limit { get; set; } = 50; + + /// + /// Where to start? How many items to skip? + /// + public int? Skip { get; set; } } } diff --git a/back/src/Kyoo.Core/Controllers/LibraryManager.cs b/back/src/Kyoo.Core/Controllers/LibraryManager.cs index bceab8f8..a4082f51 100644 --- a/back/src/Kyoo.Core/Controllers/LibraryManager.cs +++ b/back/src/Kyoo.Core/Controllers/LibraryManager.cs @@ -16,6 +16,7 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . +using System.Linq; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; @@ -26,6 +27,8 @@ namespace Kyoo.Core.Controllers /// public class LibraryManager : ILibraryManager { + private readonly IBaseRepository[] _repositories; + public LibraryManager( IRepository libraryItemRepository, IRepository collectionRepository, @@ -46,6 +49,19 @@ namespace Kyoo.Core.Controllers People = peopleRepository; Studios = studioRepository; Users = userRepository; + + _repositories = new IBaseRepository[] + { + LibraryItems, + Collections, + Movies, + Shows, + Seasons, + Episodes, + People, + Studios, + Users + }; } /// @@ -74,5 +90,11 @@ namespace Kyoo.Core.Controllers /// public IRepository Users { get; } + + public IRepository Repository() + where T : class, IResource + { + return (IRepository)_repositories.First(x => x.RepositoryType == typeof(T)); + } } } diff --git a/back/src/Kyoo.Core/Views/Helper/BaseApi.cs b/back/src/Kyoo.Core/Views/Helper/BaseApi.cs index 9b94130c..289ca809 100644 --- a/back/src/Kyoo.Core/Views/Helper/BaseApi.cs +++ b/back/src/Kyoo.Core/Views/Helper/BaseApi.cs @@ -20,7 +20,9 @@ using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; +using Kyoo.Utils; using Microsoft.AspNetCore.Mvc; namespace Kyoo.Core.Api @@ -63,5 +65,45 @@ namespace Kyoo.Core.Api limit ); } + + protected SearchPage SearchPage(SearchPage.SearchResult result) + where TResult : IResource + { + Dictionary query = Request.Query.ToDictionary( + x => x.Key, + x => x.Value.ToString(), + StringComparer.InvariantCultureIgnoreCase + ); + + string self = Request.Path + query.ToQueryString(); + string? previous = null; + string? next = null; + string first; + int limit = query.TryGetValue("limit", out string? limitStr) ? int.Parse(limitStr) : new SearchPagination().Limit; + int? skip = query.TryGetValue("skip", out string? skipStr) ? int.Parse(skipStr) : null; + + if (skip != null) + { + query["skip"] = Math.Max(0, skip.Value - limit).ToString(); + previous = Request.Path + query.ToQueryString(); + } + if (result.Items.Count == limit && limit > 0) + { + int newSkip = skip.HasValue ? skip.Value + limit : limit; + query["skip"] = newSkip.ToString(); + next = Request.Path + query.ToQueryString(); + } + + query.Remove("skip"); + first = Request.Path + query.ToQueryString(); + + return new SearchPage( + result, + self, + previous, + next, + first + ); + } } } diff --git a/back/src/Kyoo.Core/Views/Resources/SearchApi.cs b/back/src/Kyoo.Core/Views/Resources/SearchApi.cs index 35e45de2..4c30c8f1 100644 --- a/back/src/Kyoo.Core/Views/Resources/SearchApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/SearchApi.cs @@ -33,26 +33,22 @@ namespace Kyoo.Core.Api /// An endpoint to search for every resources of kyoo. Searching for only a specific type of resource /// is available on the said endpoint. /// - [Route("search/{query}")] + [Route("search/{query?}")] [ApiController] [ResourceView] [ApiDefinition("Search", Group = ResourcesGroup)] - public class SearchApi : ControllerBase + public class SearchApi : BaseApi { - /// - /// The library manager used to modify or retrieve information in the data store. - /// private readonly ILibraryManager _libraryManager; + private readonly ISearchManager _searchManager; - /// - /// Create a new . - /// - /// The library manager used to interact with the data store. - public SearchApi(ILibraryManager libraryManager) + public SearchApi(ISearchManager searchManager) { - _libraryManager = libraryManager; + _searchManager = searchManager; } + // TODO: add filters and facets + /// /// Search collections /// @@ -91,6 +87,30 @@ namespace Kyoo.Core.Api return _libraryManager.Shows.Search(query); } + /// + /// Search movie + /// + /// + /// Search for movie + /// + /// The query to search for. + /// Sort information about the query (sort by, sort order). + /// How many items per page should be returned, where should the page start... + /// The aditional fields to include in the result. + /// A list of movies found for the specified query. + [HttpGet("movies")] + [HttpGet("movie", Order = AlternativeRoute)] + [Permission(nameof(Movie), Kind.Read)] + [ApiDefinition("Movie")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> SearchMovies(string? query, + [FromQuery] Sort sortBy, + [FromQuery] SearchPagination pagination, + [FromQuery] Include fields) + { + return SearchPage(await _searchManager.SearchMovies(query, sortBy, pagination, fields)); + } + /// /// Search items /// diff --git a/back/src/Kyoo.Host/Application.cs b/back/src/Kyoo.Host/Application.cs index f266c579..f9d15f3f 100644 --- a/back/src/Kyoo.Host/Application.cs +++ b/back/src/Kyoo.Host/Application.cs @@ -23,6 +23,7 @@ using System.Threading; using System.Threading.Tasks; using Autofac; using Autofac.Extensions.DependencyInjection; +using Kyoo.Meiliseach; using Kyoo.Postgresql; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -102,6 +103,7 @@ namespace Kyoo.Host .Build(); PostgresModule.Initialize(host.Services); + await MeilisearchModule.Initialize(host.Services); await _StartWithHost(host); } diff --git a/back/src/Kyoo.Host/Kyoo.Host.csproj b/back/src/Kyoo.Host/Kyoo.Host.csproj index 51d550bd..4f9d7dc2 100644 --- a/back/src/Kyoo.Host/Kyoo.Host.csproj +++ b/back/src/Kyoo.Host/Kyoo.Host.csproj @@ -22,6 +22,7 @@ + diff --git a/back/src/Kyoo.Host/PluginsStartup.cs b/back/src/Kyoo.Host/PluginsStartup.cs index 076e4ff6..b89db6b2 100644 --- a/back/src/Kyoo.Host/PluginsStartup.cs +++ b/back/src/Kyoo.Host/PluginsStartup.cs @@ -25,6 +25,7 @@ using Kyoo.Abstractions.Controllers; using Kyoo.Authentication; using Kyoo.Core; using Kyoo.Host.Controllers; +using Kyoo.Meiliseach; using Kyoo.Postgresql; using Kyoo.Swagger; using Kyoo.Utils; @@ -64,6 +65,7 @@ namespace Kyoo.Host typeof(CoreModule), typeof(AuthenticationModule), typeof(PostgresModule), + typeof(MeilisearchModule), typeof(SwaggerModule) ); } diff --git a/back/src/Kyoo.Meilisearch/MeilisearchModule.cs b/back/src/Kyoo.Meilisearch/MeilisearchModule.cs index faa16057..affd9cc7 100644 --- a/back/src/Kyoo.Meilisearch/MeilisearchModule.cs +++ b/back/src/Kyoo.Meilisearch/MeilisearchModule.cs @@ -41,11 +41,12 @@ namespace Kyoo.Meiliseach /// Init meilisearch indexes. /// /// The service list to retrieve the meilisearch client + /// A representing the asynchronous operation. public static async Task Initialize(IServiceProvider provider) { MeilisearchClient client = provider.GetRequiredService(); - await _CreateIndex(client, "items", new Settings() + await _CreateIndex(client, "items", true, new Settings() { SearchableAttributes = new[] { @@ -61,7 +62,7 @@ namespace Kyoo.Meiliseach nameof(LibraryItem.Genres), nameof(LibraryItem.Status), nameof(LibraryItem.AirDate), - nameof(LibraryItem.StudioID), + nameof(Movie.StudioID), }, SortableAttributes = new[] { @@ -69,15 +70,19 @@ namespace Kyoo.Meiliseach nameof(LibraryItem.AddedDate), nameof(LibraryItem.Kind), }, - DisplayedAttributes = new[] { nameof(LibraryItem.Id) }, + DisplayedAttributes = new[] + { + nameof(LibraryItem.Id), + nameof(LibraryItem.Kind), + }, // TODO: Add stopwords // TODO: Extend default ranking to add ratings. }); } - private static async Task _CreateIndex(MeilisearchClient client, string index, Settings opts) + private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind, Settings opts) { - TaskInfo task = await client.CreateIndexAsync(index, "Id"); + TaskInfo task = await client.CreateIndexAsync(index, hasKind ? "Ref" : nameof(IResource.Id)); await client.WaitForTaskAsync(task.TaskUid); await client.Index(index).UpdateSettingsAsync(opts); } @@ -88,8 +93,8 @@ namespace Kyoo.Meiliseach builder.RegisterInstance(new MeilisearchClient( _configuration.GetValue("MEILI_HOST", "http://meilisearch:7700"), _configuration.GetValue("MEILI_MASTER_KEY") - )).InstancePerLifetimeScope(); - builder.RegisterType().InstancePerLifetimeScope(); + )).SingleInstance(); + builder.RegisterType().SingleInstance(); } } } diff --git a/back/src/Kyoo.Meilisearch/SearchManager.cs b/back/src/Kyoo.Meilisearch/SearchManager.cs index 48081572..ce2195d5 100644 --- a/back/src/Kyoo.Meilisearch/SearchManager.cs +++ b/back/src/Kyoo.Meilisearch/SearchManager.cs @@ -16,6 +16,9 @@ // You should have received a copy of the GNU General Public License // along with Kyoo. If not, see . +using System.ComponentModel.DataAnnotations; +using System.Dynamic; +using System.Reflection; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models.Utils; @@ -23,36 +26,148 @@ using Meilisearch; namespace Kyoo.Meiliseach; -public class SearchManager +public class SearchManager : ISearchManager { private readonly MeilisearchClient _client; private readonly ILibraryManager _libraryManager; + private static IEnumerable _GetSortsBy(Sort? sort) + { + return sort switch + { + Sort.Default => Array.Empty(), + Sort.By @sortBy => new[] { $"{sortBy.Key}:{(sortBy.Desendant ? "desc" : "asc")}" }, + Sort.Conglomerate(var list) => list.SelectMany(_GetSortsBy), + Sort.Random => throw new ValidationException("Random sorting is not supported while searching."), + _ => Array.Empty(), + }; + } + public SearchManager(MeilisearchClient client, ILibraryManager libraryManager) { _client = client; _libraryManager = libraryManager; - _libraryManager.Movies.OnCreated += (x) => _CreateOrUpdate("items", x); - _libraryManager.Movies.OnEdited += (x) => _CreateOrUpdate("items", x); - _libraryManager.Movies.OnDeleted += (x) => _Delete("items", x.Id); + IRepository.OnCreated += (x) => _CreateOrUpdate("items", x, nameof(Movie)); + IRepository.OnEdited += (x) => _CreateOrUpdate("items", x, nameof(Movie)); + IRepository.OnDeleted += (x) => _Delete("items", x.Id, nameof(Movie)); + IRepository.OnCreated += (x) => _CreateOrUpdate("items", x, nameof(Show)); + IRepository.OnEdited += (x) => _CreateOrUpdate("items", x, nameof(Show)); + IRepository.OnDeleted += (x) => _Delete("items", x.Id, nameof(Show)); + IRepository.OnCreated += (x) => _CreateOrUpdate("items", x, nameof(Collection)); + IRepository.OnEdited += (x) => _CreateOrUpdate("items", x, nameof(Collection)); + IRepository.OnDeleted += (x) => _Delete("items", x.Id, nameof(Collection)); } - private Task _CreateOrUpdate(string index, IResource item) + private Task _CreateOrUpdate(string index, IResource item, string? kind = null) { + if (kind != null) + { + dynamic expando = new ExpandoObject(); + + foreach (PropertyInfo property in item.GetType().GetProperties()) + expando.TryAdd(property.Name, property.GetValue(item)); + expando.Ref = $"{kind}/{item.Id}"; + expando.Kind = kind; + return _client.Index(index).AddDocumentsAsync(new[] { item }); + } return _client.Index(index).AddDocumentsAsync(new[] { item }); } - private Task _Delete(string index, int id) + private Task _Delete(string index, int id, string? kind = null) { + if (kind != null) + { + return _client.Index(index).DeleteOneDocumentAsync($"{kind}/{id}"); + } return _client.Index(index).DeleteOneDocumentAsync(id); } - private async Task> _Search(string index, string? query, Include? include = default) + private async Task.SearchResult> _Search(string index, string? query, + string? where = null, + Sort? sortBy = default, + SearchPagination? pagination = default, + Include? include = default) + where T : class, IResource { - ISearchable res = await _client.Index(index).SearchAsync(query, new SearchQuery() + // TODO: add filters and facets + ISearchable res = await _client.Index(index).SearchAsync(query, new SearchQuery() { + Filter = where, + Sort = _GetSortsBy(sortBy), + Limit = pagination?.Limit ?? 50, + Offset = pagination?.Skip ?? 0, }); - throw new NotImplementedException(); + return new SearchPage.SearchResult + { + Query = query, + Items = await _libraryManager.Repository() + .FromIds(res.Hits.Select(x => x.Id).ToList(), include), + }; + } + + public async Task.SearchResult> SearchItems(string? query, + Sort sortBy, + SearchPagination pagination, + Include? include = default) + { + // TODO: add filters and facets + ISearchable res = await _client.Index("items").SearchAsync(query, new SearchQuery() + { + Sort = _GetSortsBy(sortBy), + Limit = pagination?.Limit ?? 50, + Offset = pagination?.Skip ?? 0, + }); + + // Since library items's ID are still ints mapped from real items ids, we must map it here to match the db's value. + // Look at the items Migration's sql to understand where magic numbers come from. + List ids = res.Hits.Select(x => x.Kind switch + { + nameof(Show) => x.Id, + nameof(Movie) => -x.Id, + nameof(Collection) => x.Id + 10_000, + _ => throw new InvalidOperationException("An unknown item kind was found in meilisearch"), + }).ToList(); + + return new SearchPage.SearchResult + { + Query = query, + Items = await _libraryManager.LibraryItems + .FromIds(ids, include), + }; + } + + /// + public Task.SearchResult> SearchMovies(string? query, + Sort sortBy, + SearchPagination pagination, + Include? include = default) + { + return _Search("items", query, $"Kind = {nameof(Movie)}", sortBy, pagination, include); + } + + /// + public Task.SearchResult> SearchShows(string? query, + Sort sortBy, + SearchPagination pagination, + Include? include = default) + { + return _Search("items", query, $"Kind = {nameof(Show)}", sortBy, pagination, include); + } + + /// + public Task.SearchResult> SearchCollections(string? query, + Sort sortBy, + SearchPagination pagination, + Include? include = default) + { + return _Search("items", query, $"Kind = {nameof(Collection)}", sortBy, pagination, include); + } + + private class IdResource + { + public int Id { get; set; } + + public string? Kind { get; set; } } }