KHALED-ALIM commited on
Commit
bf1c6f2
·
1 Parent(s): cdb97e8

⚡ تسريع كل العمليات + تطوير شامل للرسائل والأزرار

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 CHANGED
@@ -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=4,
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
  )
bot/handlers/callback_handler.py CHANGED
@@ -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
bot/keyboards.py CHANGED
@@ -1,4 +1,4 @@
1
- """Inline keyboard builders — all text in Arabic with clear emoji labels."""
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 as selectable buttons, 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
- label = f"{i + 1}. {title} ({year})"
 
 
15
  buttons.append([InlineKeyboardButton(label, callback_data=f"tmdb_{i}")])
16
 
 
17
  row2: list[InlineKeyboardButton] = [
18
- InlineKeyboardButton("✏️ إدخال يدوي", callback_data="manual_input"),
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 (480p → 4K) with visual quality indicators."""
28
- quality_emojis = {
29
- "480p": "📺",
30
- "720p": "💿",
31
- "1080p": "✨",
32
- "2K": "🌟",
33
- "4K": "💎",
34
  }
35
  row = [
36
  InlineKeyboardButton(
37
- f"{quality_emojis.get(q, '')} {q}",
38
  callback_data=f"quality_{q}",
39
  )
40
- for q in ["480p", "720p", "1080p", "2K", "4K"]
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("✅ تأكيد وبدء الرفع", callback_data="confirm_all"),
 
 
 
 
 
 
52
  InlineKeyboardButton("❌ إلغاء", callback_data="cancel"),
53
- ]
54
  ])
55
 
56
 
57
  def cancel_only_keyboard() -> InlineKeyboardMarkup:
 
 
 
 
 
 
 
 
58
  return InlineKeyboardMarkup([
59
- [InlineKeyboardButton("❌ إلغاء", callback_data="cancel")]
 
 
 
 
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
  ])
services/pipeline.py CHANGED
@@ -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._last_progress_time: float = 0.0 # throttle: last update timestamp
71
- self._last_detail: str = "" # throttle: last stage_detail sent
 
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, ctx["file_size"], 0, 0, self.total_bw, "downloading",
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=ctx["file_size"],
231
  )
232
 
233
  result = await download_file(
234
  self.client,
235
  ctx["file_id"],
236
  ctx["original_path"],
237
- ctx["file_size"],
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
- logger.info("Download successful: {path} ({size} bytes)", path=result, size=result.stat().st_size)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, ctx["file_size"], 0, 0, self.total_bw, "processing",
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
- f"Input file for FFmpeg is empty (0 bytes): {input_path}"
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
- return await change_fingerprint(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  input_path,
294
  ctx["processed_path"],
295
- ctx["file_size"],
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, ctx["file_size"], 0, 0, self.total_bw, "uploading",
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, ctx["file_size"], 0, 0, self.total_bw, "uploading",
319
  stage_detail=msg,
320
  sub_stage="StreamTape",
321
  total_items=3,
322
  ),
323
  )
324
 
325
- return await upload_to_all_accounts(
326
  ctx["processed_path"],
327
  ctx["remote_fn"],
328
- ctx["file_size"],
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, ctx["file_size"], 0, 0, self.total_bw, "telegram_upload",
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 * ctx["file_size"] // 3,
355
- ctx["file_size"], 0, 0,
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
- return sent.video.file_id if sent.video else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
  tasks = [_upload_one(i) for i in range(1, 4)]
373
  telegram_ids = await asyncio.gather(*tasks)
374
 
375
- # Final progress update
 
 
376
  await self._update_progress(
377
  DownloadProgress(
378
- ctx["file_size"], ctx["file_size"],
379
- 0, 0, self.total_bw + sum(ctx["file_size"] for _ in range(3)),
380
  "telegram_upload",
381
- stage_detail="جميع النسخ الاحتياطية مرفوعة",
382
- current_item=3,
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 + ctx["file_size"] * 7,
397
  "archiving",
398
- stage_detail="تخزين في Turso DB...",
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 + ctx["file_size"] * 7,
411
  "admin_id": self.user_id,
412
  }
413
 
414
  if not ctx["is_series"]:
415
- return await archive_movie(**data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
- real_episode_id: int | None = None
418
- try:
419
- ep_data = await episode_details(
420
- ctx["tmdb_id"],
421
- ctx["season"] or 1,
422
- ctx["episode"] or 1,
 
 
423
  )
424
- real_episode_id = ep_data.get("id", ctx["tmdb_id"])
425
- except TMDbError as exc:
426
  logger.warning(
427
- "Failed to fetch episode details for tmdb_id={}, season={}, episode={}: {}",
428
- ctx["tmdb_id"], ctx["season"], ctx["episode"], exc.message,
 
 
 
 
 
 
 
429
  )
430
 
431
- data.update(
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 = "**اكتمل كل شيء بنجاح!** 🎉" if (all_st_ok and all_tg_ok) else "**اكتمل مع بعض الأخطاء** ⚠️"
466
-
467
- # Bandwidth calculation
468
- bw = data["file_size"] * 7 # original + processed + ST(3) + TG(3)
 
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"🌐 **إجمالي الباندويث:** `{format_size(bw)}`",
 
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
 
services/streamtape_service.py CHANGED
@@ -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=5, max_connections=10)
35
  _shared_client = httpx.AsyncClient(
36
- timeout=httpx.Timeout(600.0, connect=30.0), # 10 min overall, 30s connect
37
  limits=limits,
38
  )
39
  return _shared_client
40
  _RETRY_DELAYS = [2, 5, 10]
41
- _UPLOAD_TIMEOUT = 900 # 15 دقيقة حد أقصى لكل عملية رفع
42
- _CHUNK_SIZE = 65536 # 64KB
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=0,
133
- elapsed=0,
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
- if stage_callback:
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, # 10 دقائق حد أقصى لقراءة الملف
301
  )
302
  except asyncio.TimeoutError:
303
  raise StreamTapeError(
304
- f"SHA256 computation timed out (file too large?)",
305
  account.index,
306
  )
307
 
 
308
  if stage_callback:
309
  stage_callback(f"📡 الحساب {account.index}: SHA256 ✓, جلب رابط الرفع...")
310
-
311
- # 2. الحصول على رابط الرفع المباشر
 
 
 
 
 
 
 
 
 
 
 
 
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
- # 4. الحصول على رابط التحميل النهائي
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
utils/progress_formatter.py CHANGED
@@ -3,97 +3,158 @@
3
  from __future__ import annotations
4
 
5
  from models.schemas import DownloadProgress
6
- from utils.file_utils import format_duration, format_size, format_speed
7
-
8
- _PROGRESS_BAR_LENGTH = 16
9
-
10
- _STAGE_CONFIG = {
11
- "downloading": {
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
- """Return a detailed formatted Telegram message for the current stage."""
50
- cfg = _STAGE_CONFIG.get(p.status, {"emoji": "⏳", "label": "**⏳ جاري العمل...**"})
51
  lines: list[str] = [
52
- "━━━━━━━━━━━━━━━━━━━━━━━━",
53
- f"{cfg['emoji']} {cfg['label']}",
54
- "━━━━━━━━━━━━━━━━━━━━━━━━",
55
  ]
56
 
57
- # ── معلومات الملف الأساسية ──────────────────────────────────
58
- lines.append("")
59
- lines.append(f"📦 **الحجم الكلي:** `{format_size(p.total)}`")
60
- if p.downloaded > 0:
61
- lines.append(f"⬇️ **تمت معالجته:** `{format_size(p.downloaded)}` من `{format_size(p.total)}`")
62
-
63
- # ── تفاصيل المرحلة الحالية ────────────────────────────────────
64
- if p.stage_detail:
65
- lines.append(f"📋 **المرحلة:** `{p.stage_detail}`")
66
-
67
- if p.sub_stage:
68
- lines.append(f"🔧 **الإجراء:** `{p.sub_stage}`")
69
-
70
- if p.total_items > 0 and p.current_item > 0:
71
- lines.append(f"📌 **التقدم:** `{p.current_item}` من `{p.total_items}`")
72
-
73
- # ── شريط التقدم ──────────────────────────────────────────────
74
- if p.total > 0 and p.status not in ("done", "error"):
75
- percent = p.percent
76
- bar = _progress_bar(percent)
77
- lines.append(f"\n📊 **التقدم:** `{bar}` `{percent:.1f}%`")
78
-
79
- # ── السرعة والوقت المتبقي (للتحميل فقط) ──────────────────────
80
- if p.speed > 0 and p.status == "downloading":
81
- downloaded_mb = p.downloaded / (1024 * 1024)
82
- speed_mbps = p.speed / (1024 * 1024) * 8
83
- lines.append(f"🚀 **السرعة:** `{format_speed(p.speed)}` (`{speed_mbps:.1f} Mbps`)")
84
- lines.append(f"⏱️ **الوقت المتبقي:** ~`{format_duration(p.eta_seconds)}`")
85
-
86
- # ── الباندويث الكلي ──────────────────────────────────────────
87
- if p.bandwidth_total > 0:
 
 
88
  lines.append("")
89
- lines.append("━━━━━━━━━━━━━━━━━━━")
90
- lines.append(f"🌐 **إجمالي الباندويث المستخدم:** `{format_size(p.bandwidth_total)}`")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
- # ── الوقت المنقضي ──────���─────────────────────────────────────
93
- if p.elapsed > 0:
94
- lines.append(f"⏱️ **الوقت المنقضي:** `{format_duration(p.elapsed)}`")
 
 
 
 
 
 
 
 
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)