mirror of
https://github.com/maxdorninger/MediaManager.git
synced 2026-04-17 15:43:28 +02:00
add support for transmission
This commit is contained in:
10
config.toml
10
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -16,6 +16,8 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QbittorrentDownloadClient(AbstractDownloadClient):
|
||||
name = "qbittorrent"
|
||||
|
||||
DOWNLOADING_STATE = (
|
||||
"allocating",
|
||||
"downloading",
|
||||
|
||||
@@ -12,6 +12,8 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SabnzbdDownloadClient(AbstractDownloadClient):
|
||||
name = "sabnzbd"
|
||||
|
||||
DOWNLOADING_STATE = (
|
||||
"Downloading",
|
||||
"Queued",
|
||||
|
||||
187
media_manager/torrent/download_clients/transmission.py
Normal file
187
media_manager/torrent/download_clients/transmission.py
Normal 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
|
||||
|
||||
|
||||
@@ -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'}"
|
||||
|
||||
@@ -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
15
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user