From 68a83c31be5a2b3e78a6907c5d1daf0d6d0f5292 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 1 Nov 2023 16:35:07 +0100 Subject: [PATCH] Identify collections from themoviedb --- .../implementations/themoviedatabase.py | 52 +++++++++++++++++- scanner/providers/provider.py | 6 +++ scanner/providers/types/collection.py | 31 +++++++++++ scanner/providers/types/episode.py | 1 + scanner/providers/types/movie.py | 3 ++ scanner/scanner/scanner.py | 53 ++++++++++++++++--- 6 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 scanner/providers/types/collection.py diff --git a/scanner/providers/implementations/themoviedatabase.py b/scanner/providers/implementations/themoviedatabase.py index cca36fdc..d8bd9a77 100644 --- a/scanner/providers/implementations/themoviedatabase.py +++ b/scanner/providers/implementations/themoviedatabase.py @@ -14,6 +14,7 @@ from ..types.studio import Studio from ..types.genre import Genre from ..types.metadataid import MetadataID from ..types.show import Show, ShowTranslation, Status as ShowStatus +from ..types.collection import Collection, CollectionTranslation class TheMovieDatabase(Provider): @@ -163,7 +164,19 @@ class TheMovieDatabase(Provider): if movie["imdb_id"] else {} ) - ) + ), + collections=[ + Collection( + external_id={ + self.name: MetadataID( + movie["belongs_to_collection"]["id"], + f"https://www.themoviedb.org/collection/{movie['belongs_to_collection']['id']}", + ) + }, + ) + ] + if movie["belongs_to_collection"] is not None + else [], # TODO: Add cast information ) translation = MovieTranslation( @@ -218,7 +231,6 @@ class TheMovieDatabase(Provider): }, ) logging.debug("TMDb responded: %s", show) - # TODO: Use collection data ret = Show( original_language=show["original_language"], @@ -509,3 +521,39 @@ class TheMovieDatabase(Provider): logging.exception( "Could not retrieve absolute ordering information", exc_info=e ) + + async def identify_collection( + self, provider_id: str, *, language: list[str] + ) -> Collection: + async def for_language(lng: str) -> Collection: + collection = await self.get( + f"collection/{provider_id}", + params={ + "language": lng, + }, + ) + logging.debug("TMDb responded: %s", collection) + + ret = Collection( + external_id={ + self.name: MetadataID( + collection["id"], + f"https://www.themoviedb.org/collection/{collection['id']}", + ) + }, + ) + translation = CollectionTranslation( + name=collection["name"], + overview=collection["overview"], + posters=[ + f"https://image.tmdb.org/t/p/original{collection['poster_path']}" + ], + logos=[], + thumbnails=[ + f"https://image.tmdb.org/t/p/original{collection['backdrop_path']}" + ], + ) + ret.translations = {lng: translation} + return ret + + return await self.process_translations(for_language, language) diff --git a/scanner/providers/provider.py b/scanner/providers/provider.py index 8d6e4dd0..4dd1a5b9 100644 --- a/scanner/providers/provider.py +++ b/scanner/providers/provider.py @@ -8,6 +8,7 @@ from providers.utils import ProviderError from .types.episode import Episode, PartialShow from .types.show import Show from .types.movie import Movie +from .types.collection import Collection Self = TypeVar("Self", bound="Provider") @@ -57,3 +58,8 @@ class Provider: language: list[str] ) -> Episode: raise NotImplementedError + + @abstractmethod + async def identify_collection(self, provider_id: str, *, language: list[str]) -> Collection: + raise NotImplementedError + diff --git a/scanner/providers/types/collection.py b/scanner/providers/types/collection.py new file mode 100644 index 00000000..189a5d0c --- /dev/null +++ b/scanner/providers/types/collection.py @@ -0,0 +1,31 @@ +import os +from dataclasses import asdict, dataclass, field +from typing import Optional + +from .metadataid import MetadataID + + +@dataclass +class CollectionTranslation: + name: str + overview: Optional[str] = None + posters: list[str] = field(default_factory=list) + logos: list[str] = field(default_factory=list) + thumbnails: list[str] = field(default_factory=list) + + +@dataclass +class Collection: + external_id: dict[str, MetadataID] + translations: dict[str, CollectionTranslation] = field(default_factory=dict) + + def to_kyoo(self): + # For now, the API of kyoo only support one language so we remove the others. + default_language = os.environ["LIBRARY_LANGUAGES"].split(",")[0] + return { + **asdict(self), + **asdict(self.translations[default_language]), + "poster": next(iter(self.translations[default_language].posters), None), + "thumbnail": next(iter(self.translations[default_language].thumbnails), None), + "logo": next(iter(self.translations[default_language].logos), None), + } diff --git a/scanner/providers/types/episode.py b/scanner/providers/types/episode.py index 0d30002f..96243b69 100644 --- a/scanner/providers/types/episode.py +++ b/scanner/providers/types/episode.py @@ -33,6 +33,7 @@ class Episode: path: Optional[str] = None show_id: Optional[str] = None + season_id: Optional[str] = None translations: dict[str, EpisodeTranslation] = field(default_factory=dict) def to_kyoo(self): diff --git a/scanner/providers/types/movie.py b/scanner/providers/types/movie.py index 7f1e991e..dd1a731d 100644 --- a/scanner/providers/types/movie.py +++ b/scanner/providers/types/movie.py @@ -4,6 +4,7 @@ from datetime import date from typing import Optional from enum import Enum +from .collection import Collection from .genre import Genre from .studio import Studio from .metadataid import MetadataID @@ -43,6 +44,7 @@ class Movie: external_id: dict[str, MetadataID] path: Optional[str] = None + collections: list[Collection] = field(default_factory=list) translations: dict[str, MovieTranslation] = field(default_factory=dict) def to_kyoo(self): @@ -59,4 +61,5 @@ class Movie: "trailer": next(iter(self.translations[default_language].trailers), None), "studio": next((x.to_kyoo() for x in self.studios), None), "genres": [x.to_kyoo() for x in self.genres], + "collections": None, } diff --git a/scanner/scanner/scanner.py b/scanner/scanner/scanner.py index 060093a8..ad7432ae 100644 --- a/scanner/scanner/scanner.py +++ b/scanner/scanner/scanner.py @@ -6,8 +6,9 @@ import re from aiohttp import ClientSession from pathlib import Path from guessit import guessit -from typing import List +from typing import List, Literal from providers.provider import Provider +from providers.types.collection import Collection from providers.types.episode import Episode, PartialShow from providers.types.season import Season, SeasonTranslation from .utils import batch, log_errors, provider_cache, set_in_cache @@ -28,7 +29,7 @@ class Scanner: self._ignore_pattern = re.compile("") logging.error(f"Invalid ignore pattern. Ignoring. Error: {e}") self.provider = Provider.get_all(client)[0] - self.cache = {"shows": {}, "seasons": {}} + self.cache = {"shows": {}, "seasons": {}, "collections": {}} self.languages = languages async def scan(self, path: str): @@ -83,14 +84,21 @@ class Scanner: logging.info("Identied %s: %s", path, raw) - # TODO: Add collections support if raw["type"] == "movie": movie = await self.provider.identify_movie( raw["title"], raw.get("year"), language=self.languages ) movie.path = str(path) logging.debug("Got movie: %s", movie) - await self.post("movies", data=movie.to_kyoo()) + movie_id = await self.post("movies", data=movie.to_kyoo()) + + if any(movie.collections): + ids = await asyncio.gather( + *(self.create_or_get_collection(x) for x in movie.collections) + ) + await asyncio.gather( + *(self.link_collection(x, "movie", movie_id) for x in ids) + ) elif raw["type"] == "episode": episode = await self.provider.identify_episode( raw["title"], @@ -105,7 +113,7 @@ class Scanner: episode.show_id = await self.create_or_get_show(episode) if episode.season_number is not None: - await self.register_seasons( + episode.season_id = await self.register_seasons( show_id=episode.show_id, season_number=episode.season_number, ) @@ -113,6 +121,36 @@ class Scanner: else: logging.warn("Unknown video file type: %s", raw["type"]) + async def create_or_get_collection(self, collection: Collection) -> str: + @provider_cache("collection") + async def create_collection(provider_id: str): + # TODO: Check if a collection with the same metadata id exists already on kyoo. + new_collection = ( + await self.provider.identify_collection( + provider_id, language=self.languages + ) + if not any(collection.translations.keys()) + else collection + ) + logging.debug("Got collection: %s", new_collection) + return await self.post("collection", data=new_collection.to_kyoo()) + + # The parameter is only used as a key for the cache. + provider_id = collection.external_id[self.provider.name].data_id + return await create_collection(provider_id) + + async def link_collection( + self, collection: str, type: Literal["movie"] | Literal["show"], id: str + ): + async with self._client.put( + f"{self._url}/collections/{collection}/{type}/{id}", + headers={"X-API-Key": self._api_key}, + ) as r: + # Allow 409 and continue as if it worked. + if not r.ok and r.status != 409: + logging.error(f"Request error: {await r.text()}") + r.raise_for_status() + async def create_or_get_show(self, episode: Episode) -> str: @provider_cache("shows") async def create_show(_: str): @@ -122,6 +160,7 @@ class Scanner: if isinstance(episode.show, PartialShow) else episode.show ) + # TODO: collections logging.debug("Got show: %s", episode) ret = await self.post("show", data=show.to_kyoo()) try: @@ -138,14 +177,14 @@ class Scanner: return await create_show(provider_id) @provider_cache("seasons") - async def register_seasons(self, show_id: str, season_number: int): + async def register_seasons(self, show_id: str, season_number: int) -> str: # TODO: fetch season here. this will be useful when a new season of a show is aired after the show has been created on kyoo. season = Season( season_number=season_number, show_id=show_id, translations={lng: SeasonTranslation() for lng in self.languages}, ) - await self.post("seasons", data=season.to_kyoo()) + return await self.post("seasons", data=season.to_kyoo()) async def post(self, path: str, *, data: object) -> str: logging.debug(