Files
MediaManager-maxdorninger/media_manager/movies/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

706 lines
26 KiB
Python

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.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 (
extract_external_id_from_string,
get_files_for_import,
get_importable_media_directories,
import_file,
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
# 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)
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
)