mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-17 15:43:28 +02:00
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 -->
210 lines
7.4 KiB
Python
210 lines
7.4 KiB
Python
import logging
|
|
|
|
import qbittorrentapi
|
|
from qbittorrentapi import Conflict409Error
|
|
|
|
from media_manager.config import MediaManagerConfig
|
|
from media_manager.indexer.schemas import IndexerQueryResult
|
|
from media_manager.torrent.download_clients.abstract_download_client import (
|
|
AbstractDownloadClient,
|
|
)
|
|
from media_manager.torrent.schemas import Torrent, TorrentStatus
|
|
from media_manager.torrent.utils import get_torrent_hash
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class QbittorrentDownloadClient(AbstractDownloadClient):
|
|
name = "qbittorrent"
|
|
|
|
DOWNLOADING_STATE = (
|
|
"allocating",
|
|
"downloading",
|
|
"metaDL",
|
|
"pausedDL",
|
|
"queuedDL",
|
|
"stalledDL",
|
|
"checkingDL",
|
|
"forcedDL",
|
|
"moving",
|
|
"stoppedDL",
|
|
"forcedMetaDL",
|
|
"metaDL",
|
|
)
|
|
FINISHED_STATE = (
|
|
"uploading",
|
|
"pausedUP",
|
|
"queuedUP",
|
|
"stalledUP",
|
|
"checkingUP",
|
|
"forcedUP",
|
|
"stoppedUP",
|
|
)
|
|
ERROR_STATE = ("missingFiles", "error", "checkingResumeData")
|
|
UNKNOWN_STATE = ("unknown",)
|
|
|
|
def __init__(self) -> None:
|
|
self.config = MediaManagerConfig().torrents.qbittorrent
|
|
self.api_client = qbittorrentapi.Client(
|
|
host=self.config.host,
|
|
port=self.config.port,
|
|
password=self.config.password,
|
|
username=self.config.username,
|
|
)
|
|
try:
|
|
self.api_client.auth_log_in()
|
|
except Exception:
|
|
log.exception("Failed to log into qbittorrent")
|
|
raise
|
|
|
|
categories = self.api_client.torrents_categories()
|
|
log.debug(f"Found following categories in qBittorrent: {categories}")
|
|
if self.config.category_name in categories:
|
|
category = categories.get(self.config.category_name)
|
|
if category.get("savePath") == self.config.category_save_path:
|
|
log.debug(
|
|
f"Category '{self.config.category_name}' already exists in qBittorrent with the correct save path."
|
|
)
|
|
return
|
|
# category exists but with a different save path, attempt to update it
|
|
log.debug(
|
|
f"Category '{self.config.category_name}' already exists in qBittorrent but with a different save path. Attempting to update it."
|
|
)
|
|
try:
|
|
self.api_client.torrents_edit_category(
|
|
name=self.config.category_name,
|
|
save_path=self.config.category_save_path,
|
|
)
|
|
except Conflict409Error:
|
|
log.exception(
|
|
f"Attempt to update category '{self.config.category_name}' in qBittorrent with a different save"
|
|
f" path failed. The configured save path and the save path saved in Qbittorrent differ,"
|
|
f" manually update it in the qBittorrent WebUI or change the save path in the MediaManager"
|
|
f" config to match the one in qBittorrent."
|
|
)
|
|
else:
|
|
# create category if it doesn't exist
|
|
log.debug(
|
|
f"Category '{self.config.category_name}' does not exist in qBittorrent. Attempting to create it."
|
|
)
|
|
try:
|
|
self.api_client.torrents_create_category(
|
|
name=self.config.category_name,
|
|
save_path=self.config.category_save_path,
|
|
)
|
|
except Conflict409Error:
|
|
log.exception(
|
|
f"Attempt to create category '{self.config.category_name}' in qBittorrent failed. The category already exists but was not found in the initial category list, manually check if the category exists in the qBittorrent WebUI or change the category name in the MediaManager config."
|
|
)
|
|
|
|
def download_torrent(self, indexer_result: IndexerQueryResult) -> Torrent:
|
|
"""
|
|
Add a torrent to the download client and return the torrent object.
|
|
|
|
:param indexer_result: The indexer query result of the torrent file to download.
|
|
:return: The torrent object with calculated hash and initial status.
|
|
"""
|
|
torrent_hash = get_torrent_hash(torrent=indexer_result)
|
|
answer = None
|
|
|
|
try:
|
|
self.api_client.auth_log_in()
|
|
answer = self.api_client.torrents_add(
|
|
category="MediaManager",
|
|
urls=indexer_result.download_url,
|
|
save_path=indexer_result.title,
|
|
)
|
|
finally:
|
|
self.api_client.auth_log_out()
|
|
|
|
if answer != "Ok.":
|
|
log.error(
|
|
f"Failed to download torrent, API-Answer isn't 'Ok.'; API Answer: {answer}"
|
|
)
|
|
msg = f"Failed to download torrent, API-Answer isn't 'Ok.'; API Answer: {answer}"
|
|
raise RuntimeError(msg)
|
|
|
|
log.info(f"Successfully processed torrent: {indexer_result.title}")
|
|
|
|
# Create and return torrent object
|
|
torrent = Torrent(
|
|
status=TorrentStatus.unknown,
|
|
title=indexer_result.title,
|
|
quality=indexer_result.quality,
|
|
imported=False,
|
|
hash=torrent_hash,
|
|
)
|
|
|
|
# Get initial status from download client
|
|
torrent.status = self.get_torrent_status(torrent)
|
|
|
|
return torrent
|
|
|
|
def remove_torrent(self, torrent: Torrent, delete_data: bool = False) -> None:
|
|
"""
|
|
Remove a torrent from the download client.
|
|
|
|
:param torrent: The torrent to remove.
|
|
:param delete_data: Whether to delete the downloaded data.
|
|
"""
|
|
log.info(f"Removing torrent: {torrent.title}")
|
|
try:
|
|
self.api_client.auth_log_in()
|
|
self.api_client.torrents_delete(
|
|
torrent_hashes=torrent.hash, delete_files=delete_data
|
|
)
|
|
finally:
|
|
self.api_client.auth_log_out()
|
|
|
|
def get_torrent_status(self, torrent: Torrent) -> TorrentStatus:
|
|
"""
|
|
Get the status of a specific torrent.
|
|
|
|
:param torrent: The torrent to get the status of.
|
|
:return: The status of the torrent.
|
|
"""
|
|
try:
|
|
self.api_client.auth_log_in()
|
|
info = self.api_client.torrents_info(torrent_hashes=torrent.hash)
|
|
finally:
|
|
self.api_client.auth_log_out()
|
|
|
|
if not info:
|
|
log.warning(f"No information found for torrent: {torrent.id}")
|
|
return TorrentStatus.unknown
|
|
state: str = info[0]["state"]
|
|
|
|
if state in self.DOWNLOADING_STATE:
|
|
return TorrentStatus.downloading
|
|
if state in self.FINISHED_STATE:
|
|
return TorrentStatus.finished
|
|
if state in self.ERROR_STATE:
|
|
return TorrentStatus.error
|
|
if state in self.UNKNOWN_STATE:
|
|
return TorrentStatus.unknown
|
|
return TorrentStatus.error
|
|
|
|
def pause_torrent(self, torrent: Torrent) -> None:
|
|
"""
|
|
Pause a torrent download.
|
|
|
|
:param torrent: The torrent to pause.
|
|
"""
|
|
try:
|
|
self.api_client.auth_log_in()
|
|
self.api_client.torrents_pause(torrent_hashes=torrent.hash)
|
|
finally:
|
|
self.api_client.auth_log_out()
|
|
|
|
def resume_torrent(self, torrent: Torrent) -> None:
|
|
"""
|
|
Resume a torrent download.
|
|
|
|
:param torrent: The torrent to resume.
|
|
"""
|
|
try:
|
|
self.api_client.auth_log_in()
|
|
self.api_client.torrents_resume(torrent_hashes=torrent.hash)
|
|
finally:
|
|
self.api_client.auth_log_out()
|