diff --git a/config.toml b/config.toml index 3ffe12e..0ea8187 100644 --- a/config.toml +++ b/config.toml @@ -74,6 +74,16 @@ admin_emails = ["admin@example.com", "admin2@example.com"] username = "admin" password = "admin" + # Transmission settings + [torrents.transmission] + enabled = false + username = "admin" + password = "admin" + https_enabled = true + host = "localhost" + port = 9091 + path = "/transmission/rpc" # RPC request path target, usually "/transmission/rpc" + # SABnzbd settings [torrents.sabnzbd] enabled = false diff --git a/media_manager/database/__init__.py b/media_manager/database/__init__.py index c527ea5..e0717fd 100644 --- a/media_manager/database/__init__.py +++ b/media_manager/database/__init__.py @@ -25,7 +25,7 @@ db_url = ( + config.dbname ) -engine = create_engine(db_url, echo=False) +engine = create_engine(db_url, echo=False,pool_size=10, max_overflow=10, pool_timeout=30, pool_recycle=1800) log.debug("initializing sqlalchemy declarative base") Base = declarative_base() SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/media_manager/torrent/config.py b/media_manager/torrent/config.py index 49afc5f..c95904e 100644 --- a/media_manager/torrent/config.py +++ b/media_manager/torrent/config.py @@ -10,6 +10,17 @@ class QbittorrentConfig(BaseSettings): enabled: bool = False +class TransmissionConfig(BaseSettings): + model_config = SettingsConfigDict(env_prefix="TRANSMISSION_") + path: str = "/transmission/rpc" + https_enabled: bool = True + host: str = "localhost" + port: int = 9091 + username: str = "" + password: str = "" + enabled: bool = False + + class SabnzbdConfig(BaseSettings): model_config = SettingsConfigDict(env_prefix="SABNZBD_") host: str = "localhost" @@ -20,4 +31,5 @@ class SabnzbdConfig(BaseSettings): class TorrentConfig(BaseSettings): qbittorrent: QbittorrentConfig + transmission: TransmissionConfig sabnzbd: SabnzbdConfig diff --git a/media_manager/torrent/download_clients/abstractDownloadClient.py b/media_manager/torrent/download_clients/abstractDownloadClient.py index 2a8e20f..d2d521e 100644 --- a/media_manager/torrent/download_clients/abstractDownloadClient.py +++ b/media_manager/torrent/download_clients/abstractDownloadClient.py @@ -10,6 +10,11 @@ class AbstractDownloadClient(ABC): Defines the interface that all download clients must implement. """ + @property + @abstractmethod + def name(self) -> str: + pass + @abstractmethod def download_torrent(self, torrent: IndexerQueryResult) -> Torrent: """ diff --git a/media_manager/torrent/download_clients/qbittorrent.py b/media_manager/torrent/download_clients/qbittorrent.py index 8a646a8..a4a0392 100644 --- a/media_manager/torrent/download_clients/qbittorrent.py +++ b/media_manager/torrent/download_clients/qbittorrent.py @@ -16,6 +16,8 @@ log = logging.getLogger(__name__) class QbittorrentDownloadClient(AbstractDownloadClient): + name = "qbittorrent" + DOWNLOADING_STATE = ( "allocating", "downloading", diff --git a/media_manager/torrent/download_clients/sabnzbd.py b/media_manager/torrent/download_clients/sabnzbd.py index 5bb7e48..1a10c77 100644 --- a/media_manager/torrent/download_clients/sabnzbd.py +++ b/media_manager/torrent/download_clients/sabnzbd.py @@ -12,6 +12,8 @@ log = logging.getLogger(__name__) class SabnzbdDownloadClient(AbstractDownloadClient): + name = "sabnzbd" + DOWNLOADING_STATE = ( "Downloading", "Queued", diff --git a/media_manager/torrent/download_clients/transmission.py b/media_manager/torrent/download_clients/transmission.py new file mode 100644 index 0000000..ef509ef --- /dev/null +++ b/media_manager/torrent/download_clients/transmission.py @@ -0,0 +1,187 @@ +import hashlib +import logging +import os +import tempfile +from typing import Optional + +import bencoder +import requests +import transmission_rpc + +from media_manager.config import AllEncompassingConfig +from media_manager.indexer.schemas import IndexerQueryResult +from media_manager.torrent.download_clients.abstractDownloadClient import ( + AbstractDownloadClient, +) +from media_manager.torrent.schemas import TorrentStatus, Torrent + +log = logging.getLogger(__name__) + + +class TransmissionDownloadClient(AbstractDownloadClient): + name = "transmission" + + # Transmission status mappings + STATUS_MAPPING = { + 'stopped': TorrentStatus.unknown, + 'check pending': TorrentStatus.downloading, + 'checking': TorrentStatus.downloading, + 'download pending': TorrentStatus.downloading, + 'downloading': TorrentStatus.downloading, + 'seed pending': TorrentStatus.finished, + 'seeding': TorrentStatus.finished, + } + + def __init__(self): + self.config = AllEncompassingConfig().torrents.transmission + try: + self._client = transmission_rpc.Client( + host=self.config.host, + port=self.config.port, + username=self.config.username, + password=self.config.password, + protocol="https" if self.config.https_enabled else "http", + path=self.config.path, + logger=log, + ) + # Test connection + self._client.session_stats() + log.info("Successfully connected to Transmission") + except Exception as e: + log.error(f"Failed to connect to Transmission: {e}") + raise + + + + def download_torrent(self, indexer_result: IndexerQueryResult) -> Torrent: + """ + Add a torrent to the Transmission 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. + """ + log.info(f"Attempting to download torrent: {indexer_result.title}") + + try: + response = requests.get(str(indexer_result.download_url), timeout=30) + response.raise_for_status() + torrent_content = response.content + except Exception as e: + log.error(f"Failed to download torrent file: {e}") + raise + + try: + decoded_content = bencoder.decode(torrent_content) + torrent_hash = hashlib.sha1( + bencoder.encode(decoded_content[b"info"]) + ).hexdigest() + except Exception as e: + log.error(f"Failed to decode torrent file: {e}") + raise + download_dir = AllEncompassingConfig().misc.torrent_directory / indexer_result.title + try: + self._client.add_torrent( + torrent=str(indexer_result.download_url), + download_dir=str(download_dir), + ) + + log.info(f"Successfully added torrent to Transmission: {indexer_result.title}") + + except Exception as e: + log.error(f"Failed to add torrent to Transmission: {e}") + raise + + torrent = Torrent( + status=TorrentStatus.unknown, + title=indexer_result.title, + quality=indexer_result.quality, + imported=False, + hash=torrent_hash, + usenet=False + ) + + 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 Transmission 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._client.remove_torrent( + torrent.hash, + delete_data=delete_data + ) + log.info(f"Successfully removed torrent: {torrent.title}") + except Exception as e: + log.error(f"Failed to remove torrent: {e}") + raise + + 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. + """ + log.debug(f"Fetching status for torrent: {torrent.title}") + + try: + transmission_torrent = self._client.get_torrent(torrent.hash) + + if transmission_torrent is None: + log.warning(f"Torrent not found in Transmission: {torrent.hash}") + return TorrentStatus.unknown + + status = self.STATUS_MAPPING.get(transmission_torrent.status, TorrentStatus.unknown) + + if transmission_torrent.error != 0: + status = TorrentStatus.error + log.warning(f"Torrent {torrent.title} has error status: {transmission_torrent.error_string}") + + log.debug(f"Torrent {torrent.title} status: {status}") + return status + + except Exception as e: + log.error(f"Failed to get torrent status: {e}") + return TorrentStatus.error + + def pause_torrent(self, torrent: Torrent) -> None: + """ + Pause a torrent download. + + :param torrent: The torrent to pause. + """ + log.info(f"Pausing torrent: {torrent.title}") + + try: + self._client.stop_torrent(torrent.hash) + log.info(f"Successfully paused torrent: {torrent.title}") + + except Exception as e: + log.error(f"Failed to pause torrent: {e}") + raise + + def resume_torrent(self, torrent: Torrent) -> None: + """ + Resume a torrent download. + + :param torrent: The torrent to resume. + """ + log.info(f"Resuming torrent: {torrent.title}") + + try: + self._client.start_torrent(torrent.hash) + log.info(f"Successfully resumed torrent: {torrent.title}") + + except Exception as e: + log.error(f"Failed to resume torrent: {e}") + raise + + diff --git a/media_manager/torrent/manager.py b/media_manager/torrent/manager.py index 36df4a6..5324e8b 100644 --- a/media_manager/torrent/manager.py +++ b/media_manager/torrent/manager.py @@ -7,6 +7,7 @@ from media_manager.torrent.download_clients.abstractDownloadClient import ( AbstractDownloadClient, ) from media_manager.torrent.download_clients.qbittorrent import QbittorrentDownloadClient +from media_manager.torrent.download_clients.transmission import TransmissionDownloadClient from media_manager.torrent.download_clients.sabnzbd import SabnzbdDownloadClient from media_manager.torrent.schemas import Torrent, TorrentStatus @@ -36,7 +37,7 @@ class DownloadManager: def _initialize_clients(self) -> None: """Initialize and register the default download clients""" - # Initialize qBittorrent client for torrents + # Initialize torrent clients (prioritize qBittorrent, fallback to Transmission) if self.config.qbittorrent.enabled: try: self._torrent_client = QbittorrentDownloadClient() @@ -46,6 +47,16 @@ class DownloadManager: except Exception as e: log.error(f"Failed to initialize qBittorrent client: {e}") + # If qBittorrent is not available or failed, try Transmission + if self._torrent_client is None and self.config.transmission.enabled: + try: + self._torrent_client = TransmissionDownloadClient() + log.info( + "Transmission client initialized and set as active torrent client" + ) + except Exception as e: + log.error(f"Failed to initialize Transmission client: {e}") + # Initialize SABnzbd client for usenet if self.config.sabnzbd.enabled: try: @@ -56,9 +67,9 @@ class DownloadManager: active_clients = [] if self._torrent_client: - active_clients.append("torrent") + active_clients.append(f"torrent ({self._torrent_client.name})") if self._usenet_client: - active_clients.append("usenet") + active_clients.append(f"usenet ({self._usenet_client.name})") log.info( f"Download manager initialized with active download clients: {', '.join(active_clients) if active_clients else 'none'}" diff --git a/pyproject.toml b/pyproject.toml index f543c6b..d62cc32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "pillow>=11.2.1", "pillow-avif-plugin>=1.5.2", "sabnzbd-api>=0.1.2", + "transmission-rpc>=7.0.11", ] [tool.setuptools.packages.find] diff --git a/uv.lock b/uv.lock index 11bf69d..a65a02e 100644 --- a/uv.lock +++ b/uv.lock @@ -646,6 +646,7 @@ dependencies = [ { name = "sqlalchemy" }, { name = "starlette" }, { name = "tmdbsimple" }, + { name = "transmission-rpc" }, { name = "tvdb-v4-official" }, { name = "typing-inspect" }, { name = "uvicorn" }, @@ -678,6 +679,7 @@ requires-dist = [ { name = "sqlalchemy", specifier = ">=2.0.41" }, { name = "starlette", specifier = ">=0.46.2" }, { name = "tmdbsimple", specifier = ">=2.9.1" }, + { name = "transmission-rpc", specifier = ">=7.0.11" }, { name = "tvdb-v4-official", specifier = ">=1.1.0" }, { name = "typing-inspect", specifier = ">=0.9.0" }, { name = "uvicorn", specifier = ">=0.34.2" }, @@ -1199,6 +1201,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "transmission-rpc" +version = "7.0.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/b8/dc4debf525c3bb8a676f4fd0ab8534845e3b067c78a81ad05ac39014d849/transmission_rpc-7.0.11.tar.gz", hash = "sha256:5872322e60b42e368bc9c4724773aea4593113cb19bd2da589f0ffcdabe57963", size = 113744, upload-time = "2024-08-20T22:41:07.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4c/6319bcb1026e3f78c9cbcc9c24de77a76f09954e67ffc5ebfc29f7ce4b90/transmission_rpc-7.0.11-py3-none-any.whl", hash = "sha256:94fd008b54640dd9fff14d7ae26848f901e9d130a70950b8930f9b395988914f", size = 28231, upload-time = "2024-08-20T22:41:05.777Z" }, +] + [[package]] name = "tvdb-v4-official" version = "1.1.0"