diff --git a/scanner/providers/implementations/themoviedatabase.py b/scanner/providers/implementations/themoviedatabase.py index aa35a7d6..a8b2f591 100644 --- a/scanner/providers/implementations/themoviedatabase.py +++ b/scanner/providers/implementations/themoviedatabase.py @@ -9,7 +9,7 @@ from providers.types.metadataid import MetadataID from ..provider import Provider from ..types.movie import Movie, MovieTranslation -from ..types.status import Status +from ..types.episode import Episode from ..types.studio import Studio @@ -138,3 +138,15 @@ class TheMovieDatabase(Provider): movie = movies[0] movie.translations = {k: v.translations[k] for k, v in zip(language, movies)} return movie + + + async def identify_episode( + self, + name: str, + season: Optional[int], + episode: Optional[int], + absolute: Optional[int], + *, + language: list[str] + ) -> Episode: + raise NotImplementedError diff --git a/scanner/providers/provider.py b/scanner/providers/provider.py index 97f09afa..3275cf5c 100644 --- a/scanner/providers/provider.py +++ b/scanner/providers/provider.py @@ -3,6 +3,7 @@ from aiohttp import ClientSession from abc import abstractmethod from typing import Optional, TypeVar +from .types.episode import Episode from .types.movie import Movie @@ -27,3 +28,15 @@ class Provider: self, name: str, year: Optional[int], *, language: list[str] ) -> Movie: raise NotImplementedError + + @abstractmethod + async def identify_episode( + self, + name: str, + season: Optional[int], + episode: Optional[int], + absolute: Optional[int], + *, + language: list[str] + ) -> Episode: + raise NotImplementedError diff --git a/scanner/providers/types/episode.py b/scanner/providers/types/episode.py new file mode 100644 index 00000000..1ff47619 --- /dev/null +++ b/scanner/providers/types/episode.py @@ -0,0 +1,26 @@ +from datetime import date +from dataclasses import dataclass, field +from typing import Optional + +from .show import Show +from .season import Season +from .metadataid import MetadataID + + +@dataclass +class EpisodeTranslation: + name: str + overview: Optional[str] + thumbnails: list[str] + +@dataclass +class Episode: + show: Show | dict[str, MetadataID] + season: Optional[Season] + episode_number: Optional[int] + absolute_number: Optional[int] + release_date: Optional[date | int] + path: Optional[str] + external_id: dict[str, MetadataID] + + translations: dict[str, EpisodeTranslation] = field(default_factory=dict) diff --git a/scanner/providers/types/movie.py b/scanner/providers/types/movie.py index de22dfe1..d9255e26 100644 --- a/scanner/providers/types/movie.py +++ b/scanner/providers/types/movie.py @@ -2,14 +2,19 @@ import os from dataclasses import asdict, dataclass, field from datetime import date from typing import Optional - +from enum import Enum from .genre import Genre -from .status import Status from .studio import Studio from .metadataid import MetadataID +class Status(str, Enum): + UNKNOWN = "unknown" + FINISHED = "finished" + PLANNED = "planned" + + @dataclass class MovieTranslation: name: str diff --git a/scanner/providers/types/season.py b/scanner/providers/types/season.py new file mode 100644 index 00000000..82a70138 --- /dev/null +++ b/scanner/providers/types/season.py @@ -0,0 +1,25 @@ +from datetime import date +from dataclasses import dataclass, field +from typing import Optional + +from .show import Show +from .metadataid import MetadataID + + +@dataclass +class SeasonTranslation: + name: Optional[str] + overview: Optional[str] + poster: list[str] + thumbnails: list[str] + + +@dataclass +class Season: + show: Show | dict[str, MetadataID] + season_number: int + start_date: Optional[date | int] + end_date: Optional[date | int] + external_id: dict[str, MetadataID] + + translations: dict[str, SeasonTranslation] = field(default_factory=dict) diff --git a/scanner/providers/types/show.py b/scanner/providers/types/show.py new file mode 100644 index 00000000..506abb9c --- /dev/null +++ b/scanner/providers/types/show.py @@ -0,0 +1,71 @@ +import os +from dataclasses import asdict, dataclass, field +from datetime import date +from typing import Optional +from enum import Enum + + +from .genre import Genre +from .studio import Studio +from .metadataid import MetadataID + +class Status(str, Enum): + UNKNOWN = "unknown" + FINISHED = "finished" + AIRING = "airing" + PLANNED = "planned" + + +@dataclass +class ShowTranslation: + name: str + tagline: Optional[str] = None + keywords: list[str] = field(default_factory=list) + overview: Optional[str] = None + + posters: list[str] = field(default_factory=list) + logos: list[str] = field(default_factory=list) + trailers: list[str] = field(default_factory=list) + thumbnails: list[str] = field(default_factory=list) + + +@dataclass +class Show: + original_language: Optional[str] = None + aliases: list[str] = field(default_factory=list) + start_air: Optional[date | int] = None + end_air: Optional[date | int] = None + status: Status = Status.UNKNOWN + studios: list[Studio] = field(default_factory=list) + genres: list[Genre] = field(default_factory=list) + # TODO: handle staff + # staff: list[Staff] + external_id: dict[str, MetadataID] = field(default_factory=dict) + + translations: dict[str, ShowTranslation] = field(default_factory=dict) + + def format_date(self, date: date | int | None) -> str | None: + if date is None: + return None + if isinstance(date, int): + return f"{date}-01-01T00:00:00Z" + return date.isoformat() + + 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), + "trailer": next(iter(self.translations[default_language].trailers), None), + "studio": next(iter(x.to_kyoo() for x in self.studios), None), + "startAir": self.format_date(self.start_air), + "endAir": self.format_date(self.end_air), + "title": self.translations[default_language].name, + "genres": [x.to_kyoo() for x in self.genres], + } diff --git a/scanner/providers/types/status.py b/scanner/providers/types/status.py deleted file mode 100644 index 4881db5d..00000000 --- a/scanner/providers/types/status.py +++ /dev/null @@ -1,8 +0,0 @@ -from enum import Enum - - -class Status(str, Enum): - UNKNOWN = "unknown" - FINISHED = "finished" - AIRING = "airing" - PLANNED = "planned" diff --git a/scanner/scanner/scanner.py b/scanner/scanner/scanner.py index 8916b6a0..a8bb51f1 100644 --- a/scanner/scanner/scanner.py +++ b/scanner/scanner/scanner.py @@ -35,12 +35,14 @@ class Scanner: @log_errors async def identify(self, path: Path): - raw = guessit(path) + raw = guessit(path, "--episode-prefer-number") logging.info("Identied %s: %s", path, raw) # TODO: check if episode/movie already exists in kyoo and skip if it does. # TODO: keep a list of processing shows to only fetch metadata once even if # multiples identify of the same show run on the same time + + # TODO: Add collections support if raw["type"] == "movie": movie = await self.provider.identify_movie( raw["title"], raw.get("year"), language=self.languages @@ -49,7 +51,17 @@ class Scanner: logging.debug("Got movie: %s", movie) await self.post("movies", data=movie.to_kyoo()) elif raw["type"] == "episode": - pass + # TODO: Identify shows & seasons too. + episode = await self.provider.identify_episode( + raw["title"], + season=raw.get("season"), + episode=raw.get("episode"), + absolute=raw.get("episode") if "season" not in raw else None, + language=self.languages, + ) + episode.path = str(path) + logging.debug("Got episode: %s", episode) + await self.post("episodes", data=episode.to_kyoo()) else: logging.warn("Unknown video file type: %s", raw["type"])