diff --git a/media_manager/metadataProvider/__init__.py b/media_manager/metadataProvider/__init__.py index c2c6d24..56babce 100644 --- a/media_manager/metadataProvider/__init__.py +++ b/media_manager/metadataProvider/__init__.py @@ -19,6 +19,14 @@ def get_show_metadata(id: int = None, provider: str = "tmdb") -> Show: return metadata_providers[provider].get_show_metadata(id) +def download_show_poster_image(show: Show) -> bool: + """ + Downloads the poster image for a show. + :param show: The show to download the poster image for. + :return: True if the image was downloaded successfully, False otherwise. + """ + return metadata_providers[show.metadata_provider].download_show_poster_image(show) + @cached(search_show_cache) def search_show( query: str | None = None, provider: str = "tmdb" diff --git a/media_manager/metadataProvider/abstractMetaDataProvider.py b/media_manager/metadataProvider/abstractMetaDataProvider.py index 7289248..15258d9 100644 --- a/media_manager/metadataProvider/abstractMetaDataProvider.py +++ b/media_manager/metadataProvider/abstractMetaDataProvider.py @@ -24,6 +24,15 @@ class AbstractMetadataProvider(ABC): def search_show(self, query) -> list[MetaDataProviderShowSearchResult]: pass + @abstractmethod + def download_show_poster_image(self, show: Show) -> bool: + """ + Downloads the poster image for a show. + :param show: The show to download the poster image for. + :return: True if the image was downloaded successfully, False otherwise. + """ + pass + metadata_providers = {} diff --git a/media_manager/metadataProvider/tmdb.py b/media_manager/metadataProvider/tmdb.py index 77cb241..8658830 100644 --- a/media_manager/metadataProvider/tmdb.py +++ b/media_manager/metadataProvider/tmdb.py @@ -24,6 +24,27 @@ log = logging.getLogger(__name__) class TmdbMetadataProvider(AbstractMetadataProvider): name = "tmdb" + def download_show_poster_image(self, show: Show) -> bool: + show_metadata = TV(show.external_id).info() + # downloading the poster + # all pictures from TMDB should already be jpeg, so no need to convert + if show_metadata["poster_path"] is not None: + poster_url = ( + "https://image.tmdb.org/t/p/original" + show_metadata["poster_path"] + ) + if media_manager.metadataProvider.utils.download_poster_image( + storage_path=self.storage_path, poster_url=poster_url, show=show + ): + log.info("Successfully downloaded poster image for show " + show.name) + else: + log.warning(f"download for image of show {show.name} failed") + return False + else: + log.warning(f"image for show {show.name} could not be downloaded") + return False + return True + + def get_show_metadata(self, id: int = None) -> Show: """ @@ -73,21 +94,6 @@ class TmdbMetadataProvider(AbstractMetadataProvider): metadata_provider=self.name, ) - # downloading the poster - # all pictures from TMDB should already be jpeg, so no need to convert - if show_metadata["poster_path"] is not None: - poster_url = ( - "https://image.tmdb.org/t/p/original" + show_metadata["poster_path"] - ) - if media_manager.metadataProvider.utils.download_poster_image( - storage_path=self.storage_path, poster_url=poster_url, show=show - ): - log.info("Successfully downloaded poster image for show " + show.name) - else: - log.warning(f"download for image of show {show.name} failed") - else: - log.warning(f"image for show {show.name} could not be downloaded") - return show def search_show( diff --git a/media_manager/metadataProvider/tvdb.py b/media_manager/metadataProvider/tvdb.py index deadc4e..968a50c 100644 --- a/media_manager/metadataProvider/tvdb.py +++ b/media_manager/metadataProvider/tvdb.py @@ -24,11 +24,25 @@ log = logging.getLogger(__name__) class TvdbMetadataProvider(AbstractMetadataProvider): name = "tvdb" + tvdb_client: tvdb_v4_official.TVDB def __init__(self, api_key: str = None): self.tvdb_client = tvdb_v4_official.TVDB(api_key) + def download_show_poster_image(self, show: Show) -> bool: + show_metadata = self.tvdb_client.get_series_extended(show.external_id) + + if show_metadata["image"] is not None: + media_manager.metadataProvider.utils.download_poster_image( + storage_path=self.storage_path, poster_url=show_metadata["image"], show=show + ) + log.info("Successfully downloaded poster image for show " + show.name) + return True + else: + log.warning(f"image for show {show.name} could not be downloaded") + return False + def get_show_metadata(self, id: int = None) -> Show: """ @@ -71,17 +85,10 @@ class TvdbMetadataProvider(AbstractMetadataProvider): seasons=seasons, ) - if series["image"] is not None: - media_manager.metadataProvider.utils.download_poster_image( - storage_path=self.storage_path, poster_url=series["image"], show=show - ) - else: - log.warning(f"image for show {show.name} could not be downloaded") - return show def search_show( - self, query: str | None = None + self, query: str | None = None ) -> list[MetaDataProviderShowSearchResult]: if query is None: results = self.tvdb_client.get_all_series() diff --git a/media_manager/tv/repository.py b/media_manager/tv/repository.py index 43367e8..3260798 100644 --- a/media_manager/tv/repository.py +++ b/media_manager/tv/repository.py @@ -15,11 +15,13 @@ from media_manager.tv.schemas import ( SeasonId, Show as ShowSchema, ShowId, + Episode as EpisodeSchema, # Added EpisodeSchema import SeasonRequest as SeasonRequestSchema, SeasonFile as SeasonFileSchema, SeasonNumber, SeasonRequestId, RichSeasonRequest as RichSeasonRequestSchema, + EpisodeId, ) @@ -579,3 +581,194 @@ class TvRepository: except SQLAlchemyError as e: log.error(f"Database error retrieving show by season_id {season_id}: {e}") raise + + def add_season_to_show(self, show_id: ShowId, season_data: SeasonSchema) -> SeasonSchema: + """ + Adds a new season and its episodes to a show. + If the season number already exists for the show, it returns the existing season. + + :param show_id: The ID of the show to add the season to. + :param season_data: The SeasonSchema object for the new season. + :return: The added or existing SeasonSchema object. + :raises NotFoundError: If the show is not found. + :raises SQLAlchemyError: If a database error occurs. + """ + log.debug(f"Attempting to add season {season_data.number} to show {show_id}") + db_show = self.db.get(Show, show_id) + if not db_show: + log.warning(f"Show with id {show_id} not found when trying to add season.") + raise NotFoundError(f"Show with id {show_id} not found.") + + stmt = select(Season).where(Season.show_id == show_id).where(Season.number == season_data.number) + existing_db_season = self.db.execute(stmt).scalar_one_or_none() + if existing_db_season: + log.info(f"Season {season_data.number} already exists for show {show_id} (ID: {existing_db_season.id}). Skipping add.") + return SeasonSchema.model_validate(existing_db_season) + + db_season = Season( + id=season_data.id, + show_id=show_id, + number=season_data.number, + external_id=season_data.external_id, + name=season_data.name, + overview=season_data.overview, + episodes=[ + Episode( + id=ep_schema.id, + # season_id will be implicitly set by SQLAlchemy relationship + number=ep_schema.number, + external_id=ep_schema.external_id, + title=ep_schema.title, + ) for ep_schema in season_data.episodes + ] + ) + + + self.db.add(db_season) + self.db.commit() + self.db.refresh(db_season) + log.info(f"Successfully added season {db_season.number} (ID: {db_season.id}) to show {show_id}.") + return SeasonSchema.model_validate(db_season) + + def add_episode_to_season(self, season_id: SeasonId, episode_data: EpisodeSchema) -> EpisodeSchema: + """ + Adds a new episode to a season. + If the episode number already exists for the season, it returns the existing episode. + + :param season_id: The ID of the season to add the episode to. + :param episode_data: The EpisodeSchema object for the new episode. + :return: The added or existing EpisodeSchema object. + :raises NotFoundError: If the season is not found. + :raises SQLAlchemyError: If a database error occurs. + """ + log.debug(f"Attempting to add episode {episode_data.number} to season {season_id}") + db_season = self.db.get(Season, season_id) + if not db_season: + log.warning(f"Season with id {season_id} not found when trying to add episode.") + raise NotFoundError(f"Season with id {season_id} not found.") + + stmt = select(Episode).where(Episode.season_id == season_id).where(Episode.number == episode_data.number) + existing_db_episode = self.db.execute(stmt).scalar_one_or_none() + if existing_db_episode: + log.info(f"Episode {episode_data.number} already exists for season {season_id} (ID: {existing_db_episode.id}). Skipping add.") + return EpisodeSchema.model_validate(existing_db_episode) + + db_episode = Episode( + id=episode_data.id, + season_id=season_id, + number=episode_data.number, + external_id=episode_data.external_id, + title=episode_data.title, + ) + + self.db.add(db_episode) + self.db.commit() + self.db.refresh(db_episode) + log.info(f"Successfully added episode {db_episode.number} (ID: {db_episode.id}) to season {season_id}.") + return EpisodeSchema.model_validate(db_episode) + + def update_show_attributes(self, show_id: ShowId, name: str | None = None, overview: str | None = None, year: int | None = None) -> ShowSchema: # Removed poster_url from params + """ + Update attributes of an existing 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. + :param year: The new year for the show. + # :param poster_url: The new poster URL for the show. # Removed poster_url doc + :return: The updated ShowSchema object. + :raises NotFoundError: If the show is not found. + :raises SQLAlchemyError: If a database error occurs. + """ + log.debug(f"Attempting to update attributes for show ID: {show_id}") + db_show = self.db.get(Show, show_id) + if not db_show: + log.warning(f"Show with id {show_id} not found for attribute update.") + raise NotFoundError(f"Show with id {show_id} not found.") + + updated = False + if name is not None and db_show.name != name: + db_show.name = name + updated = True + if overview is not None and db_show.overview != overview: + db_show.overview = overview + updated = True + if year is not None and db_show.year != year: + db_show.year = year + updated = True + # if poster_url is not None and db_show.poster_url != poster_url: # Removed poster_url update logic + # db_show.poster_url = poster_url + # updated = True + + if updated: + self.db.commit() + self.db.refresh(db_show) + log.info(f"Successfully updated attributes for show ID: {show_id}") + else: + log.info(f"No attribute changes needed for show ID: {show_id}") + return ShowSchema.model_validate(db_show) + + def update_season_attributes(self, season_id: SeasonId, name: str | None = None, overview: str | None = None) -> SeasonSchema: + """ + Update attributes of an existing season. + + :param season_id: The ID of the season to update. + :param name: The new name for the season. + :param overview: The new overview for the season. + :param external_id: The new external ID for the season. + :return: The updated SeasonSchema object. + :raises NotFoundError: If the season is not found. + :raises SQLAlchemyError: If a database error occurs. + """ + log.debug(f"Attempting to update attributes for season ID: {season_id}") + db_season = self.db.get(Season, season_id) + if not db_season: + log.warning(f"Season with id {season_id} not found for attribute update.") + raise NotFoundError(f"Season with id {season_id} not found.") + + updated = False + if name is not None and db_season.name != name: + db_season.name = name + updated = True + if overview is not None and db_season.overview != overview: + db_season.overview = overview + updated = True + + if updated: + self.db.commit() + self.db.refresh(db_season) + log.info(f"Successfully updated attributes for season ID: {season_id}") + else: + log.info(f"No attribute changes needed for season ID: {season_id}") + return SeasonSchema.model_validate(db_season) + + + def update_episode_attributes(self, episode_id: EpisodeId, title: str | None = None) -> EpisodeSchema: + """ + Update attributes of an existing episode. + + :param episode_id: The ID of the episode to update. + :param title: The new title for the episode. + :param external_id: The new external ID for the episode. + :return: The updated EpisodeSchema object. + :raises NotFoundError: If the episode is not found. + :raises SQLAlchemyError: If a database error occurs. + """ + log.debug(f"Attempting to update attributes for episode ID: {episode_id}") + db_episode = self.db.get(Episode, episode_id) + if not db_episode: + log.warning(f"Episode with id {episode_id} not found for attribute update.") + raise NotFoundError(f"Episode with id {episode_id} not found.") + + updated = False + if title is not None and db_episode.title != title: + db_episode.title = title + updated = True + + if updated: + self.db.commit() + self.db.refresh(db_episode) + log.info(f"Successfully updated attributes for episode ID: {episode_id}") + else: + log.info(f"No attribute changes needed for episode ID: {episode_id}") + return EpisodeSchema.model_validate(db_episode) diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index e8b6e4d..10b5e7b 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -28,6 +28,8 @@ from media_manager.tv.schemas import ( PublicSeasonFile, SeasonRequestId, RichSeasonRequest, + EpisodeId, + Episode as EpisodeSchema, ) from media_manager.torrent.schemas import QualityStrings from media_manager.tv.repository import TvRepository @@ -55,14 +57,14 @@ class TvService: """ Add a new show to the database. - :param external_id: The ID of the show in the metadata provider's system. + :param external_id: The ID of the show in the metadata provider\\\'s system. :param metadata_provider: The name of the metadata provider. - :return: The saved show object or None if it failed. """ show_with_metadata = media_manager.metadataProvider.get_show_metadata( id=external_id, provider=metadata_provider ) saved_show = self.tv_repository.save_show(show=show_with_metadata) + media_manager.metadataProvider.download_show_poster_image(show=saved_show) return saved_show def add_season_request(self, season_request: SeasonRequest) -> SeasonRequest: @@ -566,6 +568,107 @@ class TvService: ) log.info(f"Finished organizing files for torrent {torrent.title}") + def update_show_metadata(self, show_id: ShowId) -> Show | None: + """ + Updates the metadata of a show. + This includes adding new seasons and episodes if available from the metadata provider. + It also updates existing show, season, and episode attributes if they have changed. + + :param show_id: The ID of the show to update. + :return: The updated Show object, or None if the show is not found or an error occurs. + """ + log.info(f"Starting metadata update for show ID: {show_id}") + # Get the existing show from the database + db_show = self.tv_repository.get_show_by_id(show_id=show_id) + if not db_show: + log.warning(f"Show with ID {show_id} not found for metadata update.") + return None + log.debug(f"Found show: {db_show.name} for metadata update.") + # old_poster_url = db_show.poster_url # poster_url removed from db_show + + fresh_show_data = media_manager.metadataProvider.get_show_metadata(id=db_show.external_id, provider=db_show.metadata_provider) + 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}.") + return db_show + log.debug(f"Fetched fresh metadata for show: {fresh_show_data.name}") + + # Update show attributes (poster_url is not part of ShowSchema anymore) + self.tv_repository.update_show_attributes( + show_id=db_show.id, + name=fresh_show_data.name, + overview=fresh_show_data.overview, + year=fresh_show_data.year + ) + + # Process seasons and episodes + existing_season_numbers = {s.number: s for s in db_show.seasons} + + for fresh_season_data in fresh_show_data.seasons: + if fresh_season_data.number in existing_season_numbers: + # Update existing season + existing_season = existing_season_numbers[fresh_season_data.number] + log.debug(f"Updating existing season {existing_season.number} for show {db_show.name}") + self.tv_repository.update_season_attributes( + season_id=existing_season.id, + name=fresh_season_data.name, + overview=fresh_season_data.overview, + ) + + # Process episodes for this season + existing_episode_numbers = {ep.number: ep for ep in existing_season.episodes} + for fresh_episode_data in fresh_season_data.episodes: + if fresh_episode_data.number in existing_episode_numbers: + # Update existing episode + existing_episode = existing_episode_numbers[fresh_episode_data.number] + log.debug(f"Updating existing episode {existing_episode.number} for season {existing_season.number}") + self.tv_repository.update_episode_attributes( + episode_id=existing_episode.id, + title=fresh_episode_data.title, + ) + else: + # Add new episode + log.debug(f"Adding new episode {fresh_episode_data.number} to season {existing_season.number}") + episode_schema = EpisodeSchema( + id=EpisodeId(fresh_episode_data.id), + number=fresh_episode_data.number, + external_id=fresh_episode_data.external_id, + title=fresh_episode_data.title + ) + self.tv_repository.add_episode_to_season( + season_id=existing_season.id, + episode_data=episode_schema + ) + else: + # Add new season (and its episodes) + log.debug(f"Adding new season {fresh_season_data.number} to show {db_show.name}") + episodes_for_schema = [] + for ep_data in fresh_season_data.episodes: + episodes_for_schema.append(EpisodeSchema( + id=EpisodeId(ep_data.id), + number=ep_data.number, + external_id=ep_data.external_id, + title=ep_data.title + )) + + season_schema = Season( + id=SeasonId(fresh_season_data.id), + number=fresh_season_data.number, + name=fresh_season_data.name, + overview=fresh_season_data.overview, + external_id=fresh_season_data.external_id, + episodes=episodes_for_schema, + ) + self.tv_repository.add_season_to_show( + show_id=db_show.id, + season_data=season_schema + ) + + updated_show = self.tv_repository.get_show_by_id(show_id=show_id) + log.info(f"Successfully updated metadata for show ID: {show_id}") + media_manager.metadataProvider.download_show_poster_image(show=updated_show) + return updated_show + + def auto_download_all_approved_season_requests() -> None: """ @@ -630,4 +733,4 @@ def import_all_torrents() -> None: ) continue imported_torrents.append(tv_service.import_torrent_files(torrent=t, show=show)) - log.info("Finished importing all torrents") \ No newline at end of file + log.info("Finished importing all torrents")