diff --git a/alembic/versions/16e78af9e5bf_add_original_language_columns_to_show_.py b/alembic/versions/16e78af9e5bf_add_original_language_columns_to_show_.py index bb08933..6cba8ae 100644 --- a/alembic/versions/16e78af9e5bf_add_original_language_columns_to_show_.py +++ b/alembic/versions/16e78af9e5bf_add_original_language_columns_to_show_.py @@ -5,6 +5,7 @@ Revises: eb0bd3cc1852 Create Date: 2025-12-13 18:47:02.146038 """ + from typing import Sequence, Union from alembic import op @@ -12,8 +13,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = '16e78af9e5bf' -down_revision: Union[str, None] = 'eb0bd3cc1852' +revision: str = "16e78af9e5bf" +down_revision: Union[str, None] = "eb0bd3cc1852" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,22 +22,16 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Upgrade schema.""" # Add original_language column to show table - op.add_column( - 'show', - sa.Column('original_language', sa.String(10), nullable=True) - ) - + op.add_column("show", sa.Column("original_language", sa.String(10), nullable=True)) + # Add original_language column to movie table - op.add_column( - 'movie', - sa.Column('original_language', sa.String(10), nullable=True) - ) + op.add_column("movie", sa.Column("original_language", sa.String(10), nullable=True)) def downgrade() -> None: """Downgrade schema.""" # Remove original_language column from movie table - op.drop_column('movie', 'original_language') - + op.drop_column("movie", "original_language") + # Remove original_language column from show table - op.drop_column('show', 'original_language') + op.drop_column("show", "original_language") diff --git a/alembic/versions/2c61f662ca9e_add_imdb_id_fields.py b/alembic/versions/2c61f662ca9e_add_imdb_id_fields.py new file mode 100644 index 0000000..48a61a1 --- /dev/null +++ b/alembic/versions/2c61f662ca9e_add_imdb_id_fields.py @@ -0,0 +1,35 @@ +"""add imdb_id fields + +Revision ID: 2c61f662ca9e +Revises: 16e78af9e5bf +Create Date: 2025-12-23 19:42:09.593945 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "2c61f662ca9e" +down_revision: Union[str, None] = "16e78af9e5bf" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("movie", sa.Column("imdb_id", sa.String(), nullable=True)) + op.add_column("show", sa.Column("imdb_id", sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("show", "imdb_id") + op.drop_column("movie", "imdb_id") + # ### end Alembic commands ### diff --git a/media_manager/metadataProvider/config.py b/media_manager/metadataProvider/config.py index 67de5ec..4f07b46 100644 --- a/media_manager/metadataProvider/config.py +++ b/media_manager/metadataProvider/config.py @@ -3,8 +3,9 @@ from pydantic_settings import BaseSettings class TmdbConfig(BaseSettings): tmdb_relay_url: str = "https://metadata-relay.dorninger.co/tmdb" - primary_languages: list[str] = [] # ISO 639-1 language codes - default_language: str = "en" # ISO 639-1 language codes + primary_languages: list[str] = [] # ISO 639-1 language codes + default_language: str = "en" # ISO 639-1 language codes + class TvdbConfig(BaseSettings): tvdb_relay_url: str = "https://metadata-relay.dorninger.co/tvdb" diff --git a/media_manager/metadataProvider/tmdb.py b/media_manager/metadataProvider/tmdb.py index d45caff..00b59f1 100644 --- a/media_manager/metadataProvider/tmdb.py +++ b/media_manager/metadataProvider/tmdb.py @@ -32,7 +32,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider): """ Determine the language parameter to use for TMDB API calls. Returns the original language if it's in primary_languages, otherwise returns default_language. - + :param original_language: The original language code (ISO 639-1) of the media :return: Language parameter (ISO 639-1 format, e.g., 'en', 'no') """ @@ -45,8 +45,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider): language = self.default_language try: response = requests.get( - url=f"{self.url}/tv/shows/{id}", - params={"language": language} + url=f"{self.url}/tv/shows/{id}", params={"language": language} ) response.raise_for_status() return response.json() @@ -59,13 +58,29 @@ class TmdbMetadataProvider(AbstractMetadataProvider): ) raise - def __get_season_metadata(self, show_id: int, season_number: int, language: str | None = None) -> dict: + def __get_show_external_ids(self, id: int) -> dict: + try: + response = requests.get(url=f"{self.url}/tv/shows/{id}/external_ids") + response.raise_for_status() + return response.json() + except requests.RequestException as e: + log.error(f"TMDB API error getting show external IDs for ID {id}: {e}") + if notification_manager.is_configured(): + notification_manager.send_notification( + title="TMDB API Error", + message=f"Failed to fetch show external IDs for ID {id} from TMDB. Error: {str(e)}", + ) + raise + + def __get_season_metadata( + self, show_id: int, season_number: int, language: str | None = None + ) -> dict: if language is None: language = self.default_language try: response = requests.get( url=f"{self.url}/tv/shows/{show_id}/{season_number}", - params={"language": language} + params={"language": language}, ) response.raise_for_status() return response.json() @@ -102,7 +117,10 @@ class TmdbMetadataProvider(AbstractMetadataProvider): def __get_trending_tv(self) -> dict: try: - response = requests.get(url=f"{self.url}/tv/trending", params={"language": self.default_language}) + response = requests.get( + url=f"{self.url}/tv/trending", + params={"language": self.default_language}, + ) response.raise_for_status() return response.json() except requests.RequestException as e: @@ -119,8 +137,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider): language = self.default_language try: response = requests.get( - url=f"{self.url}/movies/{id}", - params={"language": language} + url=f"{self.url}/movies/{id}", params={"language": language} ) response.raise_for_status() return response.json() @@ -133,6 +150,20 @@ class TmdbMetadataProvider(AbstractMetadataProvider): ) raise + def __get_movie_external_ids(self, id: int) -> dict: + try: + response = requests.get(url=f"{self.url}/movies/{id}/external_ids") + response.raise_for_status() + return response.json() + except requests.RequestException as e: + log.error(f"TMDB API error getting movie external IDs for ID {id}: {e}") + if notification_manager.is_configured(): + notification_manager.send_notification( + title="TMDB API Error", + message=f"Failed to fetch movie external IDs for ID {id} from TMDB. Error: {str(e)}", + ) + raise + def __search_movie(self, query: str, page: int) -> dict: try: response = requests.get( @@ -155,7 +186,10 @@ class TmdbMetadataProvider(AbstractMetadataProvider): def __get_trending_movies(self) -> dict: try: - response = requests.get(url=f"{self.url}/movies/trending", params={"language": self.default_language}) + response = requests.get( + url=f"{self.url}/movies/trending", + params={"language": self.default_language}, + ) response.raise_for_status() return response.json() except requests.RequestException as e: @@ -170,10 +204,10 @@ class TmdbMetadataProvider(AbstractMetadataProvider): def download_show_poster_image(self, show: Show) -> bool: # Determine which language to use based on show's original_language language = self.__get_language_param(show.original_language) - + # Fetch metadata in the appropriate language to get localized poster show_metadata = self.__get_show_metadata(show.external_id, language=language) - + # downloading the poster # all pictures from TMDB should already be jpeg, so no need to convert if show_metadata["poster_path"] is not None: @@ -206,20 +240,24 @@ class TmdbMetadataProvider(AbstractMetadataProvider): if language is None: show_metadata = self.__get_show_metadata(id) language = show_metadata.get("original_language") - + # Determine which language to use for metadata language = self.__get_language_param(language) - + # Fetch show metadata in the appropriate language show_metadata = self.__get_show_metadata(id, language=language) - + + # get imdb id + external_ids = self.__get_show_external_ids(id=id) + imdb_id = external_ids.get("imdb_id") + season_list = [] # inserting all the metadata into the objects for season in show_metadata["seasons"]: season_metadata = self.__get_season_metadata( - show_id=show_metadata["id"], + show_id=show_metadata["id"], season_number=season["season_number"], - language=language + language=language, ) episode_list = [] @@ -255,6 +293,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider): metadata_provider=self.name, ended=show_metadata["status"] in ENDED_STATUS, original_language=show_metadata.get("original_language"), + imdb_id=imdb_id, ) return show @@ -287,19 +326,18 @@ class TmdbMetadataProvider(AbstractMetadataProvider): ) else: poster_url = None - + # Determine which name to use based on primary_languages original_language = result.get("original_language") original_name = result.get("original_name") display_name = result["name"] - + overview = result["overview"] # Use original name if language is in primary_languages and skip overview if original_language and original_language in self.primary_languages: display_name = original_name overview = None - formatted_results.append( MetaDataProviderSearchResult( poster_path=poster_url, @@ -334,13 +372,17 @@ class TmdbMetadataProvider(AbstractMetadataProvider): if language is None: movie_metadata = self.__get_movie_metadata(id=id) language = movie_metadata.get("original_language") - + # Determine which language to use for metadata language = self.__get_language_param(language) - + # Fetch movie metadata in the appropriate language movie_metadata = self.__get_movie_metadata(id=id, language=language) - + + # get imdb id + external_ids = self.__get_movie_external_ids(id=id) + imdb_id = external_ids.get("imdb_id") + year = media_manager.metadataProvider.utils.get_year_from_date( movie_metadata["release_date"] ) @@ -352,6 +394,7 @@ class TmdbMetadataProvider(AbstractMetadataProvider): year=year, metadata_provider=self.name, original_language=movie_metadata.get("original_language"), + imdb_id=imdb_id, ) return movie @@ -384,18 +427,18 @@ class TmdbMetadataProvider(AbstractMetadataProvider): ) else: poster_url = None - + # Determine which name to use based on primary_languages original_language = result.get("original_language") original_title = result.get("original_title") display_name = result["title"] - + overview = result["overview"] # Use original name if language is in primary_languages and skip overview if original_language and original_language in self.primary_languages: display_name = original_title overview = None - + formatted_results.append( MetaDataProviderSearchResult( poster_path=poster_url, @@ -418,10 +461,12 @@ class TmdbMetadataProvider(AbstractMetadataProvider): def download_movie_poster_image(self, movie: Movie) -> bool: # Determine which language to use based on movie's original_language language = self.__get_language_param(movie.original_language) - + # Fetch metadata in the appropriate language to get localized poster - movie_metadata = self.__get_movie_metadata(id=movie.external_id, language=language) - + movie_metadata = self.__get_movie_metadata( + id=movie.external_id, language=language + ) + # downloading the poster # all pictures from TMDB should already be jpeg, so no need to convert if movie_metadata["poster_path"] is not None: diff --git a/media_manager/metadataProvider/tvdb.py b/media_manager/metadataProvider/tvdb.py index 5e5b93d..36f0182 100644 --- a/media_manager/metadataProvider/tvdb.py +++ b/media_manager/metadataProvider/tvdb.py @@ -77,6 +77,14 @@ class TvdbMetadataProvider(AbstractMetadataProvider): seasons = [] seasons_ids = [season["id"] for season in series["seasons"]] + # get imdb id from remote ids + imdb_id = None + remote_ids = series.get("remoteIds", None) + if remote_ids: + for remote_id in remote_ids: + if remote_id.get("type") == 2: + imdb_id = remote_id.get("id") + for season in seasons_ids: s = self.__get_season(id=season) # the seasons need to be filtered to a certain type, @@ -119,6 +127,7 @@ class TvdbMetadataProvider(AbstractMetadataProvider): metadata_provider=self.name, seasons=seasons, ended=False, + imdb_id=imdb_id, ) return show @@ -267,17 +276,22 @@ class TvdbMetadataProvider(AbstractMetadataProvider): :rtype: Movie """ movie = self.__get_movie(id) - try: - year = movie["year"] - except KeyError: - year = None + + # get imdb id from remote ids + imdb_id = None + remote_ids = movie.get("remoteIds", None) + if remote_ids: + for remote_id in remote_ids: + if remote_id.get("type") == 2: + imdb_id = remote_id.get("id") movie = Movie( name=movie["name"], - overview="TVDB does not provide overviews", - year=year, + overview="Overviews are not supported with TVDB", + year=movie.get("year"), external_id=movie["id"], metadata_provider=self.name, + imdb_id=imdb_id, ) return movie diff --git a/media_manager/movies/models.py b/media_manager/movies/models.py index 2a8c5c8..247b96f 100644 --- a/media_manager/movies/models.py +++ b/media_manager/movies/models.py @@ -20,6 +20,8 @@ class Movie(Base): year: Mapped[int | None] library: Mapped[str] = mapped_column(default="") original_language: Mapped[str | None] = mapped_column(default=None) + imdb_id: Mapped[str | None] = mapped_column(default=None) + movie_requests: Mapped[list["MovieRequest"]] = relationship( "MovieRequest", back_populates="movie", cascade="all, delete-orphan" ) diff --git a/media_manager/movies/repository.py b/media_manager/movies/repository.py index 38e9ff4..da70633 100644 --- a/media_manager/movies/repository.py +++ b/media_manager/movies/repository.py @@ -116,6 +116,7 @@ class MovieRepository: db_movie.overview = movie.overview db_movie.year = movie.year db_movie.original_language = movie.original_language + db_movie.imdb_id = movie.imdb_id else: # Insert new movie log.debug(f"Creating new movie: {movie.name}") db_movie = Movie(**movie.model_dump()) @@ -435,10 +436,12 @@ class MovieRepository: name: str | None = None, overview: str | None = None, year: int | None = None, + imdb_id: str | None = None, ) -> MovieSchema: """ Update attributes of an existing movie. + :param imdb_id: The new IMDb ID for the movie. :param movie_id: The ID of the movie to update. :param name: The new name for the movie. :param overview: The new overview for the movie. @@ -459,6 +462,9 @@ class MovieRepository: if year is not None and db_movie.year != year: db_movie.year = year updated = True + if imdb_id is not None and db_movie.imdb_id != imdb_id: + db_movie.imdb_id = imdb_id + updated = True if updated: self.db.commit() diff --git a/media_manager/movies/schemas.py b/media_manager/movies/schemas.py index 3094988..f6a0a2c 100644 --- a/media_manager/movies/schemas.py +++ b/media_manager/movies/schemas.py @@ -24,6 +24,7 @@ class Movie(BaseModel): metadata_provider: str library: str = "Default" original_language: str | None = None + imdb_id: str | None = None class MovieFile(BaseModel): diff --git a/media_manager/movies/service.py b/media_manager/movies/service.py index c8fa344..ee05a0c 100644 --- a/media_manager/movies/service.py +++ b/media_manager/movies/service.py @@ -63,7 +63,10 @@ class MovieService: self.notification_service = notification_service def add_movie( - self, external_id: int, metadata_provider: AbstractMetadataProvider, language: str | None = None + self, + external_id: int, + metadata_provider: AbstractMetadataProvider, + language: str | None = None, ) -> Movie | None: """ Add a new movie to the database. @@ -72,7 +75,9 @@ class MovieService: :param metadata_provider: The name of the metadata provider. :param language: Optional language code (ISO 639-1) to fetch metadata in. """ - movie_with_metadata = metadata_provider.get_movie_metadata(id=external_id, language=language) + movie_with_metadata = metadata_provider.get_movie_metadata( + id=external_id, language=language + ) saved_movie = self.movie_repository.save_movie(movie=movie_with_metadata) metadata_provider.download_movie_poster_image(movie=saved_movie) return saved_movie @@ -699,7 +704,9 @@ class MovieService: log.debug(f"Found movie: {db_movie.name} for metadata update.") # Use stored original_language preference for metadata fetching - fresh_movie_data = metadata_provider.get_movie_metadata(id=db_movie.external_id, language=db_movie.original_language) + fresh_movie_data = metadata_provider.get_movie_metadata( + id=db_movie.external_id, language=db_movie.original_language + ) if not fresh_movie_data: log.warning( f"Could not fetch fresh metadata for movie {db_movie.name} (External ID: {db_movie.external_id}) from {db_movie.metadata_provider}." @@ -712,6 +719,7 @@ class MovieService: name=fresh_movie_data.name, overview=fresh_movie_data.overview, year=fresh_movie_data.year, + imdb_id=fresh_movie_data.imdb_id, ) updated_movie = self.movie_repository.get_movie_by_id(movie_id=db_movie.id) diff --git a/media_manager/tv/models.py b/media_manager/tv/models.py index 021fca2..c72702b 100644 --- a/media_manager/tv/models.py +++ b/media_manager/tv/models.py @@ -23,6 +23,8 @@ class Show(Base): library: Mapped[str] = mapped_column(default="") original_language: Mapped[str | None] = mapped_column(default=None) + imdb_id: Mapped[str | None] = mapped_column(default=None) + seasons: Mapped[list["Season"]] = relationship( back_populates="show", cascade="all, delete" ) diff --git a/media_manager/tv/repository.py b/media_manager/tv/repository.py index bce49a5..a6612cb 100644 --- a/media_manager/tv/repository.py +++ b/media_manager/tv/repository.py @@ -136,6 +136,7 @@ class TvRepository: db_show.overview = show.overview db_show.year = show.year db_show.original_language = show.original_language + db_show.imdb_id = show.imdb_id else: # Insert new show db_show = Show( id=show.id, @@ -146,6 +147,7 @@ class TvRepository: year=show.year, ended=show.ended, original_language=show.original_language, + imdb_id=show.imdb_id, seasons=[ Season( id=season.id, @@ -643,10 +645,13 @@ class TvRepository: year: int | None = None, ended: bool | None = None, continuous_download: bool | None = None, + imdb_id: str | None = None, ) -> ShowSchema: # Removed poster_url from params """ Update attributes of an existing show. + :param imdb_id: The new IMDb ID for the show. + :param continuous_download: The new continuous download status for the show. :param show_id: The ID of the show to update. :param name: The new name for the show. :param overview: The new overview for the show. @@ -677,6 +682,9 @@ class TvRepository: ): db_show.continuous_download = continuous_download updated = True + if imdb_id is not None and db_show.imdb_id != imdb_id: + db_show.imdb_id = imdb_id + updated = True if updated: self.db.commit() self.db.refresh(db_show) diff --git a/media_manager/tv/router.py b/media_manager/tv/router.py index a1ff66f..49744ce 100644 --- a/media_manager/tv/router.py +++ b/media_manager/tv/router.py @@ -58,7 +58,10 @@ router = APIRouter() }, ) def add_a_show( - tv_service: tv_service_dep, metadata_provider: metadata_provider_dep, show_id: int, language: str | None = None + tv_service: tv_service_dep, + metadata_provider: metadata_provider_dep, + show_id: int, + language: str | None = None, ): try: show = tv_service.add_show( diff --git a/media_manager/tv/schemas.py b/media_manager/tv/schemas.py index 0996de5..6419b58 100644 --- a/media_manager/tv/schemas.py +++ b/media_manager/tv/schemas.py @@ -57,6 +57,8 @@ class Show(BaseModel): library: str = "Default" original_language: str | None = None + imdb_id: str | None = None + seasons: list[Season] diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index 76e55e3..22e2661 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -69,7 +69,10 @@ class TvService: self.notification_service = notification_service def add_show( - self, external_id: int, metadata_provider: AbstractMetadataProvider, language: str | None = None + self, + external_id: int, + metadata_provider: AbstractMetadataProvider, + language: str | None = None, ) -> Show | None: """ Add a new show to the database. @@ -78,7 +81,9 @@ class TvService: :param metadata_provider: The name of the metadata provider. :param language: Optional language code (ISO 639-1) to fetch metadata in. """ - show_with_metadata = metadata_provider.get_show_metadata(id=external_id, language=language) + show_with_metadata = metadata_provider.get_show_metadata( + id=external_id, language=language + ) saved_show = self.tv_repository.save_show(show=show_with_metadata) metadata_provider.download_show_poster_image(show=saved_show) return saved_show @@ -758,7 +763,9 @@ class TvService: # old_poster_url = db_show.poster_url # poster_url removed from db_show # Use stored original_language preference for metadata fetching - fresh_show_data = metadata_provider.get_show_metadata(id=db_show.external_id, language=db_show.original_language) + fresh_show_data = metadata_provider.get_show_metadata( + id=db_show.external_id, language=db_show.original_language + ) if not fresh_show_data: log.warning( f"Could not fetch fresh metadata for show {db_show.name} (External ID: {db_show.external_id}) from {db_show.metadata_provider}." @@ -773,6 +780,7 @@ class TvService: overview=fresh_show_data.overview, year=fresh_show_data.year, ended=fresh_show_data.ended, + imdb_id=fresh_show_data.imdb_id, continuous_download=db_show.continuous_download if fresh_show_data.ended is False else False, diff --git a/metadata_relay/app/tmdb.py b/metadata_relay/app/tmdb.py index c9a7169..22288be 100644 --- a/metadata_relay/app/tmdb.py +++ b/metadata_relay/app/tmdb.py @@ -27,9 +27,15 @@ else: async def get_tmdb_show(show_id: int, language: str = "en"): return TV(show_id).info(language=language) + @router.get("/tv/shows/{show_id}/external_ids") + async def get_tmdb_show_external_ids(show_id: int): + return TV(show_id).external_ids() + @router.get("/tv/shows/{show_id}/{season_number}") async def get_tmdb_season(season_number: int, show_id: int, language: str = "en"): - return TV_Seasons(season_number=season_number, tv_id=show_id).info(language=language) + return TV_Seasons(season_number=season_number, tv_id=show_id).info( + language=language + ) @router.get("/movies/trending") async def get_tmdb_trending_movies(language: str = "en"): @@ -42,3 +48,7 @@ else: @router.get("/movies/{movie_id}") async def get_tmdb_movie(movie_id: int, language: str = "en"): return Movies(movie_id).info(language=language) + + @router.get("/movies/{movie_id}/external_ids") + async def get_tmdb_movie_external_ids(movie_id: int): + return Movies(movie_id).external_ids() diff --git a/web/src/lib/api/api.d.ts b/web/src/lib/api/api.d.ts index 929ad10..838f7dc 100644 --- a/web/src/lib/api/api.d.ts +++ b/web/src/lib/api/api.d.ts @@ -1338,6 +1338,8 @@ export interface components { library: string; /** Original Language */ original_language?: string | null; + /** Imdb Id */ + imdb_id?: string | null; }; /** MovieRequest */ MovieRequest: { @@ -1439,6 +1441,8 @@ export interface components { library: string; /** Original Language */ original_language?: string | null; + /** Imdb Id */ + imdb_id?: string | null; /** * Downloaded * @default false @@ -1697,6 +1701,8 @@ export interface components { library: string; /** Original Language */ original_language?: string | null; + /** Imdb Id */ + imdb_id?: string | null; /** Seasons */ seasons: components['schemas']['Season'][]; };