From 3280ff9215c85f6b65f2531b9e79f992aec8ee2c Mon Sep 17 00:00:00 2001 From: Juan David Bermudez Celedon Date: Thu, 5 Mar 2026 11:39:00 -0500 Subject: [PATCH 1/8] Modify return type for TorrentService#cancel_download to avoid error --- media_manager/torrent/service.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/media_manager/torrent/service.py b/media_manager/torrent/service.py index 36f9980d..278fc054 100644 --- a/media_manager/torrent/service.py +++ b/media_manager/torrent/service.py @@ -56,7 +56,7 @@ def get_torrent_status(self, torrent: Torrent) -> Torrent: self.torrent_repository.save_torrent(torrent=torrent) return torrent - def cancel_download(self, torrent: Torrent, delete_files: bool = False) -> Torrent: + def cancel_download(self, torrent: Torrent, delete_files: bool = False) -> None: """ cancels download of a torrent @@ -65,7 +65,6 @@ def cancel_download(self, torrent: Torrent, delete_files: bool = False) -> Torre """ log.info(f"Cancelling download for torrent: {torrent.title}") self.download_manager.remove_torrent(torrent, delete_data=delete_files) - return self.get_torrent_status(torrent=torrent) def pause_download(self, torrent: Torrent) -> Torrent: """ From 1fd76931e00708d47506b73751519ede745e763a Mon Sep 17 00:00:00 2001 From: Juan David Bermudez Celedon Date: Thu, 5 Mar 2026 23:11:22 -0500 Subject: [PATCH 2/8] Add util to extract subtitle language code Refactor utility methods into a dedicated module. --- media_manager/indexer/service.py | 2 +- media_manager/movies/router.py | 2 +- media_manager/movies/service.py | 29 ++++----- media_manager/torrent/utils.py | 89 +------------------------- media_manager/tv/router.py | 2 +- media_manager/tv/service.py | 42 +++--------- media_manager/utils/__init__.py | 0 media_manager/utils/files.py | 106 +++++++++++++++++++++++++++++++ 8 files changed, 130 insertions(+), 142 deletions(-) create mode 100644 media_manager/utils/__init__.py create mode 100644 media_manager/utils/files.py diff --git a/media_manager/indexer/service.py b/media_manager/indexer/service.py index 7b1976bd..0767e543 100644 --- a/media_manager/indexer/service.py +++ b/media_manager/indexer/service.py @@ -7,7 +7,7 @@ from media_manager.indexer.repository import IndexerRepository from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId from media_manager.movies.schemas import Movie -from media_manager.torrent.utils import remove_special_chars_and_parentheses +from media_manager.utils.files import remove_special_chars_and_parentheses from media_manager.tv.schemas import Show log = logging.getLogger(__name__) diff --git a/media_manager/movies/router.py b/media_manager/movies/router.py index 09d3c53c..220b40f6 100644 --- a/media_manager/movies/router.py +++ b/media_manager/movies/router.py @@ -23,7 +23,7 @@ ) from media_manager.schemas import MediaImportSuggestion from media_manager.torrent.schemas import Torrent -from media_manager.torrent.utils import get_importable_media_directories +from media_manager.utils.files import get_importable_media_directories router = APIRouter() diff --git a/media_manager/movies/service.py b/media_manager/movies/service.py index 52fa7622..83ee4f42 100644 --- a/media_manager/movies/service.py +++ b/media_manager/movies/service.py @@ -3,6 +3,13 @@ from pathlib import Path from typing import overload +from media_manager.utils.files import ( + extract_external_id_from_string, + get_importable_media_directories, + import_file, import_subtitle, + remove_special_characters, + remove_special_chars_and_parentheses +) from sqlalchemy.exc import IntegrityError from media_manager.config import MediaManagerConfig @@ -35,12 +42,7 @@ ) from media_manager.torrent.service import TorrentService from media_manager.torrent.utils import ( - extract_external_id_from_string, get_files_for_import, - get_importable_media_directories, - import_file, - remove_special_characters, - remove_special_chars_and_parentheses, ) @@ -466,21 +468,12 @@ def import_movie( import_file(target_file=target_video_file, source_file=video_files[0]) success = True + target_subtitle_file = ( + movie_root_path / f"{movie_file_name}" + ) # import subtitles for subtitle_file in subtitle_files: - language_code_match = re.search( - r"[. ]([a-z]{2})\.srt$", subtitle_file.name, re.IGNORECASE - ) - if not language_code_match: - log.warning( - f"Subtitle file {subtitle_file.name} does not match expected format, can't extract language code, skipping." - ) - continue - language_code = language_code_match.group(1) - target_subtitle_file = ( - movie_root_path / f"{movie_file_name}.{language_code}.srt" - ) - import_file(target_file=target_subtitle_file, source_file=subtitle_file) + import_subtitle(subtitle_file=subtitle_file, target_file=target_subtitle_file) return success diff --git a/media_manager/torrent/utils.py b/media_manager/torrent/utils.py index c3e7b6cb..9ed2a580 100644 --- a/media_manager/torrent/utils.py +++ b/media_manager/torrent/utils.py @@ -1,9 +1,7 @@ import hashlib import logging import mimetypes -import re -import shutil -from pathlib import Path, UnsupportedOperation +from pathlib import Path import bencoder import libtorrent @@ -66,21 +64,6 @@ def get_torrent_filepath(torrent: Torrent) -> Path: return MediaManagerConfig().misc.torrent_directory / torrent.title -def import_file(target_file: Path, source_file: Path) -> None: - if target_file.exists(): - target_file.unlink() - - try: - target_file.hardlink_to(source_file) - except FileExistsError: - log.exception(f"File already exists at {target_file}.") - except (OSError, UnsupportedOperation, NotImplementedError): - log.exception( - f"Failed to create hardlink from {source_file} to {target_file}. Falling back to copying the file." - ) - shutil.copy(src=source_file, dst=target_file) - - def get_files_for_import( torrent: Torrent | None = None, directory: Path | None = None ) -> tuple[list[Path], list[Path], list[Path]]: @@ -179,73 +162,3 @@ def get_torrent_hash(torrent: IndexerQueryResult) -> str: return torrent_hash -def remove_special_characters(filename: str) -> str: - """ - Removes special characters from the filename to ensure it works with Jellyfin. - - :param filename: The original filename. - :return: A sanitized version of the filename. - """ - # Remove invalid characters - sanitized = re.sub(r"([<>:\"/\\|?*])", "", filename) - - # Remove leading and trailing dots or spaces - return sanitized.strip(" .") - - -def remove_special_chars_and_parentheses(title: str) -> str: - """ - Removes special characters and bracketed information from the title. - - :param title: The original title. - :return: A sanitized version of the title. - """ - - # Remove content within brackets - sanitized = re.sub(r"\[.*?\]", "", title) - - # Remove content within curly brackets - sanitized = re.sub(r"\{.*?\}", "", sanitized) - - # Remove year within parentheses - sanitized = re.sub(r"\(\d{4}\)", "", sanitized) - - # Remove special characters - sanitized = remove_special_characters(sanitized) - - # Collapse multiple whitespace characters and trim the result - return re.sub(r"\s+", " ", sanitized).strip() - - -def get_importable_media_directories(path: Path) -> list[Path]: - libraries = [ - *MediaManagerConfig().misc.movie_libraries, - *MediaManagerConfig().misc.tv_libraries, - ] - - library_paths = {Path(library.path).absolute() for library in libraries} - - unfiltered_dirs = [d for d in path.glob("*") if d.is_dir()] - - return [ - media_dir - for media_dir in unfiltered_dirs - if media_dir.absolute() not in library_paths - and not media_dir.name.startswith(".") - ] - - -def extract_external_id_from_string(input_string: str) -> tuple[str | None, int | None]: - """ - Extracts an external ID (tmdb/tvdb ID) from the given string. - - :param input_string: The string to extract the ID from. - :return: The extracted Metadata Provider and ID or None if not found. - """ - match = re.search( - r"\b(tmdb|tvdb)(?:id)?[-_]?([0-9]+)\b", input_string, re.IGNORECASE - ) - if match: - return match.group(1).lower(), int(match.group(2)) - - return None, None diff --git a/media_manager/tv/router.py b/media_manager/tv/router.py index eaf2a7b3..43150061 100644 --- a/media_manager/tv/router.py +++ b/media_manager/tv/router.py @@ -13,7 +13,7 @@ from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult from media_manager.schemas import MediaImportSuggestion from media_manager.torrent.schemas import Torrent -from media_manager.torrent.utils import get_importable_media_directories +from media_manager.utils.files import get_importable_media_directories from media_manager.tv.dependencies import ( season_dep, show_dep, diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index 2c902ea3..cb683cd3 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -4,6 +4,13 @@ from pathlib import Path from typing import overload +from media_manager.utils.files import ( + extract_external_id_from_string, + get_importable_media_directories, + import_file, import_subtitle, + remove_special_characters, + remove_special_chars_and_parentheses +) from sqlalchemy.exc import IntegrityError from media_manager.config import MediaManagerConfig @@ -26,12 +33,7 @@ ) from media_manager.torrent.service import TorrentService from media_manager.torrent.utils import ( - extract_external_id_from_string, get_files_for_import, - get_importable_media_directories, - import_file, - remove_special_characters, - remove_special_chars_and_parentheses, ) from media_manager.tv import log from media_manager.tv.repository import TvRepository @@ -613,7 +615,6 @@ def import_episode( pattern = ( r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode_number) + r"[. ].*" ) - subtitle_pattern = pattern + r"[. ]([A-Za-z]{2})[. ]srt" target_file_name = ( self.get_root_season_directory(show=show, season_number=season.number) / episode_file_name @@ -621,19 +622,7 @@ def import_episode( # import subtitle for subtitle_file in subtitle_files: - regex_result = re.search( - subtitle_pattern, subtitle_file.name, re.IGNORECASE - ) - if regex_result: - language_code = regex_result.group(1) - target_subtitle_file = target_file_name.with_suffix( - f".{language_code}.srt" - ) - import_file(target_file=target_subtitle_file, source_file=subtitle_file) - else: - log.debug( - f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}" - ) + import_subtitle(subtitle_file=subtitle_file, target_file=target_file_name) # import episode videos for file in video_files: @@ -706,7 +695,6 @@ def import_episode_files( pattern = ( r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode.number) + r"[. ].*" ) - subtitle_pattern = pattern + r"[. ]([A-Za-z]{2})[. ]srt" target_file_name = ( self.get_root_season_directory(show=show, season_number=season.number) / episode_file_name @@ -714,19 +702,7 @@ def import_episode_files( # import subtitle for subtitle_file in subtitle_files: - regex_result = re.search( - subtitle_pattern, subtitle_file.name, re.IGNORECASE - ) - if regex_result: - language_code = regex_result.group(1) - target_subtitle_file = target_file_name.with_suffix( - f".{language_code}.srt" - ) - import_file(target_file=target_subtitle_file, source_file=subtitle_file) - else: - log.debug( - f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}" - ) + import_subtitle(subtitle_file=subtitle_file, target_file=target_file_name) found_video = False diff --git a/media_manager/utils/__init__.py b/media_manager/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/media_manager/utils/files.py b/media_manager/utils/files.py new file mode 100644 index 00000000..204c023c --- /dev/null +++ b/media_manager/utils/files.py @@ -0,0 +1,106 @@ +from pathlib import Path, UnsupportedOperation +import re + +from media_manager.config import MediaManagerConfig +from media_manager.torrent.utils import log +import shutil + + +def import_subtitle(subtitle_file: Path, target_file: str) -> None: + subtitle_pattern = r"[. ]?([a-z]{2,3})(?:\.(\d+))?\.srt$" + regex_result = re.search(subtitle_pattern, subtitle_file.name, re.IGNORECASE) + if regex_result: + language_code = regex_result.group(1) + target_subtitle_file = target_file.with_suffix(f".{language_code}.srt") + import_file(target_file=target_subtitle_file, source_file=subtitle_file) + else: + log.debug( + f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}" + ) + + +def import_file(target_file: Path, source_file: Path) -> None: + if target_file.exists(): + target_file.unlink() + + try: + target_file.hardlink_to(source_file) + except FileExistsError: + log.exception(f"File already exists at {target_file}.") + except (OSError, UnsupportedOperation, NotImplementedError): + log.exception( + f"Failed to create hardlink from {source_file} to {target_file}. Falling back to copying the file." + ) + shutil.copy(src=source_file, dst=target_file) + + +def remove_special_characters(filename: str) -> str: + """ + Removes special characters from the filename to ensure it works with Jellyfin. + + :param filename: The original filename. + :return: A sanitized version of the filename. + """ + # Remove invalid characters + sanitized = re.sub(r"([<>:\"/\\|?*])", "", filename) + + # Remove leading and trailing dots or spaces + return sanitized.strip(" .") + + +def remove_special_chars_and_parentheses(title: str) -> str: + """ + Removes special characters and bracketed information from the title. + + :param title: The original title. + :return: A sanitized version of the title. + """ + + # Remove content within brackets + sanitized = re.sub(r"\[.*?\]", "", title) + + # Remove content within curly brackets + sanitized = re.sub(r"\{.*?\}", "", sanitized) + + # Remove year within parentheses + sanitized = re.sub(r"\(\d{4}\)", "", sanitized) + + # Remove special characters + sanitized = remove_special_characters(sanitized) + + # Collapse multiple whitespace characters and trim the result + return re.sub(r"\s+", " ", sanitized).strip() + + +def get_importable_media_directories(path: Path) -> list[Path]: + libraries = [ + *MediaManagerConfig().misc.movie_libraries, + *MediaManagerConfig().misc.tv_libraries, + ] + + library_paths = {Path(library.path).absolute() for library in libraries} + + unfiltered_dirs = [d for d in path.glob("*") if d.is_dir()] + + return [ + media_dir + for media_dir in unfiltered_dirs + if media_dir.absolute() not in library_paths + and not media_dir.name.startswith(".") + ] + + +def extract_external_id_from_string(input_string: str) -> tuple[str | None, int | None]: + """ + Extracts an external ID (tmdb/tvdb ID) from the given string. + + :param input_string: The string to extract the ID from. + :return: The extracted Metadata Provider and ID or None if not found. + """ + match = re.search( + r"\b(tmdb|tvdb)(?:id)?[-_]?([0-9]+)\b", input_string, re.IGNORECASE + ) + if match: + return match.group(1).lower(), int(match.group(2)) + + return None, None From 459db900f4afc37f0aa4d4d03037df1e85e54627 Mon Sep 17 00:00:00 2001 From: Juan David Bermudez Celedon Date: Fri, 6 Mar 2026 00:51:40 -0500 Subject: [PATCH 3/8] Add missing index to subtitle suffix Refactor more util methods for files import --- media_manager/indexer/service.py | 2 +- media_manager/movies/router.py | 2 +- media_manager/movies/service.py | 1394 +++++++++-------- media_manager/torrent/utils.py | 94 -- media_manager/tv/router.py | 2 +- media_manager/tv/service.py | 2180 +++++++++++++-------------- media_manager/utils/file_handler.py | 207 +++ media_manager/utils/files.py | 106 -- 8 files changed, 1995 insertions(+), 1992 deletions(-) create mode 100644 media_manager/utils/file_handler.py delete mode 100644 media_manager/utils/files.py diff --git a/media_manager/indexer/service.py b/media_manager/indexer/service.py index 0767e543..54b8e020 100644 --- a/media_manager/indexer/service.py +++ b/media_manager/indexer/service.py @@ -7,7 +7,7 @@ from media_manager.indexer.repository import IndexerRepository from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId from media_manager.movies.schemas import Movie -from media_manager.utils.files import remove_special_chars_and_parentheses +from media_manager.utils.file_handler import remove_special_chars_and_parentheses from media_manager.tv.schemas import Show log = logging.getLogger(__name__) diff --git a/media_manager/movies/router.py b/media_manager/movies/router.py index 220b40f6..d8d2cca8 100644 --- a/media_manager/movies/router.py +++ b/media_manager/movies/router.py @@ -23,7 +23,7 @@ ) from media_manager.schemas import MediaImportSuggestion from media_manager.torrent.schemas import Torrent -from media_manager.utils.files import get_importable_media_directories +from media_manager.utils.file_handler import get_importable_media_directories router = APIRouter() diff --git a/media_manager/movies/service.py b/media_manager/movies/service.py index 83ee4f42..a1f52312 100644 --- a/media_manager/movies/service.py +++ b/media_manager/movies/service.py @@ -1,698 +1,696 @@ -import re -import shutil -from pathlib import Path -from typing import overload - -from media_manager.utils.files import ( - extract_external_id_from_string, - get_importable_media_directories, - import_file, import_subtitle, - remove_special_characters, - remove_special_chars_and_parentheses -) -from sqlalchemy.exc import IntegrityError - -from media_manager.config import MediaManagerConfig -from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError -from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId -from media_manager.indexer.service import IndexerService -from media_manager.indexer.utils import evaluate_indexer_query_results -from media_manager.metadataProvider.abstract_metadata_provider import ( - AbstractMetadataProvider, -) -from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult -from media_manager.metadataProvider.tmdb import TmdbMetadataProvider -from media_manager.metadataProvider.tvdb import TvdbMetadataProvider -from media_manager.movies import log -from media_manager.movies.repository import MovieRepository -from media_manager.movies.schemas import ( - Movie, - MovieFile, - MovieId, - PublicMovie, - PublicMovieFile, - RichMovieTorrent, -) -from media_manager.notification.service import NotificationService -from media_manager.schemas import MediaImportSuggestion -from media_manager.torrent.schemas import ( - Quality, - Torrent, - TorrentStatus, -) -from media_manager.torrent.service import TorrentService -from media_manager.torrent.utils import ( - get_files_for_import, -) - - -class MovieService: - def __init__( - self, - movie_repository: MovieRepository, - torrent_service: TorrentService, - indexer_service: IndexerService, - notification_service: NotificationService, - ) -> None: - self.movie_repository = movie_repository - self.torrent_service = torrent_service - self.indexer_service = indexer_service - self.notification_service = notification_service - - def add_movie( - self, - external_id: int, - metadata_provider: AbstractMetadataProvider, - language: str | None = None, - ) -> Movie: - """ - Add a new movie to the database. - - :param external_id: The ID of the movie in the metadata provider's system. - :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( - movie_id=external_id, language=language - ) - if not movie_with_metadata: - raise NotFoundError - - saved_movie = self.movie_repository.save_movie(movie=movie_with_metadata) - metadata_provider.download_movie_poster_image(movie=saved_movie) - return saved_movie - - def delete_movie( - self, - movie: Movie, - delete_files_on_disk: bool = False, - delete_torrents: bool = False, - ) -> None: - """ - Delete a movie from the database, optionally deleting files and torrents. - - :param movie: The movie to delete. - :param delete_files_on_disk: Whether to delete the movie's files from disk. - :param delete_torrents: Whether to delete associated torrents from the torrent client. - """ - if delete_files_on_disk or delete_torrents: - if delete_files_on_disk: - # Get the movie's directory path - movie_dir = self.get_movie_root_path(movie=movie) - - if movie_dir.exists() and movie_dir.is_dir(): - try: - shutil.rmtree(movie_dir) - log.info(f"Deleted movie directory: {movie_dir}") - except OSError: - log.exception(f"Deleting movie directory: {movie_dir}") - - if delete_torrents: - # Get all torrents associated with this movie - movie_torrents = self.movie_repository.get_torrents_by_movie_id( - movie_id=movie.id - ) - - for movie_torrent in movie_torrents: - torrent = self.torrent_service.get_torrent_by_id( - torrent_id=movie_torrent.torrent_id - ) - try: - self.torrent_service.cancel_download( - torrent=torrent, delete_files=True - ) - log.info(f"Deleted torrent: {torrent.torrent_title}") - except Exception: - log.warning( - f"Failed to delete torrent {torrent.hash}", exc_info=True - ) - - # Delete from database - self.movie_repository.delete_movie(movie_id=movie.id) - - def get_public_movie_files(self, movie: Movie) -> list[PublicMovieFile]: - """ - Get all public movie files for a given movie. - - :param movie: The movie object. - :return: A list of public movie files. - """ - movie_files = self.movie_repository.get_movie_files_by_movie_id( - movie_id=movie.id - ) - public_movie_files = [PublicMovieFile.model_validate(x) for x in movie_files] - result = [] - for movie_file in public_movie_files: - movie_file.imported = self.movie_file_exists_on_file(movie_file=movie_file) - result.append(movie_file) - return result - - @overload - def check_if_movie_exists( - self, *, external_id: int, metadata_provider: str - ) -> bool: - """ - Check if a movie exists in the database. - - :param external_id: The external ID of the movie. - :param metadata_provider: The metadata provider. - :return: True if the movie exists, False otherwise. - """ - - @overload - def check_if_movie_exists(self, *, movie_id: MovieId) -> bool: - """ - Check if a movie exists in the database. - - :param movie_id: The ID of the movie. - :return: True if the movie exists, False otherwise. - """ - - def check_if_movie_exists( - self, - *, - external_id=None, - metadata_provider=None, - movie_id=None, - ) -> bool: - """ - Check if a movie exists in the database. - - :param external_id: The external ID of the movie. - :param metadata_provider: The metadata provider. - :param movie_id: The ID of the movie. - :return: True if the movie exists, False otherwise. - :raises ValueError: If neither external ID and metadata provider nor movie ID are provided. - """ - - if not (external_id is None or metadata_provider is None): - try: - self.movie_repository.get_movie_by_external_id( - external_id=external_id, metadata_provider=metadata_provider - ) - except NotFoundError: - return False - elif movie_id is not None: - try: - self.movie_repository.get_movie_by_id(movie_id=movie_id) - except NotFoundError: - return False - else: - msg = "Use one of the provided overloads for this function!" - raise ValueError(msg) - - return True - - def get_all_available_torrents_for_movie( - self, movie: Movie, search_query_override: str | None = None - ) -> list[IndexerQueryResult]: - """ - Get all available torrents for a given movie. - - :param movie: The movie object. - :param search_query_override: Optional override for the search query. - :return: A list of indexer query results. - """ - if search_query_override: - return self.indexer_service.search(query=search_query_override, is_tv=False) - - torrents = self.indexer_service.search_movie(movie=movie) - - return evaluate_indexer_query_results( - is_tv=False, query_results=torrents, media=movie - ) - - def get_all_movies(self) -> list[Movie]: - """ - Get all movies. - - :return: A list of all movies. - """ - return self.movie_repository.get_movies() - - def search_for_movie( - self, query: str, metadata_provider: AbstractMetadataProvider - ) -> list[MetaDataProviderSearchResult]: - """ - Search for movies using a given query. - - :param query: The search query. - :param metadata_provider: The metadata provider to search. - :return: A list of metadata provider movie search results. - """ - results = metadata_provider.search_movie(query) - for result in results: - if self.check_if_movie_exists( - external_id=result.external_id, metadata_provider=metadata_provider.name - ): - result.added = True - - # Fetch the internal movie ID. - try: - movie = self.movie_repository.get_movie_by_external_id( - external_id=result.external_id, - metadata_provider=metadata_provider.name, - ) - result.id = movie.id - except Exception: - log.error( - f"Unable to find internal movie ID for {result.external_id} on {metadata_provider.name}" - ) - return results - - def get_popular_movies( - self, metadata_provider: AbstractMetadataProvider - ) -> list[MetaDataProviderSearchResult]: - """ - Get popular movies from a given metadata provider. - - :param metadata_provider: The metadata provider to use. - :return: A list of metadata provider movie search results. - """ - results = metadata_provider.search_movie() - - return [ - result - for result in results - if not self.check_if_movie_exists( - external_id=result.external_id, metadata_provider=metadata_provider.name - ) - ] - - def get_public_movie_by_id(self, movie: Movie) -> PublicMovie: - """ - Get a public movie from a Movie object. - - :param movie: The movie object. - :return: A public movie. - """ - torrents = self.get_torrents_for_movie(movie=movie).torrents - public_movie = PublicMovie.model_validate(movie) - public_movie.downloaded = self.is_movie_downloaded(movie=movie) - public_movie.torrents = torrents - return public_movie - - def get_movie_by_id(self, movie_id: MovieId) -> Movie: - """ - Get a movie by its ID. - - :param movie_id: The ID of the movie. - :return: The movie. - """ - return self.movie_repository.get_movie_by_id(movie_id=movie_id) - - def is_movie_downloaded(self, movie: Movie) -> bool: - """ - Check if a movie is downloaded. - - :param movie: The movie object. - :return: True if the movie is downloaded, False otherwise. - """ - movie_files = self.movie_repository.get_movie_files_by_movie_id( - movie_id=movie.id - ) - for movie_file in movie_files: - if self.movie_file_exists_on_file(movie_file=movie_file): - return True - return False - - def movie_file_exists_on_file(self, movie_file: MovieFile) -> bool: - """ - Check if a movie file exists on the filesystem. - - :param movie_file: The movie file to check. - :return: True if the file exists, False otherwise. - """ - if movie_file.torrent_id is None: - return True - torrent_file = self.torrent_service.get_torrent_by_id( - torrent_id=movie_file.torrent_id - ) - if torrent_file.imported: - return True - return False - - def get_movie_by_external_id( - self, external_id: int, metadata_provider: str - ) -> Movie | None: - """ - Get a movie by its external ID and metadata provider. - - :param external_id: The external ID of the movie. - :param metadata_provider: The metadata provider. - :return: The movie or None if not found. - """ - return self.movie_repository.get_movie_by_external_id( - external_id=external_id, metadata_provider=metadata_provider - ) - - def set_movie_library(self, movie: Movie, library: str) -> None: - self.movie_repository.set_movie_library(movie_id=movie.id, library=library) - - def get_torrents_for_movie(self, movie: Movie) -> RichMovieTorrent: - """ - Get torrents for a given movie. - - :param movie: The movie. - :return: A rich movie torrent. - """ - movie_torrents = self.movie_repository.get_torrents_by_movie_id( - movie_id=movie.id - ) - return RichMovieTorrent( - movie_id=movie.id, - name=movie.name, - year=movie.year, - metadata_provider=movie.metadata_provider, - torrents=movie_torrents, - ) - - def get_all_movies_with_torrents(self) -> list[RichMovieTorrent]: - """ - Get all movies with torrents. - - :return: A list of rich movie torrents. - """ - movies = self.movie_repository.get_all_movies_with_torrents() - return [self.get_torrents_for_movie(movie=movie) for movie in movies] - - def download_torrent( - self, - public_indexer_result_id: IndexerQueryResultId, - movie: Movie, - override_movie_file_path_suffix: str = "", - ) -> Torrent: - """ - Download a torrent for a given indexer result and movie. - - :param public_indexer_result_id: The ID of the indexer result. - :param movie: The movie object. - :param override_movie_file_path_suffix: Optional override for the file path suffix. - :return: The downloaded torrent. - """ - indexer_result = self.indexer_service.get_result( - result_id=public_indexer_result_id - ) - movie_torrent = self.torrent_service.download(indexer_result=indexer_result) - self.torrent_service.pause_download(torrent=movie_torrent) - movie_file = MovieFile( - movie_id=movie.id, - quality=indexer_result.quality, - torrent_id=movie_torrent.id, - file_path_suffix=override_movie_file_path_suffix, - ) - try: - self.movie_repository.add_movie_file(movie_file=movie_file) - except IntegrityError: - log.warning( - f"Movie file for movie {movie.name} and torrent {movie_torrent.title} already exists" - ) - self.torrent_service.cancel_download( - torrent=movie_torrent, delete_files=True - ) - raise - else: - log.info( - f"Added movie file for movie {movie.name} and torrent {movie_torrent.title}" - ) - self.torrent_service.resume_download(torrent=movie_torrent) - return movie_torrent - - def get_movie_root_path(self, movie: Movie) -> Path: - misc_config = MediaManagerConfig().misc - movie_file_path = ( - misc_config.movie_directory - / f"{remove_special_characters(movie.name)} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]" - ) - log.debug( - f"Movie {movie.name} without special characters: {remove_special_characters(movie.name)}" - ) - if movie.library != "Default": - for library in misc_config.movie_libraries: - if library.name == movie.library: - log.debug(f"Using library {library.name} for movie {movie.name}") - return ( - Path(library.path) - / f"{remove_special_characters(movie.name)} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]" - ) - else: - log.warning( - f"Library {movie.library} not found in config, using default library" - ) - return movie_file_path - - def import_movie( - self, - movie: Movie, - video_files: list[Path], - subtitle_files: list[Path], - file_path_suffix: str = "", - ) -> bool: - movie_file_name = f"{remove_special_characters(movie.name)} ({movie.year})" - movie_root_path = self.get_movie_root_path(movie=movie) - success: bool = False - if file_path_suffix != "": - movie_file_name += f" - {file_path_suffix}" - - try: - movie_root_path.mkdir(parents=True, exist_ok=True) - except Exception: - log.exception("Failed to create directory {movie_root_path}") - return False - - # import movie video - if video_files: - target_video_file = ( - movie_root_path / f"{movie_file_name}{video_files[0].suffix}" - ) - import_file(target_file=target_video_file, source_file=video_files[0]) - success = True - - target_subtitle_file = ( - movie_root_path / f"{movie_file_name}" - ) - # import subtitles - for subtitle_file in subtitle_files: - import_subtitle(subtitle_file=subtitle_file, target_file=target_subtitle_file) - - return success - - def import_torrent_files(self, torrent: Torrent, movie: Movie) -> None: - """ - Organizes files from a torrent into the movie directory structure. - :param torrent: The Torrent object - :param movie: The Movie object - """ - - video_files, subtitle_files, _all_files = get_files_for_import(torrent=torrent) - - if len(video_files) != 1: - # Send notification about multiple video files found - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Manual Import Required", - message=f"Multiple video files found for movie {movie.name}. Please import manually.", - ) - log.error( - f"Found {len(video_files)} video files for movie {movie.name}, expected 1. Skipping auto import." - ) - return - - log.debug( - f"Importing these {len(video_files)} video files and {len(subtitle_files)} subtitle files" - ) - - movie_files: list[MovieFile] = self.torrent_service.get_movie_files_of_torrent( - torrent=torrent - ) - log.info( - f"Found {len(movie_files)} movie files associated with torrent {torrent.title}" - ) - - success = [ - self.import_movie( - movie, video_files, subtitle_files, movie_file.file_path_suffix - ) - for movie_file in movie_files - ] - - if all(success): - torrent.imported = True - self.torrent_service.torrent_repository.save_torrent(torrent=torrent) - - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Movie Downloaded", - message=f"Movie {movie.name} has been successfully downloaded and imported.", - ) - else: - log.error( - f"Failed to import files for torrent {torrent.title}. Check logs for details." - ) - - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Import Failed", - message=f"Failed to import files for movie {movie.name}. Please check logs.", - ) - - log.info(f"Finished importing files for torrent {torrent.title}") - - def get_import_candidates( - self, movie: Path, metadata_provider: AbstractMetadataProvider - ) -> MediaImportSuggestion: - search_result = self.search_for_movie( - query=remove_special_chars_and_parentheses(movie.name), - metadata_provider=metadata_provider, - ) - import_candidates = MediaImportSuggestion( - directory=movie, - candidates=search_result, - ) - log.debug( - f"Found {len(search_result)} candidates for {movie.name} in {movie.parent}" - ) - return import_candidates - - def import_existing_movie(self, movie: Movie, source_directory: Path) -> bool: - new_source_path = source_directory.parent / ("." + source_directory.name) - try: - source_directory.rename(new_source_path) - except Exception as e: - log.exception(f"Failed to rename {source_directory} to {new_source_path}") - raise RenameError from e - - video_files, subtitle_files, _all_files = get_files_for_import( - directory=new_source_path - ) - - success = self.import_movie( - movie=movie, - video_files=video_files, - subtitle_files=subtitle_files, - file_path_suffix="IMPORTED", - ) - if success: - self.movie_repository.add_movie_file( - MovieFile( - movie_id=movie.id, - file_path_suffix="IMPORTED", - torrent_id=None, - quality=Quality.unknown, - ) - ) - - return success - - def update_movie_metadata( - self, db_movie: Movie, metadata_provider: AbstractMetadataProvider - ) -> Movie | None: - """ - Updates the metadata of a movie. - - :param metadata_provider: The metadata provider object to fetch fresh data from. - :param db_movie: The Movie to update - :return: The updated Movie object, or None if the movie is not found or an error occurs. - """ - 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( - movie_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} ({db_movie.year})" - ) - return None - log.debug(f"Fetched fresh metadata for movie: {fresh_movie_data.name}") - - self.movie_repository.update_movie_attributes( - movie_id=db_movie.id, - 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) - - log.info( - f"Successfully updated metadata for movie: {db_movie.name} ({db_movie.year})" - ) - metadata_provider.download_movie_poster_image(movie=updated_movie) - return updated_movie - - def get_importable_movies( - self, metadata_provider: AbstractMetadataProvider - ) -> list[MediaImportSuggestion]: - movie_root_path = MediaManagerConfig().misc.movie_directory - importable_movies: list[MediaImportSuggestion] = [] - candidate_dirs = get_importable_media_directories(movie_root_path) - - for movie_dir in candidate_dirs: - metadata, external_id = extract_external_id_from_string(movie_dir.name) - if metadata is not None and external_id is not None: - try: - self.movie_repository.get_movie_by_external_id( - external_id=external_id, metadata_provider=metadata - ) - log.debug( - f"Movie {movie_dir.name} already exists in the database, skipping." - ) - continue - except NotFoundError: - log.debug( - f"Movie {movie_dir.name} not found in database, checking for import candidates." - ) - - import_candidates = self.get_import_candidates( - movie=movie_dir, metadata_provider=metadata_provider - ) - importable_movies.append(import_candidates) - - log.debug(f"Found {len(importable_movies)} importable movies.") - return importable_movies - - def import_all_torrents(self) -> None: - log.info("Importing all torrents") - torrents = self.torrent_service.get_all_torrents() - log.info("Found %d torrents to import", len(torrents)) - for t in torrents: - try: - if not t.imported and t.status == TorrentStatus.finished: - movie = self.torrent_service.get_movie_of_torrent(torrent=t) - if movie is None: - log.warning( - f"torrent {t.title} is not a movie torrent, skipping import." - ) - continue - self.import_torrent_files(torrent=t, movie=movie) - except RuntimeError: - log.exception(f"Failed to import torrent {t.title}") - log.info("Finished importing all torrents") - - def update_all_metadata(self) -> None: - """Updates the metadata of all movies.""" - log.info("Updating metadata for all movies") - movies = self.movie_repository.get_movies() - log.info(f"Found {len(movies)} movies to update") - for movie in movies: - try: - if movie.metadata_provider == "tmdb": - metadata_provider = TmdbMetadataProvider() - elif movie.metadata_provider == "tvdb": - metadata_provider = TvdbMetadataProvider() - else: - log.error( - f"Unsupported metadata provider {movie.metadata_provider} for movie {movie.name}, skipping update." - ) - continue - except InvalidConfigError: - log.exception( - f"Error initializing metadata provider {movie.metadata_provider} for movie {movie.name}", - ) - continue - self.update_movie_metadata( - db_movie=movie, metadata_provider=metadata_provider - ) +import re +import shutil +from pathlib import Path +from typing import overload + +from media_manager.utils.file_handler import ( + extract_external_id_from_string, + get_importable_media_directories, + get_files_for_import, + import_file, import_subtitle, + remove_special_characters, + remove_special_chars_and_parentheses +) +from sqlalchemy.exc import IntegrityError + +from media_manager.config import MediaManagerConfig +from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError +from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId +from media_manager.indexer.service import IndexerService +from media_manager.indexer.utils import evaluate_indexer_query_results +from media_manager.metadataProvider.abstract_metadata_provider import ( + AbstractMetadataProvider, +) +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.metadataProvider.tmdb import TmdbMetadataProvider +from media_manager.metadataProvider.tvdb import TvdbMetadataProvider +from media_manager.movies import log +from media_manager.movies.repository import MovieRepository +from media_manager.movies.schemas import ( + Movie, + MovieFile, + MovieId, + PublicMovie, + PublicMovieFile, + RichMovieTorrent, +) +from media_manager.notification.service import NotificationService +from media_manager.schemas import MediaImportSuggestion +from media_manager.torrent.schemas import ( + Quality, + Torrent, + TorrentStatus, +) +from media_manager.torrent.service import TorrentService + + +class MovieService: + def __init__( + self, + movie_repository: MovieRepository, + torrent_service: TorrentService, + indexer_service: IndexerService, + notification_service: NotificationService, + ) -> None: + self.movie_repository = movie_repository + self.torrent_service = torrent_service + self.indexer_service = indexer_service + self.notification_service = notification_service + + def add_movie( + self, + external_id: int, + metadata_provider: AbstractMetadataProvider, + language: str | None = None, + ) -> Movie: + """ + Add a new movie to the database. + + :param external_id: The ID of the movie in the metadata provider's system. + :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( + movie_id=external_id, language=language + ) + if not movie_with_metadata: + raise NotFoundError + + saved_movie = self.movie_repository.save_movie(movie=movie_with_metadata) + metadata_provider.download_movie_poster_image(movie=saved_movie) + return saved_movie + + def delete_movie( + self, + movie: Movie, + delete_files_on_disk: bool = False, + delete_torrents: bool = False, + ) -> None: + """ + Delete a movie from the database, optionally deleting files and torrents. + + :param movie: The movie to delete. + :param delete_files_on_disk: Whether to delete the movie's files from disk. + :param delete_torrents: Whether to delete associated torrents from the torrent client. + """ + if delete_files_on_disk or delete_torrents: + if delete_files_on_disk: + # Get the movie's directory path + movie_dir = self.get_movie_root_path(movie=movie) + + if movie_dir.exists() and movie_dir.is_dir(): + try: + shutil.rmtree(movie_dir) + log.info(f"Deleted movie directory: {movie_dir}") + except OSError: + log.exception(f"Deleting movie directory: {movie_dir}") + + if delete_torrents: + # Get all torrents associated with this movie + movie_torrents = self.movie_repository.get_torrents_by_movie_id( + movie_id=movie.id + ) + + for movie_torrent in movie_torrents: + torrent = self.torrent_service.get_torrent_by_id( + torrent_id=movie_torrent.torrent_id + ) + try: + self.torrent_service.cancel_download( + torrent=torrent, delete_files=True + ) + log.info(f"Deleted torrent: {torrent.torrent_title}") + except Exception: + log.warning( + f"Failed to delete torrent {torrent.hash}", exc_info=True + ) + + # Delete from database + self.movie_repository.delete_movie(movie_id=movie.id) + + def get_public_movie_files(self, movie: Movie) -> list[PublicMovieFile]: + """ + Get all public movie files for a given movie. + + :param movie: The movie object. + :return: A list of public movie files. + """ + movie_files = self.movie_repository.get_movie_files_by_movie_id( + movie_id=movie.id + ) + public_movie_files = [PublicMovieFile.model_validate(x) for x in movie_files] + result = [] + for movie_file in public_movie_files: + movie_file.imported = self.movie_file_exists_on_file(movie_file=movie_file) + result.append(movie_file) + return result + + @overload + def check_if_movie_exists( + self, *, external_id: int, metadata_provider: str + ) -> bool: + """ + Check if a movie exists in the database. + + :param external_id: The external ID of the movie. + :param metadata_provider: The metadata provider. + :return: True if the movie exists, False otherwise. + """ + + @overload + def check_if_movie_exists(self, *, movie_id: MovieId) -> bool: + """ + Check if a movie exists in the database. + + :param movie_id: The ID of the movie. + :return: True if the movie exists, False otherwise. + """ + + def check_if_movie_exists( + self, + *, + external_id=None, + metadata_provider=None, + movie_id=None, + ) -> bool: + """ + Check if a movie exists in the database. + + :param external_id: The external ID of the movie. + :param metadata_provider: The metadata provider. + :param movie_id: The ID of the movie. + :return: True if the movie exists, False otherwise. + :raises ValueError: If neither external ID and metadata provider nor movie ID are provided. + """ + + if not (external_id is None or metadata_provider is None): + try: + self.movie_repository.get_movie_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + except NotFoundError: + return False + elif movie_id is not None: + try: + self.movie_repository.get_movie_by_id(movie_id=movie_id) + except NotFoundError: + return False + else: + msg = "Use one of the provided overloads for this function!" + raise ValueError(msg) + + return True + + def get_all_available_torrents_for_movie( + self, movie: Movie, search_query_override: str | None = None + ) -> list[IndexerQueryResult]: + """ + Get all available torrents for a given movie. + + :param movie: The movie object. + :param search_query_override: Optional override for the search query. + :return: A list of indexer query results. + """ + if search_query_override: + return self.indexer_service.search(query=search_query_override, is_tv=False) + + torrents = self.indexer_service.search_movie(movie=movie) + + return evaluate_indexer_query_results( + is_tv=False, query_results=torrents, media=movie + ) + + def get_all_movies(self) -> list[Movie]: + """ + Get all movies. + + :return: A list of all movies. + """ + return self.movie_repository.get_movies() + + def search_for_movie( + self, query: str, metadata_provider: AbstractMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + """ + Search for movies using a given query. + + :param query: The search query. + :param metadata_provider: The metadata provider to search. + :return: A list of metadata provider movie search results. + """ + results = metadata_provider.search_movie(query) + for result in results: + if self.check_if_movie_exists( + external_id=result.external_id, metadata_provider=metadata_provider.name + ): + result.added = True + + # Fetch the internal movie ID. + try: + movie = self.movie_repository.get_movie_by_external_id( + external_id=result.external_id, + metadata_provider=metadata_provider.name, + ) + result.id = movie.id + except Exception: + log.error( + f"Unable to find internal movie ID for {result.external_id} on {metadata_provider.name}" + ) + return results + + def get_popular_movies( + self, metadata_provider: AbstractMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + """ + Get popular movies from a given metadata provider. + + :param metadata_provider: The metadata provider to use. + :return: A list of metadata provider movie search results. + """ + results = metadata_provider.search_movie() + + return [ + result + for result in results + if not self.check_if_movie_exists( + external_id=result.external_id, metadata_provider=metadata_provider.name + ) + ] + + def get_public_movie_by_id(self, movie: Movie) -> PublicMovie: + """ + Get a public movie from a Movie object. + + :param movie: The movie object. + :return: A public movie. + """ + torrents = self.get_torrents_for_movie(movie=movie).torrents + public_movie = PublicMovie.model_validate(movie) + public_movie.downloaded = self.is_movie_downloaded(movie=movie) + public_movie.torrents = torrents + return public_movie + + def get_movie_by_id(self, movie_id: MovieId) -> Movie: + """ + Get a movie by its ID. + + :param movie_id: The ID of the movie. + :return: The movie. + """ + return self.movie_repository.get_movie_by_id(movie_id=movie_id) + + def is_movie_downloaded(self, movie: Movie) -> bool: + """ + Check if a movie is downloaded. + + :param movie: The movie object. + :return: True if the movie is downloaded, False otherwise. + """ + movie_files = self.movie_repository.get_movie_files_by_movie_id( + movie_id=movie.id + ) + for movie_file in movie_files: + if self.movie_file_exists_on_file(movie_file=movie_file): + return True + return False + + def movie_file_exists_on_file(self, movie_file: MovieFile) -> bool: + """ + Check if a movie file exists on the filesystem. + + :param movie_file: The movie file to check. + :return: True if the file exists, False otherwise. + """ + if movie_file.torrent_id is None: + return True + torrent_file = self.torrent_service.get_torrent_by_id( + torrent_id=movie_file.torrent_id + ) + if torrent_file.imported: + return True + return False + + def get_movie_by_external_id( + self, external_id: int, metadata_provider: str + ) -> Movie | None: + """ + Get a movie by its external ID and metadata provider. + + :param external_id: The external ID of the movie. + :param metadata_provider: The metadata provider. + :return: The movie or None if not found. + """ + return self.movie_repository.get_movie_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + + def set_movie_library(self, movie: Movie, library: str) -> None: + self.movie_repository.set_movie_library(movie_id=movie.id, library=library) + + def get_torrents_for_movie(self, movie: Movie) -> RichMovieTorrent: + """ + Get torrents for a given movie. + + :param movie: The movie. + :return: A rich movie torrent. + """ + movie_torrents = self.movie_repository.get_torrents_by_movie_id( + movie_id=movie.id + ) + return RichMovieTorrent( + movie_id=movie.id, + name=movie.name, + year=movie.year, + metadata_provider=movie.metadata_provider, + torrents=movie_torrents, + ) + + def get_all_movies_with_torrents(self) -> list[RichMovieTorrent]: + """ + Get all movies with torrents. + + :return: A list of rich movie torrents. + """ + movies = self.movie_repository.get_all_movies_with_torrents() + return [self.get_torrents_for_movie(movie=movie) for movie in movies] + + def download_torrent( + self, + public_indexer_result_id: IndexerQueryResultId, + movie: Movie, + override_movie_file_path_suffix: str = "", + ) -> Torrent: + """ + Download a torrent for a given indexer result and movie. + + :param public_indexer_result_id: The ID of the indexer result. + :param movie: The movie object. + :param override_movie_file_path_suffix: Optional override for the file path suffix. + :return: The downloaded torrent. + """ + indexer_result = self.indexer_service.get_result( + result_id=public_indexer_result_id + ) + movie_torrent = self.torrent_service.download(indexer_result=indexer_result) + self.torrent_service.pause_download(torrent=movie_torrent) + movie_file = MovieFile( + movie_id=movie.id, + quality=indexer_result.quality, + torrent_id=movie_torrent.id, + file_path_suffix=override_movie_file_path_suffix, + ) + try: + self.movie_repository.add_movie_file(movie_file=movie_file) + except IntegrityError: + log.warning( + f"Movie file for movie {movie.name} and torrent {movie_torrent.title} already exists" + ) + self.torrent_service.cancel_download( + torrent=movie_torrent, delete_files=True + ) + raise + else: + log.info( + f"Added movie file for movie {movie.name} and torrent {movie_torrent.title}" + ) + self.torrent_service.resume_download(torrent=movie_torrent) + return movie_torrent + + def get_movie_root_path(self, movie: Movie) -> Path: + misc_config = MediaManagerConfig().misc + movie_file_path = ( + misc_config.movie_directory + / f"{remove_special_characters(movie.name)} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]" + ) + log.debug( + f"Movie {movie.name} without special characters: {remove_special_characters(movie.name)}" + ) + if movie.library != "Default": + for library in misc_config.movie_libraries: + if library.name == movie.library: + log.debug(f"Using library {library.name} for movie {movie.name}") + return ( + Path(library.path) + / f"{remove_special_characters(movie.name)} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]" + ) + else: + log.warning( + f"Library {movie.library} not found in config, using default library" + ) + return movie_file_path + + def import_movie( + self, + movie: Movie, + video_files: list[Path], + subtitle_files: list[Path], + file_path_suffix: str = "", + ) -> bool: + movie_file_name = f"{remove_special_characters(movie.name)} ({movie.year})" + movie_root_path = self.get_movie_root_path(movie=movie) + success: bool = False + if file_path_suffix != "": + movie_file_name += f" - {file_path_suffix}" + + try: + movie_root_path.mkdir(parents=True, exist_ok=True) + except Exception: + log.exception("Failed to create directory {movie_root_path}") + return False + + # import movie video + if video_files: + target_video_file = ( + movie_root_path / f"{movie_file_name}{video_files[0].suffix}" + ) + import_file(target_file=target_video_file, source_file=video_files[0]) + success = True + + target_subtitle_file = ( + movie_root_path / f"{movie_file_name}" + ) + # import subtitles + for subtitle_file in subtitle_files: + import_subtitle(subtitle_file=subtitle_file, target_file=target_subtitle_file) + + return success + + def import_torrent_files(self, torrent: Torrent, movie: Movie) -> None: + """ + Organizes files from a torrent into the movie directory structure. + :param torrent: The Torrent object + :param movie: The Movie object + """ + + video_files, subtitle_files, _all_files = get_files_for_import(torrent=torrent) + + if len(video_files) != 1: + # Send notification about multiple video files found + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Manual Import Required", + message=f"Multiple video files found for movie {movie.name}. Please import manually.", + ) + log.error( + f"Found {len(video_files)} video files for movie {movie.name}, expected 1. Skipping auto import." + ) + return + + log.debug( + f"Importing these {len(video_files)} video files and {len(subtitle_files)} subtitle files" + ) + + movie_files: list[MovieFile] = self.torrent_service.get_movie_files_of_torrent( + torrent=torrent + ) + log.info( + f"Found {len(movie_files)} movie files associated with torrent {torrent.title}" + ) + + success = [ + self.import_movie( + movie, video_files, subtitle_files, movie_file.file_path_suffix + ) + for movie_file in movie_files + ] + + if all(success): + torrent.imported = True + self.torrent_service.torrent_repository.save_torrent(torrent=torrent) + + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Movie Downloaded", + message=f"Movie {movie.name} has been successfully downloaded and imported.", + ) + else: + log.error( + f"Failed to import files for torrent {torrent.title}. Check logs for details." + ) + + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Import Failed", + message=f"Failed to import files for movie {movie.name}. Please check logs.", + ) + + log.info(f"Finished importing files for torrent {torrent.title}") + + def get_import_candidates( + self, movie: Path, metadata_provider: AbstractMetadataProvider + ) -> MediaImportSuggestion: + search_result = self.search_for_movie( + query=remove_special_chars_and_parentheses(movie.name), + metadata_provider=metadata_provider, + ) + import_candidates = MediaImportSuggestion( + directory=movie, + candidates=search_result, + ) + log.debug( + f"Found {len(search_result)} candidates for {movie.name} in {movie.parent}" + ) + return import_candidates + + def import_existing_movie(self, movie: Movie, source_directory: Path) -> bool: + new_source_path = source_directory.parent / ("." + source_directory.name) + try: + source_directory.rename(new_source_path) + except Exception as e: + log.exception(f"Failed to rename {source_directory} to {new_source_path}") + raise RenameError from e + + video_files, subtitle_files, _all_files = get_files_for_import( + directory=new_source_path + ) + + success = self.import_movie( + movie=movie, + video_files=video_files, + subtitle_files=subtitle_files, + file_path_suffix="IMPORTED", + ) + if success: + self.movie_repository.add_movie_file( + MovieFile( + movie_id=movie.id, + file_path_suffix="IMPORTED", + torrent_id=None, + quality=Quality.unknown, + ) + ) + + return success + + def update_movie_metadata( + self, db_movie: Movie, metadata_provider: AbstractMetadataProvider + ) -> Movie | None: + """ + Updates the metadata of a movie. + + :param metadata_provider: The metadata provider object to fetch fresh data from. + :param db_movie: The Movie to update + :return: The updated Movie object, or None if the movie is not found or an error occurs. + """ + 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( + movie_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} ({db_movie.year})" + ) + return None + log.debug(f"Fetched fresh metadata for movie: {fresh_movie_data.name}") + + self.movie_repository.update_movie_attributes( + movie_id=db_movie.id, + 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) + + log.info( + f"Successfully updated metadata for movie: {db_movie.name} ({db_movie.year})" + ) + metadata_provider.download_movie_poster_image(movie=updated_movie) + return updated_movie + + def get_importable_movies( + self, metadata_provider: AbstractMetadataProvider + ) -> list[MediaImportSuggestion]: + movie_root_path = MediaManagerConfig().misc.movie_directory + importable_movies: list[MediaImportSuggestion] = [] + candidate_dirs = get_importable_media_directories(movie_root_path) + + for movie_dir in candidate_dirs: + metadata, external_id = extract_external_id_from_string(movie_dir.name) + if metadata is not None and external_id is not None: + try: + self.movie_repository.get_movie_by_external_id( + external_id=external_id, metadata_provider=metadata + ) + log.debug( + f"Movie {movie_dir.name} already exists in the database, skipping." + ) + continue + except NotFoundError: + log.debug( + f"Movie {movie_dir.name} not found in database, checking for import candidates." + ) + + import_candidates = self.get_import_candidates( + movie=movie_dir, metadata_provider=metadata_provider + ) + importable_movies.append(import_candidates) + + log.debug(f"Found {len(importable_movies)} importable movies.") + return importable_movies + + def import_all_torrents(self) -> None: + log.info("Importing all torrents") + torrents = self.torrent_service.get_all_torrents() + log.info("Found %d torrents to import", len(torrents)) + for t in torrents: + try: + if not t.imported and t.status == TorrentStatus.finished: + movie = self.torrent_service.get_movie_of_torrent(torrent=t) + if movie is None: + log.warning( + f"torrent {t.title} is not a movie torrent, skipping import." + ) + continue + self.import_torrent_files(torrent=t, movie=movie) + except RuntimeError: + log.exception(f"Failed to import torrent {t.title}") + log.info("Finished importing all torrents") + + def update_all_metadata(self) -> None: + """Updates the metadata of all movies.""" + log.info("Updating metadata for all movies") + movies = self.movie_repository.get_movies() + log.info(f"Found {len(movies)} movies to update") + for movie in movies: + try: + if movie.metadata_provider == "tmdb": + metadata_provider = TmdbMetadataProvider() + elif movie.metadata_provider == "tvdb": + metadata_provider = TvdbMetadataProvider() + else: + log.error( + f"Unsupported metadata provider {movie.metadata_provider} for movie {movie.name}, skipping update." + ) + continue + except InvalidConfigError: + log.exception( + f"Error initializing metadata provider {movie.metadata_provider} for movie {movie.name}", + ) + continue + self.update_movie_metadata( + db_movie=movie, metadata_provider=metadata_provider + ) diff --git a/media_manager/torrent/utils.py b/media_manager/torrent/utils.py index 9ed2a580..bd54a2d6 100644 --- a/media_manager/torrent/utils.py +++ b/media_manager/torrent/utils.py @@ -1,11 +1,8 @@ import hashlib import logging -import mimetypes -from pathlib import Path import bencoder import libtorrent -import patoolib import requests from pathvalidate import sanitize_filename from requests.exceptions import InvalidSchema @@ -13,101 +10,10 @@ from media_manager.config import MediaManagerConfig from media_manager.indexer.schemas import IndexerQueryResult from media_manager.indexer.utils import follow_redirects_to_final_torrent_url -from media_manager.torrent.schemas import Torrent log = logging.getLogger(__name__) -def list_files_recursively(path: Path = Path()) -> list[Path]: - files = list(path.glob("**/*")) - log.debug(f"Found {len(files)} entries via glob") - valid_files = [] - for x in files: - if x.is_dir(): - log.debug(f"'{x}' is a directory") - elif x.is_symlink(): - log.debug(f"'{x}' is a symlink") - else: - valid_files.append(x) - log.debug(f"Returning {len(valid_files)} files after filtering") - return valid_files - - -def extract_archives(files: list) -> None: - archive_types = { - "application/zip", - "application/x-zip-compressedapplication/x-compressed", - "application/vnd.rar", - "application/x-7z-compressed", - "application/x-freearc", - "application/x-bzip", - "application/x-bzip2", - "application/gzip", - "application/x-gzip", - "application/x-tar", - } - for file in files: - file_type = mimetypes.guess_type(file) - log.debug(f"File: {file}, Size: {file.stat().st_size} bytes, Type: {file_type}") - - if file_type[0] in archive_types: - log.info( - f"File {file} is a compressed file, extracting it into directory {file.parent}" - ) - try: - patoolib.extract_archive(str(file), outdir=str(file.parent)) - except patoolib.util.PatoolError: - log.exception(f"Failed to extract archive {file}") - - -def get_torrent_filepath(torrent: Torrent) -> Path: - return MediaManagerConfig().misc.torrent_directory / torrent.title - - -def get_files_for_import( - torrent: Torrent | None = None, directory: Path | None = None -) -> tuple[list[Path], list[Path], list[Path]]: - """ - Extracts all files from the torrent download directory, including extracting archives. - Returns a tuple containing: seperated video files, subtitle files, and all files found in the torrent directory. - """ - if torrent: - log.info(f"Importing torrent {torrent}") - search_directory = get_torrent_filepath(torrent=torrent) - elif directory: - log.info(f"Importing files from directory {directory}") - search_directory = directory - else: - msg = "Either torrent or directory must be provided." - raise ValueError(msg) - - all_files: list[Path] = list_files_recursively(path=search_directory) - log.debug(f"Found {len(all_files)} files downloaded by the torrent") - extract_archives(all_files) - all_files = list_files_recursively(path=search_directory) - - video_files: list[Path] = [] - subtitle_files: list[Path] = [] - for file in all_files: - file_type, _ = mimetypes.guess_type(str(file)) - if file_type is not None: - if file_type.startswith("video"): - video_files.append(file) - log.debug(f"File is a video, it will be imported: {file}") - elif file_type.startswith("text") and Path(file).suffix == ".srt": - subtitle_files.append(file) - log.debug(f"File is a subtitle, it will be imported: {file}") - else: - log.debug( - f"File is neither a video nor a subtitle, will not be imported: {file}" - ) - - log.info( - f"Found {len(all_files)} files ({len(video_files)} video files, {len(subtitle_files)} subtitle files) for further processing." - ) - return video_files, subtitle_files, all_files - - def get_torrent_hash(torrent: IndexerQueryResult) -> str: """ Helper method to get the torrent hash from the torrent object. diff --git a/media_manager/tv/router.py b/media_manager/tv/router.py index 43150061..c67e9523 100644 --- a/media_manager/tv/router.py +++ b/media_manager/tv/router.py @@ -13,7 +13,7 @@ from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult from media_manager.schemas import MediaImportSuggestion from media_manager.torrent.schemas import Torrent -from media_manager.utils.files import get_importable_media_directories +from media_manager.utils.file_handler import get_importable_media_directories from media_manager.tv.dependencies import ( season_dep, show_dep, diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index cb683cd3..d356b6e5 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -1,1091 +1,1089 @@ -import pprint -import re -import shutil -from pathlib import Path -from typing import overload - -from media_manager.utils.files import ( - extract_external_id_from_string, - get_importable_media_directories, - import_file, import_subtitle, - remove_special_characters, - remove_special_chars_and_parentheses -) -from sqlalchemy.exc import IntegrityError - -from media_manager.config import MediaManagerConfig -from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError -from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId -from media_manager.indexer.service import IndexerService -from media_manager.indexer.utils import evaluate_indexer_query_results -from media_manager.metadataProvider.abstract_metadata_provider import ( - AbstractMetadataProvider, -) -from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult -from media_manager.metadataProvider.tmdb import TmdbMetadataProvider -from media_manager.metadataProvider.tvdb import TvdbMetadataProvider -from media_manager.notification.service import NotificationService -from media_manager.schemas import MediaImportSuggestion -from media_manager.torrent.schemas import ( - Quality, - Torrent, - TorrentStatus, -) -from media_manager.torrent.service import TorrentService -from media_manager.torrent.utils import ( - get_files_for_import, -) -from media_manager.tv import log -from media_manager.tv.repository import TvRepository -from media_manager.tv.schemas import ( - Episode, - EpisodeFile, - EpisodeId, - EpisodeNumber, - PublicEpisodeFile, - PublicSeason, - PublicShow, - RichSeasonTorrent, - RichShowTorrent, - Season, - SeasonId, - Show, - ShowId, -) - - -class TvService: - def __init__( - self, - tv_repository: TvRepository, - torrent_service: TorrentService, - indexer_service: IndexerService, - notification_service: NotificationService, - ) -> None: - self.tv_repository = tv_repository - self.torrent_service = torrent_service - self.indexer_service = indexer_service - self.notification_service = notification_service - - def add_show( - self, - external_id: int, - metadata_provider: AbstractMetadataProvider, - language: str | None = None, - ) -> Show: - """ - Add a new show to the database. - - :param external_id: The ID of the show in the metadata provider\'s system. - :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( - show_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 - - def get_total_downloaded_episoded_count(self) -> int: - """ - Get total number of downloaded episodes. - """ - - return self.tv_repository.get_total_downloaded_episodes_count() - - def set_show_library(self, show: Show, library: str) -> None: - self.tv_repository.set_show_library(show_id=show.id, library=library) - - def delete_show( - self, - show: Show, - delete_files_on_disk: bool = False, - delete_torrents: bool = False, - ) -> None: - """ - Delete a show from the database, optionally deleting files and torrents. - - :param show: The show to delete. - :param delete_files_on_disk: Whether to delete the show's files from disk. - :param delete_torrents: Whether to delete associated torrents from the torrent client. - """ - if delete_files_on_disk or delete_torrents: - log.debug(f"Deleting ID: {show.id} - Name: {show.name}") - - if delete_files_on_disk: - show_dir = self.get_root_show_directory(show=show) - - log.debug(f"Attempt to delete show directory: {show_dir}") - if show_dir.exists() and show_dir.is_dir(): - shutil.rmtree(show_dir) - log.info(f"Deleted show directory: {show_dir}") - - if delete_torrents: - torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id) - for torrent in torrents: - try: - self.torrent_service.cancel_download(torrent, delete_files=True) - self.torrent_service.delete_torrent(torrent_id=torrent.id) - log.info(f"Deleted torrent: {torrent.hash}") - except Exception: - log.warning( - f"Failed to delete torrent {torrent.hash}", exc_info=True - ) - - self.tv_repository.delete_show(show_id=show.id) - - def get_public_episode_files_by_season_id( - self, season: Season - ) -> list[PublicEpisodeFile]: - """ - Get all public episode files for a given season. - - :param season: The season object. - :return: A list of public episode files. - """ - episode_files = self.tv_repository.get_episode_files_by_season_id( - season_id=season.id - ) - public_episode_files = [ - PublicEpisodeFile.model_validate(x) for x in episode_files - ] - result = [] - for episode_file in public_episode_files: - if self.episode_file_exists_on_file(episode_file=episode_file): - episode_file.downloaded = True - result.append(episode_file) - return result - - @overload - def check_if_show_exists(self, *, external_id: int, metadata_provider: str) -> bool: - """ - Check if a show exists in the database. - - :param external_id: The external ID of the show. - :param metadata_provider: The metadata provider. - :return: True if the show exists, False otherwise. - """ - - @overload - def check_if_show_exists(self, *, show_id: ShowId) -> bool: - """ - Check if a show exists in the database. - - :param show_id: The ID of the show. - :return: True if the show exists, False otherwise. - """ - - def check_if_show_exists( - self, *, external_id=None, metadata_provider=None, show_id=None - ) -> bool: - if not (external_id is None or metadata_provider is None): - try: - self.tv_repository.get_show_by_external_id( - external_id=external_id, metadata_provider=metadata_provider - ) - except NotFoundError: - return False - elif show_id is not None: - try: - self.tv_repository.get_show_by_id(show_id=show_id) - except NotFoundError: - return False - else: - msg = "Use one of the provided overloads for this function!" - raise ValueError(msg) - - return True - - def get_all_available_torrents_for_a_season( - self, - season_number: int, - show_id: ShowId, - search_query_override: str | None = None, - ) -> list[IndexerQueryResult]: - """ - Get all available torrents for a given season. - - :param season_number: The number of the season. - :param show_id: The ID of the show. - :param search_query_override: Optional override for the search query. - :return: A list of indexer query results. - """ - - if search_query_override: - return self.indexer_service.search(query=search_query_override, is_tv=True) - - show = self.tv_repository.get_show_by_id(show_id=show_id) - - torrents = self.indexer_service.search_season( - show=show, season_number=season_number - ) - - results = [torrent for torrent in torrents if season_number in torrent.season] - - return evaluate_indexer_query_results( - is_tv=True, query_results=results, media=show - ) - - def get_all_shows(self) -> list[Show]: - """ - Get all shows. - - :return: A list of all shows. - """ - return self.tv_repository.get_shows() - - def search_for_show( - self, query: str, metadata_provider: AbstractMetadataProvider - ) -> list[MetaDataProviderSearchResult]: - """ - Search for shows using a given query. - - :param query: The search query. - :param metadata_provider: The metadata provider to search. - :return: A list of metadata provider show search results. - """ - results = metadata_provider.search_show(query) - for result in results: - if self.check_if_show_exists( - external_id=result.external_id, metadata_provider=metadata_provider.name - ): - result.added = True - - try: - show = self.tv_repository.get_show_by_external_id( - external_id=result.external_id, - metadata_provider=metadata_provider.name, - ) - result.id = show.id - except Exception: - log.error( - f"Unable to find internal show ID for {result.external_id} on {metadata_provider.name}" - ) - return results - - def get_popular_shows( - self, metadata_provider: AbstractMetadataProvider - ) -> list[MetaDataProviderSearchResult]: - """ - Get popular shows from a given metadata provider. - - :param metadata_provider: The metadata provider to use. - :return: A list of metadata provider show search results. - """ - results = metadata_provider.search_show() - - return [ - result - for result in results - if not self.check_if_show_exists( - external_id=result.external_id, metadata_provider=metadata_provider.name - ) - ] - - def get_public_show_by_id(self, show: Show) -> PublicShow: - """ - Get a public show from a Show object. - - :param show: The show object. - :return: A public show. - """ - public_show = PublicShow.model_validate(show) - public_seasons: list[PublicSeason] = [] - - for season in show.seasons: - public_season = PublicSeason.model_validate(season) - - for episode in public_season.episodes: - episode.downloaded = self.is_episode_downloaded( - episode=episode, - season=season, - show=show, - ) - - # A season is considered downloaded if it has episodes and all of them are downloaded, - # matching the behavior of is_season_downloaded. - public_season.downloaded = bool(public_season.episodes) and all( - episode.downloaded for episode in public_season.episodes - ) - public_seasons.append(public_season) - - public_show.seasons = public_seasons - return public_show - - def get_show_by_id(self, show_id: ShowId) -> Show: - """ - Get a show by its ID. - - :param show_id: The ID of the show. - :return: The show. - """ - return self.tv_repository.get_show_by_id(show_id=show_id) - - def is_season_downloaded(self, season: Season, show: Show) -> bool: - """ - Check if a season is downloaded. - - :param season: The season object. - :param show: The show object. - :return: True if the season is downloaded, False otherwise. - """ - episodes = season.episodes - - if not episodes: - return False - - for episode in episodes: - if not self.is_episode_downloaded( - episode=episode, season=season, show=show - ): - return False - return True - - def is_episode_downloaded( - self, episode: Episode, season: Season, show: Show - ) -> bool: - """ - Check if an episode is downloaded and imported (file exists on disk). - - An episode is considered downloaded if: - - There is at least one EpisodeFile in the database AND - - A matching episode file exists in the season directory on disk. - - :param episode: The episode object. - :param season: The season object. - :param show: The show object. - :return: True if the episode is downloaded and imported, False otherwise. - """ - episode_files = self.tv_repository.get_episode_files_by_episode_id( - episode_id=episode.id - ) - - if not episode_files: - return False - - season_dir = self.get_root_season_directory(show, season.number) - - if not season_dir.exists(): - return False - - episode_token = f"S{season.number:02d}E{episode.number:02d}" - - video_extensions = {".mkv", ".mp4", ".avi", ".mov"} - - try: - for file in season_dir.iterdir(): - if ( - file.is_file() - and episode_token.lower() in file.name.lower() - and file.suffix.lower() in video_extensions - ): - return True - - except OSError as e: - log.error( - f"Disk check failed for episode {episode.id} in {season_dir}: {e}" - ) - - return False - - def episode_file_exists_on_file(self, episode_file: EpisodeFile) -> bool: - """ - Check if an episode file exists on the filesystem. - - :param episode_file: The episode file to check. - :return: True if the file exists, False otherwise. - """ - if episode_file.torrent_id is None: - return True - try: - torrent_file = self.torrent_service.get_torrent_by_id( - torrent_id=episode_file.torrent_id - ) - - if torrent_file.imported: - return True - except RuntimeError: - log.exception("Error retrieving torrent") - - return False - - def get_show_by_external_id( - self, external_id: int, metadata_provider: str - ) -> Show | None: - """ - Get a show by its external ID and metadata provider. - - :param external_id: The external ID of the show. - :param metadata_provider: The metadata provider. - :return: The show or None if not found. - """ - return self.tv_repository.get_show_by_external_id( - external_id=external_id, metadata_provider=metadata_provider - ) - - def get_season(self, season_id: SeasonId) -> Season: - """ - Get a season by its ID. - - :param season_id: The ID of the season. - :return: The season. - """ - return self.tv_repository.get_season(season_id=season_id) - - def get_episode(self, episode_id: EpisodeId) -> Episode: - """ - Get an episode by its ID. - - :param episode_id: The ID of the episode. - :return: The episode. - """ - return self.tv_repository.get_episode(episode_id=episode_id) - - def get_season_by_episode(self, episode_id: EpisodeId) -> Season: - """ - Get a season by the episode ID. - - :param episode_id: The ID of the episode. - :return: The season. - """ - return self.tv_repository.get_season_by_episode(episode_id=episode_id) - - def get_torrents_for_show(self, show: Show) -> RichShowTorrent: - """ - Get torrents for a given show. - - :param show: The show. - :return: A rich show torrent. - """ - show_torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id) - rich_season_torrents = [] - for show_torrent in show_torrents: - seasons = self.tv_repository.get_seasons_by_torrent_id( - torrent_id=show_torrent.id - ) - episodes = self.tv_repository.get_episodes_by_torrent_id( - torrent_id=show_torrent.id - ) - episode_files = self.torrent_service.get_episode_files_of_torrent( - torrent=show_torrent - ) - - file_path_suffix = ( - episode_files[0].file_path_suffix if episode_files else "" - ) - season_torrent = RichSeasonTorrent( - torrent_id=show_torrent.id, - torrent_title=show_torrent.title, - status=show_torrent.status, - quality=show_torrent.quality, - imported=show_torrent.imported, - seasons=seasons, - episodes=episodes if len(seasons) == 1 else [], - file_path_suffix=file_path_suffix, - usenet=show_torrent.usenet, - ) - rich_season_torrents.append(season_torrent) - - return RichShowTorrent( - show_id=show.id, - name=show.name, - year=show.year, - metadata_provider=show.metadata_provider, - torrents=rich_season_torrents, - ) - - def get_all_shows_with_torrents(self) -> list[RichShowTorrent]: - """ - Get all shows with torrents. - - :return: A list of rich show torrents. - """ - shows = self.tv_repository.get_all_shows_with_torrents() - return [self.get_torrents_for_show(show=show) for show in shows] - - def download_torrent( - self, - public_indexer_result_id: IndexerQueryResultId, - show_id: ShowId, - override_show_file_path_suffix: str = "", - ) -> Torrent: - """ - Download a torrent for a given indexer result and show. - - :param public_indexer_result_id: The ID of the indexer result. - :param show_id: The ID of the show. - :param override_show_file_path_suffix: Optional override for the file path suffix. - :return: The downloaded torrent. - """ - indexer_result = self.indexer_service.get_result( - result_id=public_indexer_result_id - ) - show_torrent = self.torrent_service.download(indexer_result=indexer_result) - self.torrent_service.pause_download(torrent=show_torrent) - - try: - for season_number in indexer_result.season: - season = self.tv_repository.get_season_by_number( - season_number=season_number, show_id=show_id - ) - episodes = {episode.number: episode.id for episode in season.episodes} - - if indexer_result.episode: - episode_ids = [] - missing_episodes = [] - for ep_number in indexer_result.episode: - ep_id = episodes.get(EpisodeNumber(ep_number)) - if ep_id is None: - missing_episodes.append(ep_number) - continue - episode_ids.append(ep_id) - if missing_episodes: - log.warning( - "Some episodes from indexer result were not found in season %s " - "for show %s and will be skipped: %s", - season.id, - show_id, - ", ".join(str(ep) for ep in missing_episodes), - ) - else: - episode_ids = [episode.id for episode in season.episodes] - - for episode_id in episode_ids: - episode_file = EpisodeFile( - episode_id=episode_id, - quality=indexer_result.quality, - torrent_id=show_torrent.id, - file_path_suffix=override_show_file_path_suffix, - ) - self.tv_repository.add_episode_file(episode_file=episode_file) - - except IntegrityError: - log.error( - f"Episode file for episode {episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping." - ) - self.tv_repository.remove_episode_files_by_torrent_id(show_torrent.id) - self.torrent_service.cancel_download( - torrent=show_torrent, delete_files=True - ) - raise - else: - log.info( - f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}" - ) - self.torrent_service.resume_download(torrent=show_torrent) - - return show_torrent - - def get_root_show_directory(self, show: Show) -> Path: - misc_config = MediaManagerConfig().misc - show_directory_name = f"{remove_special_characters(show.name)} ({show.year}) [{show.metadata_provider}id-{show.external_id}]" - log.debug( - f"Show {show.name} without special characters: {remove_special_characters(show.name)}" - ) - - if show.library != "Default": - for library in misc_config.tv_libraries: - if library.name == show.library: - log.debug( - f"Using library {library.name} for show {show.name} ({show.year})" - ) - return Path(library.path) / show_directory_name - else: - log.warning( - f"Library {show.library} not defined in config, using default TV directory." - ) - return misc_config.tv_directory / show_directory_name - - def get_root_season_directory(self, show: Show, season_number: int) -> Path: - return self.get_root_show_directory(show) / Path(f"Season {season_number}") - - def import_episode( - self, - show: Show, - season: Season, - episode_number: int, - video_files: list[Path], - subtitle_files: list[Path], - file_path_suffix: str = "", - ) -> bool: - episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode_number:02d}" - if file_path_suffix != "": - episode_file_name += f" - {file_path_suffix}" - pattern = ( - r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode_number) + r"[. ].*" - ) - target_file_name = ( - self.get_root_season_directory(show=show, season_number=season.number) - / episode_file_name - ) - - # import subtitle - for subtitle_file in subtitle_files: - import_subtitle(subtitle_file=subtitle_file, target_file=target_file_name) - - # import episode videos - for file in video_files: - if re.search(pattern, file.name, re.IGNORECASE): - target_video_file = target_file_name.with_suffix(file.suffix) - import_file(target_file=target_video_file, source_file=file) - return True - else: - msg = f"Could not find any video file for episode {episode_number} of show {show.name} S{season.number}" - raise Exception(msg) # noqa: TRY002 # TODO: resolve this - - def import_season( - self, - show: Show, - season: Season, - video_files: list[Path], - subtitle_files: list[Path], - file_path_suffix: str = "", - ) -> tuple[bool, list[Episode]]: - season_path = self.get_root_season_directory( - show=show, season_number=season.number - ) - success = True - imported_episodes = [] - try: - season_path.mkdir(parents=True, exist_ok=True) - except Exception as e: - log.exception(f"Could not create path {season_path}") - msg = f"Could not create path {season_path}" - raise Exception(msg) from e # noqa: TRY002 # TODO: resolve this - - for episode in season.episodes: - try: - imported = self.import_episode( - show=show, - subtitle_files=subtitle_files, - video_files=video_files, - season=season, - episode_number=episode.number, - file_path_suffix=file_path_suffix, - ) - if imported: - imported_episodes.append(episode) - - except Exception: - # Send notification about missing episode file - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Missing Episode File", - message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", - ) - success = False - log.warning( - f"S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." - ) - return success, imported_episodes - - def import_episode_files( - self, - show: Show, - season: Season, - episode: Episode, - video_files: list[Path], - subtitle_files: list[Path], - file_path_suffix: str = "", - ) -> bool: - episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode.number:02d}" - if file_path_suffix != "": - episode_file_name += f" - {file_path_suffix}" - pattern = ( - r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode.number) + r"[. ].*" - ) - target_file_name = ( - self.get_root_season_directory(show=show, season_number=season.number) - / episode_file_name - ) - - # import subtitle - for subtitle_file in subtitle_files: - import_subtitle(subtitle_file=subtitle_file, target_file=target_file_name) - - found_video = False - - # import episode videos - for file in video_files: - if re.search(pattern, file.name, re.IGNORECASE): - target_video_file = target_file_name.with_suffix(file.suffix) - import_file(target_file=target_video_file, source_file=file) - found_video = True - break - - if not found_video: - # Send notification about missing episode file - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Missing Episode File", - message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", - ) - log.warning( - f"File for S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." - ) - return False - - return True - - def import_episode_files_from_torrent(self, torrent: Torrent, show: Show) -> None: - """ - Organizes episodes files from a torrent into the TV directory structure, mapping them to seasons and episodes. - :param torrent: The Torrent object - :param show: The Show object - """ - - video_files, subtitle_files, _all_files = get_files_for_import(torrent=torrent) - - success: list[bool] = [] - - log.debug( - f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files) - ) - - episode_files = self.torrent_service.get_episode_files_of_torrent( - torrent=torrent - ) - if not episode_files: - log.warning( - f"No episode files associated with torrent {torrent.title}, skipping import." - ) - return - - log.info( - f"Found {len(episode_files)} episode files associated with torrent {torrent.title}" - ) - - imported_episodes_by_season: dict[int, list[int]] = {} - - for episode_file in episode_files: - season = self.get_season_by_episode(episode_id=episode_file.episode_id) - episode = self.get_episode(episode_file.episode_id) - - season_path = self.get_root_season_directory( - show=show, season_number=season.number - ) - if not season_path.exists(): - try: - season_path.mkdir(parents=True) - except Exception as e: - log.warning(f"Could not create path {season_path}: {e}") - msg = f"Could not create path {season_path}" - raise Exception(msg) from e # noqa: TRY002 - - episoded_import_success = self.import_episode_files( - show=show, - season=season, - episode=episode, - video_files=video_files, - subtitle_files=subtitle_files, - file_path_suffix=episode_file.file_path_suffix, - ) - success.append(episoded_import_success) - - if episoded_import_success: - imported_episodes_by_season.setdefault(season.number, []).append( - episode.number - ) - - log.info( - f"Episode {episode.number} from Season {season.number} successfully imported from torrent {torrent.title}" - ) - else: - log.warning( - f"Episode {episode.number} from Season {season.number} failed to import from torrent {torrent.title}" - ) - - success_messages: list[str] = [] - - for season_number, episodes in imported_episodes_by_season.items(): - episode_list = ",".join(str(e) for e in sorted(episodes)) - success_messages.append( - f"Episode(s): {episode_list} from Season {season_number}" - ) - - episodes_summary = "; ".join(success_messages) - - if all(success): - torrent.imported = True - self.torrent_service.torrent_repository.save_torrent(torrent=torrent) - - # Send successful season download notification - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="TV Show imported successfully", - message=( - f"Successfully imported {episodes_summary} " - f"of {show.name} ({show.year}) " - f"from torrent {torrent.title}." - ), - ) - else: - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Failed to import TV Show", - message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.", - ) - - log.info( - f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors" - ) - - def update_show_metadata( - self, db_show: Show, metadata_provider: AbstractMetadataProvider - ) -> 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 metadata_provider: The metadata provider object to fetch fresh data from. - :param db_show: The Show to update - :return: The updated Show object, or None if the show is not found or an error occurs. - """ - log.debug(f"Found show: {db_show.name} for metadata update.") - - # Use stored original_language preference for metadata fetching - fresh_show_data = metadata_provider.get_show_metadata( - show_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}." - ) - return db_show - log.debug(f"Fetched fresh metadata for show: {fresh_show_data.name}") - - 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, - 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, - ) - - # Process seasons and episodes - existing_season_external_ids = {s.external_id: s for s in db_show.seasons} - - for fresh_season_data in fresh_show_data.seasons: - if fresh_season_data.external_id in existing_season_external_ids: - # Update existing season - existing_season = existing_season_external_ids[ - fresh_season_data.external_id - ] - - 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_external_ids = { - ep.external_id: ep for ep in existing_season.episodes - } - for fresh_episode_data in fresh_season_data.episodes: - if fresh_episode_data.external_id in existing_episode_external_ids: - # Update existing episode - existing_episode = existing_episode_external_ids[ - fresh_episode_data.external_id - ] - - self.tv_repository.update_episode_attributes( - episode_id=existing_episode.id, - title=fresh_episode_data.title, - overview=fresh_episode_data.overview, - ) - else: - # Add new episode - log.debug( - f"Adding new episode {fresh_episode_data.number} to season {existing_season.number}" - ) - episode_schema = Episode( - id=EpisodeId(fresh_episode_data.id), - number=fresh_episode_data.number, - external_id=fresh_episode_data.external_id, - title=fresh_episode_data.title, - overview=fresh_episode_data.overview, - ) - 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 = [ - Episode( - id=EpisodeId(ep_data.id), - number=ep_data.number, - external_id=ep_data.external_id, - title=ep_data.title, - overview=ep_data.overview, - ) - for ep_data in fresh_season_data.episodes - ] - - 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=db_show.id) - - log.info(f"Successfully updated metadata for show: {updated_show.name}") - metadata_provider.download_show_poster_image(show=updated_show) - return updated_show - - def set_show_continuous_download( - self, show: Show, continuous_download: bool - ) -> Show: - """ - Set the continuous download flag for a show. - - :param show: The show object. - :param continuous_download: True to enable continuous download, False to disable. - :return: The updated Show object. - """ - return self.tv_repository.update_show_attributes( - show_id=show.id, continuous_download=continuous_download - ) - - def get_import_candidates( - self, tv_show: Path, metadata_provider: AbstractMetadataProvider - ) -> MediaImportSuggestion: - search_result = self.search_for_show( - remove_special_chars_and_parentheses(tv_show.name), metadata_provider - ) - import_candidates = MediaImportSuggestion( - directory=tv_show, candidates=search_result - ) - log.debug( - f"Found {len(import_candidates.candidates)} candidates for {import_candidates.directory}" - ) - return import_candidates - - def import_existing_tv_show(self, tv_show: Show, source_directory: Path) -> None: - new_source_path = source_directory.parent / ("." + source_directory.name) - try: - source_directory.rename(new_source_path) - except Exception as e: - log.exception(f"Failed to rename {source_directory} to {new_source_path}") - raise RenameError from e - - video_files, subtitle_files, _all_files = get_files_for_import( - directory=new_source_path - ) - for season in tv_show.seasons: - _success, imported_episodes = self.import_season( - show=tv_show, - season=season, - video_files=video_files, - subtitle_files=subtitle_files, - file_path_suffix="IMPORTED", - ) - for episode in imported_episodes: - episode_file = EpisodeFile( - episode_id=episode.id, - quality=Quality.unknown, - file_path_suffix="IMPORTED", - torrent_id=None, - ) - - self.tv_repository.add_episode_file(episode_file=episode_file) - - def get_importable_tv_shows( - self, metadata_provider: AbstractMetadataProvider - ) -> list[MediaImportSuggestion]: - tv_directory = MediaManagerConfig().misc.tv_directory - import_suggestions: list[MediaImportSuggestion] = [] - candidate_dirs = get_importable_media_directories(tv_directory) - - for item in candidate_dirs: - metadata, external_id = extract_external_id_from_string(item.name) - if metadata is not None and external_id is not None: - try: - self.tv_repository.get_show_by_external_id( - external_id=external_id, - metadata_provider=metadata, - ) - log.debug( - f"Show {item.name} already exists in the database, skipping import suggestion." - ) - continue - except NotFoundError: - log.debug( - f"Show {item.name} not found in database, checking for import candidates." - ) - - import_suggestion = self.get_import_candidates( - tv_show=item, metadata_provider=metadata_provider - ) - import_suggestions.append(import_suggestion) - - log.debug(f"Detected {len(import_suggestions)} importable TV shows.") - return import_suggestions - - def import_all_torrents(self) -> None: - log.info("Importing all torrents") - torrents = self.torrent_service.get_all_torrents() - log.info("Found %d torrents to import", len(torrents)) - for t in torrents: - show = None - try: - if not t.imported and t.status == TorrentStatus.finished: - show = self.torrent_service.get_show_of_torrent(torrent=t) - if show is None: - log.warning( - f"torrent {t.title} is not a tv torrent, skipping import." - ) - continue - self.import_episode_files_from_torrent(torrent=t, show=show) - except RuntimeError as e: - show_name = show.name if show is not None else "" - log.error( - f"Error importing torrent {t.title} for show {show_name}: {e}", - exc_info=True, - ) - log.info("Finished importing all torrents") - - def update_all_non_ended_shows_metadata(self) -> None: - """Updates the metadata of all non-ended shows.""" - log.info("Updating metadata for all non-ended shows") - shows = [show for show in self.tv_repository.get_shows() if not show.ended] - log.info(f"Found {len(shows)} non-ended shows to update") - for show in shows: - try: - if show.metadata_provider == "tmdb": - metadata_provider = TmdbMetadataProvider() - elif show.metadata_provider == "tvdb": - metadata_provider = TvdbMetadataProvider() - else: - log.error( - f"Unsupported metadata provider {show.metadata_provider} for show {show.name}, skipping update." - ) - continue - except InvalidConfigError: - log.exception( - f"Error initializing metadata provider {show.metadata_provider} for show {show.name}" - ) - continue - updated_show = self.update_show_metadata( - db_show=show, metadata_provider=metadata_provider - ) - if updated_show: - log.debug("Updated show metadata", extra={"show": updated_show.name}) - else: - log.warning(f"Failed to update metadata for show: {show.name}") +import pprint +import re +import shutil +from pathlib import Path +from typing import overload + +from media_manager.utils.file_handler import ( + extract_external_id_from_string, + get_importable_media_directories, + get_files_for_import, + import_file, import_subtitle, + remove_special_characters, + remove_special_chars_and_parentheses +) +from sqlalchemy.exc import IntegrityError + +from media_manager.config import MediaManagerConfig +from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError +from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId +from media_manager.indexer.service import IndexerService +from media_manager.indexer.utils import evaluate_indexer_query_results +from media_manager.metadataProvider.abstract_metadata_provider import ( + AbstractMetadataProvider, +) +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.metadataProvider.tmdb import TmdbMetadataProvider +from media_manager.metadataProvider.tvdb import TvdbMetadataProvider +from media_manager.notification.service import NotificationService +from media_manager.schemas import MediaImportSuggestion +from media_manager.torrent.schemas import ( + Quality, + Torrent, + TorrentStatus, +) +from media_manager.torrent.service import TorrentService +from media_manager.tv import log +from media_manager.tv.repository import TvRepository +from media_manager.tv.schemas import ( + Episode, + EpisodeFile, + EpisodeId, + EpisodeNumber, + PublicEpisodeFile, + PublicSeason, + PublicShow, + RichSeasonTorrent, + RichShowTorrent, + Season, + SeasonId, + Show, + ShowId, +) + + +class TvService: + def __init__( + self, + tv_repository: TvRepository, + torrent_service: TorrentService, + indexer_service: IndexerService, + notification_service: NotificationService, + ) -> None: + self.tv_repository = tv_repository + self.torrent_service = torrent_service + self.indexer_service = indexer_service + self.notification_service = notification_service + + def add_show( + self, + external_id: int, + metadata_provider: AbstractMetadataProvider, + language: str | None = None, + ) -> Show: + """ + Add a new show to the database. + + :param external_id: The ID of the show in the metadata provider\'s system. + :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( + show_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 + + def get_total_downloaded_episoded_count(self) -> int: + """ + Get total number of downloaded episodes. + """ + + return self.tv_repository.get_total_downloaded_episodes_count() + + def set_show_library(self, show: Show, library: str) -> None: + self.tv_repository.set_show_library(show_id=show.id, library=library) + + def delete_show( + self, + show: Show, + delete_files_on_disk: bool = False, + delete_torrents: bool = False, + ) -> None: + """ + Delete a show from the database, optionally deleting files and torrents. + + :param show: The show to delete. + :param delete_files_on_disk: Whether to delete the show's files from disk. + :param delete_torrents: Whether to delete associated torrents from the torrent client. + """ + if delete_files_on_disk or delete_torrents: + log.debug(f"Deleting ID: {show.id} - Name: {show.name}") + + if delete_files_on_disk: + show_dir = self.get_root_show_directory(show=show) + + log.debug(f"Attempt to delete show directory: {show_dir}") + if show_dir.exists() and show_dir.is_dir(): + shutil.rmtree(show_dir) + log.info(f"Deleted show directory: {show_dir}") + + if delete_torrents: + torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id) + for torrent in torrents: + try: + self.torrent_service.cancel_download(torrent, delete_files=True) + self.torrent_service.delete_torrent(torrent_id=torrent.id) + log.info(f"Deleted torrent: {torrent.hash}") + except Exception: + log.warning( + f"Failed to delete torrent {torrent.hash}", exc_info=True + ) + + self.tv_repository.delete_show(show_id=show.id) + + def get_public_episode_files_by_season_id( + self, season: Season + ) -> list[PublicEpisodeFile]: + """ + Get all public episode files for a given season. + + :param season: The season object. + :return: A list of public episode files. + """ + episode_files = self.tv_repository.get_episode_files_by_season_id( + season_id=season.id + ) + public_episode_files = [ + PublicEpisodeFile.model_validate(x) for x in episode_files + ] + result = [] + for episode_file in public_episode_files: + if self.episode_file_exists_on_file(episode_file=episode_file): + episode_file.downloaded = True + result.append(episode_file) + return result + + @overload + def check_if_show_exists(self, *, external_id: int, metadata_provider: str) -> bool: + """ + Check if a show exists in the database. + + :param external_id: The external ID of the show. + :param metadata_provider: The metadata provider. + :return: True if the show exists, False otherwise. + """ + + @overload + def check_if_show_exists(self, *, show_id: ShowId) -> bool: + """ + Check if a show exists in the database. + + :param show_id: The ID of the show. + :return: True if the show exists, False otherwise. + """ + + def check_if_show_exists( + self, *, external_id=None, metadata_provider=None, show_id=None + ) -> bool: + if not (external_id is None or metadata_provider is None): + try: + self.tv_repository.get_show_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + except NotFoundError: + return False + elif show_id is not None: + try: + self.tv_repository.get_show_by_id(show_id=show_id) + except NotFoundError: + return False + else: + msg = "Use one of the provided overloads for this function!" + raise ValueError(msg) + + return True + + def get_all_available_torrents_for_a_season( + self, + season_number: int, + show_id: ShowId, + search_query_override: str | None = None, + ) -> list[IndexerQueryResult]: + """ + Get all available torrents for a given season. + + :param season_number: The number of the season. + :param show_id: The ID of the show. + :param search_query_override: Optional override for the search query. + :return: A list of indexer query results. + """ + + if search_query_override: + return self.indexer_service.search(query=search_query_override, is_tv=True) + + show = self.tv_repository.get_show_by_id(show_id=show_id) + + torrents = self.indexer_service.search_season( + show=show, season_number=season_number + ) + + results = [torrent for torrent in torrents if season_number in torrent.season] + + return evaluate_indexer_query_results( + is_tv=True, query_results=results, media=show + ) + + def get_all_shows(self) -> list[Show]: + """ + Get all shows. + + :return: A list of all shows. + """ + return self.tv_repository.get_shows() + + def search_for_show( + self, query: str, metadata_provider: AbstractMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + """ + Search for shows using a given query. + + :param query: The search query. + :param metadata_provider: The metadata provider to search. + :return: A list of metadata provider show search results. + """ + results = metadata_provider.search_show(query) + for result in results: + if self.check_if_show_exists( + external_id=result.external_id, metadata_provider=metadata_provider.name + ): + result.added = True + + try: + show = self.tv_repository.get_show_by_external_id( + external_id=result.external_id, + metadata_provider=metadata_provider.name, + ) + result.id = show.id + except Exception: + log.error( + f"Unable to find internal show ID for {result.external_id} on {metadata_provider.name}" + ) + return results + + def get_popular_shows( + self, metadata_provider: AbstractMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + """ + Get popular shows from a given metadata provider. + + :param metadata_provider: The metadata provider to use. + :return: A list of metadata provider show search results. + """ + results = metadata_provider.search_show() + + return [ + result + for result in results + if not self.check_if_show_exists( + external_id=result.external_id, metadata_provider=metadata_provider.name + ) + ] + + def get_public_show_by_id(self, show: Show) -> PublicShow: + """ + Get a public show from a Show object. + + :param show: The show object. + :return: A public show. + """ + public_show = PublicShow.model_validate(show) + public_seasons: list[PublicSeason] = [] + + for season in show.seasons: + public_season = PublicSeason.model_validate(season) + + for episode in public_season.episodes: + episode.downloaded = self.is_episode_downloaded( + episode=episode, + season=season, + show=show, + ) + + # A season is considered downloaded if it has episodes and all of them are downloaded, + # matching the behavior of is_season_downloaded. + public_season.downloaded = bool(public_season.episodes) and all( + episode.downloaded for episode in public_season.episodes + ) + public_seasons.append(public_season) + + public_show.seasons = public_seasons + return public_show + + def get_show_by_id(self, show_id: ShowId) -> Show: + """ + Get a show by its ID. + + :param show_id: The ID of the show. + :return: The show. + """ + return self.tv_repository.get_show_by_id(show_id=show_id) + + def is_season_downloaded(self, season: Season, show: Show) -> bool: + """ + Check if a season is downloaded. + + :param season: The season object. + :param show: The show object. + :return: True if the season is downloaded, False otherwise. + """ + episodes = season.episodes + + if not episodes: + return False + + for episode in episodes: + if not self.is_episode_downloaded( + episode=episode, season=season, show=show + ): + return False + return True + + def is_episode_downloaded( + self, episode: Episode, season: Season, show: Show + ) -> bool: + """ + Check if an episode is downloaded and imported (file exists on disk). + + An episode is considered downloaded if: + - There is at least one EpisodeFile in the database AND + - A matching episode file exists in the season directory on disk. + + :param episode: The episode object. + :param season: The season object. + :param show: The show object. + :return: True if the episode is downloaded and imported, False otherwise. + """ + episode_files = self.tv_repository.get_episode_files_by_episode_id( + episode_id=episode.id + ) + + if not episode_files: + return False + + season_dir = self.get_root_season_directory(show, season.number) + + if not season_dir.exists(): + return False + + episode_token = f"S{season.number:02d}E{episode.number:02d}" + + video_extensions = {".mkv", ".mp4", ".avi", ".mov"} + + try: + for file in season_dir.iterdir(): + if ( + file.is_file() + and episode_token.lower() in file.name.lower() + and file.suffix.lower() in video_extensions + ): + return True + + except OSError as e: + log.error( + f"Disk check failed for episode {episode.id} in {season_dir}: {e}" + ) + + return False + + def episode_file_exists_on_file(self, episode_file: EpisodeFile) -> bool: + """ + Check if an episode file exists on the filesystem. + + :param episode_file: The episode file to check. + :return: True if the file exists, False otherwise. + """ + if episode_file.torrent_id is None: + return True + try: + torrent_file = self.torrent_service.get_torrent_by_id( + torrent_id=episode_file.torrent_id + ) + + if torrent_file.imported: + return True + except RuntimeError: + log.exception("Error retrieving torrent") + + return False + + def get_show_by_external_id( + self, external_id: int, metadata_provider: str + ) -> Show | None: + """ + Get a show by its external ID and metadata provider. + + :param external_id: The external ID of the show. + :param metadata_provider: The metadata provider. + :return: The show or None if not found. + """ + return self.tv_repository.get_show_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + + def get_season(self, season_id: SeasonId) -> Season: + """ + Get a season by its ID. + + :param season_id: The ID of the season. + :return: The season. + """ + return self.tv_repository.get_season(season_id=season_id) + + def get_episode(self, episode_id: EpisodeId) -> Episode: + """ + Get an episode by its ID. + + :param episode_id: The ID of the episode. + :return: The episode. + """ + return self.tv_repository.get_episode(episode_id=episode_id) + + def get_season_by_episode(self, episode_id: EpisodeId) -> Season: + """ + Get a season by the episode ID. + + :param episode_id: The ID of the episode. + :return: The season. + """ + return self.tv_repository.get_season_by_episode(episode_id=episode_id) + + def get_torrents_for_show(self, show: Show) -> RichShowTorrent: + """ + Get torrents for a given show. + + :param show: The show. + :return: A rich show torrent. + """ + show_torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id) + rich_season_torrents = [] + for show_torrent in show_torrents: + seasons = self.tv_repository.get_seasons_by_torrent_id( + torrent_id=show_torrent.id + ) + episodes = self.tv_repository.get_episodes_by_torrent_id( + torrent_id=show_torrent.id + ) + episode_files = self.torrent_service.get_episode_files_of_torrent( + torrent=show_torrent + ) + + file_path_suffix = ( + episode_files[0].file_path_suffix if episode_files else "" + ) + season_torrent = RichSeasonTorrent( + torrent_id=show_torrent.id, + torrent_title=show_torrent.title, + status=show_torrent.status, + quality=show_torrent.quality, + imported=show_torrent.imported, + seasons=seasons, + episodes=episodes if len(seasons) == 1 else [], + file_path_suffix=file_path_suffix, + usenet=show_torrent.usenet, + ) + rich_season_torrents.append(season_torrent) + + return RichShowTorrent( + show_id=show.id, + name=show.name, + year=show.year, + metadata_provider=show.metadata_provider, + torrents=rich_season_torrents, + ) + + def get_all_shows_with_torrents(self) -> list[RichShowTorrent]: + """ + Get all shows with torrents. + + :return: A list of rich show torrents. + """ + shows = self.tv_repository.get_all_shows_with_torrents() + return [self.get_torrents_for_show(show=show) for show in shows] + + def download_torrent( + self, + public_indexer_result_id: IndexerQueryResultId, + show_id: ShowId, + override_show_file_path_suffix: str = "", + ) -> Torrent: + """ + Download a torrent for a given indexer result and show. + + :param public_indexer_result_id: The ID of the indexer result. + :param show_id: The ID of the show. + :param override_show_file_path_suffix: Optional override for the file path suffix. + :return: The downloaded torrent. + """ + indexer_result = self.indexer_service.get_result( + result_id=public_indexer_result_id + ) + show_torrent = self.torrent_service.download(indexer_result=indexer_result) + self.torrent_service.pause_download(torrent=show_torrent) + + try: + for season_number in indexer_result.season: + season = self.tv_repository.get_season_by_number( + season_number=season_number, show_id=show_id + ) + episodes = {episode.number: episode.id for episode in season.episodes} + + if indexer_result.episode: + episode_ids = [] + missing_episodes = [] + for ep_number in indexer_result.episode: + ep_id = episodes.get(EpisodeNumber(ep_number)) + if ep_id is None: + missing_episodes.append(ep_number) + continue + episode_ids.append(ep_id) + if missing_episodes: + log.warning( + "Some episodes from indexer result were not found in season %s " + "for show %s and will be skipped: %s", + season.id, + show_id, + ", ".join(str(ep) for ep in missing_episodes), + ) + else: + episode_ids = [episode.id for episode in season.episodes] + + for episode_id in episode_ids: + episode_file = EpisodeFile( + episode_id=episode_id, + quality=indexer_result.quality, + torrent_id=show_torrent.id, + file_path_suffix=override_show_file_path_suffix, + ) + self.tv_repository.add_episode_file(episode_file=episode_file) + + except IntegrityError: + log.error( + f"Episode file for episode {episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping." + ) + self.tv_repository.remove_episode_files_by_torrent_id(show_torrent.id) + self.torrent_service.cancel_download( + torrent=show_torrent, delete_files=True + ) + raise + else: + log.info( + f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}" + ) + self.torrent_service.resume_download(torrent=show_torrent) + + return show_torrent + + def get_root_show_directory(self, show: Show) -> Path: + misc_config = MediaManagerConfig().misc + show_directory_name = f"{remove_special_characters(show.name)} ({show.year}) [{show.metadata_provider}id-{show.external_id}]" + log.debug( + f"Show {show.name} without special characters: {remove_special_characters(show.name)}" + ) + + if show.library != "Default": + for library in misc_config.tv_libraries: + if library.name == show.library: + log.debug( + f"Using library {library.name} for show {show.name} ({show.year})" + ) + return Path(library.path) / show_directory_name + else: + log.warning( + f"Library {show.library} not defined in config, using default TV directory." + ) + return misc_config.tv_directory / show_directory_name + + def get_root_season_directory(self, show: Show, season_number: int) -> Path: + return self.get_root_show_directory(show) / Path(f"Season {season_number}") + + def import_episode( + self, + show: Show, + season: Season, + episode_number: int, + video_files: list[Path], + subtitle_files: list[Path], + file_path_suffix: str = "", + ) -> bool: + episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode_number:02d}" + if file_path_suffix != "": + episode_file_name += f" - {file_path_suffix}" + pattern = ( + r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode_number) + r"[. ].*" + ) + target_file_name = ( + self.get_root_season_directory(show=show, season_number=season.number) + / episode_file_name + ) + + # import subtitle + for subtitle_file in subtitle_files: + import_subtitle(subtitle_file=subtitle_file, target_file=target_file_name) + + # import episode videos + for file in video_files: + if re.search(pattern, file.name, re.IGNORECASE): + target_video_file = target_file_name.with_suffix(file.suffix) + import_file(target_file=target_video_file, source_file=file) + return True + else: + msg = f"Could not find any video file for episode {episode_number} of show {show.name} S{season.number}" + raise Exception(msg) # noqa: TRY002 # TODO: resolve this + + def import_season( + self, + show: Show, + season: Season, + video_files: list[Path], + subtitle_files: list[Path], + file_path_suffix: str = "", + ) -> tuple[bool, list[Episode]]: + season_path = self.get_root_season_directory( + show=show, season_number=season.number + ) + success = True + imported_episodes = [] + try: + season_path.mkdir(parents=True, exist_ok=True) + except Exception as e: + log.exception(f"Could not create path {season_path}") + msg = f"Could not create path {season_path}" + raise Exception(msg) from e # noqa: TRY002 # TODO: resolve this + + for episode in season.episodes: + try: + imported = self.import_episode( + show=show, + subtitle_files=subtitle_files, + video_files=video_files, + season=season, + episode_number=episode.number, + file_path_suffix=file_path_suffix, + ) + if imported: + imported_episodes.append(episode) + + except Exception: + # Send notification about missing episode file + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Missing Episode File", + message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", + ) + success = False + log.warning( + f"S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." + ) + return success, imported_episodes + + def import_episode_files( + self, + show: Show, + season: Season, + episode: Episode, + video_files: list[Path], + subtitle_files: list[Path], + file_path_suffix: str = "", + ) -> bool: + episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode.number:02d}" + if file_path_suffix != "": + episode_file_name += f" - {file_path_suffix}" + pattern = ( + r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode.number) + r"[. ].*" + ) + target_file_name = ( + self.get_root_season_directory(show=show, season_number=season.number) + / episode_file_name + ) + + # import subtitle + for subtitle_file in subtitle_files: + import_subtitle(subtitle_file=subtitle_file, target_file=target_file_name) + + found_video = False + + # import episode videos + for file in video_files: + if re.search(pattern, file.name, re.IGNORECASE): + target_video_file = target_file_name.with_suffix(file.suffix) + import_file(target_file=target_video_file, source_file=file) + found_video = True + break + + if not found_video: + # Send notification about missing episode file + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Missing Episode File", + message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", + ) + log.warning( + f"File for S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." + ) + return False + + return True + + def import_episode_files_from_torrent(self, torrent: Torrent, show: Show) -> None: + """ + Organizes episodes files from a torrent into the TV directory structure, mapping them to seasons and episodes. + :param torrent: The Torrent object + :param show: The Show object + """ + + video_files, subtitle_files, _all_files = get_files_for_import(torrent=torrent) + + success: list[bool] = [] + + log.debug( + f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files) + ) + + episode_files = self.torrent_service.get_episode_files_of_torrent( + torrent=torrent + ) + if not episode_files: + log.warning( + f"No episode files associated with torrent {torrent.title}, skipping import." + ) + return + + log.info( + f"Found {len(episode_files)} episode files associated with torrent {torrent.title}" + ) + + imported_episodes_by_season: dict[int, list[int]] = {} + + for episode_file in episode_files: + season = self.get_season_by_episode(episode_id=episode_file.episode_id) + episode = self.get_episode(episode_file.episode_id) + + season_path = self.get_root_season_directory( + show=show, season_number=season.number + ) + if not season_path.exists(): + try: + season_path.mkdir(parents=True) + except Exception as e: + log.warning(f"Could not create path {season_path}: {e}") + msg = f"Could not create path {season_path}" + raise Exception(msg) from e # noqa: TRY002 + + episoded_import_success = self.import_episode_files( + show=show, + season=season, + episode=episode, + video_files=video_files, + subtitle_files=subtitle_files, + file_path_suffix=episode_file.file_path_suffix, + ) + success.append(episoded_import_success) + + if episoded_import_success: + imported_episodes_by_season.setdefault(season.number, []).append( + episode.number + ) + + log.info( + f"Episode {episode.number} from Season {season.number} successfully imported from torrent {torrent.title}" + ) + else: + log.warning( + f"Episode {episode.number} from Season {season.number} failed to import from torrent {torrent.title}" + ) + + success_messages: list[str] = [] + + for season_number, episodes in imported_episodes_by_season.items(): + episode_list = ",".join(str(e) for e in sorted(episodes)) + success_messages.append( + f"Episode(s): {episode_list} from Season {season_number}" + ) + + episodes_summary = "; ".join(success_messages) + + if all(success): + torrent.imported = True + self.torrent_service.torrent_repository.save_torrent(torrent=torrent) + + # Send successful season download notification + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="TV Show imported successfully", + message=( + f"Successfully imported {episodes_summary} " + f"of {show.name} ({show.year}) " + f"from torrent {torrent.title}." + ), + ) + else: + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Failed to import TV Show", + message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.", + ) + + log.info( + f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors" + ) + + def update_show_metadata( + self, db_show: Show, metadata_provider: AbstractMetadataProvider + ) -> 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 metadata_provider: The metadata provider object to fetch fresh data from. + :param db_show: The Show to update + :return: The updated Show object, or None if the show is not found or an error occurs. + """ + log.debug(f"Found show: {db_show.name} for metadata update.") + + # Use stored original_language preference for metadata fetching + fresh_show_data = metadata_provider.get_show_metadata( + show_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}." + ) + return db_show + log.debug(f"Fetched fresh metadata for show: {fresh_show_data.name}") + + 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, + 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, + ) + + # Process seasons and episodes + existing_season_external_ids = {s.external_id: s for s in db_show.seasons} + + for fresh_season_data in fresh_show_data.seasons: + if fresh_season_data.external_id in existing_season_external_ids: + # Update existing season + existing_season = existing_season_external_ids[ + fresh_season_data.external_id + ] + + 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_external_ids = { + ep.external_id: ep for ep in existing_season.episodes + } + for fresh_episode_data in fresh_season_data.episodes: + if fresh_episode_data.external_id in existing_episode_external_ids: + # Update existing episode + existing_episode = existing_episode_external_ids[ + fresh_episode_data.external_id + ] + + self.tv_repository.update_episode_attributes( + episode_id=existing_episode.id, + title=fresh_episode_data.title, + overview=fresh_episode_data.overview, + ) + else: + # Add new episode + log.debug( + f"Adding new episode {fresh_episode_data.number} to season {existing_season.number}" + ) + episode_schema = Episode( + id=EpisodeId(fresh_episode_data.id), + number=fresh_episode_data.number, + external_id=fresh_episode_data.external_id, + title=fresh_episode_data.title, + overview=fresh_episode_data.overview, + ) + 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 = [ + Episode( + id=EpisodeId(ep_data.id), + number=ep_data.number, + external_id=ep_data.external_id, + title=ep_data.title, + overview=ep_data.overview, + ) + for ep_data in fresh_season_data.episodes + ] + + 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=db_show.id) + + log.info(f"Successfully updated metadata for show: {updated_show.name}") + metadata_provider.download_show_poster_image(show=updated_show) + return updated_show + + def set_show_continuous_download( + self, show: Show, continuous_download: bool + ) -> Show: + """ + Set the continuous download flag for a show. + + :param show: The show object. + :param continuous_download: True to enable continuous download, False to disable. + :return: The updated Show object. + """ + return self.tv_repository.update_show_attributes( + show_id=show.id, continuous_download=continuous_download + ) + + def get_import_candidates( + self, tv_show: Path, metadata_provider: AbstractMetadataProvider + ) -> MediaImportSuggestion: + search_result = self.search_for_show( + remove_special_chars_and_parentheses(tv_show.name), metadata_provider + ) + import_candidates = MediaImportSuggestion( + directory=tv_show, candidates=search_result + ) + log.debug( + f"Found {len(import_candidates.candidates)} candidates for {import_candidates.directory}" + ) + return import_candidates + + def import_existing_tv_show(self, tv_show: Show, source_directory: Path) -> None: + new_source_path = source_directory.parent / ("." + source_directory.name) + try: + source_directory.rename(new_source_path) + except Exception as e: + log.exception(f"Failed to rename {source_directory} to {new_source_path}") + raise RenameError from e + + video_files, subtitle_files, _all_files = get_files_for_import( + directory=new_source_path + ) + for season in tv_show.seasons: + _success, imported_episodes = self.import_season( + show=tv_show, + season=season, + video_files=video_files, + subtitle_files=subtitle_files, + file_path_suffix="IMPORTED", + ) + for episode in imported_episodes: + episode_file = EpisodeFile( + episode_id=episode.id, + quality=Quality.unknown, + file_path_suffix="IMPORTED", + torrent_id=None, + ) + + self.tv_repository.add_episode_file(episode_file=episode_file) + + def get_importable_tv_shows( + self, metadata_provider: AbstractMetadataProvider + ) -> list[MediaImportSuggestion]: + tv_directory = MediaManagerConfig().misc.tv_directory + import_suggestions: list[MediaImportSuggestion] = [] + candidate_dirs = get_importable_media_directories(tv_directory) + + for item in candidate_dirs: + metadata, external_id = extract_external_id_from_string(item.name) + if metadata is not None and external_id is not None: + try: + self.tv_repository.get_show_by_external_id( + external_id=external_id, + metadata_provider=metadata, + ) + log.debug( + f"Show {item.name} already exists in the database, skipping import suggestion." + ) + continue + except NotFoundError: + log.debug( + f"Show {item.name} not found in database, checking for import candidates." + ) + + import_suggestion = self.get_import_candidates( + tv_show=item, metadata_provider=metadata_provider + ) + import_suggestions.append(import_suggestion) + + log.debug(f"Detected {len(import_suggestions)} importable TV shows.") + return import_suggestions + + def import_all_torrents(self) -> None: + log.info("Importing all torrents") + torrents = self.torrent_service.get_all_torrents() + log.info("Found %d torrents to import", len(torrents)) + for t in torrents: + show = None + try: + if not t.imported and t.status == TorrentStatus.finished: + show = self.torrent_service.get_show_of_torrent(torrent=t) + if show is None: + log.warning( + f"torrent {t.title} is not a tv torrent, skipping import." + ) + continue + self.import_episode_files_from_torrent(torrent=t, show=show) + except RuntimeError as e: + show_name = show.name if show is not None else "" + log.error( + f"Error importing torrent {t.title} for show {show_name}: {e}", + exc_info=True, + ) + log.info("Finished importing all torrents") + + def update_all_non_ended_shows_metadata(self) -> None: + """Updates the metadata of all non-ended shows.""" + log.info("Updating metadata for all non-ended shows") + shows = [show for show in self.tv_repository.get_shows() if not show.ended] + log.info(f"Found {len(shows)} non-ended shows to update") + for show in shows: + try: + if show.metadata_provider == "tmdb": + metadata_provider = TmdbMetadataProvider() + elif show.metadata_provider == "tvdb": + metadata_provider = TvdbMetadataProvider() + else: + log.error( + f"Unsupported metadata provider {show.metadata_provider} for show {show.name}, skipping update." + ) + continue + except InvalidConfigError: + log.exception( + f"Error initializing metadata provider {show.metadata_provider} for show {show.name}" + ) + continue + updated_show = self.update_show_metadata( + db_show=show, metadata_provider=metadata_provider + ) + if updated_show: + log.debug("Updated show metadata", extra={"show": updated_show.name}) + else: + log.warning(f"Failed to update metadata for show: {show.name}") diff --git a/media_manager/utils/file_handler.py b/media_manager/utils/file_handler.py new file mode 100644 index 00000000..cf38fec2 --- /dev/null +++ b/media_manager/utils/file_handler.py @@ -0,0 +1,207 @@ +import logging +import mimetypes +import patoolib +from pathlib import Path, UnsupportedOperation +import re +import shutil + +from media_manager.config import MediaManagerConfig +from media_manager.torrent.utils import log +from media_manager.torrent.schemas import Torrent + +log = logging.getLogger(__name__) + + +def extract_external_id_from_string(input_string: str) -> tuple[str | None, int | None]: + """ + Extracts an external ID (tmdb/tvdb ID) from the given string. + + :param input_string: The string to extract the ID from. + :return: The extracted Metadata Provider and ID or None if not found. + """ + match = re.search( + r"\b(tmdb|tvdb)(?:id)?[-_]?([0-9]+)\b", input_string, re.IGNORECASE + ) + if match: + return match.group(1).lower(), int(match.group(2)) + + return None, None + +def list_files_recursively(path: Path = Path()) -> list[Path]: + files = list(path.glob("**/*")) + log.debug(f"Found {len(files)} entries via glob") + valid_files = [] + for x in files: + if x.is_dir(): + log.debug(f"'{x}' is a directory") + elif x.is_symlink(): + log.debug(f"'{x}' is a symlink") + else: + valid_files.append(x) + log.debug(f"Returning {len(valid_files)} files after filtering") + return valid_files + + +def extract_archives(files: list) -> None: + archive_types = { + "application/zip", + "application/x-zip-compressedapplication/x-compressed", + "application/vnd.rar", + "application/x-7z-compressed", + "application/x-freearc", + "application/x-bzip", + "application/x-bzip2", + "application/gzip", + "application/x-gzip", + "application/x-tar", + } + for file in files: + file_type = mimetypes.guess_type(file) + log.debug(f"File: {file}, Size: {file.stat().st_size} bytes, Type: {file_type}") + + if file_type[0] in archive_types: + log.info( + f"File {file} is a compressed file, extracting it into directory {file.parent}" + ) + try: + patoolib.extract_archive(str(file), outdir=str(file.parent)) + except patoolib.util.PatoolError: + log.exception(f"Failed to extract archive {file}") + + +def get_importable_media_directories(path: Path) -> list[Path]: + libraries = [ + *MediaManagerConfig().misc.movie_libraries, + *MediaManagerConfig().misc.tv_libraries, + ] + + library_paths = {Path(library.path).absolute() for library in libraries} + + unfiltered_dirs = [d for d in path.glob("*") if d.is_dir()] + + return [ + media_dir + for media_dir in unfiltered_dirs + if media_dir.absolute() not in library_paths + and not media_dir.name.startswith(".") + ] + + +def get_torrent_filepath(torrent: Torrent) -> Path: + return MediaManagerConfig().misc.torrent_directory / torrent.title + + +def get_files_for_import( + torrent: Torrent | None = None, directory: Path | None = None +) -> tuple[list[Path], list[Path], list[Path]]: + """ + Extracts all files from the torrent download directory, including extracting archives. + Returns a tuple containing: seperated video files, subtitle files, and all files found in the torrent directory. + """ + if torrent: + log.info(f"Importing torrent {torrent}") + search_directory = get_torrent_filepath(torrent=torrent) + elif directory: + log.info(f"Importing files from directory {directory}") + search_directory = directory + else: + msg = "Either torrent or directory must be provided." + raise ValueError(msg) + + all_files: list[Path] = list_files_recursively(path=search_directory) + log.debug(f"Found {len(all_files)} files downloaded by the torrent") + extract_archives(all_files) + all_files = list_files_recursively(path=search_directory) + + video_files: list[Path] = [] + subtitle_files: list[Path] = [] + for file in all_files: + file_type, _ = mimetypes.guess_type(str(file)) + if file_type is not None: + if file_type.startswith("video"): + video_files.append(file) + log.debug(f"File is a video, it will be imported: {file}") + elif file_type.startswith("text") and Path(file).suffix == ".srt": + subtitle_files.append(file) + log.debug(f"File is a subtitle, it will be imported: {file}") + else: + log.debug( + f"File is neither a video nor a subtitle, will not be imported: {file}" + ) + else: + log.debug( + f"Unknown file type, will not be imported: {file}" + ) + + log.info( + f"Found {len(all_files)} files ({len(video_files)} video files, {len(subtitle_files)} subtitle files) for further processing." + ) + return video_files, subtitle_files, all_files + + +def import_subtitle(subtitle_file: Path, target_file: str) -> None: + subtitle_pattern = r"[. ]?([a-z]{2,3})(?:\.(\d+))?\.srt$" + regex_result = re.search(subtitle_pattern, subtitle_file.name, re.IGNORECASE) + if regex_result: + language_code = regex_result.group(1) + if regex_result.group(2): + language_code += f".{regex_result.group(2)}" + target_subtitle_file = target_file.with_suffix(f".{language_code}.srt") + import_file(target_file=target_subtitle_file, source_file=subtitle_file) + else: + log.debug( + f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}" + ) + + +def import_file(target_file: Path, source_file: Path) -> None: + if target_file.exists(): + target_file.unlink() + + try: + target_file.hardlink_to(source_file) + except FileExistsError: + log.exception(f"File already exists at {target_file}.") + except (OSError, UnsupportedOperation, NotImplementedError): + log.exception( + f"Failed to create hardlink from {source_file} to {target_file}. Falling back to copying the file." + ) + shutil.copy(src=source_file, dst=target_file) + + +def remove_special_characters(filename: str) -> str: + """ + Removes special characters from the filename to ensure it works with Jellyfin. + + :param filename: The original filename. + :return: A sanitized version of the filename. + """ + # Remove invalid characters + sanitized = re.sub(r"([<>:\"/\\|?*])", "", filename) + + # Remove leading and trailing dots or spaces + return sanitized.strip(" .") + + +def remove_special_chars_and_parentheses(title: str) -> str: + """ + Removes special characters and bracketed information from the title. + + :param title: The original title. + :return: A sanitized version of the title. + """ + + # Remove content within brackets + sanitized = re.sub(r"\[.*?\]", "", title) + + # Remove content within curly brackets + sanitized = re.sub(r"\{.*?\}", "", sanitized) + + # Remove year within parentheses + sanitized = re.sub(r"\(\d{4}\)", "", sanitized) + + # Remove special characters + sanitized = remove_special_characters(sanitized) + + # Collapse multiple whitespace characters and trim the result + return re.sub(r"\s+", " ", sanitized).strip() diff --git a/media_manager/utils/files.py b/media_manager/utils/files.py deleted file mode 100644 index 204c023c..00000000 --- a/media_manager/utils/files.py +++ /dev/null @@ -1,106 +0,0 @@ -from pathlib import Path, UnsupportedOperation -import re - -from media_manager.config import MediaManagerConfig -from media_manager.torrent.utils import log -import shutil - - -def import_subtitle(subtitle_file: Path, target_file: str) -> None: - subtitle_pattern = r"[. ]?([a-z]{2,3})(?:\.(\d+))?\.srt$" - regex_result = re.search(subtitle_pattern, subtitle_file.name, re.IGNORECASE) - if regex_result: - language_code = regex_result.group(1) - target_subtitle_file = target_file.with_suffix(f".{language_code}.srt") - import_file(target_file=target_subtitle_file, source_file=subtitle_file) - else: - log.debug( - f"Didn't find any pattern {subtitle_pattern} in subtitle file: {subtitle_file.name}" - ) - - -def import_file(target_file: Path, source_file: Path) -> None: - if target_file.exists(): - target_file.unlink() - - try: - target_file.hardlink_to(source_file) - except FileExistsError: - log.exception(f"File already exists at {target_file}.") - except (OSError, UnsupportedOperation, NotImplementedError): - log.exception( - f"Failed to create hardlink from {source_file} to {target_file}. Falling back to copying the file." - ) - shutil.copy(src=source_file, dst=target_file) - - -def remove_special_characters(filename: str) -> str: - """ - Removes special characters from the filename to ensure it works with Jellyfin. - - :param filename: The original filename. - :return: A sanitized version of the filename. - """ - # Remove invalid characters - sanitized = re.sub(r"([<>:\"/\\|?*])", "", filename) - - # Remove leading and trailing dots or spaces - return sanitized.strip(" .") - - -def remove_special_chars_and_parentheses(title: str) -> str: - """ - Removes special characters and bracketed information from the title. - - :param title: The original title. - :return: A sanitized version of the title. - """ - - # Remove content within brackets - sanitized = re.sub(r"\[.*?\]", "", title) - - # Remove content within curly brackets - sanitized = re.sub(r"\{.*?\}", "", sanitized) - - # Remove year within parentheses - sanitized = re.sub(r"\(\d{4}\)", "", sanitized) - - # Remove special characters - sanitized = remove_special_characters(sanitized) - - # Collapse multiple whitespace characters and trim the result - return re.sub(r"\s+", " ", sanitized).strip() - - -def get_importable_media_directories(path: Path) -> list[Path]: - libraries = [ - *MediaManagerConfig().misc.movie_libraries, - *MediaManagerConfig().misc.tv_libraries, - ] - - library_paths = {Path(library.path).absolute() for library in libraries} - - unfiltered_dirs = [d for d in path.glob("*") if d.is_dir()] - - return [ - media_dir - for media_dir in unfiltered_dirs - if media_dir.absolute() not in library_paths - and not media_dir.name.startswith(".") - ] - - -def extract_external_id_from_string(input_string: str) -> tuple[str | None, int | None]: - """ - Extracts an external ID (tmdb/tvdb ID) from the given string. - - :param input_string: The string to extract the ID from. - :return: The extracted Metadata Provider and ID or None if not found. - """ - match = re.search( - r"\b(tmdb|tvdb)(?:id)?[-_]?([0-9]+)\b", input_string, re.IGNORECASE - ) - if match: - return match.group(1).lower(), int(match.group(2)) - - return None, None From 1e3562d616ee8f1ccfc5d3e78ce70a619e23ad3a Mon Sep 17 00:00:00 2001 From: Juan David Bermudez Celedon Date: Fri, 6 Mar 2026 13:50:13 -0500 Subject: [PATCH 4/8] Fix Ruff checks Fix line endings --- media_manager/indexer/service.py | 2 +- media_manager/movies/service.py | 1392 ++++++++--------- media_manager/tv/router.py | 2 +- media_manager/tv/service.py | 2179 ++++++++++++++------------- media_manager/utils/__init__.py | 3 + media_manager/utils/file_handler.py | 15 +- 6 files changed, 1797 insertions(+), 1796 deletions(-) diff --git a/media_manager/indexer/service.py b/media_manager/indexer/service.py index 54b8e020..c0678820 100644 --- a/media_manager/indexer/service.py +++ b/media_manager/indexer/service.py @@ -7,8 +7,8 @@ from media_manager.indexer.repository import IndexerRepository from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId from media_manager.movies.schemas import Movie -from media_manager.utils.file_handler import remove_special_chars_and_parentheses from media_manager.tv.schemas import Show +from media_manager.utils.file_handler import remove_special_chars_and_parentheses log = logging.getLogger(__name__) diff --git a/media_manager/movies/service.py b/media_manager/movies/service.py index a1f52312..f3ef706f 100644 --- a/media_manager/movies/service.py +++ b/media_manager/movies/service.py @@ -1,696 +1,696 @@ -import re -import shutil -from pathlib import Path -from typing import overload - -from media_manager.utils.file_handler import ( - extract_external_id_from_string, - get_importable_media_directories, - get_files_for_import, - import_file, import_subtitle, - remove_special_characters, - remove_special_chars_and_parentheses -) -from sqlalchemy.exc import IntegrityError - -from media_manager.config import MediaManagerConfig -from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError -from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId -from media_manager.indexer.service import IndexerService -from media_manager.indexer.utils import evaluate_indexer_query_results -from media_manager.metadataProvider.abstract_metadata_provider import ( - AbstractMetadataProvider, -) -from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult -from media_manager.metadataProvider.tmdb import TmdbMetadataProvider -from media_manager.metadataProvider.tvdb import TvdbMetadataProvider -from media_manager.movies import log -from media_manager.movies.repository import MovieRepository -from media_manager.movies.schemas import ( - Movie, - MovieFile, - MovieId, - PublicMovie, - PublicMovieFile, - RichMovieTorrent, -) -from media_manager.notification.service import NotificationService -from media_manager.schemas import MediaImportSuggestion -from media_manager.torrent.schemas import ( - Quality, - Torrent, - TorrentStatus, -) -from media_manager.torrent.service import TorrentService - - -class MovieService: - def __init__( - self, - movie_repository: MovieRepository, - torrent_service: TorrentService, - indexer_service: IndexerService, - notification_service: NotificationService, - ) -> None: - self.movie_repository = movie_repository - self.torrent_service = torrent_service - self.indexer_service = indexer_service - self.notification_service = notification_service - - def add_movie( - self, - external_id: int, - metadata_provider: AbstractMetadataProvider, - language: str | None = None, - ) -> Movie: - """ - Add a new movie to the database. - - :param external_id: The ID of the movie in the metadata provider's system. - :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( - movie_id=external_id, language=language - ) - if not movie_with_metadata: - raise NotFoundError - - saved_movie = self.movie_repository.save_movie(movie=movie_with_metadata) - metadata_provider.download_movie_poster_image(movie=saved_movie) - return saved_movie - - def delete_movie( - self, - movie: Movie, - delete_files_on_disk: bool = False, - delete_torrents: bool = False, - ) -> None: - """ - Delete a movie from the database, optionally deleting files and torrents. - - :param movie: The movie to delete. - :param delete_files_on_disk: Whether to delete the movie's files from disk. - :param delete_torrents: Whether to delete associated torrents from the torrent client. - """ - if delete_files_on_disk or delete_torrents: - if delete_files_on_disk: - # Get the movie's directory path - movie_dir = self.get_movie_root_path(movie=movie) - - if movie_dir.exists() and movie_dir.is_dir(): - try: - shutil.rmtree(movie_dir) - log.info(f"Deleted movie directory: {movie_dir}") - except OSError: - log.exception(f"Deleting movie directory: {movie_dir}") - - if delete_torrents: - # Get all torrents associated with this movie - movie_torrents = self.movie_repository.get_torrents_by_movie_id( - movie_id=movie.id - ) - - for movie_torrent in movie_torrents: - torrent = self.torrent_service.get_torrent_by_id( - torrent_id=movie_torrent.torrent_id - ) - try: - self.torrent_service.cancel_download( - torrent=torrent, delete_files=True - ) - log.info(f"Deleted torrent: {torrent.torrent_title}") - except Exception: - log.warning( - f"Failed to delete torrent {torrent.hash}", exc_info=True - ) - - # Delete from database - self.movie_repository.delete_movie(movie_id=movie.id) - - def get_public_movie_files(self, movie: Movie) -> list[PublicMovieFile]: - """ - Get all public movie files for a given movie. - - :param movie: The movie object. - :return: A list of public movie files. - """ - movie_files = self.movie_repository.get_movie_files_by_movie_id( - movie_id=movie.id - ) - public_movie_files = [PublicMovieFile.model_validate(x) for x in movie_files] - result = [] - for movie_file in public_movie_files: - movie_file.imported = self.movie_file_exists_on_file(movie_file=movie_file) - result.append(movie_file) - return result - - @overload - def check_if_movie_exists( - self, *, external_id: int, metadata_provider: str - ) -> bool: - """ - Check if a movie exists in the database. - - :param external_id: The external ID of the movie. - :param metadata_provider: The metadata provider. - :return: True if the movie exists, False otherwise. - """ - - @overload - def check_if_movie_exists(self, *, movie_id: MovieId) -> bool: - """ - Check if a movie exists in the database. - - :param movie_id: The ID of the movie. - :return: True if the movie exists, False otherwise. - """ - - def check_if_movie_exists( - self, - *, - external_id=None, - metadata_provider=None, - movie_id=None, - ) -> bool: - """ - Check if a movie exists in the database. - - :param external_id: The external ID of the movie. - :param metadata_provider: The metadata provider. - :param movie_id: The ID of the movie. - :return: True if the movie exists, False otherwise. - :raises ValueError: If neither external ID and metadata provider nor movie ID are provided. - """ - - if not (external_id is None or metadata_provider is None): - try: - self.movie_repository.get_movie_by_external_id( - external_id=external_id, metadata_provider=metadata_provider - ) - except NotFoundError: - return False - elif movie_id is not None: - try: - self.movie_repository.get_movie_by_id(movie_id=movie_id) - except NotFoundError: - return False - else: - msg = "Use one of the provided overloads for this function!" - raise ValueError(msg) - - return True - - def get_all_available_torrents_for_movie( - self, movie: Movie, search_query_override: str | None = None - ) -> list[IndexerQueryResult]: - """ - Get all available torrents for a given movie. - - :param movie: The movie object. - :param search_query_override: Optional override for the search query. - :return: A list of indexer query results. - """ - if search_query_override: - return self.indexer_service.search(query=search_query_override, is_tv=False) - - torrents = self.indexer_service.search_movie(movie=movie) - - return evaluate_indexer_query_results( - is_tv=False, query_results=torrents, media=movie - ) - - def get_all_movies(self) -> list[Movie]: - """ - Get all movies. - - :return: A list of all movies. - """ - return self.movie_repository.get_movies() - - def search_for_movie( - self, query: str, metadata_provider: AbstractMetadataProvider - ) -> list[MetaDataProviderSearchResult]: - """ - Search for movies using a given query. - - :param query: The search query. - :param metadata_provider: The metadata provider to search. - :return: A list of metadata provider movie search results. - """ - results = metadata_provider.search_movie(query) - for result in results: - if self.check_if_movie_exists( - external_id=result.external_id, metadata_provider=metadata_provider.name - ): - result.added = True - - # Fetch the internal movie ID. - try: - movie = self.movie_repository.get_movie_by_external_id( - external_id=result.external_id, - metadata_provider=metadata_provider.name, - ) - result.id = movie.id - except Exception: - log.error( - f"Unable to find internal movie ID for {result.external_id} on {metadata_provider.name}" - ) - return results - - def get_popular_movies( - self, metadata_provider: AbstractMetadataProvider - ) -> list[MetaDataProviderSearchResult]: - """ - Get popular movies from a given metadata provider. - - :param metadata_provider: The metadata provider to use. - :return: A list of metadata provider movie search results. - """ - results = metadata_provider.search_movie() - - return [ - result - for result in results - if not self.check_if_movie_exists( - external_id=result.external_id, metadata_provider=metadata_provider.name - ) - ] - - def get_public_movie_by_id(self, movie: Movie) -> PublicMovie: - """ - Get a public movie from a Movie object. - - :param movie: The movie object. - :return: A public movie. - """ - torrents = self.get_torrents_for_movie(movie=movie).torrents - public_movie = PublicMovie.model_validate(movie) - public_movie.downloaded = self.is_movie_downloaded(movie=movie) - public_movie.torrents = torrents - return public_movie - - def get_movie_by_id(self, movie_id: MovieId) -> Movie: - """ - Get a movie by its ID. - - :param movie_id: The ID of the movie. - :return: The movie. - """ - return self.movie_repository.get_movie_by_id(movie_id=movie_id) - - def is_movie_downloaded(self, movie: Movie) -> bool: - """ - Check if a movie is downloaded. - - :param movie: The movie object. - :return: True if the movie is downloaded, False otherwise. - """ - movie_files = self.movie_repository.get_movie_files_by_movie_id( - movie_id=movie.id - ) - for movie_file in movie_files: - if self.movie_file_exists_on_file(movie_file=movie_file): - return True - return False - - def movie_file_exists_on_file(self, movie_file: MovieFile) -> bool: - """ - Check if a movie file exists on the filesystem. - - :param movie_file: The movie file to check. - :return: True if the file exists, False otherwise. - """ - if movie_file.torrent_id is None: - return True - torrent_file = self.torrent_service.get_torrent_by_id( - torrent_id=movie_file.torrent_id - ) - if torrent_file.imported: - return True - return False - - def get_movie_by_external_id( - self, external_id: int, metadata_provider: str - ) -> Movie | None: - """ - Get a movie by its external ID and metadata provider. - - :param external_id: The external ID of the movie. - :param metadata_provider: The metadata provider. - :return: The movie or None if not found. - """ - return self.movie_repository.get_movie_by_external_id( - external_id=external_id, metadata_provider=metadata_provider - ) - - def set_movie_library(self, movie: Movie, library: str) -> None: - self.movie_repository.set_movie_library(movie_id=movie.id, library=library) - - def get_torrents_for_movie(self, movie: Movie) -> RichMovieTorrent: - """ - Get torrents for a given movie. - - :param movie: The movie. - :return: A rich movie torrent. - """ - movie_torrents = self.movie_repository.get_torrents_by_movie_id( - movie_id=movie.id - ) - return RichMovieTorrent( - movie_id=movie.id, - name=movie.name, - year=movie.year, - metadata_provider=movie.metadata_provider, - torrents=movie_torrents, - ) - - def get_all_movies_with_torrents(self) -> list[RichMovieTorrent]: - """ - Get all movies with torrents. - - :return: A list of rich movie torrents. - """ - movies = self.movie_repository.get_all_movies_with_torrents() - return [self.get_torrents_for_movie(movie=movie) for movie in movies] - - def download_torrent( - self, - public_indexer_result_id: IndexerQueryResultId, - movie: Movie, - override_movie_file_path_suffix: str = "", - ) -> Torrent: - """ - Download a torrent for a given indexer result and movie. - - :param public_indexer_result_id: The ID of the indexer result. - :param movie: The movie object. - :param override_movie_file_path_suffix: Optional override for the file path suffix. - :return: The downloaded torrent. - """ - indexer_result = self.indexer_service.get_result( - result_id=public_indexer_result_id - ) - movie_torrent = self.torrent_service.download(indexer_result=indexer_result) - self.torrent_service.pause_download(torrent=movie_torrent) - movie_file = MovieFile( - movie_id=movie.id, - quality=indexer_result.quality, - torrent_id=movie_torrent.id, - file_path_suffix=override_movie_file_path_suffix, - ) - try: - self.movie_repository.add_movie_file(movie_file=movie_file) - except IntegrityError: - log.warning( - f"Movie file for movie {movie.name} and torrent {movie_torrent.title} already exists" - ) - self.torrent_service.cancel_download( - torrent=movie_torrent, delete_files=True - ) - raise - else: - log.info( - f"Added movie file for movie {movie.name} and torrent {movie_torrent.title}" - ) - self.torrent_service.resume_download(torrent=movie_torrent) - return movie_torrent - - def get_movie_root_path(self, movie: Movie) -> Path: - misc_config = MediaManagerConfig().misc - movie_file_path = ( - misc_config.movie_directory - / f"{remove_special_characters(movie.name)} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]" - ) - log.debug( - f"Movie {movie.name} without special characters: {remove_special_characters(movie.name)}" - ) - if movie.library != "Default": - for library in misc_config.movie_libraries: - if library.name == movie.library: - log.debug(f"Using library {library.name} for movie {movie.name}") - return ( - Path(library.path) - / f"{remove_special_characters(movie.name)} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]" - ) - else: - log.warning( - f"Library {movie.library} not found in config, using default library" - ) - return movie_file_path - - def import_movie( - self, - movie: Movie, - video_files: list[Path], - subtitle_files: list[Path], - file_path_suffix: str = "", - ) -> bool: - movie_file_name = f"{remove_special_characters(movie.name)} ({movie.year})" - movie_root_path = self.get_movie_root_path(movie=movie) - success: bool = False - if file_path_suffix != "": - movie_file_name += f" - {file_path_suffix}" - - try: - movie_root_path.mkdir(parents=True, exist_ok=True) - except Exception: - log.exception("Failed to create directory {movie_root_path}") - return False - - # import movie video - if video_files: - target_video_file = ( - movie_root_path / f"{movie_file_name}{video_files[0].suffix}" - ) - import_file(target_file=target_video_file, source_file=video_files[0]) - success = True - - target_subtitle_file = ( - movie_root_path / f"{movie_file_name}" - ) - # import subtitles - for subtitle_file in subtitle_files: - import_subtitle(subtitle_file=subtitle_file, target_file=target_subtitle_file) - - return success - - def import_torrent_files(self, torrent: Torrent, movie: Movie) -> None: - """ - Organizes files from a torrent into the movie directory structure. - :param torrent: The Torrent object - :param movie: The Movie object - """ - - video_files, subtitle_files, _all_files = get_files_for_import(torrent=torrent) - - if len(video_files) != 1: - # Send notification about multiple video files found - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Manual Import Required", - message=f"Multiple video files found for movie {movie.name}. Please import manually.", - ) - log.error( - f"Found {len(video_files)} video files for movie {movie.name}, expected 1. Skipping auto import." - ) - return - - log.debug( - f"Importing these {len(video_files)} video files and {len(subtitle_files)} subtitle files" - ) - - movie_files: list[MovieFile] = self.torrent_service.get_movie_files_of_torrent( - torrent=torrent - ) - log.info( - f"Found {len(movie_files)} movie files associated with torrent {torrent.title}" - ) - - success = [ - self.import_movie( - movie, video_files, subtitle_files, movie_file.file_path_suffix - ) - for movie_file in movie_files - ] - - if all(success): - torrent.imported = True - self.torrent_service.torrent_repository.save_torrent(torrent=torrent) - - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Movie Downloaded", - message=f"Movie {movie.name} has been successfully downloaded and imported.", - ) - else: - log.error( - f"Failed to import files for torrent {torrent.title}. Check logs for details." - ) - - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Import Failed", - message=f"Failed to import files for movie {movie.name}. Please check logs.", - ) - - log.info(f"Finished importing files for torrent {torrent.title}") - - def get_import_candidates( - self, movie: Path, metadata_provider: AbstractMetadataProvider - ) -> MediaImportSuggestion: - search_result = self.search_for_movie( - query=remove_special_chars_and_parentheses(movie.name), - metadata_provider=metadata_provider, - ) - import_candidates = MediaImportSuggestion( - directory=movie, - candidates=search_result, - ) - log.debug( - f"Found {len(search_result)} candidates for {movie.name} in {movie.parent}" - ) - return import_candidates - - def import_existing_movie(self, movie: Movie, source_directory: Path) -> bool: - new_source_path = source_directory.parent / ("." + source_directory.name) - try: - source_directory.rename(new_source_path) - except Exception as e: - log.exception(f"Failed to rename {source_directory} to {new_source_path}") - raise RenameError from e - - video_files, subtitle_files, _all_files = get_files_for_import( - directory=new_source_path - ) - - success = self.import_movie( - movie=movie, - video_files=video_files, - subtitle_files=subtitle_files, - file_path_suffix="IMPORTED", - ) - if success: - self.movie_repository.add_movie_file( - MovieFile( - movie_id=movie.id, - file_path_suffix="IMPORTED", - torrent_id=None, - quality=Quality.unknown, - ) - ) - - return success - - def update_movie_metadata( - self, db_movie: Movie, metadata_provider: AbstractMetadataProvider - ) -> Movie | None: - """ - Updates the metadata of a movie. - - :param metadata_provider: The metadata provider object to fetch fresh data from. - :param db_movie: The Movie to update - :return: The updated Movie object, or None if the movie is not found or an error occurs. - """ - 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( - movie_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} ({db_movie.year})" - ) - return None - log.debug(f"Fetched fresh metadata for movie: {fresh_movie_data.name}") - - self.movie_repository.update_movie_attributes( - movie_id=db_movie.id, - 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) - - log.info( - f"Successfully updated metadata for movie: {db_movie.name} ({db_movie.year})" - ) - metadata_provider.download_movie_poster_image(movie=updated_movie) - return updated_movie - - def get_importable_movies( - self, metadata_provider: AbstractMetadataProvider - ) -> list[MediaImportSuggestion]: - movie_root_path = MediaManagerConfig().misc.movie_directory - importable_movies: list[MediaImportSuggestion] = [] - candidate_dirs = get_importable_media_directories(movie_root_path) - - for movie_dir in candidate_dirs: - metadata, external_id = extract_external_id_from_string(movie_dir.name) - if metadata is not None and external_id is not None: - try: - self.movie_repository.get_movie_by_external_id( - external_id=external_id, metadata_provider=metadata - ) - log.debug( - f"Movie {movie_dir.name} already exists in the database, skipping." - ) - continue - except NotFoundError: - log.debug( - f"Movie {movie_dir.name} not found in database, checking for import candidates." - ) - - import_candidates = self.get_import_candidates( - movie=movie_dir, metadata_provider=metadata_provider - ) - importable_movies.append(import_candidates) - - log.debug(f"Found {len(importable_movies)} importable movies.") - return importable_movies - - def import_all_torrents(self) -> None: - log.info("Importing all torrents") - torrents = self.torrent_service.get_all_torrents() - log.info("Found %d torrents to import", len(torrents)) - for t in torrents: - try: - if not t.imported and t.status == TorrentStatus.finished: - movie = self.torrent_service.get_movie_of_torrent(torrent=t) - if movie is None: - log.warning( - f"torrent {t.title} is not a movie torrent, skipping import." - ) - continue - self.import_torrent_files(torrent=t, movie=movie) - except RuntimeError: - log.exception(f"Failed to import torrent {t.title}") - log.info("Finished importing all torrents") - - def update_all_metadata(self) -> None: - """Updates the metadata of all movies.""" - log.info("Updating metadata for all movies") - movies = self.movie_repository.get_movies() - log.info(f"Found {len(movies)} movies to update") - for movie in movies: - try: - if movie.metadata_provider == "tmdb": - metadata_provider = TmdbMetadataProvider() - elif movie.metadata_provider == "tvdb": - metadata_provider = TvdbMetadataProvider() - else: - log.error( - f"Unsupported metadata provider {movie.metadata_provider} for movie {movie.name}, skipping update." - ) - continue - except InvalidConfigError: - log.exception( - f"Error initializing metadata provider {movie.metadata_provider} for movie {movie.name}", - ) - continue - self.update_movie_metadata( - db_movie=movie, metadata_provider=metadata_provider - ) +import shutil +from pathlib import Path +from typing import overload + +from sqlalchemy.exc import IntegrityError + +from media_manager.config import MediaManagerConfig +from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError +from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId +from media_manager.indexer.service import IndexerService +from media_manager.indexer.utils import evaluate_indexer_query_results +from media_manager.metadataProvider.abstract_metadata_provider import ( + AbstractMetadataProvider, +) +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.metadataProvider.tmdb import TmdbMetadataProvider +from media_manager.metadataProvider.tvdb import TvdbMetadataProvider +from media_manager.movies import log +from media_manager.movies.repository import MovieRepository +from media_manager.movies.schemas import ( + Movie, + MovieFile, + MovieId, + PublicMovie, + PublicMovieFile, + RichMovieTorrent, +) +from media_manager.notification.service import NotificationService +from media_manager.schemas import MediaImportSuggestion +from media_manager.torrent.schemas import ( + Quality, + Torrent, + TorrentStatus, +) +from media_manager.torrent.service import TorrentService +from media_manager.utils.file_handler import ( + extract_external_id_from_string, + get_files_for_import, + get_importable_media_directories, + import_file, + import_subtitle, + remove_special_characters, + remove_special_chars_and_parentheses, +) + + +class MovieService: + def __init__( + self, + movie_repository: MovieRepository, + torrent_service: TorrentService, + indexer_service: IndexerService, + notification_service: NotificationService, + ) -> None: + self.movie_repository = movie_repository + self.torrent_service = torrent_service + self.indexer_service = indexer_service + self.notification_service = notification_service + + def add_movie( + self, + external_id: int, + metadata_provider: AbstractMetadataProvider, + language: str | None = None, + ) -> Movie: + """ + Add a new movie to the database. + + :param external_id: The ID of the movie in the metadata provider's system. + :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( + movie_id=external_id, language=language + ) + if not movie_with_metadata: + raise NotFoundError + + saved_movie = self.movie_repository.save_movie(movie=movie_with_metadata) + metadata_provider.download_movie_poster_image(movie=saved_movie) + return saved_movie + + def delete_movie( + self, + movie: Movie, + delete_files_on_disk: bool = False, + delete_torrents: bool = False, + ) -> None: + """ + Delete a movie from the database, optionally deleting files and torrents. + + :param movie: The movie to delete. + :param delete_files_on_disk: Whether to delete the movie's files from disk. + :param delete_torrents: Whether to delete associated torrents from the torrent client. + """ + if delete_files_on_disk or delete_torrents: + if delete_files_on_disk: + # Get the movie's directory path + movie_dir = self.get_movie_root_path(movie=movie) + + if movie_dir.exists() and movie_dir.is_dir(): + try: + shutil.rmtree(movie_dir) + log.info(f"Deleted movie directory: {movie_dir}") + except OSError: + log.exception(f"Deleting movie directory: {movie_dir}") + + if delete_torrents: + # Get all torrents associated with this movie + movie_torrents = self.movie_repository.get_torrents_by_movie_id( + movie_id=movie.id + ) + + for movie_torrent in movie_torrents: + torrent = self.torrent_service.get_torrent_by_id( + torrent_id=movie_torrent.torrent_id + ) + try: + self.torrent_service.cancel_download( + torrent=torrent, delete_files=True + ) + log.info(f"Deleted torrent: {torrent.torrent_title}") + except Exception: + log.warning( + f"Failed to delete torrent {torrent.hash}", exc_info=True + ) + + # Delete from database + self.movie_repository.delete_movie(movie_id=movie.id) + + def get_public_movie_files(self, movie: Movie) -> list[PublicMovieFile]: + """ + Get all public movie files for a given movie. + + :param movie: The movie object. + :return: A list of public movie files. + """ + movie_files = self.movie_repository.get_movie_files_by_movie_id( + movie_id=movie.id + ) + public_movie_files = [PublicMovieFile.model_validate(x) for x in movie_files] + result = [] + for movie_file in public_movie_files: + movie_file.imported = self.movie_file_exists_on_file(movie_file=movie_file) + result.append(movie_file) + return result + + @overload + def check_if_movie_exists( + self, *, external_id: int, metadata_provider: str + ) -> bool: + """ + Check if a movie exists in the database. + + :param external_id: The external ID of the movie. + :param metadata_provider: The metadata provider. + :return: True if the movie exists, False otherwise. + """ + + @overload + def check_if_movie_exists(self, *, movie_id: MovieId) -> bool: + """ + Check if a movie exists in the database. + + :param movie_id: The ID of the movie. + :return: True if the movie exists, False otherwise. + """ + + def check_if_movie_exists( + self, + *, + external_id=None, + metadata_provider=None, + movie_id=None, + ) -> bool: + """ + Check if a movie exists in the database. + + :param external_id: The external ID of the movie. + :param metadata_provider: The metadata provider. + :param movie_id: The ID of the movie. + :return: True if the movie exists, False otherwise. + :raises ValueError: If neither external ID and metadata provider nor movie ID are provided. + """ + + if not (external_id is None or metadata_provider is None): + try: + self.movie_repository.get_movie_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + except NotFoundError: + return False + elif movie_id is not None: + try: + self.movie_repository.get_movie_by_id(movie_id=movie_id) + except NotFoundError: + return False + else: + msg = "Use one of the provided overloads for this function!" + raise ValueError(msg) + + return True + + def get_all_available_torrents_for_movie( + self, movie: Movie, search_query_override: str | None = None + ) -> list[IndexerQueryResult]: + """ + Get all available torrents for a given movie. + + :param movie: The movie object. + :param search_query_override: Optional override for the search query. + :return: A list of indexer query results. + """ + if search_query_override: + return self.indexer_service.search(query=search_query_override, is_tv=False) + + torrents = self.indexer_service.search_movie(movie=movie) + + return evaluate_indexer_query_results( + is_tv=False, query_results=torrents, media=movie + ) + + def get_all_movies(self) -> list[Movie]: + """ + Get all movies. + + :return: A list of all movies. + """ + return self.movie_repository.get_movies() + + def search_for_movie( + self, query: str, metadata_provider: AbstractMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + """ + Search for movies using a given query. + + :param query: The search query. + :param metadata_provider: The metadata provider to search. + :return: A list of metadata provider movie search results. + """ + results = metadata_provider.search_movie(query) + for result in results: + if self.check_if_movie_exists( + external_id=result.external_id, metadata_provider=metadata_provider.name + ): + result.added = True + + # Fetch the internal movie ID. + try: + movie = self.movie_repository.get_movie_by_external_id( + external_id=result.external_id, + metadata_provider=metadata_provider.name, + ) + result.id = movie.id + except Exception: + log.error( + f"Unable to find internal movie ID for {result.external_id} on {metadata_provider.name}" + ) + return results + + def get_popular_movies( + self, metadata_provider: AbstractMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + """ + Get popular movies from a given metadata provider. + + :param metadata_provider: The metadata provider to use. + :return: A list of metadata provider movie search results. + """ + results = metadata_provider.search_movie() + + return [ + result + for result in results + if not self.check_if_movie_exists( + external_id=result.external_id, metadata_provider=metadata_provider.name + ) + ] + + def get_public_movie_by_id(self, movie: Movie) -> PublicMovie: + """ + Get a public movie from a Movie object. + + :param movie: The movie object. + :return: A public movie. + """ + torrents = self.get_torrents_for_movie(movie=movie).torrents + public_movie = PublicMovie.model_validate(movie) + public_movie.downloaded = self.is_movie_downloaded(movie=movie) + public_movie.torrents = torrents + return public_movie + + def get_movie_by_id(self, movie_id: MovieId) -> Movie: + """ + Get a movie by its ID. + + :param movie_id: The ID of the movie. + :return: The movie. + """ + return self.movie_repository.get_movie_by_id(movie_id=movie_id) + + def is_movie_downloaded(self, movie: Movie) -> bool: + """ + Check if a movie is downloaded. + + :param movie: The movie object. + :return: True if the movie is downloaded, False otherwise. + """ + movie_files = self.movie_repository.get_movie_files_by_movie_id( + movie_id=movie.id + ) + for movie_file in movie_files: + if self.movie_file_exists_on_file(movie_file=movie_file): + return True + return False + + def movie_file_exists_on_file(self, movie_file: MovieFile) -> bool: + """ + Check if a movie file exists on the filesystem. + + :param movie_file: The movie file to check. + :return: True if the file exists, False otherwise. + """ + if movie_file.torrent_id is None: + return True + torrent_file = self.torrent_service.get_torrent_by_id( + torrent_id=movie_file.torrent_id + ) + if torrent_file.imported: + return True + return False + + def get_movie_by_external_id( + self, external_id: int, metadata_provider: str + ) -> Movie | None: + """ + Get a movie by its external ID and metadata provider. + + :param external_id: The external ID of the movie. + :param metadata_provider: The metadata provider. + :return: The movie or None if not found. + """ + return self.movie_repository.get_movie_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + + def set_movie_library(self, movie: Movie, library: str) -> None: + self.movie_repository.set_movie_library(movie_id=movie.id, library=library) + + def get_torrents_for_movie(self, movie: Movie) -> RichMovieTorrent: + """ + Get torrents for a given movie. + + :param movie: The movie. + :return: A rich movie torrent. + """ + movie_torrents = self.movie_repository.get_torrents_by_movie_id( + movie_id=movie.id + ) + return RichMovieTorrent( + movie_id=movie.id, + name=movie.name, + year=movie.year, + metadata_provider=movie.metadata_provider, + torrents=movie_torrents, + ) + + def get_all_movies_with_torrents(self) -> list[RichMovieTorrent]: + """ + Get all movies with torrents. + + :return: A list of rich movie torrents. + """ + movies = self.movie_repository.get_all_movies_with_torrents() + return [self.get_torrents_for_movie(movie=movie) for movie in movies] + + def download_torrent( + self, + public_indexer_result_id: IndexerQueryResultId, + movie: Movie, + override_movie_file_path_suffix: str = "", + ) -> Torrent: + """ + Download a torrent for a given indexer result and movie. + + :param public_indexer_result_id: The ID of the indexer result. + :param movie: The movie object. + :param override_movie_file_path_suffix: Optional override for the file path suffix. + :return: The downloaded torrent. + """ + indexer_result = self.indexer_service.get_result( + result_id=public_indexer_result_id + ) + movie_torrent = self.torrent_service.download(indexer_result=indexer_result) + self.torrent_service.pause_download(torrent=movie_torrent) + movie_file = MovieFile( + movie_id=movie.id, + quality=indexer_result.quality, + torrent_id=movie_torrent.id, + file_path_suffix=override_movie_file_path_suffix, + ) + try: + self.movie_repository.add_movie_file(movie_file=movie_file) + except IntegrityError: + log.warning( + f"Movie file for movie {movie.name} and torrent {movie_torrent.title} already exists" + ) + self.torrent_service.cancel_download( + torrent=movie_torrent, delete_files=True + ) + raise + else: + log.info( + f"Added movie file for movie {movie.name} and torrent {movie_torrent.title}" + ) + self.torrent_service.resume_download(torrent=movie_torrent) + return movie_torrent + + def get_movie_root_path(self, movie: Movie) -> Path: + misc_config = MediaManagerConfig().misc + movie_file_path = ( + misc_config.movie_directory + / f"{remove_special_characters(movie.name)} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]" + ) + log.debug( + f"Movie {movie.name} without special characters: {remove_special_characters(movie.name)}" + ) + if movie.library != "Default": + for library in misc_config.movie_libraries: + if library.name == movie.library: + log.debug(f"Using library {library.name} for movie {movie.name}") + return ( + Path(library.path) + / f"{remove_special_characters(movie.name)} ({movie.year}) [{movie.metadata_provider}id-{movie.external_id}]" + ) + else: + log.warning( + f"Library {movie.library} not found in config, using default library" + ) + return movie_file_path + + def import_movie( + self, + movie: Movie, + video_files: list[Path], + subtitle_files: list[Path], + file_path_suffix: str = "", + ) -> bool: + movie_file_name = f"{remove_special_characters(movie.name)} ({movie.year})" + movie_root_path = self.get_movie_root_path(movie=movie) + success: bool = False + if file_path_suffix != "": + movie_file_name += f" - {file_path_suffix}" + + try: + movie_root_path.mkdir(parents=True, exist_ok=True) + except Exception: + log.exception("Failed to create directory {movie_root_path}") + return False + + # import movie video + if video_files: + target_video_file = ( + movie_root_path / f"{movie_file_name}{video_files[0].suffix}" + ) + import_file(target_file=target_video_file, source_file=video_files[0]) + success = True + + target_subtitle_file = ( + movie_root_path / f"{movie_file_name}" + ) + # import subtitles + for subtitle_file in subtitle_files: + import_subtitle(subtitle_file=subtitle_file, target_file=target_subtitle_file) + + return success + + def import_torrent_files(self, torrent: Torrent, movie: Movie) -> None: + """ + Organizes files from a torrent into the movie directory structure. + :param torrent: The Torrent object + :param movie: The Movie object + """ + + video_files, subtitle_files, _all_files = get_files_for_import(torrent=torrent) + + if len(video_files) != 1: + # Send notification about multiple video files found + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Manual Import Required", + message=f"Multiple video files found for movie {movie.name}. Please import manually.", + ) + log.error( + f"Found {len(video_files)} video files for movie {movie.name}, expected 1. Skipping auto import." + ) + return + + log.debug( + f"Importing these {len(video_files)} video files and {len(subtitle_files)} subtitle files" + ) + + movie_files: list[MovieFile] = self.torrent_service.get_movie_files_of_torrent( + torrent=torrent + ) + log.info( + f"Found {len(movie_files)} movie files associated with torrent {torrent.title}" + ) + + success = [ + self.import_movie( + movie, video_files, subtitle_files, movie_file.file_path_suffix + ) + for movie_file in movie_files + ] + + if all(success): + torrent.imported = True + self.torrent_service.torrent_repository.save_torrent(torrent=torrent) + + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Movie Downloaded", + message=f"Movie {movie.name} has been successfully downloaded and imported.", + ) + else: + log.error( + f"Failed to import files for torrent {torrent.title}. Check logs for details." + ) + + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Import Failed", + message=f"Failed to import files for movie {movie.name}. Please check logs.", + ) + + log.info(f"Finished importing files for torrent {torrent.title}") + + def get_import_candidates( + self, movie: Path, metadata_provider: AbstractMetadataProvider + ) -> MediaImportSuggestion: + search_result = self.search_for_movie( + query=remove_special_chars_and_parentheses(movie.name), + metadata_provider=metadata_provider, + ) + import_candidates = MediaImportSuggestion( + directory=movie, + candidates=search_result, + ) + log.debug( + f"Found {len(search_result)} candidates for {movie.name} in {movie.parent}" + ) + return import_candidates + + def import_existing_movie(self, movie: Movie, source_directory: Path) -> bool: + new_source_path = source_directory.parent / ("." + source_directory.name) + try: + source_directory.rename(new_source_path) + except Exception as e: + log.exception(f"Failed to rename {source_directory} to {new_source_path}") + raise RenameError from e + + video_files, subtitle_files, _all_files = get_files_for_import( + directory=new_source_path + ) + + success = self.import_movie( + movie=movie, + video_files=video_files, + subtitle_files=subtitle_files, + file_path_suffix="IMPORTED", + ) + if success: + self.movie_repository.add_movie_file( + MovieFile( + movie_id=movie.id, + file_path_suffix="IMPORTED", + torrent_id=None, + quality=Quality.unknown, + ) + ) + + return success + + def update_movie_metadata( + self, db_movie: Movie, metadata_provider: AbstractMetadataProvider + ) -> Movie | None: + """ + Updates the metadata of a movie. + + :param metadata_provider: The metadata provider object to fetch fresh data from. + :param db_movie: The Movie to update + :return: The updated Movie object, or None if the movie is not found or an error occurs. + """ + 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( + movie_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} ({db_movie.year})" + ) + return None + log.debug(f"Fetched fresh metadata for movie: {fresh_movie_data.name}") + + self.movie_repository.update_movie_attributes( + movie_id=db_movie.id, + 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) + + log.info( + f"Successfully updated metadata for movie: {db_movie.name} ({db_movie.year})" + ) + metadata_provider.download_movie_poster_image(movie=updated_movie) + return updated_movie + + def get_importable_movies( + self, metadata_provider: AbstractMetadataProvider + ) -> list[MediaImportSuggestion]: + movie_root_path = MediaManagerConfig().misc.movie_directory + importable_movies: list[MediaImportSuggestion] = [] + candidate_dirs = get_importable_media_directories(movie_root_path) + + for movie_dir in candidate_dirs: + metadata, external_id = extract_external_id_from_string(movie_dir.name) + if metadata is not None and external_id is not None: + try: + self.movie_repository.get_movie_by_external_id( + external_id=external_id, metadata_provider=metadata + ) + log.debug( + f"Movie {movie_dir.name} already exists in the database, skipping." + ) + continue + except NotFoundError: + log.debug( + f"Movie {movie_dir.name} not found in database, checking for import candidates." + ) + + import_candidates = self.get_import_candidates( + movie=movie_dir, metadata_provider=metadata_provider + ) + importable_movies.append(import_candidates) + + log.debug(f"Found {len(importable_movies)} importable movies.") + return importable_movies + + def import_all_torrents(self) -> None: + log.info("Importing all torrents") + torrents = self.torrent_service.get_all_torrents() + log.info("Found %d torrents to import", len(torrents)) + for t in torrents: + try: + if not t.imported and t.status == TorrentStatus.finished: + movie = self.torrent_service.get_movie_of_torrent(torrent=t) + if movie is None: + log.warning( + f"torrent {t.title} is not a movie torrent, skipping import." + ) + continue + self.import_torrent_files(torrent=t, movie=movie) + except RuntimeError: + log.exception(f"Failed to import torrent {t.title}") + log.info("Finished importing all torrents") + + def update_all_metadata(self) -> None: + """Updates the metadata of all movies.""" + log.info("Updating metadata for all movies") + movies = self.movie_repository.get_movies() + log.info(f"Found {len(movies)} movies to update") + for movie in movies: + try: + if movie.metadata_provider == "tmdb": + metadata_provider = TmdbMetadataProvider() + elif movie.metadata_provider == "tvdb": + metadata_provider = TvdbMetadataProvider() + else: + log.error( + f"Unsupported metadata provider {movie.metadata_provider} for movie {movie.name}, skipping update." + ) + continue + except InvalidConfigError: + log.exception( + f"Error initializing metadata provider {movie.metadata_provider} for movie {movie.name}", + ) + continue + self.update_movie_metadata( + db_movie=movie, metadata_provider=metadata_provider + ) diff --git a/media_manager/tv/router.py b/media_manager/tv/router.py index c67e9523..0e3ebbbf 100644 --- a/media_manager/tv/router.py +++ b/media_manager/tv/router.py @@ -13,7 +13,6 @@ from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult from media_manager.schemas import MediaImportSuggestion from media_manager.torrent.schemas import Torrent -from media_manager.utils.file_handler import get_importable_media_directories from media_manager.tv.dependencies import ( season_dep, show_dep, @@ -27,6 +26,7 @@ Show, ShowId, ) +from media_manager.utils.file_handler import get_importable_media_directories router = APIRouter() diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index d356b6e5..b622bc48 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -1,1089 +1,1090 @@ -import pprint -import re -import shutil -from pathlib import Path -from typing import overload - -from media_manager.utils.file_handler import ( - extract_external_id_from_string, - get_importable_media_directories, - get_files_for_import, - import_file, import_subtitle, - remove_special_characters, - remove_special_chars_and_parentheses -) -from sqlalchemy.exc import IntegrityError - -from media_manager.config import MediaManagerConfig -from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError -from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId -from media_manager.indexer.service import IndexerService -from media_manager.indexer.utils import evaluate_indexer_query_results -from media_manager.metadataProvider.abstract_metadata_provider import ( - AbstractMetadataProvider, -) -from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult -from media_manager.metadataProvider.tmdb import TmdbMetadataProvider -from media_manager.metadataProvider.tvdb import TvdbMetadataProvider -from media_manager.notification.service import NotificationService -from media_manager.schemas import MediaImportSuggestion -from media_manager.torrent.schemas import ( - Quality, - Torrent, - TorrentStatus, -) -from media_manager.torrent.service import TorrentService -from media_manager.tv import log -from media_manager.tv.repository import TvRepository -from media_manager.tv.schemas import ( - Episode, - EpisodeFile, - EpisodeId, - EpisodeNumber, - PublicEpisodeFile, - PublicSeason, - PublicShow, - RichSeasonTorrent, - RichShowTorrent, - Season, - SeasonId, - Show, - ShowId, -) - - -class TvService: - def __init__( - self, - tv_repository: TvRepository, - torrent_service: TorrentService, - indexer_service: IndexerService, - notification_service: NotificationService, - ) -> None: - self.tv_repository = tv_repository - self.torrent_service = torrent_service - self.indexer_service = indexer_service - self.notification_service = notification_service - - def add_show( - self, - external_id: int, - metadata_provider: AbstractMetadataProvider, - language: str | None = None, - ) -> Show: - """ - Add a new show to the database. - - :param external_id: The ID of the show in the metadata provider\'s system. - :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( - show_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 - - def get_total_downloaded_episoded_count(self) -> int: - """ - Get total number of downloaded episodes. - """ - - return self.tv_repository.get_total_downloaded_episodes_count() - - def set_show_library(self, show: Show, library: str) -> None: - self.tv_repository.set_show_library(show_id=show.id, library=library) - - def delete_show( - self, - show: Show, - delete_files_on_disk: bool = False, - delete_torrents: bool = False, - ) -> None: - """ - Delete a show from the database, optionally deleting files and torrents. - - :param show: The show to delete. - :param delete_files_on_disk: Whether to delete the show's files from disk. - :param delete_torrents: Whether to delete associated torrents from the torrent client. - """ - if delete_files_on_disk or delete_torrents: - log.debug(f"Deleting ID: {show.id} - Name: {show.name}") - - if delete_files_on_disk: - show_dir = self.get_root_show_directory(show=show) - - log.debug(f"Attempt to delete show directory: {show_dir}") - if show_dir.exists() and show_dir.is_dir(): - shutil.rmtree(show_dir) - log.info(f"Deleted show directory: {show_dir}") - - if delete_torrents: - torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id) - for torrent in torrents: - try: - self.torrent_service.cancel_download(torrent, delete_files=True) - self.torrent_service.delete_torrent(torrent_id=torrent.id) - log.info(f"Deleted torrent: {torrent.hash}") - except Exception: - log.warning( - f"Failed to delete torrent {torrent.hash}", exc_info=True - ) - - self.tv_repository.delete_show(show_id=show.id) - - def get_public_episode_files_by_season_id( - self, season: Season - ) -> list[PublicEpisodeFile]: - """ - Get all public episode files for a given season. - - :param season: The season object. - :return: A list of public episode files. - """ - episode_files = self.tv_repository.get_episode_files_by_season_id( - season_id=season.id - ) - public_episode_files = [ - PublicEpisodeFile.model_validate(x) for x in episode_files - ] - result = [] - for episode_file in public_episode_files: - if self.episode_file_exists_on_file(episode_file=episode_file): - episode_file.downloaded = True - result.append(episode_file) - return result - - @overload - def check_if_show_exists(self, *, external_id: int, metadata_provider: str) -> bool: - """ - Check if a show exists in the database. - - :param external_id: The external ID of the show. - :param metadata_provider: The metadata provider. - :return: True if the show exists, False otherwise. - """ - - @overload - def check_if_show_exists(self, *, show_id: ShowId) -> bool: - """ - Check if a show exists in the database. - - :param show_id: The ID of the show. - :return: True if the show exists, False otherwise. - """ - - def check_if_show_exists( - self, *, external_id=None, metadata_provider=None, show_id=None - ) -> bool: - if not (external_id is None or metadata_provider is None): - try: - self.tv_repository.get_show_by_external_id( - external_id=external_id, metadata_provider=metadata_provider - ) - except NotFoundError: - return False - elif show_id is not None: - try: - self.tv_repository.get_show_by_id(show_id=show_id) - except NotFoundError: - return False - else: - msg = "Use one of the provided overloads for this function!" - raise ValueError(msg) - - return True - - def get_all_available_torrents_for_a_season( - self, - season_number: int, - show_id: ShowId, - search_query_override: str | None = None, - ) -> list[IndexerQueryResult]: - """ - Get all available torrents for a given season. - - :param season_number: The number of the season. - :param show_id: The ID of the show. - :param search_query_override: Optional override for the search query. - :return: A list of indexer query results. - """ - - if search_query_override: - return self.indexer_service.search(query=search_query_override, is_tv=True) - - show = self.tv_repository.get_show_by_id(show_id=show_id) - - torrents = self.indexer_service.search_season( - show=show, season_number=season_number - ) - - results = [torrent for torrent in torrents if season_number in torrent.season] - - return evaluate_indexer_query_results( - is_tv=True, query_results=results, media=show - ) - - def get_all_shows(self) -> list[Show]: - """ - Get all shows. - - :return: A list of all shows. - """ - return self.tv_repository.get_shows() - - def search_for_show( - self, query: str, metadata_provider: AbstractMetadataProvider - ) -> list[MetaDataProviderSearchResult]: - """ - Search for shows using a given query. - - :param query: The search query. - :param metadata_provider: The metadata provider to search. - :return: A list of metadata provider show search results. - """ - results = metadata_provider.search_show(query) - for result in results: - if self.check_if_show_exists( - external_id=result.external_id, metadata_provider=metadata_provider.name - ): - result.added = True - - try: - show = self.tv_repository.get_show_by_external_id( - external_id=result.external_id, - metadata_provider=metadata_provider.name, - ) - result.id = show.id - except Exception: - log.error( - f"Unable to find internal show ID for {result.external_id} on {metadata_provider.name}" - ) - return results - - def get_popular_shows( - self, metadata_provider: AbstractMetadataProvider - ) -> list[MetaDataProviderSearchResult]: - """ - Get popular shows from a given metadata provider. - - :param metadata_provider: The metadata provider to use. - :return: A list of metadata provider show search results. - """ - results = metadata_provider.search_show() - - return [ - result - for result in results - if not self.check_if_show_exists( - external_id=result.external_id, metadata_provider=metadata_provider.name - ) - ] - - def get_public_show_by_id(self, show: Show) -> PublicShow: - """ - Get a public show from a Show object. - - :param show: The show object. - :return: A public show. - """ - public_show = PublicShow.model_validate(show) - public_seasons: list[PublicSeason] = [] - - for season in show.seasons: - public_season = PublicSeason.model_validate(season) - - for episode in public_season.episodes: - episode.downloaded = self.is_episode_downloaded( - episode=episode, - season=season, - show=show, - ) - - # A season is considered downloaded if it has episodes and all of them are downloaded, - # matching the behavior of is_season_downloaded. - public_season.downloaded = bool(public_season.episodes) and all( - episode.downloaded for episode in public_season.episodes - ) - public_seasons.append(public_season) - - public_show.seasons = public_seasons - return public_show - - def get_show_by_id(self, show_id: ShowId) -> Show: - """ - Get a show by its ID. - - :param show_id: The ID of the show. - :return: The show. - """ - return self.tv_repository.get_show_by_id(show_id=show_id) - - def is_season_downloaded(self, season: Season, show: Show) -> bool: - """ - Check if a season is downloaded. - - :param season: The season object. - :param show: The show object. - :return: True if the season is downloaded, False otherwise. - """ - episodes = season.episodes - - if not episodes: - return False - - for episode in episodes: - if not self.is_episode_downloaded( - episode=episode, season=season, show=show - ): - return False - return True - - def is_episode_downloaded( - self, episode: Episode, season: Season, show: Show - ) -> bool: - """ - Check if an episode is downloaded and imported (file exists on disk). - - An episode is considered downloaded if: - - There is at least one EpisodeFile in the database AND - - A matching episode file exists in the season directory on disk. - - :param episode: The episode object. - :param season: The season object. - :param show: The show object. - :return: True if the episode is downloaded and imported, False otherwise. - """ - episode_files = self.tv_repository.get_episode_files_by_episode_id( - episode_id=episode.id - ) - - if not episode_files: - return False - - season_dir = self.get_root_season_directory(show, season.number) - - if not season_dir.exists(): - return False - - episode_token = f"S{season.number:02d}E{episode.number:02d}" - - video_extensions = {".mkv", ".mp4", ".avi", ".mov"} - - try: - for file in season_dir.iterdir(): - if ( - file.is_file() - and episode_token.lower() in file.name.lower() - and file.suffix.lower() in video_extensions - ): - return True - - except OSError as e: - log.error( - f"Disk check failed for episode {episode.id} in {season_dir}: {e}" - ) - - return False - - def episode_file_exists_on_file(self, episode_file: EpisodeFile) -> bool: - """ - Check if an episode file exists on the filesystem. - - :param episode_file: The episode file to check. - :return: True if the file exists, False otherwise. - """ - if episode_file.torrent_id is None: - return True - try: - torrent_file = self.torrent_service.get_torrent_by_id( - torrent_id=episode_file.torrent_id - ) - - if torrent_file.imported: - return True - except RuntimeError: - log.exception("Error retrieving torrent") - - return False - - def get_show_by_external_id( - self, external_id: int, metadata_provider: str - ) -> Show | None: - """ - Get a show by its external ID and metadata provider. - - :param external_id: The external ID of the show. - :param metadata_provider: The metadata provider. - :return: The show or None if not found. - """ - return self.tv_repository.get_show_by_external_id( - external_id=external_id, metadata_provider=metadata_provider - ) - - def get_season(self, season_id: SeasonId) -> Season: - """ - Get a season by its ID. - - :param season_id: The ID of the season. - :return: The season. - """ - return self.tv_repository.get_season(season_id=season_id) - - def get_episode(self, episode_id: EpisodeId) -> Episode: - """ - Get an episode by its ID. - - :param episode_id: The ID of the episode. - :return: The episode. - """ - return self.tv_repository.get_episode(episode_id=episode_id) - - def get_season_by_episode(self, episode_id: EpisodeId) -> Season: - """ - Get a season by the episode ID. - - :param episode_id: The ID of the episode. - :return: The season. - """ - return self.tv_repository.get_season_by_episode(episode_id=episode_id) - - def get_torrents_for_show(self, show: Show) -> RichShowTorrent: - """ - Get torrents for a given show. - - :param show: The show. - :return: A rich show torrent. - """ - show_torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id) - rich_season_torrents = [] - for show_torrent in show_torrents: - seasons = self.tv_repository.get_seasons_by_torrent_id( - torrent_id=show_torrent.id - ) - episodes = self.tv_repository.get_episodes_by_torrent_id( - torrent_id=show_torrent.id - ) - episode_files = self.torrent_service.get_episode_files_of_torrent( - torrent=show_torrent - ) - - file_path_suffix = ( - episode_files[0].file_path_suffix if episode_files else "" - ) - season_torrent = RichSeasonTorrent( - torrent_id=show_torrent.id, - torrent_title=show_torrent.title, - status=show_torrent.status, - quality=show_torrent.quality, - imported=show_torrent.imported, - seasons=seasons, - episodes=episodes if len(seasons) == 1 else [], - file_path_suffix=file_path_suffix, - usenet=show_torrent.usenet, - ) - rich_season_torrents.append(season_torrent) - - return RichShowTorrent( - show_id=show.id, - name=show.name, - year=show.year, - metadata_provider=show.metadata_provider, - torrents=rich_season_torrents, - ) - - def get_all_shows_with_torrents(self) -> list[RichShowTorrent]: - """ - Get all shows with torrents. - - :return: A list of rich show torrents. - """ - shows = self.tv_repository.get_all_shows_with_torrents() - return [self.get_torrents_for_show(show=show) for show in shows] - - def download_torrent( - self, - public_indexer_result_id: IndexerQueryResultId, - show_id: ShowId, - override_show_file_path_suffix: str = "", - ) -> Torrent: - """ - Download a torrent for a given indexer result and show. - - :param public_indexer_result_id: The ID of the indexer result. - :param show_id: The ID of the show. - :param override_show_file_path_suffix: Optional override for the file path suffix. - :return: The downloaded torrent. - """ - indexer_result = self.indexer_service.get_result( - result_id=public_indexer_result_id - ) - show_torrent = self.torrent_service.download(indexer_result=indexer_result) - self.torrent_service.pause_download(torrent=show_torrent) - - try: - for season_number in indexer_result.season: - season = self.tv_repository.get_season_by_number( - season_number=season_number, show_id=show_id - ) - episodes = {episode.number: episode.id for episode in season.episodes} - - if indexer_result.episode: - episode_ids = [] - missing_episodes = [] - for ep_number in indexer_result.episode: - ep_id = episodes.get(EpisodeNumber(ep_number)) - if ep_id is None: - missing_episodes.append(ep_number) - continue - episode_ids.append(ep_id) - if missing_episodes: - log.warning( - "Some episodes from indexer result were not found in season %s " - "for show %s and will be skipped: %s", - season.id, - show_id, - ", ".join(str(ep) for ep in missing_episodes), - ) - else: - episode_ids = [episode.id for episode in season.episodes] - - for episode_id in episode_ids: - episode_file = EpisodeFile( - episode_id=episode_id, - quality=indexer_result.quality, - torrent_id=show_torrent.id, - file_path_suffix=override_show_file_path_suffix, - ) - self.tv_repository.add_episode_file(episode_file=episode_file) - - except IntegrityError: - log.error( - f"Episode file for episode {episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping." - ) - self.tv_repository.remove_episode_files_by_torrent_id(show_torrent.id) - self.torrent_service.cancel_download( - torrent=show_torrent, delete_files=True - ) - raise - else: - log.info( - f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}" - ) - self.torrent_service.resume_download(torrent=show_torrent) - - return show_torrent - - def get_root_show_directory(self, show: Show) -> Path: - misc_config = MediaManagerConfig().misc - show_directory_name = f"{remove_special_characters(show.name)} ({show.year}) [{show.metadata_provider}id-{show.external_id}]" - log.debug( - f"Show {show.name} without special characters: {remove_special_characters(show.name)}" - ) - - if show.library != "Default": - for library in misc_config.tv_libraries: - if library.name == show.library: - log.debug( - f"Using library {library.name} for show {show.name} ({show.year})" - ) - return Path(library.path) / show_directory_name - else: - log.warning( - f"Library {show.library} not defined in config, using default TV directory." - ) - return misc_config.tv_directory / show_directory_name - - def get_root_season_directory(self, show: Show, season_number: int) -> Path: - return self.get_root_show_directory(show) / Path(f"Season {season_number}") - - def import_episode( - self, - show: Show, - season: Season, - episode_number: int, - video_files: list[Path], - subtitle_files: list[Path], - file_path_suffix: str = "", - ) -> bool: - episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode_number:02d}" - if file_path_suffix != "": - episode_file_name += f" - {file_path_suffix}" - pattern = ( - r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode_number) + r"[. ].*" - ) - target_file_name = ( - self.get_root_season_directory(show=show, season_number=season.number) - / episode_file_name - ) - - # import subtitle - for subtitle_file in subtitle_files: - import_subtitle(subtitle_file=subtitle_file, target_file=target_file_name) - - # import episode videos - for file in video_files: - if re.search(pattern, file.name, re.IGNORECASE): - target_video_file = target_file_name.with_suffix(file.suffix) - import_file(target_file=target_video_file, source_file=file) - return True - else: - msg = f"Could not find any video file for episode {episode_number} of show {show.name} S{season.number}" - raise Exception(msg) # noqa: TRY002 # TODO: resolve this - - def import_season( - self, - show: Show, - season: Season, - video_files: list[Path], - subtitle_files: list[Path], - file_path_suffix: str = "", - ) -> tuple[bool, list[Episode]]: - season_path = self.get_root_season_directory( - show=show, season_number=season.number - ) - success = True - imported_episodes = [] - try: - season_path.mkdir(parents=True, exist_ok=True) - except Exception as e: - log.exception(f"Could not create path {season_path}") - msg = f"Could not create path {season_path}" - raise Exception(msg) from e # noqa: TRY002 # TODO: resolve this - - for episode in season.episodes: - try: - imported = self.import_episode( - show=show, - subtitle_files=subtitle_files, - video_files=video_files, - season=season, - episode_number=episode.number, - file_path_suffix=file_path_suffix, - ) - if imported: - imported_episodes.append(episode) - - except Exception: - # Send notification about missing episode file - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Missing Episode File", - message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", - ) - success = False - log.warning( - f"S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." - ) - return success, imported_episodes - - def import_episode_files( - self, - show: Show, - season: Season, - episode: Episode, - video_files: list[Path], - subtitle_files: list[Path], - file_path_suffix: str = "", - ) -> bool: - episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode.number:02d}" - if file_path_suffix != "": - episode_file_name += f" - {file_path_suffix}" - pattern = ( - r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode.number) + r"[. ].*" - ) - target_file_name = ( - self.get_root_season_directory(show=show, season_number=season.number) - / episode_file_name - ) - - # import subtitle - for subtitle_file in subtitle_files: - import_subtitle(subtitle_file=subtitle_file, target_file=target_file_name) - - found_video = False - - # import episode videos - for file in video_files: - if re.search(pattern, file.name, re.IGNORECASE): - target_video_file = target_file_name.with_suffix(file.suffix) - import_file(target_file=target_video_file, source_file=file) - found_video = True - break - - if not found_video: - # Send notification about missing episode file - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Missing Episode File", - message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", - ) - log.warning( - f"File for S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." - ) - return False - - return True - - def import_episode_files_from_torrent(self, torrent: Torrent, show: Show) -> None: - """ - Organizes episodes files from a torrent into the TV directory structure, mapping them to seasons and episodes. - :param torrent: The Torrent object - :param show: The Show object - """ - - video_files, subtitle_files, _all_files = get_files_for_import(torrent=torrent) - - success: list[bool] = [] - - log.debug( - f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files) - ) - - episode_files = self.torrent_service.get_episode_files_of_torrent( - torrent=torrent - ) - if not episode_files: - log.warning( - f"No episode files associated with torrent {torrent.title}, skipping import." - ) - return - - log.info( - f"Found {len(episode_files)} episode files associated with torrent {torrent.title}" - ) - - imported_episodes_by_season: dict[int, list[int]] = {} - - for episode_file in episode_files: - season = self.get_season_by_episode(episode_id=episode_file.episode_id) - episode = self.get_episode(episode_file.episode_id) - - season_path = self.get_root_season_directory( - show=show, season_number=season.number - ) - if not season_path.exists(): - try: - season_path.mkdir(parents=True) - except Exception as e: - log.warning(f"Could not create path {season_path}: {e}") - msg = f"Could not create path {season_path}" - raise Exception(msg) from e # noqa: TRY002 - - episoded_import_success = self.import_episode_files( - show=show, - season=season, - episode=episode, - video_files=video_files, - subtitle_files=subtitle_files, - file_path_suffix=episode_file.file_path_suffix, - ) - success.append(episoded_import_success) - - if episoded_import_success: - imported_episodes_by_season.setdefault(season.number, []).append( - episode.number - ) - - log.info( - f"Episode {episode.number} from Season {season.number} successfully imported from torrent {torrent.title}" - ) - else: - log.warning( - f"Episode {episode.number} from Season {season.number} failed to import from torrent {torrent.title}" - ) - - success_messages: list[str] = [] - - for season_number, episodes in imported_episodes_by_season.items(): - episode_list = ",".join(str(e) for e in sorted(episodes)) - success_messages.append( - f"Episode(s): {episode_list} from Season {season_number}" - ) - - episodes_summary = "; ".join(success_messages) - - if all(success): - torrent.imported = True - self.torrent_service.torrent_repository.save_torrent(torrent=torrent) - - # Send successful season download notification - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="TV Show imported successfully", - message=( - f"Successfully imported {episodes_summary} " - f"of {show.name} ({show.year}) " - f"from torrent {torrent.title}." - ), - ) - else: - if self.notification_service: - self.notification_service.send_notification_to_all_providers( - title="Failed to import TV Show", - message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.", - ) - - log.info( - f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors" - ) - - def update_show_metadata( - self, db_show: Show, metadata_provider: AbstractMetadataProvider - ) -> 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 metadata_provider: The metadata provider object to fetch fresh data from. - :param db_show: The Show to update - :return: The updated Show object, or None if the show is not found or an error occurs. - """ - log.debug(f"Found show: {db_show.name} for metadata update.") - - # Use stored original_language preference for metadata fetching - fresh_show_data = metadata_provider.get_show_metadata( - show_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}." - ) - return db_show - log.debug(f"Fetched fresh metadata for show: {fresh_show_data.name}") - - 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, - 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, - ) - - # Process seasons and episodes - existing_season_external_ids = {s.external_id: s for s in db_show.seasons} - - for fresh_season_data in fresh_show_data.seasons: - if fresh_season_data.external_id in existing_season_external_ids: - # Update existing season - existing_season = existing_season_external_ids[ - fresh_season_data.external_id - ] - - 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_external_ids = { - ep.external_id: ep for ep in existing_season.episodes - } - for fresh_episode_data in fresh_season_data.episodes: - if fresh_episode_data.external_id in existing_episode_external_ids: - # Update existing episode - existing_episode = existing_episode_external_ids[ - fresh_episode_data.external_id - ] - - self.tv_repository.update_episode_attributes( - episode_id=existing_episode.id, - title=fresh_episode_data.title, - overview=fresh_episode_data.overview, - ) - else: - # Add new episode - log.debug( - f"Adding new episode {fresh_episode_data.number} to season {existing_season.number}" - ) - episode_schema = Episode( - id=EpisodeId(fresh_episode_data.id), - number=fresh_episode_data.number, - external_id=fresh_episode_data.external_id, - title=fresh_episode_data.title, - overview=fresh_episode_data.overview, - ) - 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 = [ - Episode( - id=EpisodeId(ep_data.id), - number=ep_data.number, - external_id=ep_data.external_id, - title=ep_data.title, - overview=ep_data.overview, - ) - for ep_data in fresh_season_data.episodes - ] - - 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=db_show.id) - - log.info(f"Successfully updated metadata for show: {updated_show.name}") - metadata_provider.download_show_poster_image(show=updated_show) - return updated_show - - def set_show_continuous_download( - self, show: Show, continuous_download: bool - ) -> Show: - """ - Set the continuous download flag for a show. - - :param show: The show object. - :param continuous_download: True to enable continuous download, False to disable. - :return: The updated Show object. - """ - return self.tv_repository.update_show_attributes( - show_id=show.id, continuous_download=continuous_download - ) - - def get_import_candidates( - self, tv_show: Path, metadata_provider: AbstractMetadataProvider - ) -> MediaImportSuggestion: - search_result = self.search_for_show( - remove_special_chars_and_parentheses(tv_show.name), metadata_provider - ) - import_candidates = MediaImportSuggestion( - directory=tv_show, candidates=search_result - ) - log.debug( - f"Found {len(import_candidates.candidates)} candidates for {import_candidates.directory}" - ) - return import_candidates - - def import_existing_tv_show(self, tv_show: Show, source_directory: Path) -> None: - new_source_path = source_directory.parent / ("." + source_directory.name) - try: - source_directory.rename(new_source_path) - except Exception as e: - log.exception(f"Failed to rename {source_directory} to {new_source_path}") - raise RenameError from e - - video_files, subtitle_files, _all_files = get_files_for_import( - directory=new_source_path - ) - for season in tv_show.seasons: - _success, imported_episodes = self.import_season( - show=tv_show, - season=season, - video_files=video_files, - subtitle_files=subtitle_files, - file_path_suffix="IMPORTED", - ) - for episode in imported_episodes: - episode_file = EpisodeFile( - episode_id=episode.id, - quality=Quality.unknown, - file_path_suffix="IMPORTED", - torrent_id=None, - ) - - self.tv_repository.add_episode_file(episode_file=episode_file) - - def get_importable_tv_shows( - self, metadata_provider: AbstractMetadataProvider - ) -> list[MediaImportSuggestion]: - tv_directory = MediaManagerConfig().misc.tv_directory - import_suggestions: list[MediaImportSuggestion] = [] - candidate_dirs = get_importable_media_directories(tv_directory) - - for item in candidate_dirs: - metadata, external_id = extract_external_id_from_string(item.name) - if metadata is not None and external_id is not None: - try: - self.tv_repository.get_show_by_external_id( - external_id=external_id, - metadata_provider=metadata, - ) - log.debug( - f"Show {item.name} already exists in the database, skipping import suggestion." - ) - continue - except NotFoundError: - log.debug( - f"Show {item.name} not found in database, checking for import candidates." - ) - - import_suggestion = self.get_import_candidates( - tv_show=item, metadata_provider=metadata_provider - ) - import_suggestions.append(import_suggestion) - - log.debug(f"Detected {len(import_suggestions)} importable TV shows.") - return import_suggestions - - def import_all_torrents(self) -> None: - log.info("Importing all torrents") - torrents = self.torrent_service.get_all_torrents() - log.info("Found %d torrents to import", len(torrents)) - for t in torrents: - show = None - try: - if not t.imported and t.status == TorrentStatus.finished: - show = self.torrent_service.get_show_of_torrent(torrent=t) - if show is None: - log.warning( - f"torrent {t.title} is not a tv torrent, skipping import." - ) - continue - self.import_episode_files_from_torrent(torrent=t, show=show) - except RuntimeError as e: - show_name = show.name if show is not None else "" - log.error( - f"Error importing torrent {t.title} for show {show_name}: {e}", - exc_info=True, - ) - log.info("Finished importing all torrents") - - def update_all_non_ended_shows_metadata(self) -> None: - """Updates the metadata of all non-ended shows.""" - log.info("Updating metadata for all non-ended shows") - shows = [show for show in self.tv_repository.get_shows() if not show.ended] - log.info(f"Found {len(shows)} non-ended shows to update") - for show in shows: - try: - if show.metadata_provider == "tmdb": - metadata_provider = TmdbMetadataProvider() - elif show.metadata_provider == "tvdb": - metadata_provider = TvdbMetadataProvider() - else: - log.error( - f"Unsupported metadata provider {show.metadata_provider} for show {show.name}, skipping update." - ) - continue - except InvalidConfigError: - log.exception( - f"Error initializing metadata provider {show.metadata_provider} for show {show.name}" - ) - continue - updated_show = self.update_show_metadata( - db_show=show, metadata_provider=metadata_provider - ) - if updated_show: - log.debug("Updated show metadata", extra={"show": updated_show.name}) - else: - log.warning(f"Failed to update metadata for show: {show.name}") +import pprint +import re +import shutil +from pathlib import Path +from typing import overload + +from sqlalchemy.exc import IntegrityError + +from media_manager.config import MediaManagerConfig +from media_manager.exceptions import InvalidConfigError, NotFoundError, RenameError +from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId +from media_manager.indexer.service import IndexerService +from media_manager.indexer.utils import evaluate_indexer_query_results +from media_manager.metadataProvider.abstract_metadata_provider import ( + AbstractMetadataProvider, +) +from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult +from media_manager.metadataProvider.tmdb import TmdbMetadataProvider +from media_manager.metadataProvider.tvdb import TvdbMetadataProvider +from media_manager.notification.service import NotificationService +from media_manager.schemas import MediaImportSuggestion +from media_manager.torrent.schemas import ( + Quality, + Torrent, + TorrentStatus, +) +from media_manager.torrent.service import TorrentService +from media_manager.tv import log +from media_manager.tv.repository import TvRepository +from media_manager.tv.schemas import ( + Episode, + EpisodeFile, + EpisodeId, + EpisodeNumber, + PublicEpisodeFile, + PublicSeason, + PublicShow, + RichSeasonTorrent, + RichShowTorrent, + Season, + SeasonId, + Show, + ShowId, +) +from media_manager.utils.file_handler import ( + extract_external_id_from_string, + get_files_for_import, + get_importable_media_directories, + import_file, + import_subtitle, + remove_special_characters, + remove_special_chars_and_parentheses, +) + + +class TvService: + def __init__( + self, + tv_repository: TvRepository, + torrent_service: TorrentService, + indexer_service: IndexerService, + notification_service: NotificationService, + ) -> None: + self.tv_repository = tv_repository + self.torrent_service = torrent_service + self.indexer_service = indexer_service + self.notification_service = notification_service + + def add_show( + self, + external_id: int, + metadata_provider: AbstractMetadataProvider, + language: str | None = None, + ) -> Show: + """ + Add a new show to the database. + + :param external_id: The ID of the show in the metadata provider\'s system. + :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( + show_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 + + def get_total_downloaded_episoded_count(self) -> int: + """ + Get total number of downloaded episodes. + """ + + return self.tv_repository.get_total_downloaded_episodes_count() + + def set_show_library(self, show: Show, library: str) -> None: + self.tv_repository.set_show_library(show_id=show.id, library=library) + + def delete_show( + self, + show: Show, + delete_files_on_disk: bool = False, + delete_torrents: bool = False, + ) -> None: + """ + Delete a show from the database, optionally deleting files and torrents. + + :param show: The show to delete. + :param delete_files_on_disk: Whether to delete the show's files from disk. + :param delete_torrents: Whether to delete associated torrents from the torrent client. + """ + if delete_files_on_disk or delete_torrents: + log.debug(f"Deleting ID: {show.id} - Name: {show.name}") + + if delete_files_on_disk: + show_dir = self.get_root_show_directory(show=show) + + log.debug(f"Attempt to delete show directory: {show_dir}") + if show_dir.exists() and show_dir.is_dir(): + shutil.rmtree(show_dir) + log.info(f"Deleted show directory: {show_dir}") + + if delete_torrents: + torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id) + for torrent in torrents: + try: + self.torrent_service.cancel_download(torrent, delete_files=True) + self.torrent_service.delete_torrent(torrent_id=torrent.id) + log.info(f"Deleted torrent: {torrent.hash}") + except Exception: + log.warning( + f"Failed to delete torrent {torrent.hash}", exc_info=True + ) + + self.tv_repository.delete_show(show_id=show.id) + + def get_public_episode_files_by_season_id( + self, season: Season + ) -> list[PublicEpisodeFile]: + """ + Get all public episode files for a given season. + + :param season: The season object. + :return: A list of public episode files. + """ + episode_files = self.tv_repository.get_episode_files_by_season_id( + season_id=season.id + ) + public_episode_files = [ + PublicEpisodeFile.model_validate(x) for x in episode_files + ] + result = [] + for episode_file in public_episode_files: + if self.episode_file_exists_on_file(episode_file=episode_file): + episode_file.downloaded = True + result.append(episode_file) + return result + + @overload + def check_if_show_exists(self, *, external_id: int, metadata_provider: str) -> bool: + """ + Check if a show exists in the database. + + :param external_id: The external ID of the show. + :param metadata_provider: The metadata provider. + :return: True if the show exists, False otherwise. + """ + + @overload + def check_if_show_exists(self, *, show_id: ShowId) -> bool: + """ + Check if a show exists in the database. + + :param show_id: The ID of the show. + :return: True if the show exists, False otherwise. + """ + + def check_if_show_exists( + self, *, external_id=None, metadata_provider=None, show_id=None + ) -> bool: + if not (external_id is None or metadata_provider is None): + try: + self.tv_repository.get_show_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + except NotFoundError: + return False + elif show_id is not None: + try: + self.tv_repository.get_show_by_id(show_id=show_id) + except NotFoundError: + return False + else: + msg = "Use one of the provided overloads for this function!" + raise ValueError(msg) + + return True + + def get_all_available_torrents_for_a_season( + self, + season_number: int, + show_id: ShowId, + search_query_override: str | None = None, + ) -> list[IndexerQueryResult]: + """ + Get all available torrents for a given season. + + :param season_number: The number of the season. + :param show_id: The ID of the show. + :param search_query_override: Optional override for the search query. + :return: A list of indexer query results. + """ + + if search_query_override: + return self.indexer_service.search(query=search_query_override, is_tv=True) + + show = self.tv_repository.get_show_by_id(show_id=show_id) + + torrents = self.indexer_service.search_season( + show=show, season_number=season_number + ) + + results = [torrent for torrent in torrents if season_number in torrent.season] + + return evaluate_indexer_query_results( + is_tv=True, query_results=results, media=show + ) + + def get_all_shows(self) -> list[Show]: + """ + Get all shows. + + :return: A list of all shows. + """ + return self.tv_repository.get_shows() + + def search_for_show( + self, query: str, metadata_provider: AbstractMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + """ + Search for shows using a given query. + + :param query: The search query. + :param metadata_provider: The metadata provider to search. + :return: A list of metadata provider show search results. + """ + results = metadata_provider.search_show(query) + for result in results: + if self.check_if_show_exists( + external_id=result.external_id, metadata_provider=metadata_provider.name + ): + result.added = True + + try: + show = self.tv_repository.get_show_by_external_id( + external_id=result.external_id, + metadata_provider=metadata_provider.name, + ) + result.id = show.id + except Exception: + log.error( + f"Unable to find internal show ID for {result.external_id} on {metadata_provider.name}" + ) + return results + + def get_popular_shows( + self, metadata_provider: AbstractMetadataProvider + ) -> list[MetaDataProviderSearchResult]: + """ + Get popular shows from a given metadata provider. + + :param metadata_provider: The metadata provider to use. + :return: A list of metadata provider show search results. + """ + results = metadata_provider.search_show() + + return [ + result + for result in results + if not self.check_if_show_exists( + external_id=result.external_id, metadata_provider=metadata_provider.name + ) + ] + + def get_public_show_by_id(self, show: Show) -> PublicShow: + """ + Get a public show from a Show object. + + :param show: The show object. + :return: A public show. + """ + public_show = PublicShow.model_validate(show) + public_seasons: list[PublicSeason] = [] + + for season in show.seasons: + public_season = PublicSeason.model_validate(season) + + for episode in public_season.episodes: + episode.downloaded = self.is_episode_downloaded( + episode=episode, + season=season, + show=show, + ) + + # A season is considered downloaded if it has episodes and all of them are downloaded, + # matching the behavior of is_season_downloaded. + public_season.downloaded = bool(public_season.episodes) and all( + episode.downloaded for episode in public_season.episodes + ) + public_seasons.append(public_season) + + public_show.seasons = public_seasons + return public_show + + def get_show_by_id(self, show_id: ShowId) -> Show: + """ + Get a show by its ID. + + :param show_id: The ID of the show. + :return: The show. + """ + return self.tv_repository.get_show_by_id(show_id=show_id) + + def is_season_downloaded(self, season: Season, show: Show) -> bool: + """ + Check if a season is downloaded. + + :param season: The season object. + :param show: The show object. + :return: True if the season is downloaded, False otherwise. + """ + episodes = season.episodes + + if not episodes: + return False + + for episode in episodes: + if not self.is_episode_downloaded( + episode=episode, season=season, show=show + ): + return False + return True + + def is_episode_downloaded( + self, episode: Episode, season: Season, show: Show + ) -> bool: + """ + Check if an episode is downloaded and imported (file exists on disk). + + An episode is considered downloaded if: + - There is at least one EpisodeFile in the database AND + - A matching episode file exists in the season directory on disk. + + :param episode: The episode object. + :param season: The season object. + :param show: The show object. + :return: True if the episode is downloaded and imported, False otherwise. + """ + episode_files = self.tv_repository.get_episode_files_by_episode_id( + episode_id=episode.id + ) + + if not episode_files: + return False + + season_dir = self.get_root_season_directory(show, season.number) + + if not season_dir.exists(): + return False + + episode_token = f"S{season.number:02d}E{episode.number:02d}" + + video_extensions = {".mkv", ".mp4", ".avi", ".mov"} + + try: + for file in season_dir.iterdir(): + if ( + file.is_file() + and episode_token.lower() in file.name.lower() + and file.suffix.lower() in video_extensions + ): + return True + + except OSError as e: + log.error( + f"Disk check failed for episode {episode.id} in {season_dir}: {e}" + ) + + return False + + def episode_file_exists_on_file(self, episode_file: EpisodeFile) -> bool: + """ + Check if an episode file exists on the filesystem. + + :param episode_file: The episode file to check. + :return: True if the file exists, False otherwise. + """ + if episode_file.torrent_id is None: + return True + try: + torrent_file = self.torrent_service.get_torrent_by_id( + torrent_id=episode_file.torrent_id + ) + + if torrent_file.imported: + return True + except RuntimeError: + log.exception("Error retrieving torrent") + + return False + + def get_show_by_external_id( + self, external_id: int, metadata_provider: str + ) -> Show | None: + """ + Get a show by its external ID and metadata provider. + + :param external_id: The external ID of the show. + :param metadata_provider: The metadata provider. + :return: The show or None if not found. + """ + return self.tv_repository.get_show_by_external_id( + external_id=external_id, metadata_provider=metadata_provider + ) + + def get_season(self, season_id: SeasonId) -> Season: + """ + Get a season by its ID. + + :param season_id: The ID of the season. + :return: The season. + """ + return self.tv_repository.get_season(season_id=season_id) + + def get_episode(self, episode_id: EpisodeId) -> Episode: + """ + Get an episode by its ID. + + :param episode_id: The ID of the episode. + :return: The episode. + """ + return self.tv_repository.get_episode(episode_id=episode_id) + + def get_season_by_episode(self, episode_id: EpisodeId) -> Season: + """ + Get a season by the episode ID. + + :param episode_id: The ID of the episode. + :return: The season. + """ + return self.tv_repository.get_season_by_episode(episode_id=episode_id) + + def get_torrents_for_show(self, show: Show) -> RichShowTorrent: + """ + Get torrents for a given show. + + :param show: The show. + :return: A rich show torrent. + """ + show_torrents = self.tv_repository.get_torrents_by_show_id(show_id=show.id) + rich_season_torrents = [] + for show_torrent in show_torrents: + seasons = self.tv_repository.get_seasons_by_torrent_id( + torrent_id=show_torrent.id + ) + episodes = self.tv_repository.get_episodes_by_torrent_id( + torrent_id=show_torrent.id + ) + episode_files = self.torrent_service.get_episode_files_of_torrent( + torrent=show_torrent + ) + + file_path_suffix = ( + episode_files[0].file_path_suffix if episode_files else "" + ) + season_torrent = RichSeasonTorrent( + torrent_id=show_torrent.id, + torrent_title=show_torrent.title, + status=show_torrent.status, + quality=show_torrent.quality, + imported=show_torrent.imported, + seasons=seasons, + episodes=episodes if len(seasons) == 1 else [], + file_path_suffix=file_path_suffix, + usenet=show_torrent.usenet, + ) + rich_season_torrents.append(season_torrent) + + return RichShowTorrent( + show_id=show.id, + name=show.name, + year=show.year, + metadata_provider=show.metadata_provider, + torrents=rich_season_torrents, + ) + + def get_all_shows_with_torrents(self) -> list[RichShowTorrent]: + """ + Get all shows with torrents. + + :return: A list of rich show torrents. + """ + shows = self.tv_repository.get_all_shows_with_torrents() + return [self.get_torrents_for_show(show=show) for show in shows] + + def download_torrent( + self, + public_indexer_result_id: IndexerQueryResultId, + show_id: ShowId, + override_show_file_path_suffix: str = "", + ) -> Torrent: + """ + Download a torrent for a given indexer result and show. + + :param public_indexer_result_id: The ID of the indexer result. + :param show_id: The ID of the show. + :param override_show_file_path_suffix: Optional override for the file path suffix. + :return: The downloaded torrent. + """ + indexer_result = self.indexer_service.get_result( + result_id=public_indexer_result_id + ) + show_torrent = self.torrent_service.download(indexer_result=indexer_result) + self.torrent_service.pause_download(torrent=show_torrent) + + try: + for season_number in indexer_result.season: + season = self.tv_repository.get_season_by_number( + season_number=season_number, show_id=show_id + ) + episodes = {episode.number: episode.id for episode in season.episodes} + + if indexer_result.episode: + episode_ids = [] + missing_episodes = [] + for ep_number in indexer_result.episode: + ep_id = episodes.get(EpisodeNumber(ep_number)) + if ep_id is None: + missing_episodes.append(ep_number) + continue + episode_ids.append(ep_id) + if missing_episodes: + log.warning( + "Some episodes from indexer result were not found in season %s " + "for show %s and will be skipped: %s", + season.id, + show_id, + ", ".join(str(ep) for ep in missing_episodes), + ) + else: + episode_ids = [episode.id for episode in season.episodes] + + for episode_id in episode_ids: + episode_file = EpisodeFile( + episode_id=episode_id, + quality=indexer_result.quality, + torrent_id=show_torrent.id, + file_path_suffix=override_show_file_path_suffix, + ) + self.tv_repository.add_episode_file(episode_file=episode_file) + + except IntegrityError: + log.error( + f"Episode file for episode {episode_id} of season {season.id} and quality {indexer_result.quality} already exists, skipping." + ) + self.tv_repository.remove_episode_files_by_torrent_id(show_torrent.id) + self.torrent_service.cancel_download( + torrent=show_torrent, delete_files=True + ) + raise + else: + log.info( + f"Successfully added episode files for torrent {show_torrent.title} and show ID {show_id}" + ) + self.torrent_service.resume_download(torrent=show_torrent) + + return show_torrent + + def get_root_show_directory(self, show: Show) -> Path: + misc_config = MediaManagerConfig().misc + show_directory_name = f"{remove_special_characters(show.name)} ({show.year}) [{show.metadata_provider}id-{show.external_id}]" + log.debug( + f"Show {show.name} without special characters: {remove_special_characters(show.name)}" + ) + + if show.library != "Default": + for library in misc_config.tv_libraries: + if library.name == show.library: + log.debug( + f"Using library {library.name} for show {show.name} ({show.year})" + ) + return Path(library.path) / show_directory_name + else: + log.warning( + f"Library {show.library} not defined in config, using default TV directory." + ) + return misc_config.tv_directory / show_directory_name + + def get_root_season_directory(self, show: Show, season_number: int) -> Path: + return self.get_root_show_directory(show) / Path(f"Season {season_number}") + + def import_episode( + self, + show: Show, + season: Season, + episode_number: int, + video_files: list[Path], + subtitle_files: list[Path], + file_path_suffix: str = "", + ) -> bool: + episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode_number:02d}" + if file_path_suffix != "": + episode_file_name += f" - {file_path_suffix}" + pattern = ( + r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode_number) + r"[. ].*" + ) + target_file_name = ( + self.get_root_season_directory(show=show, season_number=season.number) + / episode_file_name + ) + + # import subtitle + for subtitle_file in subtitle_files: + import_subtitle(subtitle_file=subtitle_file, target_file=target_file_name) + + # import episode videos + for file in video_files: + if re.search(pattern, file.name, re.IGNORECASE): + target_video_file = target_file_name.with_suffix(file.suffix) + import_file(target_file=target_video_file, source_file=file) + return True + else: + msg = f"Could not find any video file for episode {episode_number} of show {show.name} S{season.number}" + raise Exception(msg) # noqa: TRY002 # TODO: resolve this + + def import_season( + self, + show: Show, + season: Season, + video_files: list[Path], + subtitle_files: list[Path], + file_path_suffix: str = "", + ) -> tuple[bool, list[Episode]]: + season_path = self.get_root_season_directory( + show=show, season_number=season.number + ) + success = True + imported_episodes = [] + try: + season_path.mkdir(parents=True, exist_ok=True) + except Exception as e: + log.exception(f"Could not create path {season_path}") + msg = f"Could not create path {season_path}" + raise Exception(msg) from e # noqa: TRY002 # TODO: resolve this + + for episode in season.episodes: + try: + imported = self.import_episode( + show=show, + subtitle_files=subtitle_files, + video_files=video_files, + season=season, + episode_number=episode.number, + file_path_suffix=file_path_suffix, + ) + if imported: + imported_episodes.append(episode) + + except Exception: + # Send notification about missing episode file + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Missing Episode File", + message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", + ) + success = False + log.warning( + f"S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." + ) + return success, imported_episodes + + def import_episode_files( + self, + show: Show, + season: Season, + episode: Episode, + video_files: list[Path], + subtitle_files: list[Path], + file_path_suffix: str = "", + ) -> bool: + episode_file_name = f"{remove_special_characters(show.name)} S{season.number:02d}E{episode.number:02d}" + if file_path_suffix != "": + episode_file_name += f" - {file_path_suffix}" + pattern = ( + r".*[. ]S0?" + str(season.number) + r"E0?" + str(episode.number) + r"[. ].*" + ) + target_file_name = ( + self.get_root_season_directory(show=show, season_number=season.number) + / episode_file_name + ) + + # import subtitle + for subtitle_file in subtitle_files: + import_subtitle(subtitle_file=subtitle_file, target_file=target_file_name) + + found_video = False + + # import episode videos + for file in video_files: + if re.search(pattern, file.name, re.IGNORECASE): + target_video_file = target_file_name.with_suffix(file.suffix) + import_file(target_file=target_video_file, source_file=file) + found_video = True + break + + if not found_video: + # Send notification about missing episode file + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Missing Episode File", + message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", + ) + log.warning( + f"File for S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." + ) + return False + + return True + + def import_episode_files_from_torrent(self, torrent: Torrent, show: Show) -> None: + """ + Organizes episodes files from a torrent into the TV directory structure, mapping them to seasons and episodes. + :param torrent: The Torrent object + :param show: The Show object + """ + + video_files, subtitle_files, _all_files = get_files_for_import(torrent=torrent) + + success: list[bool] = [] + + log.debug( + f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files) + ) + + episode_files = self.torrent_service.get_episode_files_of_torrent( + torrent=torrent + ) + if not episode_files: + log.warning( + f"No episode files associated with torrent {torrent.title}, skipping import." + ) + return + + log.info( + f"Found {len(episode_files)} episode files associated with torrent {torrent.title}" + ) + + imported_episodes_by_season: dict[int, list[int]] = {} + + for episode_file in episode_files: + season = self.get_season_by_episode(episode_id=episode_file.episode_id) + episode = self.get_episode(episode_file.episode_id) + + season_path = self.get_root_season_directory( + show=show, season_number=season.number + ) + if not season_path.exists(): + try: + season_path.mkdir(parents=True) + except Exception as e: + log.warning(f"Could not create path {season_path}: {e}") + msg = f"Could not create path {season_path}" + raise Exception(msg) from e # noqa: TRY002 + + episoded_import_success = self.import_episode_files( + show=show, + season=season, + episode=episode, + video_files=video_files, + subtitle_files=subtitle_files, + file_path_suffix=episode_file.file_path_suffix, + ) + success.append(episoded_import_success) + + if episoded_import_success: + imported_episodes_by_season.setdefault(season.number, []).append( + episode.number + ) + + log.info( + f"Episode {episode.number} from Season {season.number} successfully imported from torrent {torrent.title}" + ) + else: + log.warning( + f"Episode {episode.number} from Season {season.number} failed to import from torrent {torrent.title}" + ) + + success_messages: list[str] = [] + + for season_number, episodes in imported_episodes_by_season.items(): + episode_list = ",".join(str(e) for e in sorted(episodes)) + success_messages.append( + f"Episode(s): {episode_list} from Season {season_number}" + ) + + episodes_summary = "; ".join(success_messages) + + if all(success): + torrent.imported = True + self.torrent_service.torrent_repository.save_torrent(torrent=torrent) + + # Send successful season download notification + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="TV Show imported successfully", + message=( + f"Successfully imported {episodes_summary} " + f"of {show.name} ({show.year}) " + f"from torrent {torrent.title}." + ), + ) + else: + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Failed to import TV Show", + message=f"Importing {show.name} ({show.year}) from torrent {torrent.title} completed with errors. Please check the logs for details.", + ) + + log.info( + f"Finished importing files for torrent {torrent.title} {'without' if all(success) else 'with'} errors" + ) + + def update_show_metadata( + self, db_show: Show, metadata_provider: AbstractMetadataProvider + ) -> 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 metadata_provider: The metadata provider object to fetch fresh data from. + :param db_show: The Show to update + :return: The updated Show object, or None if the show is not found or an error occurs. + """ + log.debug(f"Found show: {db_show.name} for metadata update.") + + # Use stored original_language preference for metadata fetching + fresh_show_data = metadata_provider.get_show_metadata( + show_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}." + ) + return db_show + log.debug(f"Fetched fresh metadata for show: {fresh_show_data.name}") + + 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, + 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, + ) + + # Process seasons and episodes + existing_season_external_ids = {s.external_id: s for s in db_show.seasons} + + for fresh_season_data in fresh_show_data.seasons: + if fresh_season_data.external_id in existing_season_external_ids: + # Update existing season + existing_season = existing_season_external_ids[ + fresh_season_data.external_id + ] + + 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_external_ids = { + ep.external_id: ep for ep in existing_season.episodes + } + for fresh_episode_data in fresh_season_data.episodes: + if fresh_episode_data.external_id in existing_episode_external_ids: + # Update existing episode + existing_episode = existing_episode_external_ids[ + fresh_episode_data.external_id + ] + + self.tv_repository.update_episode_attributes( + episode_id=existing_episode.id, + title=fresh_episode_data.title, + overview=fresh_episode_data.overview, + ) + else: + # Add new episode + log.debug( + f"Adding new episode {fresh_episode_data.number} to season {existing_season.number}" + ) + episode_schema = Episode( + id=EpisodeId(fresh_episode_data.id), + number=fresh_episode_data.number, + external_id=fresh_episode_data.external_id, + title=fresh_episode_data.title, + overview=fresh_episode_data.overview, + ) + 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 = [ + Episode( + id=EpisodeId(ep_data.id), + number=ep_data.number, + external_id=ep_data.external_id, + title=ep_data.title, + overview=ep_data.overview, + ) + for ep_data in fresh_season_data.episodes + ] + + 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=db_show.id) + + log.info(f"Successfully updated metadata for show: {updated_show.name}") + metadata_provider.download_show_poster_image(show=updated_show) + return updated_show + + def set_show_continuous_download( + self, show: Show, continuous_download: bool + ) -> Show: + """ + Set the continuous download flag for a show. + + :param show: The show object. + :param continuous_download: True to enable continuous download, False to disable. + :return: The updated Show object. + """ + return self.tv_repository.update_show_attributes( + show_id=show.id, continuous_download=continuous_download + ) + + def get_import_candidates( + self, tv_show: Path, metadata_provider: AbstractMetadataProvider + ) -> MediaImportSuggestion: + search_result = self.search_for_show( + remove_special_chars_and_parentheses(tv_show.name), metadata_provider + ) + import_candidates = MediaImportSuggestion( + directory=tv_show, candidates=search_result + ) + log.debug( + f"Found {len(import_candidates.candidates)} candidates for {import_candidates.directory}" + ) + return import_candidates + + def import_existing_tv_show(self, tv_show: Show, source_directory: Path) -> None: + new_source_path = source_directory.parent / ("." + source_directory.name) + try: + source_directory.rename(new_source_path) + except Exception as e: + log.exception(f"Failed to rename {source_directory} to {new_source_path}") + raise RenameError from e + + video_files, subtitle_files, _all_files = get_files_for_import( + directory=new_source_path + ) + for season in tv_show.seasons: + _success, imported_episodes = self.import_season( + show=tv_show, + season=season, + video_files=video_files, + subtitle_files=subtitle_files, + file_path_suffix="IMPORTED", + ) + for episode in imported_episodes: + episode_file = EpisodeFile( + episode_id=episode.id, + quality=Quality.unknown, + file_path_suffix="IMPORTED", + torrent_id=None, + ) + + self.tv_repository.add_episode_file(episode_file=episode_file) + + def get_importable_tv_shows( + self, metadata_provider: AbstractMetadataProvider + ) -> list[MediaImportSuggestion]: + tv_directory = MediaManagerConfig().misc.tv_directory + import_suggestions: list[MediaImportSuggestion] = [] + candidate_dirs = get_importable_media_directories(tv_directory) + + for item in candidate_dirs: + metadata, external_id = extract_external_id_from_string(item.name) + if metadata is not None and external_id is not None: + try: + self.tv_repository.get_show_by_external_id( + external_id=external_id, + metadata_provider=metadata, + ) + log.debug( + f"Show {item.name} already exists in the database, skipping import suggestion." + ) + continue + except NotFoundError: + log.debug( + f"Show {item.name} not found in database, checking for import candidates." + ) + + import_suggestion = self.get_import_candidates( + tv_show=item, metadata_provider=metadata_provider + ) + import_suggestions.append(import_suggestion) + + log.debug(f"Detected {len(import_suggestions)} importable TV shows.") + return import_suggestions + + def import_all_torrents(self) -> None: + log.info("Importing all torrents") + torrents = self.torrent_service.get_all_torrents() + log.info("Found %d torrents to import", len(torrents)) + for t in torrents: + show = None + try: + if not t.imported and t.status == TorrentStatus.finished: + show = self.torrent_service.get_show_of_torrent(torrent=t) + if show is None: + log.warning( + f"torrent {t.title} is not a tv torrent, skipping import." + ) + continue + self.import_episode_files_from_torrent(torrent=t, show=show) + except RuntimeError as e: + show_name = show.name if show is not None else "" + log.error( + f"Error importing torrent {t.title} for show {show_name}: {e}", + exc_info=True, + ) + log.info("Finished importing all torrents") + + def update_all_non_ended_shows_metadata(self) -> None: + """Updates the metadata of all non-ended shows.""" + log.info("Updating metadata for all non-ended shows") + shows = [show for show in self.tv_repository.get_shows() if not show.ended] + log.info(f"Found {len(shows)} non-ended shows to update") + for show in shows: + try: + if show.metadata_provider == "tmdb": + metadata_provider = TmdbMetadataProvider() + elif show.metadata_provider == "tvdb": + metadata_provider = TvdbMetadataProvider() + else: + log.error( + f"Unsupported metadata provider {show.metadata_provider} for show {show.name}, skipping update." + ) + continue + except InvalidConfigError: + log.exception( + f"Error initializing metadata provider {show.metadata_provider} for show {show.name}" + ) + continue + updated_show = self.update_show_metadata( + db_show=show, metadata_provider=metadata_provider + ) + if updated_show: + log.debug("Updated show metadata", extra={"show": updated_show.name}) + else: + log.warning(f"Failed to update metadata for show: {show.name}") diff --git a/media_manager/utils/__init__.py b/media_manager/utils/__init__.py index e69de29b..3988bf12 100644 --- a/media_manager/utils/__init__.py +++ b/media_manager/utils/__init__.py @@ -0,0 +1,3 @@ +import logging + +log = logging.getLogger(__name__) diff --git a/media_manager/utils/file_handler.py b/media_manager/utils/file_handler.py index cf38fec2..4d4416a6 100644 --- a/media_manager/utils/file_handler.py +++ b/media_manager/utils/file_handler.py @@ -1,15 +1,13 @@ -import logging import mimetypes -import patoolib -from pathlib import Path, UnsupportedOperation import re import shutil +from pathlib import Path, UnsupportedOperation + +import patoolib from media_manager.config import MediaManagerConfig -from media_manager.torrent.utils import log from media_manager.torrent.schemas import Torrent - -log = logging.getLogger(__name__) +from media_manager.utils import log def extract_external_id_from_string(input_string: str) -> tuple[str | None, int | None]: @@ -27,6 +25,7 @@ def extract_external_id_from_string(input_string: str) -> tuple[str | None, int return None, None + def list_files_recursively(path: Path = Path()) -> list[Path]: files = list(path.glob("**/*")) log.debug(f"Found {len(files)} entries via glob") @@ -129,9 +128,7 @@ def get_files_for_import( f"File is neither a video nor a subtitle, will not be imported: {file}" ) else: - log.debug( - f"Unknown file type, will not be imported: {file}" - ) + log.debug(f"Unknown file type, will not be imported: {file}") log.info( f"Found {len(all_files)} files ({len(video_files)} video files, {len(subtitle_files)} subtitle files) for further processing." From f290ab39dc0d5e7741c3c71b9f26d64aecb9fdfd Mon Sep 17 00:00:00 2001 From: Juan David Bermudez Celedon Date: Fri, 6 Mar 2026 20:07:05 -0500 Subject: [PATCH 5/8] Add util to extract quality when import existing media --- .gitattributes | 25 +++++++++++++++++++++++++ media_manager/indexer/schemas.py | 2 +- media_manager/movies/service.py | 11 +++++++---- media_manager/tv/service.py | 5 ++++- media_manager/utils/file_handler.py | 29 ++++++++++++++++++++++++++++- 5 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..64fd7a0b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,25 @@ +*.py text eol=lf +*.md text eol=lf +*.toml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.sh text eol=lf +*.txt text eol=lf +*.ini text eol=lf +*.mako text eol=lf +*.iml text eol=lf +*.js text eol=lf +*.ts text eol=lf +*.json text eol=lf +*.css text eol=lf +*.svelte text eol=lf + +Makefile text eol=lf +Dockerfile text eol=lf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary \ No newline at end of file diff --git a/media_manager/indexer/schemas.py b/media_manager/indexer/schemas.py index 0236da3b..8317e250 100644 --- a/media_manager/indexer/schemas.py +++ b/media_manager/indexer/schemas.py @@ -35,7 +35,7 @@ class IndexerQueryResult(BaseModel): @computed_field @property def quality(self) -> Quality: - high_quality_pattern = r"\b(4k|2160p|uhd)\b" + high_quality_pattern = r"\b(4k|2160p|uhd|ultra[ ._-]?hd)\b" medium_quality_pattern = r"\b(1080p|full[ ._-]?hd)\b" low_quality_pattern = r"\b(720p|(? bool: directory=new_source_path ) + file_quality = extract_quality_video_file(video_files[0]) + file_suffix = f"{file_quality} - IMPORTED" + success = self.import_movie( movie=movie, video_files=video_files, subtitle_files=subtitle_files, - file_path_suffix="IMPORTED", + file_path_suffix=file_suffix, ) if success: self.movie_repository.add_movie_file( MovieFile( movie_id=movie.id, - file_path_suffix="IMPORTED", + file_path_suffix=file_suffix, torrent_id=None, - quality=Quality.unknown, + quality=file_quality, ) ) diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index b622bc48..b2c6e175 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -44,6 +44,7 @@ ) from media_manager.utils.file_handler import ( extract_external_id_from_string, + extract_quality_video_file, get_files_for_import, get_importable_media_directories, import_file, @@ -655,13 +656,15 @@ def import_season( for episode in season.episodes: try: + file_quality = extract_quality_video_file(video_files[0]) + new_path_suffix = f"{file_quality} - {file_path_suffix}" imported = self.import_episode( show=show, subtitle_files=subtitle_files, video_files=video_files, season=season, episode_number=episode.number, - file_path_suffix=file_path_suffix, + file_path_suffix=new_path_suffix, ) if imported: imported_episodes.append(episode) diff --git a/media_manager/utils/file_handler.py b/media_manager/utils/file_handler.py index 4d4416a6..89136aa0 100644 --- a/media_manager/utils/file_handler.py +++ b/media_manager/utils/file_handler.py @@ -6,7 +6,7 @@ import patoolib from media_manager.config import MediaManagerConfig -from media_manager.torrent.schemas import Torrent +from media_manager.torrent.schemas import Quality, Torrent from media_manager.utils import log @@ -25,6 +25,27 @@ def extract_external_id_from_string(input_string: str) -> tuple[str | None, int return None, None +def extract_quality_video_file(file: Path) -> Quality: + """ + Extracts the quality of a video file based on its name. + + :param file: The path to the video file. + :return: The extracted quality or None if not found. + """ + quality_pattern = r"\b(4k|UHD|ultra[ ._-]?hd|2160p|FHD|full[ ._-]?hd|1080p|HD|720p|SD|480p)\b" + match = re.search(quality_pattern, file.stem, re.IGNORECASE) + if match: + quality = match.group(1).lower() + if quality in {"4k", "uhd", "2160p"}: + return Quality.uhd + if quality in {"fhd", "1080p", "hd"}: + return Quality.fullhd + if quality in {"720p"}: + return Quality.hd + if quality in {"sd", "480p"}: + return Quality.sd + return Quality.unknown + def list_files_recursively(path: Path = Path()) -> list[Path]: files = list(path.glob("**/*")) @@ -137,6 +158,12 @@ def get_files_for_import( def import_subtitle(subtitle_file: Path, target_file: str) -> None: + """ + Imports a subtitle file by renaming it to match the target video file and placing it in the same directory. + + :param subtitle_file: The path to the subtitle file to be imported. + :return None + """ subtitle_pattern = r"[. ]?([a-z]{2,3})(?:\.(\d+))?\.srt$" regex_result = re.search(subtitle_pattern, subtitle_file.name, re.IGNORECASE) if regex_result: From 48c617ac049ef555d7defb301186e85dc8df9ba2 Mon Sep 17 00:00:00 2001 From: Juan David Bermudez Celedon Date: Mon, 9 Mar 2026 17:33:34 -0500 Subject: [PATCH 6/8] Fix shows quality when import --- media_manager/movies/service.py | 11 +++---- media_manager/torrent/schemas.py | 3 ++ media_manager/tv/schemas.py | 1 + media_manager/tv/service.py | 46 +++++++++++++++++++---------- media_manager/utils/file_handler.py | 2 +- 5 files changed, 42 insertions(+), 21 deletions(-) diff --git a/media_manager/movies/service.py b/media_manager/movies/service.py index 627988bf..36fd5ba0 100644 --- a/media_manager/movies/service.py +++ b/media_manager/movies/service.py @@ -28,6 +28,7 @@ from media_manager.notification.service import NotificationService from media_manager.schemas import MediaImportSuggestion from media_manager.torrent.schemas import ( + QualityStrings, Torrent, TorrentStatus, ) @@ -466,12 +467,12 @@ def import_movie( import_file(target_file=target_video_file, source_file=video_files[0]) success = True - target_subtitle_file = ( - movie_root_path / f"{movie_file_name}" - ) + target_subtitle_file = movie_root_path / f"{movie_file_name}" # import subtitles for subtitle_file in subtitle_files: - import_subtitle(subtitle_file=subtitle_file, target_file=target_subtitle_file) + import_subtitle( + subtitle_file=subtitle_file, target_file=target_subtitle_file + ) return success @@ -565,7 +566,7 @@ def import_existing_movie(self, movie: Movie, source_directory: Path) -> bool: ) file_quality = extract_quality_video_file(video_files[0]) - file_suffix = f"{file_quality} - IMPORTED" + file_suffix = f"{QualityStrings.get_label(file_quality)} - IMPORTED" success = self.import_movie( movie=movie, diff --git a/media_manager/torrent/schemas.py b/media_manager/torrent/schemas.py index 1ae4650f..de44931e 100644 --- a/media_manager/torrent/schemas.py +++ b/media_manager/torrent/schemas.py @@ -22,6 +22,9 @@ class QualityStrings(Enum): sd = "400p" unknown = "unknown" + def get_label(quality: Quality) -> str: + return QualityStrings[quality.name].value + class TorrentStatus(Enum): finished = 1 diff --git a/media_manager/tv/schemas.py b/media_manager/tv/schemas.py index c9096684..dcb00322 100644 --- a/media_manager/tv/schemas.py +++ b/media_manager/tv/schemas.py @@ -23,6 +23,7 @@ class Episode(BaseModel): external_id: int title: str overview: str | None = None + quality: Quality = Quality.unknown class Season(BaseModel): diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index b2c6e175..7f3d1d1a 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -20,7 +20,7 @@ from media_manager.notification.service import NotificationService from media_manager.schemas import MediaImportSuggestion from media_manager.torrent.schemas import ( - Quality, + QualityStrings, Torrent, TorrentStatus, ) @@ -626,13 +626,17 @@ def import_episode( # import episode videos for file in video_files: - if re.search(pattern, file.name, re.IGNORECASE): - target_video_file = target_file_name.with_suffix(file.suffix) - import_file(target_file=target_video_file, source_file=file) - return True - else: - msg = f"Could not find any video file for episode {episode_number} of show {show.name} S{season.number}" - raise Exception(msg) # noqa: TRY002 # TODO: resolve this + try: + if re.search(pattern, file.name, re.IGNORECASE): + target_video_file = target_file_name.with_suffix(file.suffix) + import_file(target_file=target_video_file, source_file=file) + return True + except FileNotFoundError: + log.error( + f"Could not find any video file for episode {episode_number} of show {show.name} S{season.number}" + ) + return False + return False def import_season( self, @@ -656,8 +660,8 @@ def import_season( for episode in season.episodes: try: - file_quality = extract_quality_video_file(video_files[0]) - new_path_suffix = f"{file_quality} - {file_path_suffix}" + episode.quality = extract_quality_video_file(video_files[0]) + new_path_suffix = f"{QualityStrings.get_label(quality=episode.quality)} - {file_path_suffix}" imported = self.import_episode( show=show, subtitle_files=subtitle_files, @@ -669,17 +673,29 @@ def import_season( if imported: imported_episodes.append(episode) - except Exception: + except FileNotFoundError as e: + log.exception( + f"IO/FST problem while importing S{season.number:02d}E{episode.number:02d}: {e}" + ) # Send notification about missing episode file if self.notification_service: self.notification_service.send_notification_to_all_providers( title="Missing Episode File", message=f"No video file found for S{season.number:02d}E{episode.number:02d} for show {show.name}. Manual intervention may be required.", ) - success = False - log.warning( - f"S{season.number}E{episode.number} not found when trying to import episode for show {show.name}." + raise + except Exception as e: + # Send notification about unknown error + log.exception( + f"Unexpected error importing S{season.number:02d}E{episode.number:02d}: {e}" ) + if self.notification_service: + self.notification_service.send_notification_to_all_providers( + title="Unexpected Error", + message=f"An unexpected error occurred while importing S{season.number:02d}E{episode.number:02d} for {show.name}. Check server logs.", + ) + success = False + return success, imported_episodes def import_episode_files( @@ -1001,7 +1017,7 @@ def import_existing_tv_show(self, tv_show: Show, source_directory: Path) -> None for episode in imported_episodes: episode_file = EpisodeFile( episode_id=episode.id, - quality=Quality.unknown, + quality=episode.quality, file_path_suffix="IMPORTED", torrent_id=None, ) diff --git a/media_manager/utils/file_handler.py b/media_manager/utils/file_handler.py index 89136aa0..a9887185 100644 --- a/media_manager/utils/file_handler.py +++ b/media_manager/utils/file_handler.py @@ -157,7 +157,7 @@ def get_files_for_import( return video_files, subtitle_files, all_files -def import_subtitle(subtitle_file: Path, target_file: str) -> None: +def import_subtitle(subtitle_file: Path, target_file: Path) -> None: """ Imports a subtitle file by renaming it to match the target video file and placing it in the same directory. From 270b1c3403ea18b77be3ae4d02e416429a4ee93f Mon Sep 17 00:00:00 2001 From: Juan David Bermudez Celedon Date: Mon, 9 Mar 2026 17:54:49 -0500 Subject: [PATCH 7/8] Fix QualityString method access modifier --- media_manager/torrent/schemas.py | 1 + media_manager/tv/service.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/media_manager/torrent/schemas.py b/media_manager/torrent/schemas.py index de44931e..8c6b5263 100644 --- a/media_manager/torrent/schemas.py +++ b/media_manager/torrent/schemas.py @@ -22,6 +22,7 @@ class QualityStrings(Enum): sd = "400p" unknown = "unknown" + @staticmethod def get_label(quality: Quality) -> str: return QualityStrings[quality.name].value diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index 7f3d1d1a..d42877b7 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -661,7 +661,9 @@ def import_season( for episode in season.episodes: try: episode.quality = extract_quality_video_file(video_files[0]) - new_path_suffix = f"{QualityStrings.get_label(quality=episode.quality)} - {file_path_suffix}" + new_path_suffix = ( + f"{QualityStrings.get_label(episode.quality)} - {file_path_suffix}" + ) imported = self.import_episode( show=show, subtitle_files=subtitle_files, From e8c5eff9b93dc90ce89fb3dd3627bba456f0fc77 Mon Sep 17 00:00:00 2001 From: Juan David Bermudez Celedon Date: Mon, 9 Mar 2026 18:20:19 -0500 Subject: [PATCH 8/8] Fix util quality for HD/720p --- media_manager/utils/file_handler.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/media_manager/utils/file_handler.py b/media_manager/utils/file_handler.py index a9887185..9dc0986c 100644 --- a/media_manager/utils/file_handler.py +++ b/media_manager/utils/file_handler.py @@ -25,6 +25,7 @@ def extract_external_id_from_string(input_string: str) -> tuple[str | None, int return None, None + def extract_quality_video_file(file: Path) -> Quality: """ Extracts the quality of a video file based on its name. @@ -32,15 +33,17 @@ def extract_quality_video_file(file: Path) -> Quality: :param file: The path to the video file. :return: The extracted quality or None if not found. """ - quality_pattern = r"\b(4k|UHD|ultra[ ._-]?hd|2160p|FHD|full[ ._-]?hd|1080p|HD|720p|SD|480p)\b" + quality_pattern = ( + r"\b(4k|UHD|ultra[ ._-]?hd|2160p|FHD|full[ ._-]?hd|1080p|HD|720p|SD|480p)\b" + ) match = re.search(quality_pattern, file.stem, re.IGNORECASE) if match: quality = match.group(1).lower() if quality in {"4k", "uhd", "2160p"}: return Quality.uhd - if quality in {"fhd", "1080p", "hd"}: + if quality in {"fhd", "1080p"}: return Quality.fullhd - if quality in {"720p"}: + if quality in {"hd", "720p"}: return Quality.hd if quality in {"sd", "480p"}: return Quality.sd