From 377d85c7f18e4ebc3db29b3ccc2fa7483c5771de Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 31 Oct 2023 21:10:35 +0100 Subject: [PATCH] Add sorts to search items --- .../Kyoo.Core/Views/Resources/SearchApi.cs | 45 ++--- .../src/Kyoo.Meilisearch/MeilisearchModule.cs | 170 ++++++++++-------- back/src/Kyoo.Meilisearch/SearchManager.cs | 12 +- front/apps/web/src/pages/browse/[slug].tsx | 24 --- front/packages/ui/src/browse/header.tsx | 20 ++- front/packages/ui/src/browse/index.tsx | 17 +- front/packages/ui/src/browse/types.ts | 6 + front/packages/ui/src/search/index.tsx | 61 +++++-- front/translations/en.json | 2 + 9 files changed, 198 insertions(+), 159 deletions(-) delete mode 100644 front/apps/web/src/pages/browse/[slug].tsx diff --git a/back/src/Kyoo.Core/Views/Resources/SearchApi.cs b/back/src/Kyoo.Core/Views/Resources/SearchApi.cs index d4d4a982..59c4a081 100644 --- a/back/src/Kyoo.Core/Views/Resources/SearchApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/SearchApi.cs @@ -16,7 +16,6 @@ // 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; @@ -33,7 +32,7 @@ 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")] [ApiController] [ResourceView] [ApiDefinition("Search", Group = ResourcesGroup)] @@ -54,7 +53,7 @@ namespace Kyoo.Core.Api /// /// Search for collections /// - /// The query to search for. + /// 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. @@ -64,12 +63,13 @@ namespace Kyoo.Core.Api [Permission(nameof(Collection), Kind.Read)] [ApiDefinition("Collections")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchCollections(string? query, + public async Task> SearchCollections( + [FromQuery] string? q, [FromQuery] Sort sortBy, [FromQuery] SearchPagination pagination, [FromQuery] Include fields) { - return SearchPage(await _searchManager.SearchCollections(query, sortBy, pagination, fields)); + return SearchPage(await _searchManager.SearchCollections(q, sortBy, pagination, fields)); } /// @@ -78,7 +78,7 @@ namespace Kyoo.Core.Api /// /// Search for shows /// - /// The query to search for. + /// 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. @@ -88,12 +88,13 @@ namespace Kyoo.Core.Api [Permission(nameof(Show), Kind.Read)] [ApiDefinition("Show")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchShows(string? query, + public async Task> SearchShows( + [FromQuery] string? q, [FromQuery] Sort sortBy, [FromQuery] SearchPagination pagination, [FromQuery] Include fields) { - return SearchPage(await _searchManager.SearchShows(query, sortBy, pagination, fields)); + return SearchPage(await _searchManager.SearchShows(q, sortBy, pagination, fields)); } /// @@ -102,7 +103,7 @@ namespace Kyoo.Core.Api /// /// Search for movie /// - /// The query to search for. + /// 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. @@ -112,12 +113,13 @@ namespace Kyoo.Core.Api [Permission(nameof(Movie), Kind.Read)] [ApiDefinition("Movie")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchMovies(string? query, + public async Task> SearchMovies( + [FromQuery] string? q, [FromQuery] Sort sortBy, [FromQuery] SearchPagination pagination, [FromQuery] Include fields) { - return SearchPage(await _searchManager.SearchMovies(query, sortBy, pagination, fields)); + return SearchPage(await _searchManager.SearchMovies(q, sortBy, pagination, fields)); } /// @@ -126,7 +128,7 @@ namespace Kyoo.Core.Api /// /// Search for items /// - /// The query to search for. + /// 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. @@ -136,12 +138,13 @@ namespace Kyoo.Core.Api [Permission(nameof(LibraryItem), Kind.Read)] [ApiDefinition("Item")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchItems(string? query, + public async Task> SearchItems( + [FromQuery] string? q, [FromQuery] Sort sortBy, [FromQuery] SearchPagination pagination, [FromQuery] Include fields) { - return SearchPage(await _searchManager.SearchItems(query, sortBy, pagination, fields)); + return SearchPage(await _searchManager.SearchItems(q, sortBy, pagination, fields)); } /// @@ -150,7 +153,7 @@ namespace Kyoo.Core.Api /// /// Search for episodes /// - /// The query to search for. + /// 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. @@ -160,12 +163,13 @@ namespace Kyoo.Core.Api [Permission(nameof(Episode), Kind.Read)] [ApiDefinition("Episodes")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchEpisodes(string? query, + public async Task> SearchEpisodes( + [FromQuery] string? q, [FromQuery] Sort sortBy, [FromQuery] SearchPagination pagination, [FromQuery] Include fields) { - return SearchPage(await _searchManager.SearchEpisodes(query, sortBy, pagination, fields)); + return SearchPage(await _searchManager.SearchEpisodes(q, sortBy, pagination, fields)); } /// @@ -174,7 +178,7 @@ namespace Kyoo.Core.Api /// /// Search for studios /// - /// The query to search for. + /// 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. @@ -184,12 +188,13 @@ namespace Kyoo.Core.Api [Permission(nameof(Studio), Kind.Read)] [ApiDefinition("Studios")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> SearchStudios(string? query, + public async Task> SearchStudios( + [FromQuery] string? q, [FromQuery] Sort sortBy, [FromQuery] SearchPagination pagination, [FromQuery] Include fields) { - return SearchPage(await _searchManager.SearchStudios(query, sortBy, pagination, fields)); + return SearchPage(await _searchManager.SearchStudios(q, sortBy, pagination, fields)); } } } diff --git a/back/src/Kyoo.Meilisearch/MeilisearchModule.cs b/back/src/Kyoo.Meilisearch/MeilisearchModule.cs index 5dedf474..642d9617 100644 --- a/back/src/Kyoo.Meilisearch/MeilisearchModule.cs +++ b/back/src/Kyoo.Meilisearch/MeilisearchModule.cs @@ -33,6 +33,93 @@ namespace Kyoo.Meiliseach private readonly IConfiguration _configuration; + public static Dictionary IndexSettings => new() + { + { + "items", + new Settings() + { + SearchableAttributes = new[] + { + CamelCase.ConvertName(nameof(LibraryItem.Name)), + CamelCase.ConvertName(nameof(LibraryItem.Slug)), + CamelCase.ConvertName(nameof(LibraryItem.Aliases)), + CamelCase.ConvertName(nameof(LibraryItem.Path)), + CamelCase.ConvertName(nameof(LibraryItem.Tags)), + CamelCase.ConvertName(nameof(LibraryItem.Overview)), + }, + FilterableAttributes = new[] + { + CamelCase.ConvertName(nameof(LibraryItem.Genres)), + CamelCase.ConvertName(nameof(LibraryItem.Status)), + CamelCase.ConvertName(nameof(LibraryItem.AirDate)), + CamelCase.ConvertName(nameof(Movie.StudioID)), + CamelCase.ConvertName(nameof(LibraryItem.Kind)), + }, + SortableAttributes = new[] + { + CamelCase.ConvertName(nameof(LibraryItem.AirDate)), + CamelCase.ConvertName(nameof(LibraryItem.AddedDate)), + }, + DisplayedAttributes = new[] + { + CamelCase.ConvertName(nameof(LibraryItem.Id)), + CamelCase.ConvertName(nameof(LibraryItem.Kind)), + }, + // TODO: Add stopwords + // TODO: Extend default ranking to add ratings. + } + }, + { + nameof(Episode), + new Settings() + { + SearchableAttributes = new[] + { + CamelCase.ConvertName(nameof(Episode.Name)), + CamelCase.ConvertName(nameof(Episode.Overview)), + CamelCase.ConvertName(nameof(Episode.Slug)), + CamelCase.ConvertName(nameof(Episode.Path)), + }, + FilterableAttributes = new[] + { + CamelCase.ConvertName(nameof(Episode.SeasonNumber)), + }, + SortableAttributes = new[] + { + CamelCase.ConvertName(nameof(Episode.ReleaseDate)), + CamelCase.ConvertName(nameof(Episode.AddedDate)), + CamelCase.ConvertName(nameof(Episode.SeasonNumber)), + CamelCase.ConvertName(nameof(Episode.EpisodeNumber)), + CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)), + }, + DisplayedAttributes = new[] + { + CamelCase.ConvertName(nameof(Episode.Id)), + }, + // TODO: Add stopwords + } + }, + { + nameof(Studio), + new Settings() + { + SearchableAttributes = new[] + { + CamelCase.ConvertName(nameof(Studio.Name)), + CamelCase.ConvertName(nameof(Studio.Slug)), + }, + FilterableAttributes = Array.Empty(), + SortableAttributes = Array.Empty(), + DisplayedAttributes = new[] + { + CamelCase.ConvertName(nameof(Studio.Id)), + }, + // TODO: Add stopwords + } + }, + }; + public MeilisearchModule(IConfiguration configuration) { _configuration = configuration; @@ -47,89 +134,16 @@ namespace Kyoo.Meiliseach { MeilisearchClient client = provider.GetRequiredService(); - await _CreateIndex(client, "items", true, new Settings() - { - SearchableAttributes = new[] - { - CamelCase.ConvertName(nameof(LibraryItem.Name)), - CamelCase.ConvertName(nameof(LibraryItem.Slug)), - CamelCase.ConvertName(nameof(LibraryItem.Aliases)), - CamelCase.ConvertName(nameof(LibraryItem.Path)), - CamelCase.ConvertName(nameof(LibraryItem.Tags)), - // Overview could be included as well but I think it would be better without. - }, - FilterableAttributes = new[] - { - CamelCase.ConvertName(nameof(LibraryItem.Genres)), - CamelCase.ConvertName(nameof(LibraryItem.Status)), - CamelCase.ConvertName(nameof(LibraryItem.AirDate)), - CamelCase.ConvertName(nameof(Movie.StudioID)), - CamelCase.ConvertName(nameof(LibraryItem.Kind)), - }, - SortableAttributes = new[] - { - CamelCase.ConvertName(nameof(LibraryItem.AirDate)), - CamelCase.ConvertName(nameof(LibraryItem.AddedDate)), - }, - DisplayedAttributes = new[] - { - CamelCase.ConvertName(nameof(LibraryItem.Id)), - CamelCase.ConvertName(nameof(LibraryItem.Kind)), - }, - // TODO: Add stopwords - // TODO: Extend default ranking to add ratings. - }); - - await _CreateIndex(client, nameof(Episode), false, new Settings() - { - SearchableAttributes = new[] - { - CamelCase.ConvertName(nameof(Episode.Name)), - CamelCase.ConvertName(nameof(Episode.Overview)), - CamelCase.ConvertName(nameof(Episode.Slug)), - CamelCase.ConvertName(nameof(Episode.Path)), - }, - FilterableAttributes = new[] - { - CamelCase.ConvertName(nameof(Episode.SeasonNumber)), - }, - SortableAttributes = new[] - { - CamelCase.ConvertName(nameof(Episode.ReleaseDate)), - CamelCase.ConvertName(nameof(Episode.AddedDate)), - CamelCase.ConvertName(nameof(Episode.SeasonNumber)), - CamelCase.ConvertName(nameof(Episode.EpisodeNumber)), - CamelCase.ConvertName(nameof(Episode.AbsoluteNumber)), - }, - DisplayedAttributes = new[] - { - CamelCase.ConvertName(nameof(Episode.Id)), - }, - // TODO: Add stopwords - }); - - await _CreateIndex(client, nameof(Studio), false, new Settings() - { - SearchableAttributes = new[] - { - CamelCase.ConvertName(nameof(Studio.Name)), - CamelCase.ConvertName(nameof(Studio.Slug)), - }, - FilterableAttributes = Array.Empty(), - SortableAttributes = Array.Empty(), - DisplayedAttributes = new[] - { - CamelCase.ConvertName(nameof(Studio.Id)), - }, - // TODO: Add stopwords - }); + await _CreateIndex(client, "items", true); + await _CreateIndex(client, nameof(Episode), false); + await _CreateIndex(client, nameof(Studio), false); } - private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind, Settings opts) + private static async Task _CreateIndex(MeilisearchClient client, string index, bool hasKind) { TaskInfo task = await client.CreateIndexAsync(index, hasKind ? "ref" : CamelCase.ConvertName(nameof(IResource.Id))); await client.WaitForTaskAsync(task.TaskUid); - await client.Index(index).UpdateSettingsAsync(opts); + await client.Index(index).UpdateSettingsAsync(IndexSettings[index]); } /// diff --git a/back/src/Kyoo.Meilisearch/SearchManager.cs b/back/src/Kyoo.Meilisearch/SearchManager.cs index 0e2ddbe5..22c18d09 100644 --- a/back/src/Kyoo.Meilisearch/SearchManager.cs +++ b/back/src/Kyoo.Meilisearch/SearchManager.cs @@ -32,13 +32,15 @@ public class SearchManager : ISearchManager private readonly MeilisearchClient _client; private readonly ILibraryManager _libraryManager; - private static IEnumerable _GetSortsBy(Sort? sort) + private static IEnumerable _GetSortsBy(string index, 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.By @sortBy => 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}"), + Sort.Conglomerate(var list) => list.SelectMany(x => _GetSortsBy(index, x)), Sort.Random => throw new ValidationException("Random sorting is not supported while searching."), _ => Array.Empty(), }; @@ -106,7 +108,7 @@ public class SearchManager : ISearchManager ISearchable res = await _client.Index(index).SearchAsync(query, new SearchQuery() { Filter = where, - Sort = _GetSortsBy(sortBy), + Sort = _GetSortsBy(index, sortBy), Limit = pagination?.Limit ?? 50, Offset = pagination?.Skip ?? 0, }); @@ -126,7 +128,7 @@ public class SearchManager : ISearchManager // TODO: add filters and facets ISearchable res = await _client.Index("items").SearchAsync(query, new SearchQuery() { - Sort = _GetSortsBy(sortBy), + Sort = _GetSortsBy("items", sortBy), Limit = pagination?.Limit ?? 50, Offset = pagination?.Skip ?? 0, }); diff --git a/front/apps/web/src/pages/browse/[slug].tsx b/front/apps/web/src/pages/browse/[slug].tsx deleted file mode 100644 index e756ef4e..00000000 --- a/front/apps/web/src/pages/browse/[slug].tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 . - */ - -import { BrowsePage } from "@kyoo/ui"; -import { withRoute } from "~/router"; - -export default withRoute(BrowsePage); diff --git a/front/packages/ui/src/browse/header.tsx b/front/packages/ui/src/browse/header.tsx index 2f2dbd44..aeb6ccaa 100644 --- a/front/packages/ui/src/browse/header.tsx +++ b/front/packages/ui/src/browse/header.tsx @@ -37,11 +37,11 @@ import ViewList from "@material-symbols/svg-400/rounded/view_list.svg"; import Sort from "@material-symbols/svg-400/rounded/sort.svg"; import ArrowUpward from "@material-symbols/svg-400/rounded/arrow_upward.svg"; import ArrowDownward from "@material-symbols/svg-400/rounded/arrow_downward.svg"; -import { Layout, SortBy, SortOrd } from "./types"; +import { Layout, SearchSort, SortOrd } from "./types"; import { forwardRef } from "react"; import { View, PressableProps } from "react-native"; -const SortTrigger = forwardRef(function SortTrigger( +const SortTrigger = forwardRef(function SortTrigger( { sortKey, ...props }, ref, ) { @@ -61,15 +61,17 @@ const SortTrigger = forwardRef(funct }); export const BrowseSettings = ({ + availableSorts, sortKey, sortOrd, setSort, layout, setLayout, }: { - sortKey: SortBy; + availableSorts: string[]; + sortKey: string; sortOrd: SortOrd; - setSort: (sort: SortBy, ord: SortOrd) => void; + setSort: (sort: string, ord: SortOrd) => void; layout: Layout; setLayout: (layout: Layout) => void; }) => { @@ -97,12 +99,18 @@ export const BrowseSettings = ({ )} - {Object.values(SortBy).map((x) => ( + {availableSorts.map((x) => ( setSort(x, sortKey === x && sortOrd === SortOrd.Asc ? SortOrd.Desc : SortOrd.Asc) } diff --git a/front/packages/ui/src/browse/index.tsx b/front/packages/ui/src/browse/index.tsx index e32c9ba1..d7f259d6 100644 --- a/front/packages/ui/src/browse/index.tsx +++ b/front/packages/ui/src/browse/index.tsx @@ -53,20 +53,16 @@ export const itemMap = ( }; }; -const query = ( - slug?: string, - sortKey?: SortBy, - sortOrd?: SortOrd, -): QueryIdentifier => ({ +const query = (sortKey?: SortBy, sortOrd?: SortOrd): QueryIdentifier => ({ parser: LibraryItemP, - path: slug ? ["library", slug, "items"] : ["items"], + path: ["items"], infinite: true, params: { sortBy: sortKey ? `${sortKey}:${sortOrd ?? "asc"}` : "name:asc", }, }); -export const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => { +export const BrowsePage: QueryPage = () => { const [sort, setSort] = useParam("sortBy"); const sortKey = (sort?.split(":")[0] as SortBy) || SortBy.Name; const sortOrd = (sort?.split(":")[1] as SortOrd) || SortOrd.Asc; @@ -76,11 +72,12 @@ export const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => { return ( { @@ -98,6 +95,6 @@ export const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => { BrowsePage.getLayout = DefaultLayout; -BrowsePage.getFetchUrls = ({ slug, sortBy }) => [ - query(slug, sortBy?.split("-")[0] as SortBy, sortBy?.split("-")[1] as SortOrd), +BrowsePage.getFetchUrls = ({ sortBy }) => [ + query(sortBy?.split("-")[0] as SortBy, sortBy?.split("-")[1] as SortOrd), ]; diff --git a/front/packages/ui/src/browse/types.ts b/front/packages/ui/src/browse/types.ts index c3e3b43c..55e33f3b 100644 --- a/front/packages/ui/src/browse/types.ts +++ b/front/packages/ui/src/browse/types.ts @@ -25,6 +25,12 @@ export enum SortBy { AddedDate = "addedDate", } +export enum SearchSort { + Relevance = "relevance", + AirDate = "airDate", + AddedDate = "addedDate", +} + export enum SortOrd { Asc = "asc", Desc = "desc", diff --git a/front/packages/ui/src/search/index.tsx b/front/packages/ui/src/search/index.tsx index ebe08fdd..e5ced300 100644 --- a/front/packages/ui/src/search/index.tsx +++ b/front/packages/ui/src/search/index.tsx @@ -19,38 +19,67 @@ */ import { LibraryItem, LibraryItemP, QueryIdentifier, QueryPage } from "@kyoo/models"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { ItemGrid } from "../browse/grid"; -import { itemMap } from "../browse/index"; -import { EmptyView } from "../fetch"; -import { InfiniteFetch } from "../fetch-infinite"; +import { createParam } from "solito"; import { DefaultLayout } from "../layout"; +import { InfiniteFetch } from "../fetch-infinite"; +import { itemMap } from "../browse"; +import { SearchSort, SortOrd, SortBy, Layout } from "../browse/types"; +import { BrowseSettings } from "../browse/header"; +import { ItemGrid } from "../browse/grid"; +import { ItemList } from "../browse/list"; -const query = (query: string): QueryIdentifier => ({ +const { useParam } = createParam<{ sortBy?: string }>(); + +const query = ( + query?: string, + sortKey?: SearchSort, + sortOrd?: SortOrd, +): QueryIdentifier => ({ parser: LibraryItemP, - path: ["search", query, "items"], + path: ["search", "items"], infinite: true, - getNext: () => undefined, + params: { + q: query, + sortBy: + sortKey && sortKey != SearchSort.Relevance ? `${sortKey}:${sortOrd ?? "asc"}` : undefined, + }, }); export const SearchPage: QueryPage<{ q?: string }> = ({ q }) => { const { t } = useTranslation(); + const [sort, setSort] = useParam("sortBy"); + const sortKey = (sort?.split(":")[0] as SearchSort) || SearchSort.Relevance; + const sortOrd = (sort?.split(":")[1] as SortOrd) || SortOrd.Asc; + const [layout, setLayout] = useState(Layout.Grid); + + const LayoutComponent = layout === Layout.Grid ? ItemGrid : ItemList; - const empty = ; - if (!q) return empty; return ( { + setSort(`${key}:${ord}`); + }} + layout={layout} + setLayout={setLayout} + /> + } > - {(item) => } + {(item) => } ); }; SearchPage.getLayout = DefaultLayout; -SearchPage.getFetchUrls = ({ q }) => (q ? [query(q)] : []); +SearchPage.getFetchUrls = ({ q, sortBy }) => [ + query(q, sortBy?.split("-")[0] as SearchSort, sortBy?.split("-")[1] as SortOrd), +]; diff --git a/front/translations/en.json b/front/translations/en.json index e4bac99c..3ed9f7de 100644 --- a/front/translations/en.json +++ b/front/translations/en.json @@ -23,7 +23,9 @@ "sortby": "Sort by {{key}}", "sortby-tt": "Sort by", "sortkey": { + "relevance": "Relevance", "name": "Name", + "airDate": "Air Date", "startAir": "Start air", "endAir": "End air", "addedDate": "Added date"