Skip to content

Commit 61335bd

Browse files
authored
Merge pull request #1922 from Miorszx/feat/alldebrid-buzzheavier
feat: add AllDebrid (-ad) and BuzzHeavier (-bh) integrations
2 parents 600e460 + 448ebb8 commit 61335bd

17 files changed

Lines changed: 1907 additions & 12 deletions

bot/core/config_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66

77

88
class Config:
9+
ALLDEBRID_API_KEY = ""
910
AS_DOCUMENT = False
1011
AUTHORIZED_CHATS = ""
1112
BASE_URL = ""
1213
BASE_URL_PORT = 80
1314
BOT_TOKEN = ""
15+
BUZZHEAVIER_ACCOUNT_ID = ""
1416
CMD_SUFFIX = ""
1517
CLONE_DUMP_CHATS = ""
1618
DATABASE_URL = ""

bot/helper/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ def __init__(self):
120120
self.is_file = False
121121
self.bot_trans = False
122122
self.user_trans = False
123+
self.is_alldebrid = False
124+
self.is_buzzheavier = False
125+
self._alldebrid_magnet_id = 0
123126
self.is_rss = getattr(self.message, "_rss_trigger", False)
124127
self.progress = True
125128
self.ffmpeg_cmds = None

bot/helper/ext_utils/bot_utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ def arg_parser(items, arg_base):
115115
"-med",
116116
"-ut",
117117
"-bt",
118+
"-ad",
119+
"-bh",
118120
}
119121

120122
while i < total:
@@ -140,6 +142,8 @@ def arg_parser(items, arg_base):
140142
"-med",
141143
"-ut",
142144
"-bt",
145+
"-ad",
146+
"-bh",
143147
]
144148
):
145149
arg_base[part] = True

bot/helper/ext_utils/help_messages.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,30 @@
284284
4. Fourth cmd: the input is mltb.audio so this cmd will work on all audios and the output is mltb.mp3 so the output extension is mp3.
285285
5. Fifth cmd: You can add telegram link for small size input like photo to set watermark"""
286286

287+
alldebrid_arg = """<b>AllDebrid Unlock</b>: -ad
288+
289+
/cmd link -ad
290+
Resolves filehost links (1fichier, rapidgator, mega, etc.) via the
291+
AllDebrid API before handing off to the existing direct downloader.
292+
293+
Magnet/torrent inputs are also routed through AllDebrid when ``-ad``
294+
is set: the bot uploads the magnet (or replied <code>.torrent</code>
295+
file), waits for AllDebrid to finish torrenting, then downloads each
296+
file directly from AllDebrid CDNs. This bypasses aria2/qBittorrent
297+
entirely so dead torrents finish faster on a debrid plan.
298+
299+
Requires <code>ALLDEBRID_API_KEY</code> in the bot configuration."""
300+
301+
buzzheavier_arg = """<b>BuzzHeavier Upload</b>: -bh
302+
303+
/cmd link -bh
304+
After the download finishes, streams every file to BuzzHeavier instead
305+
of Telegram/Gdrive/Rclone and posts the resulting URLs in the final
306+
message. Optionally set <code>BUZZHEAVIER_ACCOUNT_ID</code> in the bot
307+
configuration to authenticate uploads against your BuzzHeavier
308+
account; without it the file is uploaded as a guest and expires
309+
sooner."""
310+
287311
YT_HELP_DICT = {
288312
"main": yt,
289313
"New-Name": f"{new_name}\nNote: Don't add file extension",
@@ -359,6 +383,8 @@
359383
"Thumb-Layout": thumbnail_layout,
360384
"Leech-Type": leech_as,
361385
"FFmpeg-Cmds": ffmpeg_cmds,
386+
"AllDebrid": alldebrid_arg,
387+
"BuzzHeavier": buzzheavier_arg,
362388
}
363389

364390
CLONE_HELP_DICT = {

bot/helper/listeners/task_listener.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@
3636
from ..ext_utils.task_manager import start_from_queued, check_running_tasks
3737
from ..mirror_leech_utils.gdrive_utils.upload import GoogleDriveUpload
3838
from ..mirror_leech_utils.rclone_utils.transfer import RcloneTransferHelper
39+
from ..mirror_leech_utils.buzzheavier_uploader import BuzzHeavierUploader
3940
from ..mirror_leech_utils.status_utils.gdrive_status import GoogleDriveStatus
4041
from ..mirror_leech_utils.status_utils.queue_status import QueueStatus
4142
from ..mirror_leech_utils.status_utils.rclone_status import RcloneStatus
4243
from ..mirror_leech_utils.status_utils.telegram_status import TelegramStatus
44+
from ..mirror_leech_utils.status_utils.buzzheavier_status import BuzzHeavierStatus
4345
from ..mirror_leech_utils.telegram_uploader import TelegramUploader
4446
from ..telegram_helper.button_build import ButtonMaker
4547
from ..telegram_helper.message_utils import (
@@ -301,6 +303,16 @@ async def on_download_complete(self):
301303
tg.upload(),
302304
)
303305
del tg
306+
elif self.is_buzzheavier:
307+
LOGGER.info(f"BuzzHeavier Upload Name: {self.name}")
308+
bh = BuzzHeavierUploader(self, up_path)
309+
async with task_dict_lock:
310+
task_dict[self.mid] = BuzzHeavierStatus(self, bh, gid, "up")
311+
await gather(
312+
update_status_message(self.message.chat.id),
313+
bh.upload(),
314+
)
315+
del bh
304316
elif is_gdrive_id(self.up_dest):
305317
LOGGER.info(f"Gdrive Upload Name: {self.name}")
306318
drive = GoogleDriveUpload(self, up_path)
@@ -421,6 +433,19 @@ async def on_download_error(self, error, button=None):
421433
del task_dict[self.mid]
422434
count = len(task_dict)
423435
await self.remove_from_same_dir()
436+
# Best-effort: release the AllDebrid magnet so it does not
437+
# linger in the user's history if the task aborts mid-flight.
438+
magnet_id = getattr(self, "_alldebrid_magnet_id", 0) or 0
439+
if magnet_id:
440+
try:
441+
from ..mirror_leech_utils.download_utils.alldebrid_resolver import (
442+
delete_magnet,
443+
)
444+
445+
await delete_magnet(magnet_id)
446+
except Exception:
447+
pass
448+
self._alldebrid_magnet_id = 0
424449
msg = f"{self.tag} Download: {escape(str(error))}"
425450
await send_message(self.message, msg, button)
426451
if count == 0:
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""BuzzHeavier upload helper.
2+
3+
Used by mirror tasks when the ``-bh`` flag is supplied. Walks the
4+
download directory, streams each file to BuzzHeavier with progress
5+
callbacks that the existing status renderer can read, and finishes by
6+
calling ``listener.on_upload_complete``.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from logging import getLogger
12+
from os import walk, path as ospath
13+
from time import time
14+
from typing import AsyncIterator
15+
16+
from aiofiles import open as aiopen
17+
from httpx import AsyncClient, HTTPError, Limits, Timeout
18+
19+
from ...core.config_manager import Config
20+
21+
22+
LOGGER = getLogger(__name__)
23+
24+
_UPLOAD_BASE = "https://w.buzzheavier.com"
25+
_UPLOAD_CHUNK = 16 * 1024 * 1024 # 16 MiB read window
26+
_HTTP_TIMEOUT = Timeout(connect=30.0, read=600.0, write=600.0, pool=30.0)
27+
28+
29+
def _auth_headers() -> dict[str, str]:
30+
headers: dict[str, str] = {}
31+
account_id = (Config.BUZZHEAVIER_ACCOUNT_ID or "").strip()
32+
if account_id:
33+
headers["Authorization"] = f"Bearer {account_id}"
34+
return headers
35+
36+
37+
class BuzzHeavierUploader:
38+
"""Stream files in ``self._path`` to BuzzHeavier sequentially."""
39+
40+
def __init__(self, listener, path: str):
41+
self._listener = listener
42+
self._path = path
43+
self._processed_bytes = 0
44+
self._start_time = time()
45+
self._last_speed_bytes = 0
46+
self._last_speed_at = self._start_time
47+
self._speed = 0.0
48+
self._files_dict: dict[str, str] = {}
49+
self._total_files = 0
50+
self._error: str = ""
51+
52+
# ── status interface ─────────────────────────────────────────────
53+
54+
@property
55+
def processed_bytes(self) -> int:
56+
return self._processed_bytes
57+
58+
@property
59+
def speed(self) -> float:
60+
now = time()
61+
elapsed = now - self._last_speed_at
62+
if elapsed >= 1.0:
63+
delta = self._processed_bytes - self._last_speed_bytes
64+
self._speed = delta / elapsed if elapsed > 0 else 0.0
65+
self._last_speed_at = now
66+
self._last_speed_bytes = self._processed_bytes
67+
return self._speed
68+
69+
# ── upload ────────────────────────────────────────────────────────
70+
71+
async def _stream_file(self, file_path: str, file_size: int) -> AsyncIterator[bytes]:
72+
async with aiopen(file_path, "rb") as fh:
73+
while True:
74+
if self._listener.is_cancelled:
75+
return
76+
chunk = await fh.read(_UPLOAD_CHUNK)
77+
if not chunk:
78+
return
79+
self._processed_bytes += len(chunk)
80+
yield chunk
81+
82+
async def _upload_one(self, client: AsyncClient, file_path: str) -> str:
83+
file_name = ospath.basename(file_path)
84+
file_size = ospath.getsize(file_path)
85+
url = f"{_UPLOAD_BASE}/{file_name}"
86+
headers = {
87+
"Content-Type": "application/octet-stream",
88+
"Content-Length": str(file_size),
89+
**_auth_headers(),
90+
}
91+
92+
LOGGER.info(f"Uploading to BuzzHeavier: {file_name} ({file_size} bytes)")
93+
response = await client.put(
94+
url,
95+
content=self._stream_file(file_path, file_size),
96+
headers=headers,
97+
)
98+
if response.status_code not in (200, 201):
99+
raise RuntimeError(
100+
f"BuzzHeavier upload failed [{response.status_code}]: "
101+
f"{response.text[:200]}"
102+
)
103+
104+
try:
105+
payload = response.json()
106+
except ValueError as exc:
107+
raise RuntimeError(
108+
f"BuzzHeavier returned non-JSON response: {response.text[:200]}"
109+
) from exc
110+
111+
data = payload.get("data") if isinstance(payload, dict) else None
112+
if not isinstance(data, dict):
113+
raise RuntimeError("BuzzHeavier response missing 'data' object")
114+
file_id = (data.get("id") or "").strip()
115+
if not file_id:
116+
raise RuntimeError("BuzzHeavier response missing file id")
117+
return f"https://buzzheavier.com/{file_id}"
118+
119+
async def upload(self) -> None:
120+
files: list[str] = []
121+
if ospath.isfile(self._path):
122+
files.append(self._path)
123+
else:
124+
for root, _, names in walk(self._path):
125+
for name in sorted(names):
126+
candidate = ospath.join(root, name)
127+
if ospath.isfile(candidate):
128+
files.append(candidate)
129+
130+
if not files:
131+
await self._listener.on_upload_error(
132+
"BuzzHeavier: no files were found to upload"
133+
)
134+
return
135+
136+
self._total_files = len(files)
137+
first_link = ""
138+
139+
try:
140+
async with AsyncClient(
141+
timeout=_HTTP_TIMEOUT,
142+
limits=Limits(max_connections=4, max_keepalive_connections=2),
143+
) as client:
144+
for file_path in files:
145+
if self._listener.is_cancelled:
146+
await self._listener.on_upload_error(
147+
"BuzzHeavier upload cancelled by user"
148+
)
149+
return
150+
try:
151+
link = await self._upload_one(client, file_path)
152+
except (HTTPError, RuntimeError) as exc:
153+
LOGGER.error(
154+
f"BuzzHeavier upload error for "
155+
f"{ospath.basename(file_path)}: {exc}"
156+
)
157+
self._error = str(exc)
158+
await self._listener.on_upload_error(
159+
f"BuzzHeavier: {exc}"
160+
)
161+
return
162+
self._files_dict[link] = ospath.basename(file_path)
163+
if not first_link:
164+
first_link = link
165+
except Exception as exc: # pragma: no cover - safety net
166+
LOGGER.error(f"BuzzHeavier session error: {exc}")
167+
await self._listener.on_upload_error(f"BuzzHeavier: {exc}")
168+
return
169+
170+
if self._listener.is_cancelled:
171+
await self._listener.on_upload_error(
172+
"BuzzHeavier upload cancelled by user"
173+
)
174+
return
175+
176+
LOGGER.info(
177+
f"BuzzHeavier upload completed: {self._total_files} file(s)"
178+
)
179+
# Mirror behaviour: ``link`` is the primary URL, ``files`` is a
180+
# link → name dict (for multi-file), ``folders``/``mime_type``
181+
# carry the totals expected by ``on_upload_complete``.
182+
await self._listener.on_upload_complete(
183+
first_link,
184+
self._files_dict,
185+
self._total_files,
186+
"BuzzHeavier",
187+
)

0 commit comments

Comments
 (0)