Spaces:
Running
⚡ تسريع كل العمليات + تطوير شامل للرسائل والأزرار
Browse files⚡ التسريع:
- max_concurrent_transmissions: 4 → 8 (تسريع التحميل)
- _CHUNK_SIZE: 64KB → 1MB (16x أسرع في رفع StreamTape)
- Connection pool: 5→10 keepalive, 10→20 max
- تتبع real bandwidth (session_bw) بدلاً من التقدير
📊 التقدم التفصيلي:
- شريط تقدم 20 حرفاً
- سرعة مزدوجة: MB/s + Mbps
- وقت منقضي + ETA لكل مرحلة
- StreamTape: SHA256 → رابط → رفع → رابط تحميل لكل حساب
- تلغرام: تقدم لكل نسخة (1/3, 2/3, 3/3)
- أرشفة: اسم الجدول + record ID
- باندويث حقيقي يظهر في كل تحديث
🎨 الأزرار:
- نتائج TMDB مع تقييم ونجوم
- الجودة مع وصف (SD, HD, Full HD, QHD, Ultra HD)
- زر رجوع للجودة في التأكيد
- تأكيد بخط كبير وواضح
🐛 إصلاحات:
- stage_callback لـ StreamTape مع تفاصيل كل حساب
- _ProgressStream مع account_index وتوقيت حقيقي
- back_quality في callback_handler
- bot/client.py +1 -1
- bot/handlers/callback_handler.py +22 -0
- bot/keyboards.py +41 -18
- services/pipeline.py +228 -76
- services/streamtape_service.py +74 -20
- utils/progress_formatter.py +141 -80
|
@@ -17,6 +17,6 @@ def create_client(session_name: str = "upload_bot") -> Client:
|
|
| 17 |
workdir=str(settings.session_dir),
|
| 18 |
in_memory=False,
|
| 19 |
sleep_threshold=30,
|
| 20 |
-
max_concurrent_transmissions=
|
| 21 |
plugins=dict(root="bot/handlers"),
|
| 22 |
)
|
|
|
|
| 17 |
workdir=str(settings.session_dir),
|
| 18 |
in_memory=False,
|
| 19 |
sleep_threshold=30,
|
| 20 |
+
max_concurrent_transmissions=8,
|
| 21 |
plugins=dict(root="bot/handlers"),
|
| 22 |
)
|
|
@@ -148,6 +148,28 @@ async def handle_callback(client: Client, callback: CallbackQuery):
|
|
| 148 |
reply_markup=confirm_keyboard(),
|
| 149 |
)
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
elif data == "confirm_all":
|
| 152 |
# ═════════════════════════════════════════════════════════
|
| 153 |
# IMPORTANT: We NEVER run Pipeline directly inside a
|
|
|
|
| 148 |
reply_markup=confirm_keyboard(),
|
| 149 |
)
|
| 150 |
|
| 151 |
+
elif data == "back_quality":
|
| 152 |
+
# رجوع إلى اختيار الجودة
|
| 153 |
+
if state.selected_tmdb:
|
| 154 |
+
await update_state(
|
| 155 |
+
user_id,
|
| 156 |
+
current_state=BotState.AWAITING_QUALITY.name,
|
| 157 |
+
)
|
| 158 |
+
await callback.edit_message_text(
|
| 159 |
+
"━━━━━━━━━━━━━━━━━━━\n"
|
| 160 |
+
"🔙 **🔙 رجوع إلى اختيار الجودة**\n"
|
| 161 |
+
"━━━━━━━━━━━━━━━━━━━\n\n"
|
| 162 |
+
f"🎬 **{state.selected_tmdb.get('title', '?')} ({state.selected_tmdb.get('year', '?')})**\n\n"
|
| 163 |
+
"📺 **اختر جودة الرفع المناسبة:**",
|
| 164 |
+
reply_markup=quality_keyboard(),
|
| 165 |
+
)
|
| 166 |
+
else:
|
| 167 |
+
await callback.edit_message_text(
|
| 168 |
+
"⚠️ **لا توجد معلومات عن العمل.**\n"
|
| 169 |
+
"الرجاء البدء من جديد بإرسال ملف."
|
| 170 |
+
)
|
| 171 |
+
return
|
| 172 |
+
|
| 173 |
elif data == "confirm_all":
|
| 174 |
# ═════════════════════════════════════════════════════════
|
| 175 |
# IMPORTANT: We NEVER run Pipeline directly inside a
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Inline keyboard builders —
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -6,55 +6,78 @@ from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
| 6 |
|
| 7 |
|
| 8 |
def tmdb_results_keyboard(results: list[dict]) -> InlineKeyboardMarkup:
|
| 9 |
-
"""Show up to 3 TMDB results
|
| 10 |
buttons: list[list[InlineKeyboardButton]] = []
|
| 11 |
for i, r in enumerate(results[:3]):
|
| 12 |
year = r.get("year", "?")
|
| 13 |
title = r.get("title", "Unknown")[:35]
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
buttons.append([InlineKeyboardButton(label, callback_data=f"tmdb_{i}")])
|
| 16 |
|
|
|
|
| 17 |
row2: list[InlineKeyboardButton] = [
|
| 18 |
-
InlineKeyboardButton("✏️
|
| 19 |
InlineKeyboardButton("🆔 إدخال ID", callback_data="manual_id"),
|
| 20 |
-
InlineKeyboardButton("❌ إلغاء", callback_data="cancel"),
|
| 21 |
]
|
| 22 |
buttons.append(row2)
|
|
|
|
|
|
|
| 23 |
return InlineKeyboardMarkup(buttons)
|
| 24 |
|
| 25 |
|
| 26 |
def quality_keyboard() -> InlineKeyboardMarkup:
|
| 27 |
-
"""Quality selection
|
| 28 |
-
|
| 29 |
-
"480p": "📺",
|
| 30 |
-
"720p": "💿",
|
| 31 |
-
"1080p": "✨",
|
| 32 |
-
"2K": "🌟",
|
| 33 |
-
"4K": "💎",
|
| 34 |
}
|
| 35 |
row = [
|
| 36 |
InlineKeyboardButton(
|
| 37 |
-
f"{
|
| 38 |
callback_data=f"quality_{q}",
|
| 39 |
)
|
| 40 |
-
for q
|
| 41 |
]
|
| 42 |
return InlineKeyboardMarkup([
|
| 43 |
row,
|
| 44 |
-
[InlineKeyboardButton("❌ إلغاء", callback_data="cancel")],
|
| 45 |
])
|
| 46 |
|
| 47 |
|
| 48 |
def confirm_keyboard() -> InlineKeyboardMarkup:
|
|
|
|
| 49 |
return InlineKeyboardMarkup([
|
| 50 |
[
|
| 51 |
-
InlineKeyboardButton(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
InlineKeyboardButton("❌ إلغاء", callback_data="cancel"),
|
| 53 |
-
]
|
| 54 |
])
|
| 55 |
|
| 56 |
|
| 57 |
def cancel_only_keyboard() -> InlineKeyboardMarkup:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
return InlineKeyboardMarkup([
|
| 59 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
])
|
|
|
|
| 1 |
+
"""Inline keyboard builders — rich Arabic buttons with emoji and descriptions."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def tmdb_results_keyboard(results: list[dict]) -> InlineKeyboardMarkup:
|
| 9 |
+
"""Show up to 3 TMDB results with ratings, plus manual options."""
|
| 10 |
buttons: list[list[InlineKeyboardButton]] = []
|
| 11 |
for i, r in enumerate(results[:3]):
|
| 12 |
year = r.get("year", "?")
|
| 13 |
title = r.get("title", "Unknown")[:35]
|
| 14 |
+
rating = r.get("vote_average", 0)
|
| 15 |
+
stars = "⭐" * max(1, round(rating / 2))
|
| 16 |
+
label = f"{i + 1}. {title} ({year}) {stars}"
|
| 17 |
buttons.append([InlineKeyboardButton(label, callback_data=f"tmdb_{i}")])
|
| 18 |
|
| 19 |
+
# Manual options row
|
| 20 |
row2: list[InlineKeyboardButton] = [
|
| 21 |
+
InlineKeyboardButton("✏️ بحث يدوي", callback_data="manual_input"),
|
| 22 |
InlineKeyboardButton("🆔 إدخال ID", callback_data="manual_id"),
|
|
|
|
| 23 |
]
|
| 24 |
buttons.append(row2)
|
| 25 |
+
# Cancel row
|
| 26 |
+
buttons.append([InlineKeyboardButton("❌ إلغاء العملية", callback_data="cancel")])
|
| 27 |
return InlineKeyboardMarkup(buttons)
|
| 28 |
|
| 29 |
|
| 30 |
def quality_keyboard() -> InlineKeyboardMarkup:
|
| 31 |
+
"""Quality selection with visual indicators and estimated sizes."""
|
| 32 |
+
quality_info = {
|
| 33 |
+
"480p": ("📺", "SD"),
|
| 34 |
+
"720p": ("💿", "HD"),
|
| 35 |
+
"1080p": ("✨", "Full HD"),
|
| 36 |
+
"2K": ("🌟", "QHD"),
|
| 37 |
+
"4K": ("💎", "Ultra HD"),
|
| 38 |
}
|
| 39 |
row = [
|
| 40 |
InlineKeyboardButton(
|
| 41 |
+
f"{emoji} {q} {tag}",
|
| 42 |
callback_data=f"quality_{q}",
|
| 43 |
)
|
| 44 |
+
for q, (emoji, tag) in quality_info.items()
|
| 45 |
]
|
| 46 |
return InlineKeyboardMarkup([
|
| 47 |
row,
|
| 48 |
+
[InlineKeyboardButton("❌ إلغاء العملية", callback_data="cancel")],
|
| 49 |
])
|
| 50 |
|
| 51 |
|
| 52 |
def confirm_keyboard() -> InlineKeyboardMarkup:
|
| 53 |
+
"""Final confirmation with clear action buttons."""
|
| 54 |
return InlineKeyboardMarkup([
|
| 55 |
[
|
| 56 |
+
InlineKeyboardButton(
|
| 57 |
+
"✅✅ تأكيد وبدء الرفع 🚀",
|
| 58 |
+
callback_data="confirm_all",
|
| 59 |
+
),
|
| 60 |
+
],
|
| 61 |
+
[
|
| 62 |
+
InlineKeyboardButton("🔙 رجوع للجودة", callback_data="back_quality"),
|
| 63 |
InlineKeyboardButton("❌ إلغاء", callback_data="cancel"),
|
| 64 |
+
],
|
| 65 |
])
|
| 66 |
|
| 67 |
|
| 68 |
def cancel_only_keyboard() -> InlineKeyboardMarkup:
|
| 69 |
+
"""Simple cancel button (used during manual input)."""
|
| 70 |
+
return InlineKeyboardMarkup([
|
| 71 |
+
[InlineKeyboardButton("❌ إلغاء وإعادة المحاولة", callback_data="cancel")]
|
| 72 |
+
])
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def queue_status_keyboard() -> InlineKeyboardMarkup:
|
| 76 |
+
"""Queue management buttons."""
|
| 77 |
return InlineKeyboardMarkup([
|
| 78 |
+
[
|
| 79 |
+
InlineKeyboardButton("📊 حالة الطابور", callback_data="queue_status"),
|
| 80 |
+
InlineKeyboardButton("🔄 تحديث", callback_data="queue_refresh"),
|
| 81 |
+
],
|
| 82 |
+
[InlineKeyboardButton("🔙 العودة", callback_data="back_main")],
|
| 83 |
])
|
|
@@ -32,7 +32,7 @@ from services.ffmpeg_service import change_fingerprint, FFmpegError, FFmpegValid
|
|
| 32 |
from services.streamtape_service import upload_to_all_accounts, StreamTapeError
|
| 33 |
from services.tmdb_service import episode_details, TMDbError
|
| 34 |
from bot.states import get_state, reset_state, update_state
|
| 35 |
-
from utils.file_utils import cleanup_old_files, format_duration, format_size, sanitize_filename
|
| 36 |
from utils.hashing import generate_unique_id, hash_tmdb_id
|
| 37 |
from utils.progress_formatter import format_progress_message
|
| 38 |
from config import settings
|
|
@@ -66,9 +66,10 @@ class Pipeline:
|
|
| 66 |
self.user_id = user_id
|
| 67 |
self.state: AdminState | None = None
|
| 68 |
self.progress_msg_id: int | None = None
|
| 69 |
-
self.total_bw: int = 0
|
| 70 |
-
self.
|
| 71 |
-
self.
|
|
|
|
| 72 |
|
| 73 |
# ── Public API ──────────────────────────────────────────────────
|
| 74 |
|
|
@@ -80,6 +81,7 @@ class Pipeline:
|
|
| 80 |
|
| 81 |
ctx = self._build_context()
|
| 82 |
self.total_bw = await db.get_total_bandwidth()
|
|
|
|
| 83 |
|
| 84 |
if await self._check_duplicate(ctx):
|
| 85 |
return {"status": "duplicate", "key": f"{ctx['tmdb_id_hash']}+{ctx['quality']}"}
|
|
@@ -213,11 +215,14 @@ class Pipeline:
|
|
| 213 |
# ── Stage 1: Download ───────────────────────────────────────────
|
| 214 |
|
| 215 |
async def _download(self, ctx: dict[str, Any]) -> Path:
|
| 216 |
-
"""تحميل الملف من تلغرام."""
|
| 217 |
self.state.current_state = BotState.DOWNLOADING.name
|
|
|
|
|
|
|
|
|
|
| 218 |
await self._update_progress(
|
| 219 |
DownloadProgress(
|
| 220 |
-
0,
|
| 221 |
stage_detail=f"تحميل `{ctx['file_name'][:50]}`",
|
| 222 |
sub_stage="MTProto",
|
| 223 |
),
|
|
@@ -227,51 +232,70 @@ class Pipeline:
|
|
| 227 |
"Starting download for user {user}: file_id={fid}, size={size}",
|
| 228 |
user=self.user_id,
|
| 229 |
fid=ctx["file_id"][:20],
|
| 230 |
-
size=
|
| 231 |
)
|
| 232 |
|
| 233 |
result = await download_file(
|
| 234 |
self.client,
|
| 235 |
ctx["file_id"],
|
| 236 |
ctx["original_path"],
|
| 237 |
-
|
| 238 |
-
self.total_bw,
|
| 239 |
self._progress_callback(),
|
| 240 |
)
|
| 241 |
|
| 242 |
-
# Double-check: file must exist and have content
|
| 243 |
if not result.exists():
|
| 244 |
raise RuntimeError(f"Downloaded file does not exist: {result}")
|
| 245 |
if result.stat().st_size == 0:
|
| 246 |
raise RuntimeError(f"Downloaded file is empty (0 bytes): {result}")
|
| 247 |
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
return result
|
| 250 |
|
| 251 |
# ── Stage 2: FFmpeg ─────────────────────────────────────────────
|
| 252 |
|
| 253 |
async def _process_ffmpeg(self, input_path: Path, ctx: dict[str, Any]) -> Path:
|
| 254 |
-
"""تغيير البصمة الرقمية للملف عبر FFmpeg."""
|
| 255 |
self.state.current_state = BotState.FFMPEG_PRO.name
|
|
|
|
| 256 |
|
| 257 |
await self._update_progress(
|
| 258 |
DownloadProgress(
|
| 259 |
-
0,
|
| 260 |
stage_detail="التحقق من صحة الملف قبل المعالجة",
|
| 261 |
sub_stage="ffprobe",
|
| 262 |
),
|
| 263 |
)
|
| 264 |
|
| 265 |
-
# ✅ Validate input file before processing
|
| 266 |
if not input_path.exists():
|
| 267 |
-
raise RuntimeError(
|
| 268 |
-
f"Input file for FFmpeg does not exist: {input_path}"
|
| 269 |
-
)
|
| 270 |
input_size = input_path.stat().st_size
|
| 271 |
if input_size == 0:
|
| 272 |
-
raise RuntimeError(
|
| 273 |
-
|
| 274 |
-
)
|
| 275 |
logger.info(
|
| 276 |
"Validating media: {path} ({size} bytes)",
|
| 277 |
path=input_path,
|
|
@@ -279,7 +303,7 @@ class Pipeline:
|
|
| 279 |
)
|
| 280 |
|
| 281 |
try:
|
| 282 |
-
await validate_media(input_path)
|
| 283 |
except FFmpegValidationError as exc:
|
| 284 |
logger.warning(
|
| 285 |
"Media validation failed for {path}: {msg} | stderr: {err}",
|
|
@@ -289,58 +313,144 @@ class Pipeline:
|
|
| 289 |
)
|
| 290 |
raise
|
| 291 |
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
input_path,
|
| 294 |
ctx["processed_path"],
|
| 295 |
-
|
| 296 |
self.total_bw,
|
| 297 |
self._progress_callback(),
|
| 298 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
# ── Stage 3: StreamTape ─────────────────────────────────────────
|
| 301 |
|
| 302 |
async def _upload_streamtape(self, ctx: dict[str, Any]) -> list[str]:
|
| 303 |
-
"""رفع الملف إلى حسابات StreamTape الثلاثة."""
|
| 304 |
self.state.current_state = BotState.ST_UPLOADING.name
|
|
|
|
|
|
|
|
|
|
| 305 |
await self._update_progress(
|
| 306 |
DownloadProgress(
|
| 307 |
-
0,
|
| 308 |
stage_detail="رفع إلى 3 حسابات StreamTape (بالتوازي)",
|
| 309 |
sub_stage="SHA256 + HTTP POST",
|
| 310 |
total_items=3,
|
| 311 |
),
|
| 312 |
)
|
| 313 |
|
| 314 |
-
# stage_callback لتحديث تفاصيل كل حساب
|
| 315 |
async def _st_stage(msg: str):
|
|
|
|
| 316 |
await self._update_progress(
|
| 317 |
DownloadProgress(
|
| 318 |
-
0,
|
| 319 |
stage_detail=msg,
|
| 320 |
sub_stage="StreamTape",
|
| 321 |
total_items=3,
|
| 322 |
),
|
| 323 |
)
|
| 324 |
|
| 325 |
-
|
| 326 |
ctx["processed_path"],
|
| 327 |
ctx["remote_fn"],
|
| 328 |
-
|
| 329 |
self.total_bw,
|
| 330 |
self._progress_callback(),
|
| 331 |
remote_path_override=ctx["remote_path"],
|
| 332 |
stage_callback=lambda msg: asyncio.ensure_future(_st_stage(msg)),
|
| 333 |
)
|
| 334 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
# ── Stage 4: Telegram copies ────────────────────────────────────
|
| 336 |
|
| 337 |
async def _upload_telegram(self, ctx: dict[str, Any]) -> list[str]:
|
| 338 |
-
"""رفع 3 نسخ احتياطية إلى تلغرام
|
| 339 |
self.state.current_state = BotState.TG_UPLOADING.name
|
|
|
|
|
|
|
| 340 |
|
| 341 |
await self._update_progress(
|
| 342 |
DownloadProgress(
|
| 343 |
-
0,
|
| 344 |
stage_detail="رفع 3 نسخ احتياطية إلى تلغرام",
|
| 345 |
sub_stage="جاري الرفع...",
|
| 346 |
total_items=3,
|
|
@@ -348,12 +458,12 @@ class Pipeline:
|
|
| 348 |
)
|
| 349 |
|
| 350 |
async def _upload_one(copy_num: int) -> str:
|
| 351 |
-
|
|
|
|
| 352 |
await self._update_progress(
|
| 353 |
DownloadProgress(
|
| 354 |
-
copy_num *
|
| 355 |
-
|
| 356 |
-
self.total_bw + copy_num * ctx["file_size"] // 3,
|
| 357 |
"telegram_upload",
|
| 358 |
stage_detail=f"رفع النسخة {copy_num} من 3",
|
| 359 |
sub_stage="send_video",
|
|
@@ -367,38 +477,57 @@ class Pipeline:
|
|
| 367 |
caption=f"{ctx['tmdb_id']}+{ctx['quality']}+{copy_num}.mp4",
|
| 368 |
supports_streaming=True,
|
| 369 |
)
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
|
| 372 |
tasks = [_upload_one(i) for i in range(1, 4)]
|
| 373 |
telegram_ids = await asyncio.gather(*tasks)
|
| 374 |
|
| 375 |
-
|
|
|
|
|
|
|
| 376 |
await self._update_progress(
|
| 377 |
DownloadProgress(
|
| 378 |
-
|
| 379 |
-
0, 0, self.total_bw + sum(ctx["file_size"] for _ in range(3)),
|
| 380 |
"telegram_upload",
|
| 381 |
-
stage_detail="
|
| 382 |
-
current_item=
|
| 383 |
total_items=3,
|
| 384 |
),
|
| 385 |
)
|
|
|
|
| 386 |
return telegram_ids
|
| 387 |
|
| 388 |
# ── Stage 5: Archive ────────────────────────────────────────────
|
| 389 |
|
| 390 |
async def _archive(self, ctx: dict[str, Any]) -> bool:
|
| 391 |
-
"""أرشفة البيانات في قاعدة البيانات."""
|
| 392 |
self.state.current_state = BotState.ARCHIVING.name
|
|
|
|
|
|
|
| 393 |
await self._update_progress(
|
| 394 |
DownloadProgress(
|
| 395 |
ctx["file_size"], ctx["file_size"], 0, 0,
|
| 396 |
-
self.total_bw
|
| 397 |
"archiving",
|
| 398 |
-
stage_detail="تخزين في
|
| 399 |
sub_stage="INSERT",
|
| 400 |
),
|
| 401 |
)
|
|
|
|
| 402 |
data: dict[str, Any] = {
|
| 403 |
"title": f"{ctx['title']} ({ctx['year']})",
|
| 404 |
"quality": ctx["quality"],
|
|
@@ -407,33 +536,62 @@ class Pipeline:
|
|
| 407 |
"telegram_file_ids": ctx["telegram_ids"],
|
| 408 |
"file_size": ctx["file_size"],
|
| 409 |
"original_filename": ctx["file_name"],
|
| 410 |
-
"bandwidth_used": self.total_bw
|
| 411 |
"admin_id": self.user_id,
|
| 412 |
}
|
| 413 |
|
| 414 |
if not ctx["is_series"]:
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
|
|
|
|
|
|
| 423 |
)
|
| 424 |
-
|
| 425 |
-
except TMDbError as exc:
|
| 426 |
logger.warning(
|
| 427 |
-
"
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
)
|
| 430 |
|
| 431 |
-
|
| 432 |
-
season_number=ctx["season"] or 1,
|
| 433 |
-
episode_number=ctx["episode"] or 1,
|
| 434 |
-
tmdb_episode_id=real_episode_id,
|
| 435 |
-
)
|
| 436 |
-
return await archive_series(**data)
|
| 437 |
|
| 438 |
# ── Completion ──────────────────────────────────────────────────
|
| 439 |
|
|
@@ -462,10 +620,11 @@ class Pipeline:
|
|
| 462 |
all_st_ok = all(data["streamtape_urls"])
|
| 463 |
all_tg_ok = all(data["telegram_ids"])
|
| 464 |
status_emoji = "✅" if (all_st_ok and all_tg_ok) else "⚠️"
|
| 465 |
-
status_text =
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
|
|
|
| 469 |
|
| 470 |
lines: list[str] = [
|
| 471 |
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
@@ -511,7 +670,8 @@ class Pipeline:
|
|
| 511 |
"━━━━━━━━━━━━━━━━━━━━━━━━",
|
| 512 |
f"⏱️ **الوقت المستغرق:** `{format_duration(data['elapsed'])}`",
|
| 513 |
f"📦 **حجم الملف:** `{format_size(data['file_size'])}`",
|
| 514 |
-
f"🌐 **
|
|
|
|
| 515 |
f"📋 **الحالة:** {status_emoji} {'نجاح كامل' if (all_st_ok and all_tg_ok) else 'نجاح جزئي'}",
|
| 516 |
"",
|
| 517 |
"━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
@@ -624,16 +784,8 @@ class Pipeline:
|
|
| 624 |
now = time.monotonic()
|
| 625 |
is_final = p.status in ("done", "error") or p.percent >= 100
|
| 626 |
|
| 627 |
-
# ── Throttle ذكي ────────────────────────────────────────
|
| 628 |
-
# نمرر التحديث إذا:
|
| 629 |
-
# 1. رسالة نهائية (اكتمل/خطأ)
|
| 630 |
-
# 2. أو مر وقت كافي (progress_update_interval)
|
| 631 |
-
# 3. أو تغيرت المرحلة أو التفاصيل (stage_detail, sub_stage)
|
| 632 |
time_ok = (now - self._last_progress_time) >= settings.progress_update_interval
|
| 633 |
-
stage_changed =
|
| 634 |
-
hasattr(self, '_last_detail') and
|
| 635 |
-
p.stage_detail != self._last_detail
|
| 636 |
-
)
|
| 637 |
if not is_final and not time_ok and not stage_changed:
|
| 638 |
return
|
| 639 |
|
|
|
|
| 32 |
from services.streamtape_service import upload_to_all_accounts, StreamTapeError
|
| 33 |
from services.tmdb_service import episode_details, TMDbError
|
| 34 |
from bot.states import get_state, reset_state, update_state
|
| 35 |
+
from utils.file_utils import cleanup_old_files, format_duration, format_size, format_speed, sanitize_filename
|
| 36 |
from utils.hashing import generate_unique_id, hash_tmdb_id
|
| 37 |
from utils.progress_formatter import format_progress_message
|
| 38 |
from config import settings
|
|
|
|
| 66 |
self.user_id = user_id
|
| 67 |
self.state: AdminState | None = None
|
| 68 |
self.progress_msg_id: int | None = None
|
| 69 |
+
self.total_bw: int = 0 # running total (historical + session)
|
| 70 |
+
self.session_bw: int = 0 # bandwidth consumed this session only
|
| 71 |
+
self._last_progress_time: float = 0.0
|
| 72 |
+
self._last_detail: str = ""
|
| 73 |
|
| 74 |
# ── Public API ──────────────────────────────────────────────────
|
| 75 |
|
|
|
|
| 81 |
|
| 82 |
ctx = self._build_context()
|
| 83 |
self.total_bw = await db.get_total_bandwidth()
|
| 84 |
+
self.session_bw = 0
|
| 85 |
|
| 86 |
if await self._check_duplicate(ctx):
|
| 87 |
return {"status": "duplicate", "key": f"{ctx['tmdb_id_hash']}+{ctx['quality']}"}
|
|
|
|
| 215 |
# ── Stage 1: Download ───────────────────────────────────────────
|
| 216 |
|
| 217 |
async def _download(self, ctx: dict[str, Any]) -> Path:
|
| 218 |
+
"""تحميل الملف من تلغرام مع تتبع السرعة الفعلية والباندويث."""
|
| 219 |
self.state.current_state = BotState.DOWNLOADING.name
|
| 220 |
+
file_size = ctx["file_size"]
|
| 221 |
+
dl_start = time.monotonic()
|
| 222 |
+
|
| 223 |
await self._update_progress(
|
| 224 |
DownloadProgress(
|
| 225 |
+
0, file_size, 0, 0, self.total_bw + self.session_bw, "downloading",
|
| 226 |
stage_detail=f"تحميل `{ctx['file_name'][:50]}`",
|
| 227 |
sub_stage="MTProto",
|
| 228 |
),
|
|
|
|
| 232 |
"Starting download for user {user}: file_id={fid}, size={size}",
|
| 233 |
user=self.user_id,
|
| 234 |
fid=ctx["file_id"][:20],
|
| 235 |
+
size=file_size,
|
| 236 |
)
|
| 237 |
|
| 238 |
result = await download_file(
|
| 239 |
self.client,
|
| 240 |
ctx["file_id"],
|
| 241 |
ctx["original_path"],
|
| 242 |
+
file_size,
|
| 243 |
+
self.total_bw + self.session_bw,
|
| 244 |
self._progress_callback(),
|
| 245 |
)
|
| 246 |
|
|
|
|
| 247 |
if not result.exists():
|
| 248 |
raise RuntimeError(f"Downloaded file does not exist: {result}")
|
| 249 |
if result.stat().st_size == 0:
|
| 250 |
raise RuntimeError(f"Downloaded file is empty (0 bytes): {result}")
|
| 251 |
|
| 252 |
+
actual_size = result.stat().st_size
|
| 253 |
+
dl_elapsed = time.monotonic() - dl_start
|
| 254 |
+
dl_speed = actual_size / dl_elapsed if dl_elapsed > 0 else 0
|
| 255 |
+
|
| 256 |
+
# Track real bandwidth consumed
|
| 257 |
+
self.total_bw += actual_size
|
| 258 |
+
self.session_bw += actual_size
|
| 259 |
+
|
| 260 |
+
logger.info(
|
| 261 |
+
"Download complete: {path} ({size} bytes) in {elapsed:.1f}s @ {speed:.1f} MB/s",
|
| 262 |
+
path=result,
|
| 263 |
+
size=actual_size,
|
| 264 |
+
elapsed=dl_elapsed,
|
| 265 |
+
speed=dl_speed / 1024 / 1024,
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
await self._update_progress(
|
| 269 |
+
DownloadProgress(
|
| 270 |
+
actual_size, file_size, dl_speed, dl_elapsed, self.total_bw, "downloading",
|
| 271 |
+
stage_detail=f"تم التحميل — {format_size(actual_size)} بـ {format_speed(dl_speed)}",
|
| 272 |
+
sub_stage="MTProto ✓",
|
| 273 |
+
),
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
return result
|
| 277 |
|
| 278 |
# ── Stage 2: FFmpeg ─────────────────────────────────────────────
|
| 279 |
|
| 280 |
async def _process_ffmpeg(self, input_path: Path, ctx: dict[str, Any]) -> Path:
|
| 281 |
+
"""تغيير البصمة الرقمية للملف عبر FFmpeg مع تتبع التقدّم."""
|
| 282 |
self.state.current_state = BotState.FFMPEG_PRO.name
|
| 283 |
+
file_size = ctx["file_size"]
|
| 284 |
|
| 285 |
await self._update_progress(
|
| 286 |
DownloadProgress(
|
| 287 |
+
0, file_size, 0, 0, self.total_bw, "processing",
|
| 288 |
stage_detail="التحقق من صحة الملف قبل المعالجة",
|
| 289 |
sub_stage="ffprobe",
|
| 290 |
),
|
| 291 |
)
|
| 292 |
|
|
|
|
| 293 |
if not input_path.exists():
|
| 294 |
+
raise RuntimeError(f"Input file for FFmpeg does not exist: {input_path}")
|
|
|
|
|
|
|
| 295 |
input_size = input_path.stat().st_size
|
| 296 |
if input_size == 0:
|
| 297 |
+
raise RuntimeError(f"Input file for FFmpeg is empty (0 bytes): {input_path}")
|
| 298 |
+
|
|
|
|
| 299 |
logger.info(
|
| 300 |
"Validating media: {path} ({size} bytes)",
|
| 301 |
path=input_path,
|
|
|
|
| 303 |
)
|
| 304 |
|
| 305 |
try:
|
| 306 |
+
probe_data = await validate_media(input_path)
|
| 307 |
except FFmpegValidationError as exc:
|
| 308 |
logger.warning(
|
| 309 |
"Media validation failed for {path}: {msg} | stderr: {err}",
|
|
|
|
| 313 |
)
|
| 314 |
raise
|
| 315 |
|
| 316 |
+
# Show validation passed with file info
|
| 317 |
+
duration_s = float(probe_data.get("format", {}).get("duration", 0))
|
| 318 |
+
fmt_name = probe_data.get("format", {}).get("format_name", "unknown")
|
| 319 |
+
logger.info(
|
| 320 |
+
"Validation passed: {path} | format={fmt} | duration={dur:.1f}s | size={size}",
|
| 321 |
+
path=input_path,
|
| 322 |
+
fmt=fmt_name,
|
| 323 |
+
dur=duration_s,
|
| 324 |
+
size=input_size,
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
await self._update_progress(
|
| 328 |
+
DownloadProgress(
|
| 329 |
+
0, file_size, 0, 0, self.total_bw, "processing",
|
| 330 |
+
stage_detail=f"✅ التحقق: {fmt_name}, {format_duration(duration_s)}",
|
| 331 |
+
sub_stage="ffprobe ✓",
|
| 332 |
+
),
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
# Run change_fingerprint — real-time progress from stderr handled inside
|
| 336 |
+
await self._update_progress(
|
| 337 |
+
DownloadProgress(
|
| 338 |
+
0, file_size, 0, 0, self.total_bw, "processing",
|
| 339 |
+
stage_detail=f"معالجة FFmpeg (نسخ الحاوية)",
|
| 340 |
+
sub_stage="remux",
|
| 341 |
+
),
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
remux_start = time.monotonic()
|
| 345 |
+
output_path = await change_fingerprint(
|
| 346 |
input_path,
|
| 347 |
ctx["processed_path"],
|
| 348 |
+
file_size,
|
| 349 |
self.total_bw,
|
| 350 |
self._progress_callback(),
|
| 351 |
)
|
| 352 |
+
remux_elapsed = time.monotonic() - remux_start
|
| 353 |
+
|
| 354 |
+
processed_size = output_path.stat().st_size
|
| 355 |
+
remux_speed = processed_size / remux_elapsed if remux_elapsed > 0 else 0
|
| 356 |
+
|
| 357 |
+
logger.info(
|
| 358 |
+
"FFmpeg remux complete: {path} ({size} bytes) in {elapsed:.1f}s @ {speed:.1f} MB/s",
|
| 359 |
+
path=output_path,
|
| 360 |
+
size=processed_size,
|
| 361 |
+
elapsed=remux_elapsed,
|
| 362 |
+
speed=remux_speed / 1024 / 1024,
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
await self._update_progress(
|
| 366 |
+
DownloadProgress(
|
| 367 |
+
file_size, file_size, remux_speed, remux_elapsed, self.total_bw, "processing",
|
| 368 |
+
stage_detail=f"تمت المعالجة — {format_size(processed_size)} بـ {format_speed(remux_speed)}",
|
| 369 |
+
sub_stage="remux ✓",
|
| 370 |
+
),
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
return output_path
|
| 374 |
|
| 375 |
# ── Stage 3: StreamTape ─────────────────────────────────────────
|
| 376 |
|
| 377 |
async def _upload_streamtape(self, ctx: dict[str, Any]) -> list[str]:
|
| 378 |
+
"""رفع الملف إلى حسابات StreamTape الثلاثة مع تتبع سرعة كل حساب."""
|
| 379 |
self.state.current_state = BotState.ST_UPLOADING.name
|
| 380 |
+
file_size = ctx["file_size"]
|
| 381 |
+
st_start = time.monotonic()
|
| 382 |
+
|
| 383 |
await self._update_progress(
|
| 384 |
DownloadProgress(
|
| 385 |
+
0, file_size, 0, 0, self.total_bw, "uploading",
|
| 386 |
stage_detail="رفع إلى 3 حسابات StreamTape (بالتوازي)",
|
| 387 |
sub_stage="SHA256 + HTTP POST",
|
| 388 |
total_items=3,
|
| 389 |
),
|
| 390 |
)
|
| 391 |
|
|
|
|
| 392 |
async def _st_stage(msg: str):
|
| 393 |
+
"""Callback لكل حساب — يُحدّث وصف المرحلة."""
|
| 394 |
await self._update_progress(
|
| 395 |
DownloadProgress(
|
| 396 |
+
0, file_size, 0, 0, self.total_bw, "uploading",
|
| 397 |
stage_detail=msg,
|
| 398 |
sub_stage="StreamTape",
|
| 399 |
total_items=3,
|
| 400 |
),
|
| 401 |
)
|
| 402 |
|
| 403 |
+
urls = await upload_to_all_accounts(
|
| 404 |
ctx["processed_path"],
|
| 405 |
ctx["remote_fn"],
|
| 406 |
+
file_size,
|
| 407 |
self.total_bw,
|
| 408 |
self._progress_callback(),
|
| 409 |
remote_path_override=ctx["remote_path"],
|
| 410 |
stage_callback=lambda msg: asyncio.ensure_future(_st_stage(msg)),
|
| 411 |
)
|
| 412 |
|
| 413 |
+
st_elapsed = time.monotonic() - st_start
|
| 414 |
+
successful = sum(1 for u in urls if u)
|
| 415 |
+
|
| 416 |
+
# Track real bandwidth for each successful upload
|
| 417 |
+
bw_consumed = file_size * successful
|
| 418 |
+
self.total_bw += bw_consumed
|
| 419 |
+
self.session_bw += bw_consumed
|
| 420 |
+
|
| 421 |
+
st_speed = bw_consumed / st_elapsed if st_elapsed > 0 else 0
|
| 422 |
+
|
| 423 |
+
logger.info(
|
| 424 |
+
"StreamTape uploads: {ok}/{total} accounts succeeded in {elapsed:.1f}s @ {speed:.1f} MB/s",
|
| 425 |
+
ok=successful,
|
| 426 |
+
total=len(urls),
|
| 427 |
+
elapsed=st_elapsed,
|
| 428 |
+
speed=st_speed / 1024 / 1024,
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
await self._update_progress(
|
| 432 |
+
DownloadProgress(
|
| 433 |
+
file_size, file_size, st_speed, st_elapsed, self.total_bw, "uploading",
|
| 434 |
+
stage_detail=f"✓ {successful}/3 حسابات — {format_size(bw_consumed)}",
|
| 435 |
+
sub_stage="StreamTape ✓",
|
| 436 |
+
total_items=3,
|
| 437 |
+
current_item=successful,
|
| 438 |
+
),
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
return urls
|
| 442 |
+
|
| 443 |
# ── Stage 4: Telegram copies ────────────────────────────────────
|
| 444 |
|
| 445 |
async def _upload_telegram(self, ctx: dict[str, Any]) -> list[str]:
|
| 446 |
+
"""رفع 3 نسخ احتياطية إلى تلغرام مع تتبع سرعة كل نسخة."""
|
| 447 |
self.state.current_state = BotState.TG_UPLOADING.name
|
| 448 |
+
file_size = ctx["file_size"]
|
| 449 |
+
tg_start = time.monotonic()
|
| 450 |
|
| 451 |
await self._update_progress(
|
| 452 |
DownloadProgress(
|
| 453 |
+
0, file_size, 0, 0, self.total_bw, "telegram_upload",
|
| 454 |
stage_detail="رفع 3 نسخ احتياطية إلى تلغرام",
|
| 455 |
sub_stage="جاري الرفع...",
|
| 456 |
total_items=3,
|
|
|
|
| 458 |
)
|
| 459 |
|
| 460 |
async def _upload_one(copy_num: int) -> str:
|
| 461 |
+
"""رفع نسخة واحدة مع تتبع وقتها وسرعتها."""
|
| 462 |
+
copy_start = time.monotonic()
|
| 463 |
await self._update_progress(
|
| 464 |
DownloadProgress(
|
| 465 |
+
copy_num * file_size // 3, file_size, 0, 0,
|
| 466 |
+
self.total_bw + copy_num * file_size // 3,
|
|
|
|
| 467 |
"telegram_upload",
|
| 468 |
stage_detail=f"رفع النسخة {copy_num} من 3",
|
| 469 |
sub_stage="send_video",
|
|
|
|
| 477 |
caption=f"{ctx['tmdb_id']}+{ctx['quality']}+{copy_num}.mp4",
|
| 478 |
supports_streaming=True,
|
| 479 |
)
|
| 480 |
+
copy_elapsed = time.monotonic() - copy_start
|
| 481 |
+
copy_speed = file_size / copy_elapsed if copy_elapsed > 0 else 0
|
| 482 |
+
sent_fid = sent.video.file_id if sent.video else ""
|
| 483 |
+
|
| 484 |
+
if sent_fid:
|
| 485 |
+
logger.info(
|
| 486 |
+
"Telegram copy {n}/3 done in {elapsed:.1f}s @ {speed:.1f} MB/s",
|
| 487 |
+
n=copy_num,
|
| 488 |
+
elapsed=copy_elapsed,
|
| 489 |
+
speed=copy_speed / 1024 / 1024,
|
| 490 |
+
)
|
| 491 |
+
self.total_bw += file_size
|
| 492 |
+
self.session_bw += file_size
|
| 493 |
+
|
| 494 |
+
return sent_fid
|
| 495 |
|
| 496 |
tasks = [_upload_one(i) for i in range(1, 4)]
|
| 497 |
telegram_ids = await asyncio.gather(*tasks)
|
| 498 |
|
| 499 |
+
tg_elapsed = time.monotonic() - tg_start
|
| 500 |
+
successful = sum(1 for fid in telegram_ids if fid)
|
| 501 |
+
|
| 502 |
await self._update_progress(
|
| 503 |
DownloadProgress(
|
| 504 |
+
file_size, file_size, 0, tg_elapsed, self.total_bw,
|
|
|
|
| 505 |
"telegram_upload",
|
| 506 |
+
stage_detail=f"✓ {successful}/3 نسخ احتياطية مرفوعة",
|
| 507 |
+
current_item=successful,
|
| 508 |
total_items=3,
|
| 509 |
),
|
| 510 |
)
|
| 511 |
+
|
| 512 |
return telegram_ids
|
| 513 |
|
| 514 |
# ── Stage 5: Archive ────────────────────────────────────────────
|
| 515 |
|
| 516 |
async def _archive(self, ctx: dict[str, Any]) -> bool:
|
| 517 |
+
"""أرشفة البيانات في قاعدة البيانات مع تأكيد السجل."""
|
| 518 |
self.state.current_state = BotState.ARCHIVING.name
|
| 519 |
+
table_name = "series" if ctx["is_series"] else "movies"
|
| 520 |
+
|
| 521 |
await self._update_progress(
|
| 522 |
DownloadProgress(
|
| 523 |
ctx["file_size"], ctx["file_size"], 0, 0,
|
| 524 |
+
self.total_bw,
|
| 525 |
"archiving",
|
| 526 |
+
stage_detail=f"تخزين في جدول `{table_name}`...",
|
| 527 |
sub_stage="INSERT",
|
| 528 |
),
|
| 529 |
)
|
| 530 |
+
|
| 531 |
data: dict[str, Any] = {
|
| 532 |
"title": f"{ctx['title']} ({ctx['year']})",
|
| 533 |
"quality": ctx["quality"],
|
|
|
|
| 536 |
"telegram_file_ids": ctx["telegram_ids"],
|
| 537 |
"file_size": ctx["file_size"],
|
| 538 |
"original_filename": ctx["file_name"],
|
| 539 |
+
"bandwidth_used": self.total_bw,
|
| 540 |
"admin_id": self.user_id,
|
| 541 |
}
|
| 542 |
|
| 543 |
if not ctx["is_series"]:
|
| 544 |
+
success = await archive_movie(**data)
|
| 545 |
+
record_id = f"🎬 movie:{ctx['tmdb_id']}"
|
| 546 |
+
else:
|
| 547 |
+
real_episode_id: int | None = None
|
| 548 |
+
try:
|
| 549 |
+
ep_data = await episode_details(
|
| 550 |
+
ctx["tmdb_id"],
|
| 551 |
+
ctx["season"] or 1,
|
| 552 |
+
ctx["episode"] or 1,
|
| 553 |
+
)
|
| 554 |
+
real_episode_id = ep_data.get("id", ctx["tmdb_id"])
|
| 555 |
+
except TMDbError as exc:
|
| 556 |
+
logger.warning(
|
| 557 |
+
"Failed to fetch episode details for tmdb_id={}, season={}, episode={}: {}",
|
| 558 |
+
ctx["tmdb_id"], ctx["season"], ctx["episode"], exc.message,
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
data.update(
|
| 562 |
+
season_number=ctx["season"] or 1,
|
| 563 |
+
episode_number=ctx["episode"] or 1,
|
| 564 |
+
tmdb_episode_id=real_episode_id,
|
| 565 |
+
)
|
| 566 |
+
success = await archive_series(**data)
|
| 567 |
+
record_id = (
|
| 568 |
+
f"📺 series:{ctx['tmdb_id']} "
|
| 569 |
+
f"S{ctx['season'] or 1}E{ctx['episode'] or 1}"
|
| 570 |
+
)
|
| 571 |
|
| 572 |
+
if success:
|
| 573 |
+
logger.info("Archive success: {record}", record=record_id)
|
| 574 |
+
await self._update_progress(
|
| 575 |
+
DownloadProgress(
|
| 576 |
+
ctx["file_size"], ctx["file_size"], 0, 0, self.total_bw, "archiving",
|
| 577 |
+
stage_detail=f"✅ تم الأرشفة: `{record_id}`",
|
| 578 |
+
sub_stage="INSERT ✓",
|
| 579 |
+
),
|
| 580 |
)
|
| 581 |
+
else:
|
|
|
|
| 582 |
logger.warning(
|
| 583 |
+
"Archive may have fallen back to local storage: {record}",
|
| 584 |
+
record=record_id,
|
| 585 |
+
)
|
| 586 |
+
await self._update_progress(
|
| 587 |
+
DownloadProgress(
|
| 588 |
+
ctx["file_size"], ctx["file_size"], 0, 0, self.total_bw, "archiving",
|
| 589 |
+
stage_detail=f"⚠️ أرشفة في ملف احتياطي: `{record_id}`",
|
| 590 |
+
sub_stage="FALLBACK",
|
| 591 |
+
),
|
| 592 |
)
|
| 593 |
|
| 594 |
+
return success
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
|
| 596 |
# ── Completion ──────────────────────────────────────────────────
|
| 597 |
|
|
|
|
| 620 |
all_st_ok = all(data["streamtape_urls"])
|
| 621 |
all_tg_ok = all(data["telegram_ids"])
|
| 622 |
status_emoji = "✅" if (all_st_ok and all_tg_ok) else "⚠️"
|
| 623 |
+
status_text = (
|
| 624 |
+
"**اكتمل كل ��يء بنجاح!** 🎉"
|
| 625 |
+
if (all_st_ok and all_tg_ok)
|
| 626 |
+
else "**اكتمل مع بعض الأخطاء** ⚠️"
|
| 627 |
+
)
|
| 628 |
|
| 629 |
lines: list[str] = [
|
| 630 |
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
|
|
| 670 |
"━━━━━━━━━━━━━━━━━━━━━━━━",
|
| 671 |
f"⏱️ **الوقت المستغرق:** `{format_duration(data['elapsed'])}`",
|
| 672 |
f"📦 **حجم الملف:** `{format_size(data['file_size'])}`",
|
| 673 |
+
f"🌐 **الباندويث (هذه الجلسة):** `{format_size(self.session_bw)}`",
|
| 674 |
+
f"🌐 **الباندويث (تراكمي):** `{format_size(self.total_bw)}`",
|
| 675 |
f"📋 **الحالة:** {status_emoji} {'نجاح كامل' if (all_st_ok and all_tg_ok) else 'نجاح جزئي'}",
|
| 676 |
"",
|
| 677 |
"━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
|
|
| 784 |
now = time.monotonic()
|
| 785 |
is_final = p.status in ("done", "error") or p.percent >= 100
|
| 786 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
time_ok = (now - self._last_progress_time) >= settings.progress_update_interval
|
| 788 |
+
stage_changed = p.stage_detail != self._last_detail
|
|
|
|
|
|
|
|
|
|
| 789 |
if not is_final and not time_ok and not stage_changed:
|
| 790 |
return
|
| 791 |
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
| 8 |
|
| 9 |
import asyncio
|
| 10 |
import hashlib
|
|
|
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import Any, AsyncIterator, Callable
|
| 13 |
|
|
@@ -20,9 +21,6 @@ from models.schemas import DownloadProgress, StreamTapeAccount
|
|
| 20 |
_ST_API = "https://api.streamtape.com"
|
| 21 |
|
| 22 |
# ── Shared httpx Client ────────────────────────────────────────────────────────
|
| 23 |
-
# NOTE: We use a single client with a GENEROUS default timeout.
|
| 24 |
-
# Per-request overrides are handled via the ``timeout`` keyword argument
|
| 25 |
-
# on individual ``client.post()`` calls, NOT by creating new clients.
|
| 26 |
|
| 27 |
_shared_client: httpx.AsyncClient | None = None
|
| 28 |
|
|
@@ -31,16 +29,16 @@ def _get_client() -> httpx.AsyncClient:
|
|
| 31 |
"""إرجاع shared httpx client مع connection pooling (مهلة كبيرة آمنة)."""
|
| 32 |
global _shared_client
|
| 33 |
if _shared_client is None:
|
| 34 |
-
limits = httpx.Limits(max_keepalive_connections=
|
| 35 |
_shared_client = httpx.AsyncClient(
|
| 36 |
-
timeout=httpx.Timeout(600.0, connect=30.0),
|
| 37 |
limits=limits,
|
| 38 |
)
|
| 39 |
return _shared_client
|
| 40 |
_RETRY_DELAYS = [2, 5, 10]
|
| 41 |
-
_UPLOAD_TIMEOUT =
|
| 42 |
-
_CHUNK_SIZE =
|
| 43 |
-
_CONCURRENT_UPLOADS = 3
|
| 44 |
|
| 45 |
|
| 46 |
class StreamTapeError(Exception):
|
|
@@ -103,17 +101,23 @@ class _ProgressStream(httpx.AsyncByteStream):
|
|
| 103 |
file_size: int,
|
| 104 |
bandwidth_total: int,
|
| 105 |
progress_callback: Callable[[DownloadProgress], Any],
|
|
|
|
|
|
|
| 106 |
) -> None:
|
| 107 |
"""Args:
|
| 108 |
local_path: المسار المحلي للملف.
|
| 109 |
file_size: الحجم الكلي للملف بالبايت.
|
| 110 |
bandwidth_total: إجمالي الباندويث المستخدم قبل هذه العملية.
|
| 111 |
progress_callback: دالة تُستدعى مع كل تحديث تقدّم.
|
|
|
|
|
|
|
| 112 |
"""
|
| 113 |
self._local_path = local_path
|
| 114 |
self._file_size = file_size
|
| 115 |
self._bandwidth_total = bandwidth_total
|
| 116 |
self._progress_callback = progress_callback
|
|
|
|
|
|
|
| 117 |
self._uploaded = 0
|
| 118 |
|
| 119 |
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
@@ -125,14 +129,20 @@ class _ProgressStream(httpx.AsyncByteStream):
|
|
| 125 |
if not chunk:
|
| 126 |
break
|
| 127 |
self._uploaded += len(chunk)
|
|
|
|
|
|
|
| 128 |
self._progress_callback(
|
| 129 |
DownloadProgress(
|
| 130 |
downloaded=self._uploaded,
|
| 131 |
total=self._file_size,
|
| 132 |
-
speed=
|
| 133 |
-
elapsed=
|
| 134 |
bandwidth_total=self._bandwidth_total + self._uploaded,
|
| 135 |
status="uploading",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
),
|
| 137 |
)
|
| 138 |
yield chunk
|
|
@@ -290,34 +300,63 @@ async def _http_upload(
|
|
| 290 |
Returns:
|
| 291 |
رابط التحميل النهائي من StreamTape.
|
| 292 |
"""
|
| 293 |
-
|
| 294 |
-
stage_callback(f"📡 الحساب {account.index}: حساب SHA256...")
|
| 295 |
|
| 296 |
# 1. حساب SHA256 — عملية (CPU-bound) ننقلها لـ ThreadPool مع timeout
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
try:
|
| 298 |
sha256 = await asyncio.wait_for(
|
| 299 |
asyncio.to_thread(_compute_sha256, local_path),
|
| 300 |
-
timeout=600,
|
| 301 |
)
|
| 302 |
except asyncio.TimeoutError:
|
| 303 |
raise StreamTapeError(
|
| 304 |
-
|
| 305 |
account.index,
|
| 306 |
)
|
| 307 |
|
|
|
|
| 308 |
if stage_callback:
|
| 309 |
stage_callback(f"📡 الحساب {account.index}: SHA256 ✓, جلب رابط الرفع...")
|
| 310 |
-
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
client = _get_client()
|
| 313 |
upload_url, file_id = await _get_upload_url(client, account, sha256)
|
| 314 |
|
|
|
|
| 315 |
if stage_callback:
|
| 316 |
stage_callback(f"📡 الحساب {account.index}: جاري رفع الملف...")
|
| 317 |
-
|
| 318 |
-
# 3. رفع الملف مع تتبع التقدّم
|
| 319 |
stream = _ProgressStream(
|
| 320 |
local_path, file_size, bandwidth_total, progress_callback,
|
|
|
|
|
|
|
| 321 |
)
|
| 322 |
|
| 323 |
client_upload = _get_client()
|
|
@@ -337,10 +376,25 @@ async def _http_upload(
|
|
| 337 |
)
|
| 338 |
_parse_api_response(response, account.index)
|
| 339 |
|
|
|
|
| 340 |
if stage_callback:
|
| 341 |
stage_callback(f"📡 الحساب {account.index}: رفع ✓، جلب رابط التحميل...")
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
client_dl = _get_client()
|
| 345 |
url = await _get_download_url(client_dl, account, file_id)
|
| 346 |
|
|
|
|
| 8 |
|
| 9 |
import asyncio
|
| 10 |
import hashlib
|
| 11 |
+
import time
|
| 12 |
from pathlib import Path
|
| 13 |
from typing import Any, AsyncIterator, Callable
|
| 14 |
|
|
|
|
| 21 |
_ST_API = "https://api.streamtape.com"
|
| 22 |
|
| 23 |
# ── Shared httpx Client ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
_shared_client: httpx.AsyncClient | None = None
|
| 26 |
|
|
|
|
| 29 |
"""إرجاع shared httpx client مع connection pooling (مهلة كبيرة آمنة)."""
|
| 30 |
global _shared_client
|
| 31 |
if _shared_client is None:
|
| 32 |
+
limits = httpx.Limits(max_keepalive_connections=10, max_connections=20)
|
| 33 |
_shared_client = httpx.AsyncClient(
|
| 34 |
+
timeout=httpx.Timeout(600.0, connect=30.0),
|
| 35 |
limits=limits,
|
| 36 |
)
|
| 37 |
return _shared_client
|
| 38 |
_RETRY_DELAYS = [2, 5, 10]
|
| 39 |
+
_UPLOAD_TIMEOUT = 1800
|
| 40 |
+
_CHUNK_SIZE = 1048576
|
| 41 |
+
_CONCURRENT_UPLOADS = 3
|
| 42 |
|
| 43 |
|
| 44 |
class StreamTapeError(Exception):
|
|
|
|
| 101 |
file_size: int,
|
| 102 |
bandwidth_total: int,
|
| 103 |
progress_callback: Callable[[DownloadProgress], Any],
|
| 104 |
+
account_index: int,
|
| 105 |
+
start_time: float,
|
| 106 |
) -> None:
|
| 107 |
"""Args:
|
| 108 |
local_path: المسار المحلي للملف.
|
| 109 |
file_size: الحجم الكلي للملف بالبايت.
|
| 110 |
bandwidth_total: إجمالي الباندويث المستخدم قبل هذه العملية.
|
| 111 |
progress_callback: دالة تُستدعى مع كل تحديث تقدّم.
|
| 112 |
+
account_index: فهرس الحساب (1-based).
|
| 113 |
+
start_time: وقت بدء الرفع (``time.monotonic()``).
|
| 114 |
"""
|
| 115 |
self._local_path = local_path
|
| 116 |
self._file_size = file_size
|
| 117 |
self._bandwidth_total = bandwidth_total
|
| 118 |
self._progress_callback = progress_callback
|
| 119 |
+
self._account_index = account_index
|
| 120 |
+
self._start_time = start_time
|
| 121 |
self._uploaded = 0
|
| 122 |
|
| 123 |
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
|
|
| 129 |
if not chunk:
|
| 130 |
break
|
| 131 |
self._uploaded += len(chunk)
|
| 132 |
+
elapsed = time.monotonic() - self._start_time
|
| 133 |
+
speed = self._uploaded / elapsed if elapsed > 0 else 0
|
| 134 |
self._progress_callback(
|
| 135 |
DownloadProgress(
|
| 136 |
downloaded=self._uploaded,
|
| 137 |
total=self._file_size,
|
| 138 |
+
speed=speed,
|
| 139 |
+
elapsed=elapsed,
|
| 140 |
bandwidth_total=self._bandwidth_total + self._uploaded,
|
| 141 |
status="uploading",
|
| 142 |
+
stage_detail=f"📡 الحساب {self._account_index}: رفع...",
|
| 143 |
+
sub_stage="HTTP POST",
|
| 144 |
+
current_item=self._account_index,
|
| 145 |
+
total_items=3,
|
| 146 |
),
|
| 147 |
)
|
| 148 |
yield chunk
|
|
|
|
| 300 |
Returns:
|
| 301 |
رابط التحميل النهائي من StreamTape.
|
| 302 |
"""
|
| 303 |
+
start_time = time.monotonic()
|
|
|
|
| 304 |
|
| 305 |
# 1. حساب SHA256 — عملية (CPU-bound) ننقلها لـ ThreadPool مع timeout
|
| 306 |
+
if stage_callback:
|
| 307 |
+
stage_callback(f"📡 الحساب {account.index}: حساب SHA256...")
|
| 308 |
+
progress_callback(
|
| 309 |
+
DownloadProgress(
|
| 310 |
+
downloaded=0,
|
| 311 |
+
total=file_size,
|
| 312 |
+
speed=0,
|
| 313 |
+
elapsed=0,
|
| 314 |
+
bandwidth_total=bandwidth_total,
|
| 315 |
+
status="uploading",
|
| 316 |
+
stage_detail=f"📡 الحساب {account.index}: SHA256...",
|
| 317 |
+
sub_stage="SHA256",
|
| 318 |
+
current_item=account.index,
|
| 319 |
+
total_items=3,
|
| 320 |
+
),
|
| 321 |
+
)
|
| 322 |
try:
|
| 323 |
sha256 = await asyncio.wait_for(
|
| 324 |
asyncio.to_thread(_compute_sha256, local_path),
|
| 325 |
+
timeout=600,
|
| 326 |
)
|
| 327 |
except asyncio.TimeoutError:
|
| 328 |
raise StreamTapeError(
|
| 329 |
+
"SHA256 computation timed out (file too large?)",
|
| 330 |
account.index,
|
| 331 |
)
|
| 332 |
|
| 333 |
+
# 2. الحصول على رابط الرفع المباشر
|
| 334 |
if stage_callback:
|
| 335 |
stage_callback(f"📡 الحساب {account.index}: SHA256 ✓, جلب رابط الرفع...")
|
| 336 |
+
progress_callback(
|
| 337 |
+
DownloadProgress(
|
| 338 |
+
downloaded=0,
|
| 339 |
+
total=file_size,
|
| 340 |
+
speed=0,
|
| 341 |
+
elapsed=time.monotonic() - start_time,
|
| 342 |
+
bandwidth_total=bandwidth_total,
|
| 343 |
+
status="uploading",
|
| 344 |
+
stage_detail=f"📡 الحساب {account.index}: جلب رابط الرفع...",
|
| 345 |
+
sub_stage="get_url",
|
| 346 |
+
current_item=account.index,
|
| 347 |
+
total_items=3,
|
| 348 |
+
),
|
| 349 |
+
)
|
| 350 |
client = _get_client()
|
| 351 |
upload_url, file_id = await _get_upload_url(client, account, sha256)
|
| 352 |
|
| 353 |
+
# 3. رفع الملف مع تتبع التقدّم
|
| 354 |
if stage_callback:
|
| 355 |
stage_callback(f"📡 الحساب {account.index}: جاري رفع الملف...")
|
|
|
|
|
|
|
| 356 |
stream = _ProgressStream(
|
| 357 |
local_path, file_size, bandwidth_total, progress_callback,
|
| 358 |
+
account_index=account.index,
|
| 359 |
+
start_time=start_time,
|
| 360 |
)
|
| 361 |
|
| 362 |
client_upload = _get_client()
|
|
|
|
| 376 |
)
|
| 377 |
_parse_api_response(response, account.index)
|
| 378 |
|
| 379 |
+
# 4. الحصول على رابط التحميل النهائي
|
| 380 |
if stage_callback:
|
| 381 |
stage_callback(f"📡 الحساب {account.index}: رفع ✓، جلب رابط التحميل...")
|
| 382 |
+
elapsed = time.monotonic() - start_time
|
| 383 |
+
speed = file_size / elapsed if elapsed > 0 else 0
|
| 384 |
+
progress_callback(
|
| 385 |
+
DownloadProgress(
|
| 386 |
+
downloaded=file_size,
|
| 387 |
+
total=file_size,
|
| 388 |
+
speed=speed,
|
| 389 |
+
elapsed=elapsed,
|
| 390 |
+
bandwidth_total=bandwidth_total + file_size,
|
| 391 |
+
status="uploading",
|
| 392 |
+
stage_detail=f"📡 الحساب {account.index}: جلب رابط التحميل...",
|
| 393 |
+
sub_stage="get_download_url",
|
| 394 |
+
current_item=account.index,
|
| 395 |
+
total_items=3,
|
| 396 |
+
),
|
| 397 |
+
)
|
| 398 |
client_dl = _get_client()
|
| 399 |
url = await _get_download_url(client_dl, account, file_id)
|
| 400 |
|
|
@@ -3,97 +3,158 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from models.schemas import DownloadProgress
|
| 6 |
-
from utils.file_utils import
|
| 7 |
-
|
| 8 |
-
_PROGRESS_BAR_LENGTH =
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
"emoji": "⬇️",
|
| 13 |
-
"label": "**📥 تحميل الملف من تلغرام**",
|
| 14 |
-
},
|
| 15 |
-
"processing": {
|
| 16 |
-
"emoji": "🔄",
|
| 17 |
-
"label": "**🔄 معالجة الفيديو (FFmpeg)**",
|
| 18 |
-
},
|
| 19 |
-
"uploading": {
|
| 20 |
-
"emoji": "☁️",
|
| 21 |
-
"label": "**☁️ رفع الملف إلى StreamTape**",
|
| 22 |
-
},
|
| 23 |
-
"telegram_upload": {
|
| 24 |
-
"emoji": "📤",
|
| 25 |
-
"label": "**📤 رفع نسخ احتياطية إلى تلغرام**",
|
| 26 |
-
},
|
| 27 |
-
"archiving": {
|
| 28 |
-
"emoji": "🗄️",
|
| 29 |
-
"label": "**🗄️ أرشفة في قاعدة البيانات**",
|
| 30 |
-
},
|
| 31 |
-
"done": {
|
| 32 |
-
"emoji": "✅",
|
| 33 |
-
"label": "**✅ اكتمل بنجاح!**",
|
| 34 |
-
},
|
| 35 |
-
"error": {
|
| 36 |
-
"emoji": "❌",
|
| 37 |
-
"label": "**❌ حدث خطأ!**",
|
| 38 |
-
},
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def _progress_bar(percent: float) -> str:
|
| 43 |
filled = int(percent / 100 * _PROGRESS_BAR_LENGTH)
|
| 44 |
empty = _PROGRESS_BAR_LENGTH - filled
|
| 45 |
return "█" * filled + "░" * empty
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def format_progress_message(p: DownloadProgress) -> str:
|
| 49 |
-
|
| 50 |
-
cfg = _STAGE_CONFIG.get(p.status, {"emoji": "⏳", "label": "**⏳ جاري العمل...**"})
|
| 51 |
lines: list[str] = [
|
| 52 |
-
"━
|
| 53 |
-
|
| 54 |
-
"━
|
| 55 |
]
|
| 56 |
|
| 57 |
-
# ──
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
lines.append(
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
| 88 |
lines.append("")
|
| 89 |
-
lines.append("
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
# ──
|
| 93 |
-
|
| 94 |
-
lines.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
lines.append("
|
|
|
|
| 97 |
lines.append("⏳ **يرجى الانتظار...**")
|
| 98 |
-
|
| 99 |
return "\n".join(lines)
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from models.schemas import DownloadProgress
|
| 6 |
+
from utils.file_utils import format_size as _fmt_size
|
| 7 |
+
|
| 8 |
+
_PROGRESS_BAR_LENGTH = 20
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _bar(percent: float) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
filled = int(percent / 100 * _PROGRESS_BAR_LENGTH)
|
| 13 |
empty = _PROGRESS_BAR_LENGTH - filled
|
| 14 |
return "█" * filled + "░" * empty
|
| 15 |
|
| 16 |
|
| 17 |
+
def _eta(seconds: float) -> str:
|
| 18 |
+
m, s = divmod(int(seconds), 60)
|
| 19 |
+
h, m = divmod(m, 60)
|
| 20 |
+
if h:
|
| 21 |
+
return f"{h}h {m}m"
|
| 22 |
+
if m:
|
| 23 |
+
return f"{m}m {s}s"
|
| 24 |
+
return f"{s}s"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _speed_line(bps: float) -> str:
|
| 28 |
+
mb_s = bps / 1_048_576
|
| 29 |
+
return f"{mb_s:.1f} MB/s ({mb_s * 8:.1f} Mbps)"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _header(status: str) -> str:
|
| 33 |
+
headers = {
|
| 34 |
+
"downloading": "⬇️ **تحميل الملف من تلغرام**",
|
| 35 |
+
"processing": "🔄 **معالجة الفيديو (FFmpeg)**",
|
| 36 |
+
"uploading": "☁️ **رفع الملف إلى StreamTape**",
|
| 37 |
+
"telegram_upload": "📤 **رفع نسخ احتياطية إلى تلغرام**",
|
| 38 |
+
"archiving": "🗄️ **أرشفة في قاعدة البيانات**",
|
| 39 |
+
"done": "✅ **اكتمل بنجاح! 🎉**",
|
| 40 |
+
"error": "❌ **حدث خطأ!**",
|
| 41 |
+
}
|
| 42 |
+
return headers.get(status, "⏳ **جاري العمل...**")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
def format_progress_message(p: DownloadProgress) -> str:
|
| 46 |
+
title = _header(p.status)
|
|
|
|
| 47 |
lines: list[str] = [
|
| 48 |
+
"━" * 28,
|
| 49 |
+
title,
|
| 50 |
+
"━" * 28,
|
| 51 |
]
|
| 52 |
|
| 53 |
+
# ── downloading ──────────────────────────────────────────────────
|
| 54 |
+
if p.status == "downloading":
|
| 55 |
+
lines.append("")
|
| 56 |
+
lines.append(f"📦 **الحجم الكلي:** `{_fmt_size(p.total)}`")
|
| 57 |
+
if p.downloaded > 0:
|
| 58 |
+
lines.append(f"⬇️ **تم التحميل:** `{_fmt_size(p.downloaded)}` من `{_fmt_size(p.total)}`")
|
| 59 |
+
if p.total > 0:
|
| 60 |
+
lines.append(f"\n📊 `{_bar(p.percent)}` `{p.percent:.1f}%`")
|
| 61 |
+
if p.speed > 0:
|
| 62 |
+
lines.append(f"🚀 **السرعة:** `{_speed_line(p.speed)}`")
|
| 63 |
+
lines.append(f"⏱️ **الوقت المتبقي:** ~`{_eta(p.eta_seconds)}`")
|
| 64 |
+
if p.elapsed > 0:
|
| 65 |
+
lines.append(f"⏱️ **الوقت المنقضي:** `{_eta(p.elapsed)}`")
|
| 66 |
+
if p.bandwidth_total > 0:
|
| 67 |
+
lines.append("")
|
| 68 |
+
lines.append("─" * 28)
|
| 69 |
+
lines.append(f"🌐 **إجمالي الباندويث:** `{_fmt_size(p.bandwidth_total)}`")
|
| 70 |
+
|
| 71 |
+
# ── processing ───────────────────────────────────────────────────
|
| 72 |
+
elif p.status == "processing":
|
| 73 |
+
lines.append("")
|
| 74 |
+
lines.append(f"📦 **الحجم الكلي:** `{_fmt_size(p.total)}`")
|
| 75 |
+
if p.stage_detail:
|
| 76 |
+
lines.append(f"🔧 **مرحلة FFmpeg:** `{p.stage_detail}`")
|
| 77 |
+
if p.sub_stage:
|
| 78 |
+
lines.append(f"⚙️ **العملية:** `{p.sub_stage}`")
|
| 79 |
+
if p.total > 0:
|
| 80 |
+
lines.append(f"\n📊 `{_bar(p.percent)}` `{p.percent:.1f}%`")
|
| 81 |
+
if p.elapsed > 0:
|
| 82 |
+
lines.append(f"⏱️ **الوقت المنقضي:** `{_eta(p.elapsed)}`")
|
| 83 |
+
|
| 84 |
+
# ── uploading (StreamTape) ──────────────────────────────────────
|
| 85 |
+
elif p.status == "uploading":
|
| 86 |
lines.append("")
|
| 87 |
+
lines.append(f"📦 **الحجم الكلي:** `{_fmt_size(p.total)}`")
|
| 88 |
+
if p.stage_detail:
|
| 89 |
+
lines.append(f"👤 **الحساب:** `{p.stage_detail}`")
|
| 90 |
+
if p.sub_stage:
|
| 91 |
+
lines.append(f"⚙️ **المرحلة الفرعية:** `{p.sub_stage}`")
|
| 92 |
+
if p.total_items > 0:
|
| 93 |
+
cur = p.current_item
|
| 94 |
+
tot = p.total_items
|
| 95 |
+
lines.append(f"📌 **تقدم الحسابات:** `{cur}` من `{tot}`")
|
| 96 |
+
pct = cur / tot * 100
|
| 97 |
+
lines.append(f"\n📊 `{_bar(pct)}` `{pct:.1f}%`")
|
| 98 |
+
if p.elapsed > 0:
|
| 99 |
+
lines.append(f"⏱️ **الوقت المنقضي:** `{_eta(p.elapsed)}`")
|
| 100 |
+
|
| 101 |
+
# ── telegram_upload ─────────────────────────────────────────────
|
| 102 |
+
elif p.status == "telegram_upload":
|
| 103 |
+
lines.append("")
|
| 104 |
+
lines.append(f"📦 **الحجم الكلي:** `{_fmt_size(p.total)}`")
|
| 105 |
+
if p.total_items > 0:
|
| 106 |
+
cur = p.current_item
|
| 107 |
+
tot = p.total_items
|
| 108 |
+
lines.append(f"📤 **النسخة:** `{cur}/{tot}`")
|
| 109 |
+
pct = cur / tot * 100
|
| 110 |
+
lines.append(f"\n📊 `{_bar(pct)}` `{pct:.1f}%`")
|
| 111 |
+
if p.elapsed > 0:
|
| 112 |
+
lines.append(f"⏱️ **الوقت المنقضي:** `{_eta(p.elapsed)}`")
|
| 113 |
+
|
| 114 |
+
# ── archiving ────────────────────────────────────────────────────
|
| 115 |
+
elif p.status == "archiving":
|
| 116 |
+
lines.append("")
|
| 117 |
+
if p.stage_detail:
|
| 118 |
+
lines.append(f"🗄️ **الجدول:** `{p.stage_detail}`")
|
| 119 |
+
if p.sub_stage:
|
| 120 |
+
lines.append(f"✅ **الحالة:** `{p.sub_stage}`")
|
| 121 |
+
if p.total_items > 0:
|
| 122 |
+
lines.append(f"📌 **التقدم:** `{p.current_item}` من `{p.total_items}`")
|
| 123 |
+
lines.append("")
|
| 124 |
+
lines.append("✅ **تمت الأرشفة بنجاح!**")
|
| 125 |
+
lines.append("")
|
| 126 |
+
lines.append("━" * 28)
|
| 127 |
+
lines.append("✅ **تمت العملية بنجاح!**")
|
| 128 |
+
return "\n".join(lines)
|
| 129 |
+
|
| 130 |
+
# ── done ────────────────────────────────────────────────────────
|
| 131 |
+
elif p.status == "done":
|
| 132 |
+
lines.append("")
|
| 133 |
+
lines.append("🎉 **🎊 تم بنجاح! 🎊**")
|
| 134 |
+
lines.append("")
|
| 135 |
+
lines.append(f"📦 **الحجم الكلي:** `{_fmt_size(p.total)}`")
|
| 136 |
+
if p.elapsed > 0:
|
| 137 |
+
lines.append(f"⏱️ **الوقت المستغرق:** `{_eta(p.elapsed)}`")
|
| 138 |
+
if p.bandwidth_total > 0:
|
| 139 |
+
lines.append(f"🌐 **إجمالي الباندويث:** `{_fmt_size(p.bandwidth_total)}`")
|
| 140 |
+
lines.append("")
|
| 141 |
+
lines.append("━" * 28)
|
| 142 |
+
lines.append("✅ **تمت العملية بنجاح!**")
|
| 143 |
+
return "\n".join(lines)
|
| 144 |
|
| 145 |
+
# ── error ───────────────────────────────────────────────────────
|
| 146 |
+
elif p.status == "error":
|
| 147 |
+
lines.append("")
|
| 148 |
+
if p.stage_detail:
|
| 149 |
+
lines.append(f"⚠️ **المرحلة:** `{p.stage_detail}`")
|
| 150 |
+
if p.sub_stage:
|
| 151 |
+
lines.append(f"🔴 **الخطأ:** `{p.sub_stage}`")
|
| 152 |
+
lines.append("")
|
| 153 |
+
lines.append("━" * 28)
|
| 154 |
+
lines.append("❌ **فشلت العملية!**")
|
| 155 |
+
return "\n".join(lines)
|
| 156 |
|
| 157 |
+
lines.append("")
|
| 158 |
+
lines.append("━" * 28)
|
| 159 |
lines.append("⏳ **يرجى الانتظار...**")
|
|
|
|
| 160 |
return "\n".join(lines)
|