From 49961c341e3ce67970e494d3594bc9e63f69fca2 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 22 Mar 2026 10:42:22 +0100 Subject: [PATCH] Add search endpoint on the scanner --- .env.example | 2 +- chart/values.yaml | 2 +- scanner/scanner/__init__.py | 5 +- scanner/scanner/providers/themoviedatabase.py | 4 +- scanner/scanner/providers/thetvdb.py | 18 +++++++- scanner/scanner/routers/dependencies.py | 40 ++++++++++++++++ scanner/scanner/routers/routes.py | 46 ++++++++++++++++++- 7 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 scanner/scanner/routers/dependencies.py diff --git a/.env.example b/.env.example index 5d7b3557..feb3749c 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", "scanner.guess"], "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", "scanner.search"], "verified": true}' # Guest (meaning unlogged in users) can be: # unauthorized (they need to connect before doing anything) diff --git a/chart/values.yaml b/chart/values.yaml index 652698c3..9d07a631 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", "scanner.guess"], "verified": true}' + firstUserClaims: '{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write", "core.play", "scanner.trigger", "scanner.guess", "scanner.search"], "verified": true}' guestClaims: '{"permissions": ["core.read"], "verified": true}' extraClaims: '{"permissions": ["core.read", "core.play"], "verified": false}' protectedClaims: "permissions,verified" diff --git a/scanner/scanner/__init__.py b/scanner/scanner/__init__.py index db6a0a91..4babc850 100644 --- a/scanner/scanner/__init__.py +++ b/scanner/scanner/__init__.py @@ -18,7 +18,7 @@ from .routers.routes import router @asynccontextmanager -async def lifespan(_): +async def lifespan(app: FastAPI): async with ( init_pool() as pool, get_db() as db, @@ -26,6 +26,7 @@ async def lifespan(_): TVDB() as tvdb, TheMovieDatabase() as tmdb, ): + app.state.provider = CompositeProvider(tvdb, tmdb) # there's no way someone else used the same id, right? is_master = await db.fetchval("select pg_try_advisory_lock(198347)") is_http = not is_master and await db.fetchval( @@ -39,7 +40,7 @@ async def lifespan(_): processor = RequestProcessor( pool, client, - CompositeProvider(tvdb, tmdb), + app.state.provider, ) scanner = FsScanner(client, RequestCreator(db)) tasks = create_task( diff --git a/scanner/scanner/providers/themoviedatabase.py b/scanner/scanner/providers/themoviedatabase.py index 8657b95b..6dae2136 100644 --- a/scanner/scanner/providers/themoviedatabase.py +++ b/scanner/scanner/providers/themoviedatabase.py @@ -111,7 +111,7 @@ class TheMovieDatabase(Provider): params={ "query": title, "year": year, - "languages": [str(x) for x in language], + "language": next((str(x) for x in language), None), }, ) )["results"] @@ -245,7 +245,7 @@ class TheMovieDatabase(Provider): params={ "query": title, "year": year, - "languages": [str(x) for x in language], + "language": next((str(x) for x in language), None), }, ) )["results"] diff --git a/scanner/scanner/providers/thetvdb.py b/scanner/scanner/providers/thetvdb.py index c071930d..67a0e3a4 100644 --- a/scanner/scanner/providers/thetvdb.py +++ b/scanner/scanner/providers/thetvdb.py @@ -172,8 +172,22 @@ class TVDB(Provider): return [ SearchSerie( slug=x["slug"], - name=x["name"], - description=x.get("overview"), + name=next( + ( + x["translations"][lang.to_alpha3()] + for lang in language + if "translations" in x and lang.to_alpha3() in x["translations"] + ), + x["name"], + ), + description=next( + ( + x["overviews"][lang.to_alpha3()] + for lang in language + if "overviews" in x and lang.to_alpha3() in x["overviews"] + ), + x.get("overview"), + ), start_air=datetime.strptime(x["first_air_time"], "%Y-%m-%d").date() if x.get("first_air_time") else None, diff --git a/scanner/scanner/routers/dependencies.py b/scanner/scanner/routers/dependencies.py new file mode 100644 index 00000000..54335144 --- /dev/null +++ b/scanner/scanner/routers/dependencies.py @@ -0,0 +1,40 @@ +from typing import Annotated + +from fastapi import Header, Request +from langcodes import Language + +from ..providers.composite import CompositeProvider + + +def get_provider(request: Request) -> CompositeProvider: + return request.app.state.provider + + +def get_preferred_languages( + accept_language: Annotated[str | None, Header()] = None, +) -> list[Language]: + if not accept_language: + return [] + + ret: list[tuple[float, int, Language]] = [] + for index, item in enumerate(accept_language.split(",")): + part = item.strip() + if not part: + continue + + tag, *params = [x.strip() for x in part.split(";")] + if tag == "*": + continue + + try: + q = next((float(x[2:]) for x in params if x.startswith("q=")), 1) + if q <= 0: + continue + + language = Language.get(tag) + ret.append((q, index, language)) + except Exception: + continue + + ret.sort(key=lambda x: (-x[0], x[1])) + return [x for _q, _i, x in ret] + [Language.get("en")] diff --git a/scanner/scanner/routers/routes.py b/scanner/scanner/routers/routes.py index 68b3c0d8..ae74ae4d 100644 --- a/scanner/scanner/routers/routes.py +++ b/scanner/scanner/routers/routes.py @@ -5,8 +5,14 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Security from ..fsscan import create_scanner from ..identifiers.identify import identify from ..jwt import validate_bearer +from ..models.movie import SearchMovie from ..models.request import RequestRet +from ..models.serie import SearchSerie +from ..models.videos import Video +from ..providers.composite import CompositeProvider from ..status import StatusService +from ..utils import Language +from .dependencies import get_preferred_languages, get_provider router = APIRouter() @@ -52,9 +58,47 @@ async def trigger_scan( async def get_guess( path: str, _: Annotated[None, Security(validate_bearer, scopes=["scanner.guess"])], -): +) -> Video: """ Identify a video path and return a serie/movie guess. """ return await identify(path) + + +@router.get( + "/movies", + status_code=200, + response_description="Found movies", +) +async def get_movies( + provider: Annotated[CompositeProvider, Depends(get_provider)], + language: Annotated[list[Language], Depends(get_preferred_languages)], + _: Annotated[None, Security(validate_bearer, scopes=["scanner.search"])], + query: str, + year: int | None = None, +) -> list[SearchMovie]: + """ + Search for a movie + """ + + return await provider.search_movies(query, year=year, language=language) + + +@router.get( + "/series", + status_code=200, + response_description="Found series", +) +async def get_series( + provider: Annotated[CompositeProvider, Depends(get_provider)], + language: Annotated[list[Language], Depends(get_preferred_languages)], + _: Annotated[None, Security(validate_bearer, scopes=["scanner.search"])], + query: str, + year: int | None = None, +) -> list[SearchSerie]: + """ + Search for a serie + """ + + return await provider.search_series(query, year=year, language=language)