Files
MediaManager-maxdorninger-1/media_manager/tv/service.py
Maximilian Dorninger f5253990e0 switch from APScheduler to Taskiq (#461)
This PR replaces the APScheduler lib with the Taskiq task queuing lib. 

# why

APScheduler doesn't support FastAPI's DI in tasks, this makes them quite
cumbersome to read and write since DB, Repositories and Services all
need to be instanciated manually.

Moreover, Taskiq makes it easier to start background tasks from FastAPI
requests. This enables MM to move to a more event-based architecture.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* App now uses an orchestrated async startup/shutdown and runs
background scheduling via a database-backed task queue; startup enqueues
pre-load/import/update tasks.

* **Bug Fixes**
* Improved torrent client handling with clearer conflict messages and
guidance for manual resolution.
* Enhanced logging around season, episode and metadata update
operations; minor regex/behaviour formatting preserved.

* **Chores**
* Updated dependencies to support the new task queue and connection
pooling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-26 21:23:24 +01:00

1116 lines
42 KiB
Python

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.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
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"[. ].*"
)
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
)
# 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 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"[. ].*"
)
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
)
# 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}"
)
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 "<unknown>"
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}")