diff --git a/back/src/Kyoo.Abstractions/Controllers/IScanner.cs b/back/src/Kyoo.Abstractions/Controllers/IScanner.cs index 62f14f9f..776131b5 100644 --- a/back/src/Kyoo.Abstractions/Controllers/IScanner.cs +++ b/back/src/Kyoo.Abstractions/Controllers/IScanner.cs @@ -23,5 +23,6 @@ namespace Kyoo.Abstractions.Controllers; public interface IScanner { + Task SendRescanRequest(); Task SendRefreshRequest(string kind, Guid id); } diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs b/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs index 6c03dc08..430c1218 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs @@ -317,7 +317,7 @@ public abstract record Filter : Filter ); public static readonly Parser> Ge = _GetOperationParser( - Parse.IgnoreCase("ge").Or(Parse.String(">=")).Token(), + Parse.IgnoreCase("ge").Or(Parse.IgnoreCase("gte")).Or(Parse.String(">=")).Token(), (property, value) => new Ge(property, value) ); @@ -327,7 +327,7 @@ public abstract record Filter : Filter ); public static readonly Parser> Le = _GetOperationParser( - Parse.IgnoreCase("le").Or(Parse.String("<=")).Token(), + Parse.IgnoreCase("le").Or(Parse.IgnoreCase("lte")).Or(Parse.String("<=")).Token(), (property, value) => new Le(property, value) ); diff --git a/back/src/Kyoo.Core/Views/Admin/Misc.cs b/back/src/Kyoo.Core/Views/Admin/Misc.cs index 3f0f9c96..edd53514 100644 --- a/back/src/Kyoo.Core/Views/Admin/Misc.cs +++ b/back/src/Kyoo.Core/Views/Admin/Misc.cs @@ -19,6 +19,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models.Permissions; using Kyoo.Core.Controllers; using Microsoft.AspNetCore.Http; @@ -65,6 +66,22 @@ public class Misc(MiscRepository repo) : BaseApi return NoContent(); } + /// + /// Rescan library + /// + /// + /// Trigger a complete library rescan + /// + /// Nothing + [HttpPost("/rescan")] + [PartialPermission(Kind.Write)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task RescanLibrary([FromServices] IScanner scanner) + { + await scanner.SendRescanRequest(); + return NoContent(); + } + /// /// List items to refresh. /// diff --git a/back/src/Kyoo.Core/Views/Resources/SearchApi.cs b/back/src/Kyoo.Core/Views/Resources/SearchApi.cs index c0043c15..b3754d3c 100644 --- a/back/src/Kyoo.Core/Views/Resources/SearchApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/SearchApi.cs @@ -34,7 +34,7 @@ namespace Kyoo.Core.Api; /// [Route("search")] [ApiController] -[ApiDefinition("Search", Group = ResourcesGroup)] +[ApiDefinition("Search", Group = OtherGroup)] public class SearchApi : BaseApi { private readonly ISearchManager _searchManager; @@ -60,7 +60,7 @@ public class SearchApi : BaseApi [HttpGet("collections")] [HttpGet("collection", Order = AlternativeRoute)] [Permission(nameof(Collection), Kind.Read)] - [ApiDefinition("Collections")] + [ApiDefinition("Collections", Group = ResourcesGroup)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SearchCollections( [FromQuery] string? q, @@ -86,7 +86,7 @@ public class SearchApi : BaseApi [HttpGet("shows")] [HttpGet("show", Order = AlternativeRoute)] [Permission(nameof(Show), Kind.Read)] - [ApiDefinition("Show")] + [ApiDefinition("Shows", Group = ResourcesGroup)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SearchShows( [FromQuery] string? q, @@ -112,7 +112,7 @@ public class SearchApi : BaseApi [HttpGet("movies")] [HttpGet("movie", Order = AlternativeRoute)] [Permission(nameof(Movie), Kind.Read)] - [ApiDefinition("Movie")] + [ApiDefinition("Movies", Group = ResourcesGroup)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SearchMovies( [FromQuery] string? q, @@ -138,7 +138,7 @@ public class SearchApi : BaseApi [HttpGet("items")] [HttpGet("item", Order = AlternativeRoute)] [Permission(nameof(ILibraryItem), Kind.Read)] - [ApiDefinition("Item")] + [ApiDefinition("Items", Group = ResourcesGroup)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SearchItems( [FromQuery] string? q, @@ -164,7 +164,7 @@ public class SearchApi : BaseApi [HttpGet("episodes")] [HttpGet("episode", Order = AlternativeRoute)] [Permission(nameof(Episode), Kind.Read)] - [ApiDefinition("Episodes")] + [ApiDefinition("Episodes", Group = ResourcesGroup)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SearchEpisodes( [FromQuery] string? q, @@ -190,7 +190,7 @@ public class SearchApi : BaseApi [HttpGet("studios")] [HttpGet("studio", Order = AlternativeRoute)] [Permission(nameof(Studio), Kind.Read)] - [ApiDefinition("Studios")] + [ApiDefinition("Studios", Group = MetadataGroup)] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SearchStudios( [FromQuery] string? q, diff --git a/back/src/Kyoo.Core/Views/Resources/ShowApi.cs b/back/src/Kyoo.Core/Views/Resources/ShowApi.cs index fb23e4f3..0517095a 100644 --- a/back/src/Kyoo.Core/Views/Resources/ShowApi.cs +++ b/back/src/Kyoo.Core/Views/Resources/ShowApi.cs @@ -50,7 +50,7 @@ public class ShowApi(ILibraryManager libraryManager) : CrudThumbsApi(libra /// /// The ID or slug of the . /// Nothing - /// No episode with the given ID or slug could be found. + /// No show with the given ID or slug could be found. [HttpPost("{identifier:id}/refresh")] [PartialPermission(Kind.Write)] [ProducesResponseType(StatusCodes.Status204NoContent)] diff --git a/back/src/Kyoo.RabbitMq/ScannerProducer.cs b/back/src/Kyoo.RabbitMq/ScannerProducer.cs index bc9db49d..75c575a0 100644 --- a/back/src/Kyoo.RabbitMq/ScannerProducer.cs +++ b/back/src/Kyoo.RabbitMq/ScannerProducer.cs @@ -19,7 +19,6 @@ using System.Text; using System.Text.Json; using Kyoo.Abstractions.Controllers; -using Kyoo.Abstractions.Models; using Kyoo.Utils; using RabbitMQ.Client; @@ -35,29 +34,17 @@ public class ScannerProducer : IScanner _channel.QueueDeclare("scanner", exclusive: false, autoDelete: false); } - private IRepository.ResourceEventHandler _Publish( - string exchange, - string type, - string action - ) - where T : IResource, IQuery + private Task _Publish(T message) { - return (T resource) => - { - Message message = - new() - { - Action = action, - Type = type, - Value = resource, - }; - _channel.BasicPublish( - exchange, - routingKey: message.AsRoutingKey(), - body: message.AsBytes() - ); - return Task.CompletedTask; - }; + var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message, Utility.JsonOptions)); + _channel.BasicPublish("", routingKey: "scanner", body: body); + return Task.CompletedTask; + } + + public Task SendRescanRequest() + { + var message = new { Action = "rescan", }; + return _Publish(message); } public Task SendRefreshRequest(string kind, Guid id) @@ -68,8 +55,6 @@ public class ScannerProducer : IScanner Kind = kind.ToLowerInvariant(), Id = id }; - var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message, Utility.JsonOptions)); - _channel.BasicPublish("", routingKey: "scanner", body: body); - return Task.CompletedTask; + return _Publish(message); } } diff --git a/front/packages/models/src/accounts.tsx b/front/packages/models/src/accounts.tsx index b92a9ac8..668710f9 100644 --- a/front/packages/models/src/accounts.tsx +++ b/front/packages/models/src/accounts.tsx @@ -144,6 +144,9 @@ export const AccountProvider = ({ const oldSelected = useRef<{ id: string; token: string } | null>( selected ? { id: selected.id, token: selected.token.access_token } : null, ); + + const [permissionError, setPermissionError] = useState(null); + const userIsError = user.isError; useEffect(() => { // if the user change account (or connect/disconnect), reset query cache. @@ -152,6 +155,7 @@ export const AccountProvider = ({ (userIsError && selected?.token.access_token !== oldSelected.current?.token) ) { initialSsrError.current = undefined; + setPermissionError(null); queryClient.resetQueries(); } oldSelected.current = selected ? { id: selected.id, token: selected.token.access_token } : null; @@ -164,8 +168,6 @@ export const AccountProvider = ({ } }, [selected, queryClient, userIsError]); - const [permissionError, setPermissionError] = useState(null); - return ( . */ -import { Issue, IssueP, QueryIdentifier, useFetch } from "@kyoo/models"; +import { Issue, IssueP, QueryIdentifier, queryFn, useFetch } from "@kyoo/models"; import { useTranslation } from "react-i18next"; import { SettingsContainer } from "../settings/base"; -import { Icon, P, Skeleton, tooltip, ts } from "@kyoo/primitives"; +import { Button, Icon, P, Skeleton, tooltip, ts } from "@kyoo/primitives"; import { ErrorView } from "../errors"; import { z } from "zod"; import { View } from "react-native"; import { useYoshiki } from "yoshiki/native"; import Info from "@material-symbols/svg-400/outlined/info.svg"; +import Scan from "@material-symbols/svg-400/outlined/sensors.svg"; +import { useMutation } from "@tanstack/react-query"; export const Scanner = () => { const { css } = useYoshiki(); const { t } = useTranslation(); const { data, error } = useFetch(Scanner.query()); + const metadataRefreshMutation = useMutation({ + mutationFn: () => + queryFn({ + path: ["rescan"], + method: "POST", + }), + }); + return ( - + } + text={t("admin.scanner.scan")} + onPress={() => metadataRefreshMutation.mutate()} + {...css({ marginBottom: ts(2) })} + /> + } + > <> {error != null ? ( diff --git a/front/packages/ui/src/details/season.tsx b/front/packages/ui/src/details/season.tsx index cff1dc74..af6a83a8 100644 --- a/front/packages/ui/src/details/season.tsx +++ b/front/packages/ui/src/details/season.tsx @@ -166,7 +166,7 @@ EpisodeList.query = ( parser: EpisodeP, path: ["show", slug, "episode"], params: { - seasonNumber: season ? `gte:${season}` : undefined, + filter: season ? `seasonNumber gte ${season}` : undefined, fields: ["watchStatus"], }, infinite: { diff --git a/front/packages/ui/src/settings/base.tsx b/front/packages/ui/src/settings/base.tsx index e2bbec68..4c6cfbe0 100644 --- a/front/packages/ui/src/settings/base.tsx +++ b/front/packages/ui/src/settings/base.tsx @@ -88,17 +88,20 @@ export const SettingsContainer = ({ children, title, extra, + extraTop, ...props }: { children: ReactElement | (ReactElement | Falsy)[] | Falsy; title: string; extra?: ReactElement; + extraTop?: ReactElement; }) => { const { css } = useYoshiki(); return (

{title}

+ {extraTop} {({ css }) => ( List[str]: + async with self.client.get( + f"{self._url}/issues", + params={"limit": 0}, + headers={"X-API-Key": self._api_key}, + ) as r: + r.raise_for_status() + ret = await r.json() + return [x["cause"] for x in ret if x["domain"] == "scanner"] + async def delete_issue(self, path: str): async with self.client.delete( f'{self._url}/issues?filter=domain eq scanner and cause eq "{quote(path)}"', diff --git a/scanner/scanner/scanner.py b/scanner/scanner/scanner.py index 3275188a..811dce2b 100644 --- a/scanner/scanner/scanner.py +++ b/scanner/scanner/scanner.py @@ -1,6 +1,7 @@ import os import re import asyncio +from typing import Optional from logging import getLogger from .publisher import Publisher @@ -10,8 +11,10 @@ logger = getLogger(__name__) async def scan( - path: str, publisher: Publisher, client: KyooClient, remove_deleted=False + path_: Optional[str], publisher: Publisher, client: KyooClient, remove_deleted=False ): + path = path_ or os.environ.get("SCANNER_LIBRARY_ROOT", "/video") + logger.info("Starting the scan. It can take some times...") ignore_pattern = None try: @@ -35,5 +38,10 @@ async def scan( elif len(deleted) > 0: logger.warning("All video files are unavailable. Check your disks.") + issues = await client.get_issues() + for x in issues: + if x not in videos: + await client.delete_issue(x) + await asyncio.gather(*map(publisher.add, to_register)) logger.info(f"Scan finished for {path}.")