Skip to content
Open
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
9 changes: 9 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ REALDEBRID_HOST="https://api.real-debrid.com/rest/1.0/"
REALDEBRID_API_KEY=<realdebrid_api_key>
REALDEBRID_MOUNT_TORRENTS_PATH=

# Additional accounts
REALDEBRID_HOST_2="https://api.real-debrid.com/rest/1.0/"
REALDEBRID_API_KEY_2=<realdebrid_api_key>
REALDEBRID_MOUNT_TORRENTS_PATH_2=

REALDEBRID_HOST_3="https://api.real-debrid.com/rest/1.0/"
REALDEBRID_API_KEY_3=
REALDEBRID_MOUNT_TORRENTS_PATH_3=

#---------------------------#
# TORBOX - BLACKHOLE, REPAIR #
#---------------------------#
Expand Down
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,22 @@

- **Tautulli** - Reclaim Space:
- `TAUTULLI_HOST`: The host address of your Tautulli instance.
- `TAUTULLI_API_KEY`: The API key for accessing Tautulli.

- **RealDebrid** - Blackhole, Repair:
- `TAUTULLI_API_KEY`: The API key for accessing Tautulli. - **RealDebrid** - Blackhole, Repair:
- `REALDEBRID_ENABLED`: Set to `true` to enable RealDebrid services.
- `REALDEBRID_HOST`: The host address for the RealDebrid API.
- `REALDEBRID_API_KEY`: The API key for accessing RealDebrid services.
- `REALDEBRID_MOUNT_TORRENTS_PATH`: The path to the RealDebrid mount torrents folder.

**Multiple Real-Debrid Accounts Support:**
To configure multiple Real-Debrid accounts for load balancing and rate limit bypass:
- `REALDEBRID_HOST_2`: Host for second account
- `REALDEBRID_API_KEY_2`: API key for second account
- `REALDEBRID_MOUNT_TORRENTS_PATH_2`: Mount path for second account
- `REALDEBRID_HOST_3`: Host for third account (and so on...)
- `REALDEBRID_API_KEY_3`: API key for third account
- `REALDEBRID_MOUNT_TORRENTS_PATH_3`: Mount path for third account

The system will automatically load balance requests across all healthy accounts and handle rate limits gracefully. Use `python3 monitor_realdebrid.py --status` to check account health.

- **TorBox** - Blackhole, Repair:
- `TORBOX_ENABLED`: Set to `true` to enable TorBox services.
Expand Down Expand Up @@ -243,4 +252,14 @@ Here is an example of how you might use this script:
python3 delete_non_linked_folders.py /path/to/destination/folder --src-folder /path/to/source/folder --dry-run
```

In this example, the script will check the "/path/to/destination/folder" directory againts the "/path/to/source/folder" directory to find directories that are not linked to. It will print the the directories that would be deleted without actually deleting them, because the --dry-run flag is set.
In this example, the script will check the "/path/to/destination/folder" directory againts the "/path/to/source/folder" directory to find directories that are not linked to. It will print the the directories that would be deleted without actually deleting them, because the --dry-run flag is set.


## Real-Debrid Account Management

The blackhole script now supports multiple Real-Debrid accounts for load balancing and rate limit mitigation. When multiple accounts are configured, the system will:

- Automatically distribute torrent requests across healthy accounts
- Handle rate limits by switching to other accounts
- Perform health checks and disable failing accounts
- Search for torrents across all account mount paths (useful with Zurg multi-account setups)
73 changes: 59 additions & 14 deletions blackhole.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,27 +305,72 @@ async def is_accessible(path, timeout=10):
f.seek(0)

torrentConstructors = []
torrent_instances = []

# Build torrent constructors and instances for Real Debrid accounts
if realdebrid['enabled']:
torrentConstructors.append(RealDebridTorrent if file.torrentInfo.isDotTorrentFile else RealDebridMagnet)
from shared.realdebrid_manager import realdebrid_manager
constructor = RealDebridTorrent if file.torrentInfo.isDotTorrentFile else RealDebridMagnet

# Get healthy accounts for load balancing
healthy_accounts = realdebrid_manager.get_healthy_accounts()
if not healthy_accounts:
print("No healthy Real-Debrid accounts available")
discordError("No healthy Real-Debrid accounts", "All accounts are rate limited or disabled")
else:
# Create torrent instances for each healthy account
for account in healthy_accounts:
torrent_instances.append((constructor, account))

# Add TorBox if enabled (keeping existing single account logic for TorBox)
if torbox['enabled']:
torrentConstructors.append(TorboxTorrent if file.torrentInfo.isDotTorrentFile else TorboxMagnet)
constructor = TorboxTorrent if file.torrentInfo.isDotTorrentFile else TorboxMagnet
torrent_instances.append((constructor, None)) # None for TorBox (no account parameter)

onlyLargestFile = isRadarr or bool(re.search(r'S[\d]{2}E[\d]{2}(?![\W_][\d]{2}[\W_])', file.fileInfo.filename))

if not blackhole['failIfNotCached']:
torrents = [constructor(f, fileData, file, blackhole['failIfNotCached'], onlyLargestFile) for constructor in torrentConstructors]
results = await asyncio.gather(*(processTorrent(torrent, file, arr) for torrent in torrents))
# Try all torrent instances simultaneously for fastest processing
torrents = []
for constructor, account in torrent_instances:
if account is not None: # Real Debrid
torrents.append(constructor(f, fileData, file, blackhole['failIfNotCached'], onlyLargestFile, account=account))
else: # TorBox
torrents.append(constructor(f, fileData, file, blackhole['failIfNotCached'], onlyLargestFile))

if not any(results):
await asyncio.gather(*(fail(torrent, arr, isRadarr) for torrent in torrents))
if torrents:
results = await asyncio.gather(*(processTorrent(torrent, file, arr) for torrent in torrents))

if not any(results):
await asyncio.gather(*(fail(torrent, arr, isRadarr) for torrent in torrents))
else:
print("No torrent services available")
discordError("No services available", "No healthy accounts or services")
else:
for i, constructor in enumerate(torrentConstructors):
isLast = (i == len(torrentConstructors) - 1)
torrent = constructor(f, fileData, file, blackhole['failIfNotCached'], onlyLargestFile)

if await processTorrent(torrent, file, arr):
break
elif isLast:
await fail(torrent, arr, isRadarr)
# Try each torrent instance sequentially until one succeeds
success = False
for i, (constructor, account) in enumerate(torrent_instances):
isLast = (i == len(torrent_instances) - 1)

try:
if account is not None: # Real Debrid
torrent = constructor(f, fileData, file, blackhole['failIfNotCached'], onlyLargestFile, account=account)
else: # TorBox
torrent = constructor(f, fileData, file, blackhole['failIfNotCached'], onlyLargestFile)

if await processTorrent(torrent, file, arr):
success = True
break
elif isLast:
await fail(torrent, arr, isRadarr)
except Exception as e:
print(f"Error with {'Real-Debrid account ' + str(account['id']) if account else 'TorBox'}: {e}")
if isLast:
discordError("All services failed", str(e))

if not torrent_instances:
print("No torrent services available")
discordError("No services available", "No healthy accounts or services")

os.remove(file.fileInfo.filePathProcessing)
except:
Expand Down
108 changes: 81 additions & 27 deletions shared/debrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from shared.discord import discordUpdate
from shared.requests import retryRequest
from shared.shared import realdebrid, torbox, mediaExtensions, checkRequiredEnvs
from shared.realdebrid_manager import realdebrid_manager

def validateDebridEnabled():
if not realdebrid['enabled'] and not torbox['enabled']:
Expand Down Expand Up @@ -158,10 +159,21 @@ def _enforceId(self):
raise Exception("Id is required. Must be acquired via successfully running submitTorrent() first.")

class RealDebrid(TorrentBase):
def __init__(self, f, fileData, file, failIfNotCached, onlyLargestFile) -> None:
def __init__(self, f, fileData, file, failIfNotCached, onlyLargestFile, account=None) -> None:
super().__init__(f, fileData, file, failIfNotCached, onlyLargestFile)
self.headers = {'Authorization': f'Bearer {realdebrid["apiKey"]}'}
self.mountTorrentsPath = realdebrid["mountTorrentsPath"]

# Use provided account or get one from the account manager
self.account = account or realdebrid_manager.get_available_account()
if not self.account:
raise Exception("No available Real-Debrid accounts")

self.headers = {'Authorization': f'Bearer {self.account["apiKey"]}'}
self.mountTorrentsPath = self.account["mountTorrentsPath"]
self.host = self.account["host"]

def print(self, *values: object):
account_id = self.account.get('id', 'unknown')
print(f"[{datetime.now()}] [{self.__class__.__name__}] [Account {account_id}] [{self.file.fileInfo.filenameWithoutExt}]", *values)

def submitTorrent(self):
if self.failIfNotCached:
Expand All @@ -180,10 +192,14 @@ def _getInstantAvailability(self, refresh=False):
return True

def _getAvailableHost(self):
availableHostsRequest = retryRequest(
lambda: requests.get(urljoin(realdebrid['host'], "torrents/availableHosts"), headers=self.headers),
print=self.print
)
def make_request():
response = requests.get(urljoin(self.host, "torrents/availableHosts"), headers=self.headers)
if response.status_code == 429: # Rate limited
realdebrid_manager.mark_rate_limited(self.account)
return None
return response

availableHostsRequest = retryRequest(make_request, print=self.print)
if availableHostsRequest is None:
return None

Expand All @@ -194,10 +210,14 @@ async def getInfo(self, refresh=False):
self._enforceId()

if refresh or not self._info:
infoRequest = retryRequest(
lambda: requests.get(urljoin(realdebrid['host'], f"torrents/info/{self.id}"), headers=self.headers),
print=self.print
)
def make_request():
response = requests.get(urljoin(self.host, f"torrents/info/{self.id}"), headers=self.headers)
if response.status_code == 429: # Rate limited
realdebrid_manager.mark_rate_limited(self.account)
return None
return response

infoRequest = retryRequest(make_request, print=self.print)
if infoRequest is None:
self._info = None
else:
Expand Down Expand Up @@ -233,10 +253,15 @@ async def selectFiles(self):
discordUpdate('largest file:', largestMediaFile['path'])

files = {'files': [largestMediaFileId] if self.onlyLargestFile else ','.join(mediaFileIds)}
selectFilesRequest = retryRequest(
lambda: requests.post(urljoin(realdebrid['host'], f"torrents/selectFiles/{self.id}"), headers=self.headers, data=files),
print=self.print
)

def make_request():
response = requests.post(urljoin(self.host, f"torrents/selectFiles/{self.id}"), headers=self.headers, data=files)
if response.status_code == 429: # Rate limited
realdebrid_manager.mark_rate_limited(self.account)
return None
return response

selectFilesRequest = retryRequest(make_request, print=self.print)
if selectFilesRequest is None:
return False

Expand All @@ -245,17 +270,20 @@ async def selectFiles(self):
def delete(self):
self._enforceId()

deleteRequest = retryRequest(
lambda: requests.delete(urljoin(realdebrid['host'], f"torrents/delete/{self.id}"), headers=self.headers),
print=self.print
)
return not not deleteRequest

def make_request():
response = requests.delete(urljoin(self.host, f"torrents/delete/{self.id}"), headers=self.headers)
if response.status_code == 429: # Rate limited
realdebrid_manager.mark_rate_limited(self.account)
return None
return response

deleteRequest = retryRequest(make_request, print=self.print)
return not not deleteRequest
async def getTorrentPath(self):
filename = (await self.getInfo())['filename']
originalFilename = (await self.getInfo())['original_filename']

# Primary search in this account's mount path
folderPathMountFilenameTorrent = os.path.join(self.mountTorrentsPath, filename)
folderPathMountOriginalFilenameTorrent = os.path.join(self.mountTorrentsPath, originalFilename)
folderPathMountOriginalFilenameWithoutExtTorrent = os.path.join(self.mountTorrentsPath, os.path.splitext(originalFilename)[0])
Expand All @@ -268,7 +296,29 @@ async def getTorrentPath(self):
os.path.exists(folderPathMountOriginalFilenameWithoutExtTorrent) and os.listdir(folderPathMountOriginalFilenameWithoutExtTorrent)):
folderPathMountTorrent = folderPathMountOriginalFilenameWithoutExtTorrent
else:
# If not found in current account, search across all healthy accounts (for cross-account access via Zurg)
folderPathMountTorrent = None
healthy_accounts = realdebrid_manager.get_healthy_accounts()

for account in healthy_accounts:
if account['id'] == self.account['id']:
continue # Skip current account as we already checked it

mount_path = account['mountTorrentsPath']
test_paths = [
os.path.join(mount_path, filename),
os.path.join(mount_path, originalFilename),
os.path.join(mount_path, os.path.splitext(originalFilename)[0]) if originalFilename.endswith(('.mkv', '.mp4')) else None
]

for test_path in test_paths:
if test_path and os.path.exists(test_path) and os.listdir(test_path):
self.print(f'Found torrent in account {account["id"]} mount path: {test_path}')
folderPathMountTorrent = test_path
break

if folderPathMountTorrent:
break

return folderPathMountTorrent

Expand All @@ -277,14 +327,18 @@ def _addFile(self, request, endpoint, data):
if host is None:
return None

request = retryRequest(
lambda: request(urljoin(realdebrid['host'], endpoint), params={'host': host}, headers=self.headers, data=data),
print=self.print
)
if request is None:
def make_request():
response = request(urljoin(self.host, endpoint), params={'host': host}, headers=self.headers, data=data)
if response.status_code == 429: # Rate limited
realdebrid_manager.mark_rate_limited(self.account)
return None
return response

request_result = retryRequest(make_request, print=self.print)
if request_result is None:
return None

response = request.json()
response = request_result.json()
self.print('response info:', response)
self.id = response['id']

Expand Down
Loading