add support for transmission

This commit is contained in:
maxDorninger
2025-07-15 19:56:46 +02:00
parent d404663f16
commit b3c762040d
10 changed files with 249 additions and 4 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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:
"""

View File

@@ -16,6 +16,8 @@ log = logging.getLogger(__name__)
class QbittorrentDownloadClient(AbstractDownloadClient):
name = "qbittorrent"
DOWNLOADING_STATE = (
"allocating",
"downloading",

View File

@@ -12,6 +12,8 @@ log = logging.getLogger(__name__)
class SabnzbdDownloadClient(AbstractDownloadClient):
name = "sabnzbd"
DOWNLOADING_STATE = (
"Downloading",
"Queued",

View File

@@ -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

View File

@@ -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'}"

View File

@@ -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]

15
uv.lock generated
View File

@@ -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"