mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-07-08 10:44:20 -04:00
Add rescan button on admin interface (#462)
This commit is contained in:
commit
9d00aa6d2f
@ -23,5 +23,6 @@ namespace Kyoo.Abstractions.Controllers;
|
||||
|
||||
public interface IScanner
|
||||
{
|
||||
Task SendRescanRequest();
|
||||
Task SendRefreshRequest(string kind, Guid id);
|
||||
}
|
||||
|
@ -317,7 +317,7 @@ public abstract record Filter<T> : Filter
|
||||
);
|
||||
|
||||
public static readonly Parser<Filter<T>> 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<T> : Filter
|
||||
);
|
||||
|
||||
public static readonly Parser<Filter<T>> 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)
|
||||
);
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rescan library
|
||||
/// </summary>
|
||||
/// <remark>
|
||||
/// Trigger a complete library rescan
|
||||
/// </remark>
|
||||
/// <returns>Nothing</returns>
|
||||
[HttpPost("/rescan")]
|
||||
[PartialPermission(Kind.Write)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> RescanLibrary([FromServices] IScanner scanner)
|
||||
{
|
||||
await scanner.SendRescanRequest();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List items to refresh.
|
||||
/// </summary>
|
||||
|
@ -34,7 +34,7 @@ namespace Kyoo.Core.Api;
|
||||
/// </summary>
|
||||
[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<SearchPage<Collection>> 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<SearchPage<Show>> 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<SearchPage<Movie>> 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<SearchPage<ILibraryItem>> 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<SearchPage<Episode>> 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<SearchPage<Studio>> SearchStudios(
|
||||
[FromQuery] string? q,
|
||||
|
@ -50,7 +50,7 @@ public class ShowApi(ILibraryManager libraryManager) : CrudThumbsApi<Show>(libra
|
||||
/// </remarks>
|
||||
/// <param name="identifier">The ID or slug of the <see cref="Show"/>.</param>
|
||||
/// <returns>Nothing</returns>
|
||||
/// <response code="404">No episode with the given ID or slug could be found.</response>
|
||||
/// <response code="404">No show with the given ID or slug could be found.</response>
|
||||
[HttpPost("{identifier:id}/refresh")]
|
||||
[PartialPermission(Kind.Write)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
|
@ -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<T>.ResourceEventHandler _Publish<T>(
|
||||
string exchange,
|
||||
string type,
|
||||
string action
|
||||
)
|
||||
where T : IResource, IQuery
|
||||
private Task _Publish<T>(T message)
|
||||
{
|
||||
return (T resource) =>
|
||||
{
|
||||
Message<T> 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);
|
||||
}
|
||||
}
|
||||
|
@ -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<KyooErrors | null>(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<KyooErrors | null>(null);
|
||||
|
||||
return (
|
||||
<AccountContext.Provider value={accounts}>
|
||||
<ConnectionErrorContext.Provider
|
||||
|
@ -18,24 +18,44 @@
|
||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 (
|
||||
<SettingsContainer title={t("admin.scanner.label")}>
|
||||
<SettingsContainer
|
||||
title={t("admin.scanner.label")}
|
||||
extraTop={
|
||||
<Button
|
||||
licon={<Icon icon={Scan} {...css({ marginX: ts(1) })} />}
|
||||
text={t("admin.scanner.scan")}
|
||||
onPress={() => metadataRefreshMutation.mutate()}
|
||||
{...css({ marginBottom: ts(2) })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<>
|
||||
{error != null ? (
|
||||
<ErrorView error={error} />
|
||||
|
@ -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: {
|
||||
|
@ -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 (
|
||||
<Container {...props}>
|
||||
<H1 {...css({ fontSize: rem(2) })}>{title}</H1>
|
||||
{extraTop}
|
||||
<SwitchVariant>
|
||||
{({ css }) => (
|
||||
<View
|
||||
|
@ -237,6 +237,7 @@
|
||||
},
|
||||
"scanner": {
|
||||
"label": "Scanner",
|
||||
"scan": "Trigger library scan",
|
||||
"empty": "No issue found. All your items are registered."
|
||||
}
|
||||
}
|
||||
|
@ -237,6 +237,7 @@
|
||||
},
|
||||
"scanner": {
|
||||
"label": "Scanner",
|
||||
"scan": "Déclencher le scan de la bibliothèque",
|
||||
"empty": "Aucun problème trouvé. Toutes vos vidéos sont enregistrés."
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import asyncio
|
||||
from typing import Union, Literal
|
||||
from msgspec import Struct, json
|
||||
import os
|
||||
from logging import getLogger
|
||||
from aio_pika import connect_robust
|
||||
from aio_pika.abc import AbstractIncomingMessage
|
||||
|
||||
from scanner.publisher import Publisher
|
||||
from scanner.scanner import scan
|
||||
|
||||
from matcher.matcher import Matcher
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@ -28,38 +29,29 @@ class Refresh(Message):
|
||||
id: str
|
||||
|
||||
|
||||
decoder = json.Decoder(Union[Scan, Delete, Refresh])
|
||||
class Rescan(Message):
|
||||
pass
|
||||
|
||||
|
||||
class Subscriber:
|
||||
QUEUE = "scanner"
|
||||
decoder = json.Decoder(Union[Scan, Delete, Refresh, Rescan])
|
||||
|
||||
async def __aenter__(self):
|
||||
self._con = await connect_robust(
|
||||
host=os.environ.get("RABBITMQ_HOST", "rabbitmq"),
|
||||
port=int(os.environ.get("RABBITMQ_PORT", "5672")),
|
||||
login=os.environ.get("RABBITMQ_DEFAULT_USER", "guest"),
|
||||
password=os.environ.get("RABBITMQ_DEFAULT_PASS", "guest"),
|
||||
)
|
||||
self._channel = await self._con.channel()
|
||||
self._queue = await self._channel.declare_queue(self.QUEUE)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, exc_tb):
|
||||
await self._con.close()
|
||||
|
||||
async def listen(self, scanner: Matcher):
|
||||
class Subscriber(Publisher):
|
||||
async def listen(self, matcher: Matcher):
|
||||
async def on_message(message: AbstractIncomingMessage):
|
||||
try:
|
||||
msg = decoder.decode(message.body)
|
||||
ack = False
|
||||
match msg:
|
||||
case Scan(path):
|
||||
ack = await scanner.identify(path)
|
||||
ack = await matcher.identify(path)
|
||||
case Delete(path):
|
||||
ack = await scanner.delete(path)
|
||||
ack = await matcher.delete(path)
|
||||
case Refresh(kind, id):
|
||||
ack = await scanner.refresh(kind, id)
|
||||
ack = await matcher.refresh(kind, id)
|
||||
case Rescan():
|
||||
await scan(None, self, matcher._client)
|
||||
ack = True
|
||||
case _:
|
||||
logger.error(f"Invalid action: {msg.action}")
|
||||
if ack:
|
||||
|
@ -58,6 +58,16 @@ class KyooClient:
|
||||
logger.error(f"Request error: {await r.text()}")
|
||||
r.raise_for_status()
|
||||
|
||||
async def get_issues(self) -> 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)}"',
|
||||
|
@ -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}.")
|
||||
|
Loading…
x
Reference in New Issue
Block a user