Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 22 additions & 112 deletions media_manager/torrent/download_clients/sabnzbd.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,23 @@
import logging

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.usenet.download_clients.torrentNzbAdapter import TorrentNzbAdapter
from media_manager.usenet.download_clients.sabnzbd import SabnzbdDownloadClient as SabnzbdUsenetDownloadClient
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.torrent.schemas import Torrent, TorrentStatus
import sabnzbd_api

log = logging.getLogger(__name__)


class SabnzbdDownloadClient(AbstractDownloadClient):
name = "sabnzbd"
"""
Sabnzbd download client abiding by the abstract download client.

DOWNLOADING_STATE = (
"Downloading",
"Queued",
"Paused",
"Extracting",
"Moving",
"Running",
)
FINISHED_STATE = ("Completed",)
ERROR_STATE = ("Failed",)
UNKNOWN_STATE = ("Unknown",)
Converts calls to/from compatible calls with usenet.download_clients.sabnzbd.SabnzbdDownloadClient. This allows the
Sabnzbd download client to exist with Usenet/Nzb-first logic, while still only being partially abstracted from the
torrent-first functionality.
"""
name = "sabnzbd"

def __init__(self):
self.config = AllEncompassingConfig().torrents.sabnzbd
self.client = sabnzbd_api.SabnzbdClient(
host=self.config.host,
port=str(self.config.port),
api_key=self.config.api_key,
)
self.client._base_url = f"{self.config.host.rstrip('/')}:{self.config.port}{self.config.base_path}" # the library expects a /sabnzbd prefix for whatever reason
try:
# Test connection
version = self.client.version()

log.info(f"Successfully connected to SABnzbd version: {version}")
except Exception as e:
log.error(f"Failed to connect to SABnzbd: {e}")
raise
self.usenet = SabnzbdUsenetDownloadClient()

def download_torrent(self, indexer_result: IndexerQueryResult) -> Torrent:
"""
Expand All @@ -50,41 +26,9 @@ def download_torrent(self, indexer_result: IndexerQueryResult) -> Torrent:
:param indexer_result: The indexer query result of the NZB file to download.
:return: The torrent object with calculated hash and initial status.
"""
log.info(f"Attempting to download NZB: {indexer_result.title}")

try:
# Add NZB to SABnzbd queue
response = self.client.add_uri(
url=str(indexer_result.download_url), nzbname=indexer_result.title
)
if not response["status"]:
error_msg = response
log.error(f"Failed to add NZB to SABnzbd: {error_msg}")
raise RuntimeError(f"Failed to add NZB to SABnzbd: {error_msg}")
nzb = self.usenet.download_nzb(indexer_result)
return TorrentNzbAdapter.nzb_to_torrent(nzb)

# Generate a hash for the NZB (using title and download URL)
nzo_id = response["nzo_ids"][0]

log.info(f"Successfully added NZB: {indexer_result.title}")

# Create and return torrent object
torrent = Torrent(
status=TorrentStatus.unknown,
title=indexer_result.title,
quality=indexer_result.quality,
imported=False,
hash=nzo_id,
usenet=True,
)

# Get initial status from SABnzbd
torrent.status = self.get_torrent_status(torrent)

return torrent

except Exception as e:
log.error(f"Failed to download NZB {indexer_result.title}: {e}")
raise

def remove_torrent(self, torrent: Torrent, delete_data: bool = False) -> None:
"""
Expand All @@ -93,41 +37,26 @@ def remove_torrent(self, torrent: Torrent, delete_data: bool = False) -> None:
:param torrent: The torrent to remove.
:param delete_data: Whether to delete the downloaded files.
"""
log.info(f"Removing torrent: {torrent.title} (Delete data: {delete_data})")
try:
self.client.delete_job(nzo_id=torrent.hash, delete_files=delete_data)
log.info(f"Successfully removed torrent: {torrent.title}")
except Exception as e:
log.error(f"Failed to remove torrent {torrent.title}: {e}")
raise
nzb = TorrentNzbAdapter.torrent_to_nzb(torrent)
self.usenet.remove_nzb(nzb, delete_data)

def pause_torrent(self, torrent: Torrent) -> None:
"""
Pause a torrent in SABnzbd.

:param torrent: The torrent to pause.
"""
log.info(f"Pausing torrent: {torrent.title}")
try:
self.client.pause_job(nzo_id=torrent.hash)
log.info(f"Successfully paused torrent: {torrent.title}")
except Exception as e:
log.error(f"Failed to pause torrent {torrent.title}: {e}")
raise
nzb = TorrentNzbAdapter.torrent_to_nzb(torrent)
self.usenet.pause_nzb(nzb)

def resume_torrent(self, torrent: Torrent) -> None:
"""
Resume a paused torrent in SABnzbd.

:param torrent: The torrent to resume.
"""
log.info(f"Resuming torrent: {torrent.title}")
try:
self.client.resume_job(nzo_id=torrent.hash)
log.info(f"Successfully resumed torrent: {torrent.title}")
except Exception as e:
log.error(f"Failed to resume torrent {torrent.title}: {e}")
raise
nzb = TorrentNzbAdapter.torrent_to_nzb(torrent)
return self.usenet.resume_nzb(nzb)

def get_torrent_status(self, torrent: Torrent) -> TorrentStatus:
"""
Expand All @@ -136,25 +65,6 @@ def get_torrent_status(self, torrent: Torrent) -> TorrentStatus:
:param torrent: The torrent to get the status of.
:return: The status of the torrent.
"""
log.info(f"Fetching status for download: {torrent.title}")
response = self.client.get_downloads(nzo_ids=torrent.hash)
log.debug("SABnzbd response: %s", response)
status = response["queue"]["status"]
log.info(f"Download status for NZB {torrent.title}: {status}")
return self._map_status(status)

def _map_status(self, sabnzbd_status: str) -> TorrentStatus:
"""
Map SABnzbd status to TorrentStatus.

:param sabnzbd_status: The status from SABnzbd.
:return: The corresponding TorrentStatus.
"""
if sabnzbd_status in self.DOWNLOADING_STATE:
return TorrentStatus.downloading
elif sabnzbd_status in self.FINISHED_STATE:
return TorrentStatus.finished
elif sabnzbd_status in self.ERROR_STATE:
return TorrentStatus.error
else:
return TorrentStatus.unknown
nzb = TorrentNzbAdapter.torrent_to_nzb(torrent)
status = self.usenet.get_nzb_status(nzb)
return TorrentNzbAdapter.convert_status(status)
4 changes: 4 additions & 0 deletions media_manager/torrent/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,13 @@ class QualityStrings(Enum):

class TorrentStatus(Enum):
finished = 1
# Torrent is finished downloading and ready to move.
downloading = 2
# Torrent is downloading.
error = 3
# Torrent failed to download.
unknown = 4
# Unable to obtain status of torrent.


class Torrent(BaseModel):
Expand Down
Empty file.
64 changes: 64 additions & 0 deletions media_manager/usenet/download_clients/abstractDownloadClient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from abc import ABC, abstractmethod
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for introducing a copy of the torrent.AbstractDownloadClient here?


from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.usenet.schemas import NzbStatus, Nzb


class AbstractDownloadClient(ABC):
"""
Abstract base class for download clients.
Defines the interface that all download clients must implement.
"""

@property
@abstractmethod
def name(self) -> str:
pass

@abstractmethod
def download_nzb(self, nzb: IndexerQueryResult) -> Nzb:
"""
Add a nzb to the download client and return the nzb object.

:param nzb: The indexer query result of the nzb file to download.
:return: The nzb object with calculated hash and initial status.
"""
pass

@abstractmethod
def remove_nzb(self, nzb: Nzb, delete_data: bool = False) -> None:
"""
Remove a nzb from the download client.

:param nzb: The nzb to remove.
:param delete_data: Whether to delete the downloaded data.
"""
pass

@abstractmethod
def get_nzb_status(self, nzb: Nzb) -> NzbStatus:
"""
Get the status of a specific nzb.

:param nzb: The nzb to get the status of.
:return: The status of the nzb.
"""
pass

@abstractmethod
def pause_nzb(self, nzb: Nzb) -> None:
"""
Pause a nzb download.

:param nzb: The nzb to pause.
"""
pass

@abstractmethod
def resume_nzb(self, nzb: Nzb) -> None:
"""
Resume a nzb download.

:param nzb: The nzb to resume.
"""
pass
Loading