diff --git a/back/src/Kyoo.Core/Controllers/MiscRepository.cs b/back/src/Kyoo.Core/Controllers/MiscRepository.cs index 06afb8ad..eb32ae8d 100644 --- a/back/src/Kyoo.Core/Controllers/MiscRepository.cs +++ b/back/src/Kyoo.Core/Controllers/MiscRepository.cs @@ -84,6 +84,27 @@ public class MiscRepository( .ToListAsync(); } + public async Task DeletePath(string path, bool recurse) + { + // Make sure to include a path separator to prevents deletions from things like: + // DeletePath("/video/abc", true) -> /video/abdc (should not be deleted) + string dirPath = path.EndsWith("/") ? path : $"{path}/"; + + int count = await context + .Episodes.Where(x => x.Path == path || (recurse && x.Path.StartsWith(dirPath))) + .ExecuteDeleteAsync(); + count += await context + .Movies.Where(x => x.Path == path || (recurse && x.Path.StartsWith(dirPath))) + .ExecuteDeleteAsync(); + await context + .Issues.Where(x => + x.Domain == "scanner" + && (x.Cause == path || (recurse && x.Cause.StartsWith(dirPath))) + ) + .ExecuteDeleteAsync(); + return count; + } + public async Task> GetRefreshableItems(DateTime end) { IQueryable GetItems() diff --git a/back/src/Kyoo.Core/Views/Admin/Misc.cs b/back/src/Kyoo.Core/Views/Admin/Misc.cs index b2a35991..3f0f9c96 100644 --- a/back/src/Kyoo.Core/Views/Admin/Misc.cs +++ b/back/src/Kyoo.Core/Views/Admin/Misc.cs @@ -30,7 +30,7 @@ namespace Kyoo.Core.Api; /// Private APIs only used for other services. Can change at any time without notice. /// [ApiController] -[Permission(nameof(Misc), Kind.Read, Group = Group.Admin)] +[PartialPermission(nameof(Misc), Group = Group.Admin)] public class Misc(MiscRepository repo) : BaseApi { /// @@ -38,18 +38,40 @@ public class Misc(MiscRepository repo) : BaseApi /// /// The list of paths known to Kyoo. [HttpGet("/paths")] + [PartialPermission(Kind.Read)] [ProducesResponseType(StatusCodes.Status200OK)] public Task> GetAllPaths() { return repo.GetRegisteredPaths(); } + /// + /// Delete item at path. + /// + /// The path to delete. + /// + /// If true, the path will be considered as a directory and every children will be removed. + /// + /// Nothing + [HttpDelete("/paths")] + [PartialPermission(Kind.Delete)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task DeletePath( + [FromQuery] string path, + [FromQuery] bool recursive = false + ) + { + await repo.DeletePath(path, recursive); + return NoContent(); + } + /// /// List items to refresh. /// /// The upper limit for the refresh date. /// The items that should be refreshed before the given date [HttpGet("/refreshables")] + [PartialPermission(Kind.Read)] [ProducesResponseType(StatusCodes.Status200OK)] public Task> GetAllPaths([FromQuery] DateTime? date) { diff --git a/scanner/matcher/parser/guess.py b/scanner/matcher/parser/guess.py index 54b96a0f..5341e32a 100644 --- a/scanner/matcher/parser/guess.py +++ b/scanner/matcher/parser/guess.py @@ -45,10 +45,11 @@ if __name__ == "__main__": async with ClientSession() as client: xem = TheXemClient(client) + advanced = any(x == "-a" for x in sys.argv) ret = guessit( sys.argv[1], xem_titles=await xem.get_expected_titles(), - # extra_flags={"advanced": True}, + extra_flags={"advanced": advanced}, ) print(json.dumps(ret, cls=GuessitEncoder, indent=4)) diff --git a/scanner/matcher/parser/rules.py b/scanner/matcher/parser/rules.py index d7fb0517..849e5f44 100644 --- a/scanner/matcher/parser/rules.py +++ b/scanner/matcher/parser/rules.py @@ -54,7 +54,7 @@ class UnlistTitles(Rule): consequence = [RemoveMatch, AppendMatch] def when(self, matches: Matches, context) -> Any: - titles: List[Match] = matches.named("title") # type: ignore + titles: List[Match] = matches.named("title", lambda x: x.tagged("title")) # type: ignore if not titles or len(titles) <= 1: return diff --git a/scanner/providers/kyoo_client.py b/scanner/providers/kyoo_client.py index e718c434..19751d35 100644 --- a/scanner/providers/kyoo_client.py +++ b/scanner/providers/kyoo_client.py @@ -104,29 +104,16 @@ class KyooClient: async def delete( self, path: str, - type: Literal["episode", "movie"] | None = None, ): logger.info("Deleting %s", path) - if type is None or type == "movie": - async with self.client.delete( - f'{self._url}/movies?filter=path eq "{quote(path)}"', - headers={"X-API-Key": self._api_key}, - ) as r: - if not r.ok: - logger.error(f"Request error: {await r.text()}") - r.raise_for_status() - - if type is None or type == "episode": - async with self.client.delete( - f'{self._url}/episodes?filter=path eq "{quote(path)}"', - headers={"X-API-Key": self._api_key}, - ) as r: - if not r.ok: - logger.error(f"Request error: {await r.text()}") - r.raise_for_status() - - await self.delete_issue(path) + async with self.client.delete( + f"{self._url}/paths?recursive=true&path={quote(path)}", + headers={"X-API-Key": self._api_key}, + ) as r: + if not r.ok: + logger.error(f"Request error: {await r.text()}") + r.raise_for_status() async def get(self, path: str): async with self.client.get( diff --git a/scanner/scanner/__init__.py b/scanner/scanner/__init__.py index e034b653..bce4273e 100644 --- a/scanner/scanner/__init__.py +++ b/scanner/scanner/__init__.py @@ -14,7 +14,7 @@ async def main(): async with Publisher() as publisher, KyooClient() as client: path = os.environ.get("SCANNER_LIBRARY_ROOT", "/video") await asyncio.gather( - monitor(path, publisher), - scan(path, publisher, client), + monitor(path, publisher, client), + scan(path, publisher, client, remove_deleted=True), refresh(publisher, client), ) diff --git a/scanner/scanner/monitor.py b/scanner/scanner/monitor.py index 439bc93f..ff48a891 100644 --- a/scanner/scanner/monitor.py +++ b/scanner/scanner/monitor.py @@ -1,16 +1,21 @@ from logging import getLogger +from os.path import isdir from watchfiles import awatch, Change - from .publisher import Publisher +from .scanner import scan +from providers.kyoo_client import KyooClient logger = getLogger(__name__) -async def monitor(path: str, publisher: Publisher): +async def monitor(path: str, publisher: Publisher, client: KyooClient): async for changes in awatch(path, ignore_permission_denied=True): for event, file in changes: if event == Change.added: - await publisher.add(file) + if isdir(file): + await scan(file, publisher, client) + else: + await publisher.add(file) elif event == Change.deleted: await publisher.delete(file) elif event == Change.modified: diff --git a/scanner/scanner/scanner.py b/scanner/scanner/scanner.py index b74ecc14..3275188a 100644 --- a/scanner/scanner/scanner.py +++ b/scanner/scanner/scanner.py @@ -9,7 +9,9 @@ from providers.kyoo_client import KyooClient logger = getLogger(__name__) -async def scan(path: str, publisher: Publisher, client: KyooClient): +async def scan( + path: str, publisher: Publisher, client: KyooClient, remove_deleted=False +): logger.info("Starting the scan. It can take some times...") ignore_pattern = None try: @@ -25,12 +27,13 @@ async def scan(path: str, publisher: Publisher, client: KyooClient): to_register = [ p for p in videos if p not in registered and not ignore_pattern.match(p) ] - deleted = [x for x in registered if x not in videos] - if len(deleted) != len(registered): - await asyncio.gather(*map(publisher.delete, deleted)) - elif len(deleted) > 0: - logger.warning("All video files are unavailable. Check your disks.") + if remove_deleted: + deleted = [x for x in registered if x not in videos] + if len(deleted) != len(registered): + await asyncio.gather(*map(publisher.delete, deleted)) + elif len(deleted) > 0: + logger.warning("All video files are unavailable. Check your disks.") await asyncio.gather(*map(publisher.add, to_register)) - logger.info("Scan finished.") + logger.info(f"Scan finished for {path}.")