From c9102fa4b3f01b4258810d43537aba48120e3ec2 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 20 Mar 2026 11:17:36 +0100 Subject: [PATCH] Add back `TitleNumberFixup` rule --- .env.example | 4 +- chart/values.yaml | 2 +- scanner/scanner/identifiers/guess/rules.py | 71 ++++++++++++++++++++++ scanner/scanner/identifiers/identify.py | 2 - scanner/scanner/routers/routes.py | 22 ++++++- shell.nix | 3 + 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 97713502..5d7b3557 100644 --- a/.env.example +++ b/.env.example @@ -38,7 +38,7 @@ PUBLIC_URL=http://localhost:8901 # Set `verified` to true if you don't wanna manually verify users. EXTRA_CLAIMS='{"permissions": ["core.read", "core.play"], "verified": false}' # This is the permissions of the first user (aka the first user is admin) -FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "users.delete", "apikeys.read", "apikeys.write", "core.read", "core.write", "core.play", "scanner.trigger"], "verified": true}' +FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "users.delete", "apikeys.read", "apikeys.write", "core.read", "core.write", "core.play", "scanner.trigger", "scanner.guess"], "verified": true}' # Guest (meaning unlogged in users) can be: # unauthorized (they need to connect before doing anything) @@ -46,7 +46,7 @@ FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "users.delete", # able to browse & see what you have but not able to play GUEST_CLAIMS='{"permissions": ["core.read"], "verified": true}' # or have browse & play permissions -GUEST_CLAIMS='{"permissions": ["core.read", "core.play"], "verified": true}' +# GUEST_CLAIMS='{"permissions": ["core.read", "core.play"], "verified": true}' # DO NOT change this. PROTECTED_CLAIMS="permissions,verified" diff --git a/chart/values.yaml b/chart/values.yaml index 90da66de..652698c3 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -103,7 +103,7 @@ kyoo: # auth settings auth: - firstUserClaims: '{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "core.play", "scanner.trigger"], "verified": true}' + firstUserClaims: '{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "core.play", "scanner.trigger", "scanner.guess"], "verified": true}' guestClaims: '{"permissions": ["core.read"], "verified": true}' extraClaims: '{"permissions": ["core.read", "core.play"], "verified": false}' protectedClaims: "permissions,verified" diff --git a/scanner/scanner/identifiers/guess/rules.py b/scanner/scanner/identifiers/guess/rules.py index 1cedc64d..f9cc4d22 100644 --- a/scanner/scanner/identifiers/guess/rules.py +++ b/scanner/scanner/identifiers/guess/rules.py @@ -259,6 +259,77 @@ class PreferFilenameOverDirectory(Rule): return to_remove if to_remove else None +class TitleNumberFixup(Rule): + """Fix titles having numbers in them + + Example: '[Erai-raws] Zom 100 - Zombie ni Naru made ni Shitai 100 no Koto - 01 [1080p][Multiple Subtitle][8AFBB298].mkv' + (or '[SubsPlease] Mob Psycho 100 Season 3 - 12 (1080p) [E5058D7B].mkv') + Default: + ```json + { + "release_group": "Erai-raws", + "title": "Zom", + "episode": [ + 100, + 1 + ], + "episode_title": "Zombie ni Naru made ni Shitai", + } + ``` + Expected: + ```json + { + "release_group": "Erai-raws", + "title": "Zom 100", + "episode": 1, + "episode_title": "Zombie ni Naru made ni Shitai 100 no Koto", + } + ``` + """ + + priority = POST_PROCESS + consequence = [RemoveMatch, AppendMatch] + + @override + def when(self, matches: Matches, context) -> Any: + episodes: List[Match] = matches.named("episode") # type: ignore + + if len(episodes) < 2 or all(x.value == episodes[0].value for x in episodes): + return + + to_remove = [] + to_add = [] + for episode in episodes: + prevs: List[Match] = matches.previous(episode) # type: ignore + title = prevs[0] if prevs and prevs[0].tagged("title") else None + if not title: + continue + + # do not fixup if there was a - or any separator between the title and the episode number + hole: List[Match] = matches.holes(title.end, episode.start) # type: ignore + if hole: + continue + + to_remove.extend([title, episode]) + new_title = copy(title) + new_title.end = episode.end + + nmatch: List[Match] = matches.next(episode) # type: ignore + if nmatch: + end = ( + nmatch[0].initiator.start + if isinstance(nmatch[0].initiator, Match) + else nmatch[0].start + ) + # If an hole was created to parse the episode at the current pos, merge it back into the title + holes: List[Match] = matches.holes(start=episode.end, end=end) # type: ignore + if holes and holes[0].start == episode.end: + new_title.end = holes[0].end + + to_add.append(new_title) + return [to_remove, to_add] + + class ExpectedTitles(Rule): """Fix both alternate names and seasons that are known titles but parsed differently by guessit diff --git a/scanner/scanner/identifiers/identify.py b/scanner/scanner/identifiers/identify.py index ab607c8b..39583de3 100644 --- a/scanner/scanner/identifiers/identify.py +++ b/scanner/scanner/identifiers/identify.py @@ -4,8 +4,6 @@ from itertools import zip_longest from logging import getLogger from typing import Callable, Literal, cast -from rebulk.match import Match - from ..models.videos import Guess, Video from .anilist import get_anilist_data, identify_anilist from .guess.guess import guessit diff --git a/scanner/scanner/routers/routes.py b/scanner/scanner/routers/routes.py index 07d02f53..68b3c0d8 100644 --- a/scanner/scanner/routers/routes.py +++ b/scanner/scanner/routers/routes.py @@ -2,11 +2,11 @@ from typing import Annotated, Literal from fastapi import APIRouter, BackgroundTasks, Depends, Security -from scanner.models.request import RequestRet -from scanner.status import StatusService - from ..fsscan import create_scanner +from ..identifiers.identify import identify from ..jwt import validate_bearer +from ..models.request import RequestRet +from ..status import StatusService router = APIRouter() @@ -42,3 +42,19 @@ async def trigger_scan( await scanner.scan() tasks.add_task(run) + + +@router.get( + "/guess", + status_code=200, + response_description="Identify a path", +) +async def get_guess( + path: str, + _: Annotated[None, Security(validate_bearer, scopes=["scanner.guess"])], +): + """ + Identify a video path and return a serie/movie guess. + """ + + return await identify(path) diff --git a/shell.nix b/shell.nix index 16222c3a..13ed5b27 100644 --- a/shell.nix +++ b/shell.nix @@ -12,6 +12,9 @@ pkgs.mkShell { packages = [ pkgs.devspace + (pkgs.writeShellScriptBin "guess" '' + curl "localhost:8901/scanner/guess" -G --data-urlencode "path=$1" -H 'X-API-KEY: admin' | jq + '') ]; # env vars aren't inherited from the `inputsFrom`