Z User commited on
Commit
acd3e1c
·
1 Parent(s): 5fedbfd

v2.0: Invidious-first strategy + FFmpeg merge + dynamic instance discovery

Browse files

Major rewrite based on real API testing (June 2026):

- Removed dead APIs: Cobalt (v7 shut down), Piped (all instances down)
- Added Invidious as primary download source
- Added FFmpeg video+audio merge for high quality downloads
- Added dynamic Invidious instance discovery from api.invidious.io
- Updated yt-dlp clients: tv_embedded, android_vr, mediaconnect
- Better error messages for users
- Frontend shows download source in progress
- Fixed anti-ban client priorities based on real tests
- Health check endpoint with instance status

README.md CHANGED
@@ -14,13 +14,13 @@ app_port: 8555
14
 
15
  **تحميل فيديوهات يوتيوب مع الترجمات بسهولة وأمان**
16
 
17
- [![Python](https://img.shields.io/badge/Python-3.9%2B-ff4444?style=flat-square&logo=python&logoColor=white)](https://python.org)
18
  [![FastAPI](https://img.shields.io/badge/FastAPI-0.100%2B-009688?style=flat-square&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com)
19
  [![yt-dlp](https://img.shields.io/badge/yt--dlp-2024%2B-282828?style=flat-square&logo=youtube&logoColor=ff4444)](https://github.com/yt-dlp/yt-dlp)
20
 
21
  <br>
22
 
23
- **Desktop Web UI** · **Anti-Ban Protection** · **Subtitle Support**
24
 
25
  <br>
26
  </div>
@@ -29,7 +29,14 @@ app_port: 8555
29
 
30
  ## Overview
31
 
32
- DownTube is a powerful YouTube downloader with a web interface built with FastAPI. It downloads both **video** and **subtitles** with advanced anti-ban strategies to avoid YouTube rate limiting.
 
 
 
 
 
 
 
33
 
34
  ---
35
 
@@ -38,27 +45,16 @@ DownTube is a powerful YouTube downloader with a web interface built with FastAP
38
  | Feature | Status |
39
  |---------|:------:|
40
  | Video Info Fetching | ✅ |
41
- | Video Download (best/720p/480p) | ✅ |
42
  | Subtitle Download (SRT/VTT) | ✅ |
43
  | Auto Subtitles | ✅ |
44
  | Anti-Ban Protection | ✅ |
45
  | Cookies Import | ✅ |
46
  | Download Manager | ✅ |
47
  | Dark Theme | ✅ |
48
-
49
- ### Anti-Ban System
50
- - User-Agent rotation (15+ browsers/devices)
51
- - YouTube client switching (`web` / `android`)
52
- - Smart request delays with progressive backoff
53
- - HTTP 429 detection and automatic cooldown
54
- - Session limits to prevent detection
55
- - Cookie support to raise download limits
56
-
57
- ### Subtitle Features
58
- - Download handwritten and auto-generated subtitles
59
- - Convert between SRT and VTT formats
60
- - Timing adjustment for perfect sync
61
- - Clean formatting and duplicate removal
62
 
63
  ---
64
 
@@ -79,6 +75,7 @@ DownTube is a powerful YouTube downloader with a web interface built with FastAP
79
  | `DELETE` | `/api/download/file` | Delete a file |
80
  | `GET` | `/api/anti-ban/status` | Anti-ban system status |
81
  | `POST` | `/api/anti-ban/reset` | Reset anti-ban session |
 
82
  | `POST` | `/api/cookies/set` | Set cookies (paste) |
83
  | `POST` | `/api/cookies/upload` | Upload cookies file |
84
  | `GET` | `/api/cookies/status` | Cookies status |
 
14
 
15
  **تحميل فيديوهات يوتيوب مع الترجمات بسهولة وأمان**
16
 
17
+ [![Python](https://img.shields.io/badge/Python-3.11%2B-ff4444?style=flat-square&logo=python&logoColor=white)](https://python.org)
18
  [![FastAPI](https://img.shields.io/badge/FastAPI-0.100%2B-009688?style=flat-square&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com)
19
  [![yt-dlp](https://img.shields.io/badge/yt--dlp-2024%2B-282828?style=flat-square&logo=youtube&logoColor=ff4444)](https://github.com/yt-dlp/yt-dlp)
20
 
21
  <br>
22
 
23
+ **Invidious-First** · **Anti-Ban Protection** · **FFmpeg Merge** · **Subtitle Support**
24
 
25
  <br>
26
  </div>
 
29
 
30
  ## Overview
31
 
32
+ DownTube is a powerful YouTube downloader with a web interface built with FastAPI. It downloads both **video** and **subtitles** using an Invidious-first strategy that works from cloud servers without cookies.
33
+
34
+ ### Download Strategy (v2.0)
35
+
36
+ 1. **Invidious** (primary): Gets video info + download URLs from Invidious instances (works from cloud IPs!)
37
+ 2. **FFmpeg merge**: Downloads video+audio separately from Invidious for high quality, then merges
38
+ 3. **yt-dlp** (fallback): Uses `tv_embedded` client if Invidious fails
39
+ 4. **Dynamic instance discovery**: Automatically finds working Invidious instances from official API
40
 
41
  ---
42
 
 
45
  | Feature | Status |
46
  |---------|:------:|
47
  | Video Info Fetching | ✅ |
48
+ | Video Download (360p-4K) | ✅ |
49
  | Subtitle Download (SRT/VTT) | ✅ |
50
  | Auto Subtitles | ✅ |
51
  | Anti-Ban Protection | ✅ |
52
  | Cookies Import | ✅ |
53
  | Download Manager | ✅ |
54
  | Dark Theme | ✅ |
55
+ | Invidious-First Strategy | ✅ |
56
+ | FFmpeg Video Merge | ✅ |
57
+ | Dynamic Instance Discovery | ✅ |
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  ---
60
 
 
75
  | `DELETE` | `/api/download/file` | Delete a file |
76
  | `GET` | `/api/anti-ban/status` | Anti-ban system status |
77
  | `POST` | `/api/anti-ban/reset` | Reset anti-ban session |
78
+ | `GET` | `/api/fallback/status` | Invidious instance status |
79
  | `POST` | `/api/cookies/set` | Set cookies (paste) |
80
  | `POST` | `/api/cookies/upload` | Upload cookies file |
81
  | `GET` | `/api/cookies/status` | Cookies status |
core/anti_ban.py CHANGED
@@ -43,8 +43,10 @@ USER_AGENTS = [
43
  "Mozilla/5.0 (Web0S; Linux/SmartTV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.34 Safari/537.36",
44
  ]
45
 
46
- # ترتيب العملاء حسب الأولوية - الأقل حظراً على السيرفرات السحابية أولاً
47
- CLIENT_PRIORITY = ["mweb", "ios", "tv_embedded", "android", "web_creator", "web"]
 
 
48
 
49
  YOUTUBE_CLIENTS = CLIENT_PRIORITY
50
 
@@ -313,12 +315,17 @@ class AntiBanManager:
313
  opts["extractor_args"] = {"youtube": {"player_client": ["ios"]}}
314
  elif client == "tv_embedded":
315
  opts["extractor_args"] = {"youtube": {"player_client": ["tv_embedded"]}}
 
 
 
 
316
  elif client == "android":
317
- opts["extractor_args"] = {"youtube": {"player_client": ["android", "web"]}}
318
  elif client == "web_creator":
319
  opts["extractor_args"] = {"youtube": {"player_client": ["web_creator"]}}
320
  else:
321
- opts["extractor_args"] = {"youtube": {"player_client": ["web"]}}
 
322
 
323
  return opts
324
 
 
43
  "Mozilla/5.0 (Web0S; Linux/SmartTV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.34 Safari/537.36",
44
  ]
45
 
46
+ # ترتيب العملاء حسب الأولوية - بناءً على اختبارات حقيقية يونيو 2026
47
+ # tv_embedded و android_vr و mediaconnect هم الأكثر فعالية
48
+ # android يعطي 5 صيغ فقط لكنه قد يعمل من datacenter IPs
49
+ CLIENT_PRIORITY = ["tv_embedded", "android_vr", "mediaconnect", "android", "web"]
50
 
51
  YOUTUBE_CLIENTS = CLIENT_PRIORITY
52
 
 
315
  opts["extractor_args"] = {"youtube": {"player_client": ["ios"]}}
316
  elif client == "tv_embedded":
317
  opts["extractor_args"] = {"youtube": {"player_client": ["tv_embedded"]}}
318
+ elif client == "android_vr":
319
+ opts["extractor_args"] = {"youtube": {"player_client": ["android_vr"]}}
320
+ elif client == "mediaconnect":
321
+ opts["extractor_args"] = {"youtube": {"player_client": ["mediaconnect"]}}
322
  elif client == "android":
323
+ opts["extractor_args"] = {"youtube": {"player_client": ["android"]}}
324
  elif client == "web_creator":
325
  opts["extractor_args"] = {"youtube": {"player_client": ["web_creator"]}}
326
  else:
327
+ # default - لا نحدد عميل، yt-dlp يختار الأنسب
328
+ pass
329
 
330
  return opts
331
 
core/downloader.py CHANGED
@@ -1,11 +1,12 @@
1
  """
2
  محمل الفيديوهات الرئيسي - نسخة السحابة المحسنة
3
- YouTube Video Downloader with Smart Hybrid System
4
 
5
- نظام التحميل الذكي:
6
- 1. جلب معلومات الفيديو من Invidious/Pipedوفر حدود YouTube)
7
- 2. محاولة التحميل بـ yt-dlp مع Fallback بين العملاء
8
- 3. لو فشل yt-dlp Fallback تلقائي لـ Cobalt → Invidious → Piped
 
9
  """
10
 
11
  import os
@@ -46,6 +47,7 @@ class DownloadStatus(Enum):
46
  WAITING_ANTI_BAN = "waiting_anti_ban"
47
  DOWNLOADING_VIDEO = "downloading_video"
48
  FALLBACK_DOWNLOAD = "fallback_download"
 
49
  COMPLETED = "completed"
50
  CANCELLED = "cancelled"
51
  FAILED = "failed"
@@ -63,8 +65,10 @@ class VideoInfo:
63
  view_count: int = 0
64
  available_subtitles: List[SubtitleInfo] = field(default_factory=list)
65
  formats: List[Dict[str, Any]] = field(default_factory=list)
66
- # مصدر المعلومات (للتتبع)
67
  info_source: str = "youtube"
 
 
68
 
69
 
70
  @dataclass
@@ -78,12 +82,16 @@ class DownloadProgress:
78
  total_bytes: int = 0
79
  filename: str = ""
80
  message: str = ""
 
81
 
82
 
83
  class YouTubeDownloader:
84
  """
85
- محمل فيديوهات يوتيوب مع نظام تحميل ذكي مختلط
86
- yt-dlp → Cobalt → Invidious → Piped
 
 
 
87
  """
88
 
89
  def __init__(self, download_dir: str = "/tmp/youtube_downloads"):
@@ -125,13 +133,26 @@ class YouTubeDownloader:
125
  def _convert_fallback_info(self, fallback_info: FallbackVideoInfo) -> VideoInfo:
126
  """تحويل معلومات الفيديو من FallbackVideoInfo إلى VideoInfo"""
127
  subtitles = []
 
 
 
 
 
 
 
 
 
 
 
 
128
  for sub in fallback_info.available_subtitles:
129
- subtitles.append(SubtitleInfo(
130
- language=sub.get("language", ""),
131
- language_code=sub.get("language_code", ""),
132
- auto_generated=sub.get("auto_generated", False),
133
- url=sub.get("url", ""),
134
- ))
 
135
 
136
  return VideoInfo(
137
  title=fallback_info.title,
@@ -142,39 +163,51 @@ class YouTubeDownloader:
142
  uploader=fallback_info.uploader,
143
  view_count=fallback_info.view_count,
144
  available_subtitles=subtitles,
145
- info_source="fallback",
 
146
  )
147
 
148
  async def fetch_video_info(self, url: str) -> VideoInfo:
149
  """
150
- جلب معلومات الفيديو - نظام مختلط:
151
- 1. Invidious/Piped أولاً (يوفر حدود YouTube)
152
- 2. لو فشلوا → yt-dlp مع Fallback بين العملاء
153
  """
154
  self._cancelled = False
155
- self._update_progress(status=DownloadStatus.FETCHING_INFO, message="جاري جلب معلومات الفيديو...")
 
 
 
 
156
 
157
- # ═══ المحاولة 1: Invidious/Piped (لا يستهلك حدود YouTube) ═══
158
  try:
159
  fallback_info = await asyncio.wait_for(
160
  self.fallback.get_video_info(url),
161
- timeout=15,
162
  )
163
  if fallback_info and fallback_info.title:
164
  info = self._convert_fallback_info(fallback_info)
165
- info.info_source = "fallback"
166
- logger.info(f"Got video info from fallback API (source={info.info_source})")
167
- self._update_progress(status=DownloadStatus.IDLE, message="تم جلب المعلومات بنجاح")
 
 
 
168
  return info
169
  except asyncio.TimeoutError:
170
- logger.warning("Fallback API timeout for video info, trying yt-dlp...")
171
  except Exception as e:
172
- logger.warning(f"Fallback API failed for video info: {e}, trying yt-dlp...")
 
 
 
 
 
 
173
 
174
- # ═══ المحاولة 2: yt-dlp مع Fallback بين العملاء ═══
175
  await anti_ban.wait_before_request()
176
 
177
- clients_to_try = [anti_ban.get_current_client()] + anti_ban.get_fallback_clients()
 
178
  last_error = None
179
 
180
  for client in clients_to_try:
@@ -242,7 +275,10 @@ class YouTubeDownloader:
242
  break
243
 
244
  anti_ban.report_success()
245
- self._update_progress(status=DownloadStatus.IDLE, message="تم جلب المعلومات بنجاح")
 
 
 
246
  return video_info
247
 
248
  except CancelledError:
@@ -261,8 +297,12 @@ class YouTubeDownloader:
261
  break
262
 
263
  # ═══ كل الطرق فشلت ═══
264
- self._update_progress(status=DownloadStatus.FAILED, message=f"فشل جلب المعلومات: {str(last_error)}")
265
- raise last_error
 
 
 
 
266
 
267
  async def download_subtitle(
268
  self,
@@ -274,21 +314,21 @@ class YouTubeDownloader:
274
  """تحميل الترجمة"""
275
  self._update_progress(
276
  status=DownloadStatus.DOWNLOADING_SUBTITLE,
277
- message=f"جاري تحميل الترجمة ({language_code})...",
278
  percent=0,
 
279
  )
280
 
281
  if self._cancelled:
282
  return None
283
 
284
- # ═══ المحاولة 1: Invidious (لا يستهلك حدود YouTube) ═══
285
  try:
286
  sub_content = await asyncio.wait_for(
287
  self.fallback.get_subtitle_content(url, language_code),
288
  timeout=15,
289
  )
290
  if sub_content:
291
- # تحويل الصيغة إذا لزم الأمر
292
  content = self.subtitle_converter.format_subtitle(sub_content, subtitle_format)
293
 
294
  final_path = os.path.join(self.download_dir, f"subtitle_{language_code}.{subtitle_format}")
@@ -296,12 +336,21 @@ class YouTubeDownloader:
296
  f.write(content)
297
 
298
  logger.info(f"Subtitle downloaded from Invidious: {final_path}")
299
- self._update_progress(status=DownloadStatus.DOWNLOADING_SUBTITLE, percent=100, message="تم تحميل الترجمة")
 
 
 
 
300
  return final_path
301
  except Exception as e:
302
  logger.warning(f"Invidious subtitle failed: {e}, trying yt-dlp...")
303
 
304
  # ═══ المحاولة 2: yt-dlp ═══
 
 
 
 
 
305
  await anti_ban.wait_before_request()
306
 
307
  try:
@@ -334,7 +383,11 @@ class YouTubeDownloader:
334
 
335
  await loop.run_in_executor(None, _download_sub)
336
 
337
- self._update_progress(status=DownloadStatus.DOWNLOADING_SUBTITLE, percent=100, message="تم تحميل الترجمة")
 
 
 
 
338
  anti_ban.report_success()
339
 
340
  subtitle_file = self._find_subtitle_file(language_code, sub_format)
@@ -362,7 +415,10 @@ class YouTubeDownloader:
362
  if self._cancelled:
363
  return None
364
  anti_ban.report_failure(status_code=_extract_429_status(e))
365
- self._update_progress(status=DownloadStatus.FAILED, message=f"فشل تحميل الترجمة: {str(e)}")
 
 
 
366
  raise
367
 
368
  async def download_video(
@@ -372,53 +428,121 @@ class YouTubeDownloader:
372
  output_filename: Optional[str] = None,
373
  ) -> Optional[str]:
374
  """
375
- تحميل الفيديو - نظام مختلط:
376
- 1. yt-dlp مع Fallback بين العملاء
377
- 2. لو فشل → Cobalt → Invidious → Piped
378
  """
379
  self._update_progress(
380
  status=DownloadStatus.DOWNLOADING_VIDEO,
381
- message="جاري تحميل الفيديو...",
382
  percent=0,
 
383
  )
384
 
385
  if self._cancelled:
386
  return None
387
 
388
- # انتظار بين الترجمة والفيديو
389
- self._update_progress(
390
- status=DownloadStatus.WAITING_ANTI_BAN,
391
- message="جاري الانتظار لتجنب الحظر...",
392
- )
393
- await anti_ban.wait_between_subtitle_and_video()
394
-
395
- if self._cancelled:
396
- return None
397
-
398
- # ═══ المحاولة 1: yt-dlp مع Fallback بين العملاء ═══
399
- video_file = await self._try_ytdlp_download(url, quality, output_filename)
400
  if video_file:
401
  return video_file
402
 
403
  if self._cancelled:
404
  return None
405
 
406
- # ═══ المحاولة 2: Fallback APIs (Cobalt → Invidious → Piped) ═══
407
- logger.info("yt-dlp failed, trying fallback APIs...")
408
  self._update_progress(
409
- status=DownloadStatus.FALLBACK_DOWNLOAD,
410
- message="جاري التحميل من مصدر بديل...",
411
  percent=0,
 
412
  )
413
 
414
- video_file = await self._try_fallback_download(url, quality)
415
  if video_file:
416
  return video_file
417
 
418
  if self._cancelled:
419
  return None
420
 
421
- self._update_progress(status=DownloadStatus.FAILED, message="فشل تحميل الفيديو من جميع المصادر")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  return None
423
 
424
  async def _try_ytdlp_download(
@@ -427,8 +551,9 @@ class YouTubeDownloader:
427
  quality: str = "best",
428
  output_filename: Optional[str] = None,
429
  ) -> Optional[str]:
430
- """محاولة التحميل بـ yt-dlp مع Fallback بين العملاء"""
431
- clients_to_try = [anti_ban.get_current_client()] + anti_ban.get_fallback_clients()
 
432
 
433
  for client in clients_to_try:
434
  if self._cancelled:
@@ -484,7 +609,7 @@ class YouTubeDownloader:
484
  self._update_progress(
485
  status=DownloadStatus.COMPLETED,
486
  percent=100,
487
- message="تم تحميل الفيديو بنجاح!",
488
  filename=video_file,
489
  )
490
  anti_ban.report_success()
@@ -503,46 +628,6 @@ class YouTubeDownloader:
503
 
504
  return None
505
 
506
- async def _try_fallback_download(
507
- self,
508
- url: str,
509
- quality: str = "best",
510
- ) -> Optional[str]:
511
- """محاولة التحميل من APIs البديلة"""
512
- try:
513
- result = await self.fallback.get_download_url(url, quality)
514
- if not result:
515
- logger.error("All fallback APIs failed to get download URL")
516
- return None
517
-
518
- download_url = result["url"]
519
- filename = result.get("filename", "video.mp4")
520
- source = result.get("source", "unknown")
521
-
522
- logger.info(f"Got download URL from {source}, downloading...")
523
- self._update_progress(
524
- status=DownloadStatus.FALLBACK_DOWNLOAD,
525
- percent=10,
526
- message=f"جاري التحميل من {source}...",
527
- )
528
-
529
- # تحميل الملف من الرابط المباشر
530
- filepath = await self.fallback.download_from_url(download_url, filename)
531
-
532
- if filepath:
533
- self._update_progress(
534
- status=DownloadStatus.COMPLETED,
535
- percent=100,
536
- message=f"تم تحميل الفيديو بنجاح من {source}!",
537
- filename=filepath,
538
- )
539
- return filepath
540
-
541
- except Exception as e:
542
- logger.error(f"Fallback download failed: {e}")
543
-
544
- return None
545
-
546
  async def download_full(
547
  self,
548
  url: str,
@@ -551,7 +636,7 @@ class YouTubeDownloader:
551
  quality: str = "best",
552
  auto_subtitle: bool = True,
553
  ) -> Dict[str, Optional[str]]:
554
- """التدفق الكامل: جلب معلومات → تحميل ترجمة → انتظار → تحميل فيديو"""
555
  results = {
556
  "video": None,
557
  "subtitle": None,
@@ -613,7 +698,8 @@ class YouTubeDownloader:
613
  eta=eta,
614
  downloaded_bytes=d.get('downloaded_bytes', 0),
615
  total_bytes=d.get('total_bytes') or d.get('total_bytes_estimate', 0),
616
- message=f"جاري التحميل... {percent}%",
 
617
  )
618
 
619
  elif d['status'] == 'finished':
 
1
  """
2
  محمل الفيديوهات الرئيسي - نسخة السحابة المحسنة
3
+ YouTube Video Downloader with Invidious-First Strategy
4
 
5
+ نظام التحميل الذكي (مُعاد تصميمه بناءً على اختبارات يونيو 2026):
6
+ 1. جلب معلومات الفيديو من Invidious (لا يستهلك حدود YouTube)
7
+ 2. تحميل الفيديو من Invidious ميع الجودات عبر adaptive formats + FFmpeg)
8
+ 3. لو فشل Invidious → yt-dlp مع tv_embedded client كـ fallback
9
+ 4. الترجمات: Invidious captions → yt-dlp كـ fallback
10
  """
11
 
12
  import os
 
47
  WAITING_ANTI_BAN = "waiting_anti_ban"
48
  DOWNLOADING_VIDEO = "downloading_video"
49
  FALLBACK_DOWNLOAD = "fallback_download"
50
+ MERGING_VIDEO = "merging_video"
51
  COMPLETED = "completed"
52
  CANCELLED = "cancelled"
53
  FAILED = "failed"
 
65
  view_count: int = 0
66
  available_subtitles: List[SubtitleInfo] = field(default_factory=list)
67
  formats: List[Dict[str, Any]] = field(default_factory=list)
68
+ # مصدر المعلومات
69
  info_source: str = "youtube"
70
+ # captions من Invidious
71
+ captions: List[Dict] = field(default_factory=list)
72
 
73
 
74
  @dataclass
 
82
  total_bytes: int = 0
83
  filename: str = ""
84
  message: str = ""
85
+ source: str = "" # مصدر التحميل الحالي
86
 
87
 
88
  class YouTubeDownloader:
89
  """
90
+ محمل فيديوهات يوتيوب - Invidious أولاً
91
+ الاستراتيجية:
92
+ - معلومات الفيديو: Invidious → yt-dlp
93
+ - تحميل الفيديو: Invidious (direct/adaptive+FFmpeg) → yt-dlp
94
+ - الترجمات: Invidious captions → yt-dlp
95
  """
96
 
97
  def __init__(self, download_dir: str = "/tmp/youtube_downloads"):
 
133
  def _convert_fallback_info(self, fallback_info: FallbackVideoInfo) -> VideoInfo:
134
  """تحويل معلومات الفيديو من FallbackVideoInfo إلى VideoInfo"""
135
  subtitles = []
136
+
137
+ # استخراج الترجمات من captions (أكثر فعالية)
138
+ if fallback_info.captions:
139
+ for cap in fallback_info.captions:
140
+ subtitles.append(SubtitleInfo(
141
+ language=cap.get("language", ""),
142
+ language_code=cap.get("language_code", ""),
143
+ auto_generated=cap.get("auto_generated", False),
144
+ url=cap.get("url", ""),
145
+ ))
146
+
147
+ # كمان subtitles
148
  for sub in fallback_info.available_subtitles:
149
+ if not any(s.language_code == sub.get("language_code") for s in subtitles):
150
+ subtitles.append(SubtitleInfo(
151
+ language=sub.get("language", ""),
152
+ language_code=sub.get("language_code", ""),
153
+ auto_generated=sub.get("auto_generated", False),
154
+ url=sub.get("url", ""),
155
+ ))
156
 
157
  return VideoInfo(
158
  title=fallback_info.title,
 
163
  uploader=fallback_info.uploader,
164
  view_count=fallback_info.view_count,
165
  available_subtitles=subtitles,
166
+ info_source="invidious",
167
+ captions=fallback_info.captions or [],
168
  )
169
 
170
  async def fetch_video_info(self, url: str) -> VideoInfo:
171
  """
172
+ جلب معلومات الفيديو - Invidious أولاً
 
 
173
  """
174
  self._cancelled = False
175
+ self._update_progress(
176
+ status=DownloadStatus.FETCHING_INFO,
177
+ message="جاري جلب معلومات الفيديو من Invidious...",
178
+ source="invidious",
179
+ )
180
 
181
+ # ═══ المحاولة 1: Invidious (لا يستهلك حدود YouTube) ═══
182
  try:
183
  fallback_info = await asyncio.wait_for(
184
  self.fallback.get_video_info(url),
185
+ timeout=20,
186
  )
187
  if fallback_info and fallback_info.title:
188
  info = self._convert_fallback_info(fallback_info)
189
+ info.info_source = "invidious"
190
+ logger.info(f"Got video info from Invidious: {info.title}")
191
+ self._update_progress(
192
+ status=DownloadStatus.IDLE,
193
+ message="تم جلب المعلومات بنجاح من Invidious",
194
+ )
195
  return info
196
  except asyncio.TimeoutError:
197
+ logger.warning("Invidious timeout for video info, trying yt-dlp...")
198
  except Exception as e:
199
+ logger.warning(f"Invidious failed for video info: {e}, trying yt-dlp...")
200
+
201
+ # ═══ المحاولة 2: yt-dlp مع tv_embedded client ═══
202
+ self._update_progress(
203
+ message="Invidious فشل، جاري المحاولة بـ yt-dlp...",
204
+ source="yt_dlp",
205
+ )
206
 
 
207
  await anti_ban.wait_before_request()
208
 
209
+ # نستخدم tv_embedded و android_vr لأنهم الأكثر فعالية
210
+ clients_to_try = ["tv_embedded", "android_vr", "mediaconnect", "android", "web"]
211
  last_error = None
212
 
213
  for client in clients_to_try:
 
275
  break
276
 
277
  anti_ban.report_success()
278
+ self._update_progress(
279
+ status=DownloadStatus.IDLE,
280
+ message="تم جلب المعلومات بنجاح من yt-dlp",
281
+ )
282
  return video_info
283
 
284
  except CancelledError:
 
297
  break
298
 
299
  # ═══ كل الطرق فشلت ═══
300
+ error_msg = str(last_error) if last_error else "فشل جلب المعلومات"
301
+ self._update_progress(
302
+ status=DownloadStatus.FAILED,
303
+ message=f"فشل جلب المعلومات: {error_msg}",
304
+ )
305
+ raise last_error if last_error else Exception("فشل جلب معلومات الفيديو من جميع المصادر")
306
 
307
  async def download_subtitle(
308
  self,
 
314
  """تحميل الترجمة"""
315
  self._update_progress(
316
  status=DownloadStatus.DOWNLOADING_SUBTITLE,
317
+ message=f"جاري تحميل الترجمة ({language_code}) من Invidious...",
318
  percent=0,
319
+ source="invidious",
320
  )
321
 
322
  if self._cancelled:
323
  return None
324
 
325
+ # ═══ المحاولة 1: Invidious captions ═══
326
  try:
327
  sub_content = await asyncio.wait_for(
328
  self.fallback.get_subtitle_content(url, language_code),
329
  timeout=15,
330
  )
331
  if sub_content:
 
332
  content = self.subtitle_converter.format_subtitle(sub_content, subtitle_format)
333
 
334
  final_path = os.path.join(self.download_dir, f"subtitle_{language_code}.{subtitle_format}")
 
336
  f.write(content)
337
 
338
  logger.info(f"Subtitle downloaded from Invidious: {final_path}")
339
+ self._update_progress(
340
+ status=DownloadStatus.DOWNLOADING_SUBTITLE,
341
+ percent=100,
342
+ message="تم تحميل الترجمة من Invidious",
343
+ )
344
  return final_path
345
  except Exception as e:
346
  logger.warning(f"Invidious subtitle failed: {e}, trying yt-dlp...")
347
 
348
  # ═══ المحاولة 2: yt-dlp ═══
349
+ self._update_progress(
350
+ message="Invidious فشل للترجمة، جاري المحاولة بـ yt-dlp...",
351
+ source="yt_dlp",
352
+ )
353
+
354
  await anti_ban.wait_before_request()
355
 
356
  try:
 
383
 
384
  await loop.run_in_executor(None, _download_sub)
385
 
386
+ self._update_progress(
387
+ status=DownloadStatus.DOWNLOADING_SUBTITLE,
388
+ percent=100,
389
+ message="تم تحميل الترجمة من yt-dlp",
390
+ )
391
  anti_ban.report_success()
392
 
393
  subtitle_file = self._find_subtitle_file(language_code, sub_format)
 
415
  if self._cancelled:
416
  return None
417
  anti_ban.report_failure(status_code=_extract_429_status(e))
418
+ self._update_progress(
419
+ status=DownloadStatus.FAILED,
420
+ message=f"فشل تحميل الترجمة: {str(e)}",
421
+ )
422
  raise
423
 
424
  async def download_video(
 
428
  output_filename: Optional[str] = None,
429
  ) -> Optional[str]:
430
  """
431
+ تحميل الفيديو - Invidious أولاً ثم yt-dlp
 
 
432
  """
433
  self._update_progress(
434
  status=DownloadStatus.DOWNLOADING_VIDEO,
435
+ message="جاري تحميل الفيديو من Invidious...",
436
  percent=0,
437
+ source="invidious",
438
  )
439
 
440
  if self._cancelled:
441
  return None
442
 
443
+ # ═══ المحاولة 1: Invidious ═══
444
+ video_file = await self._try_invidious_download(url, quality, output_filename)
 
 
 
 
 
 
 
 
 
 
445
  if video_file:
446
  return video_file
447
 
448
  if self._cancelled:
449
  return None
450
 
451
+ # ═══ المحاولة 2: yt-dlp ═══
452
+ logger.info("Invidious failed, trying yt-dlp...")
453
  self._update_progress(
454
+ status=DownloadStatus.DOWNLOADING_VIDEO,
455
+ message="Invidious فشل، جاري المحاولة بـ yt-dlp...",
456
  percent=0,
457
+ source="yt_dlp",
458
  )
459
 
460
+ video_file = await self._try_ytdlp_download(url, quality, output_filename)
461
  if video_file:
462
  return video_file
463
 
464
  if self._cancelled:
465
  return None
466
 
467
+ self._update_progress(
468
+ status=DownloadStatus.FAILED,
469
+ message="فشل تحميل الفيديو من جميع المصادر. جرب فيديو آخر أو حاول لاحقاً.",
470
+ )
471
+ return None
472
+
473
+ async def _try_invidious_download(
474
+ self,
475
+ url: str,
476
+ quality: str = "best",
477
+ output_filename: Optional[str] = None,
478
+ ) -> Optional[str]:
479
+ """تحميل الفيديو من Invidious"""
480
+ try:
481
+ result = await self.fallback.get_download_url(url, quality)
482
+ if not result:
483
+ logger.warning("Invidious returned no download URL")
484
+ return None
485
+
486
+ needs_merge = result.get("needs_merge", False)
487
+
488
+ if needs_merge:
489
+ # تحميل فيديو + صوت منفصلين ودمجهم
490
+ video_url = result.get("video_url", "")
491
+ audio_url = result.get("audio_url", "")
492
+ filename = result.get("filename", "video.mp4")
493
+
494
+ if output_filename:
495
+ filename = output_filename
496
+
497
+ self._update_progress(
498
+ status=DownloadStatus.FALLBACK_DOWNLOAD,
499
+ percent=10,
500
+ message=f"جاري تحميل الفيديو بجودة {result.get('quality', '?')} من Invidious...",
501
+ source="invidious_adaptive",
502
+ )
503
+
504
+ filepath = await self.fallback.download_and_merge(
505
+ video_url, audio_url, filename
506
+ )
507
+
508
+ if filepath:
509
+ self._update_progress(
510
+ status=DownloadStatus.COMPLETED,
511
+ percent=100,
512
+ message=f"تم تحميل الفيديو بنجاح من Invidious! (جودة: {result.get('quality', '?')})",
513
+ filename=filepath,
514
+ )
515
+ return filepath
516
+
517
+ else:
518
+ # تحميل مباشر (فيديو + صوت معاً)
519
+ download_url = result["url"]
520
+ filename = result.get("filename", "video.mp4")
521
+
522
+ if output_filename:
523
+ filename = output_filename
524
+
525
+ self._update_progress(
526
+ status=DownloadStatus.FALLBACK_DOWNLOAD,
527
+ percent=10,
528
+ message=f"جاري تحميل الفيديو بجودة {result.get('quality', '?')} من Invidious...",
529
+ source="invidious_direct",
530
+ )
531
+
532
+ filepath = await self.fallback.download_from_url(download_url, filename)
533
+
534
+ if filepath:
535
+ self._update_progress(
536
+ status=DownloadStatus.COMPLETED,
537
+ percent=100,
538
+ message=f"تم تحميل الفيديو بنجاح من Invidious! (جودة: {result.get('quality', '?')})",
539
+ filename=filepath,
540
+ )
541
+ return filepath
542
+
543
+ except Exception as e:
544
+ logger.error(f"Invidious download failed: {e}")
545
+
546
  return None
547
 
548
  async def _try_ytdlp_download(
 
551
  quality: str = "best",
552
  output_filename: Optional[str] = None,
553
  ) -> Optional[str]:
554
+ """محاولة التحميل بـ yt-dlp"""
555
+ # tv_embedded و android_vr هم الأكثر فعالية
556
+ clients_to_try = ["tv_embedded", "android_vr", "mediaconnect", "android", "web"]
557
 
558
  for client in clients_to_try:
559
  if self._cancelled:
 
609
  self._update_progress(
610
  status=DownloadStatus.COMPLETED,
611
  percent=100,
612
+ message="تم تحميل الفيديو بنجاح من yt-dlp!",
613
  filename=video_file,
614
  )
615
  anti_ban.report_success()
 
628
 
629
  return None
630
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
  async def download_full(
632
  self,
633
  url: str,
 
636
  quality: str = "best",
637
  auto_subtitle: bool = True,
638
  ) -> Dict[str, Optional[str]]:
639
+ """التدفق الكامل: جلب معلومات → تحميل ترجمة → تحميل فيديو"""
640
  results = {
641
  "video": None,
642
  "subtitle": None,
 
698
  eta=eta,
699
  downloaded_bytes=d.get('downloaded_bytes', 0),
700
  total_bytes=d.get('total_bytes') or d.get('total_bytes_estimate', 0),
701
+ message=f"جاري التحميل من yt-dlp... {percent}%",
702
+ source="yt_dlp",
703
  )
704
 
705
  elif d['status'] == 'finished':
core/fallback_downloader.py CHANGED
@@ -1,7 +1,12 @@
1
  """
2
- نظام التحميل البديل - Fallback Downloader
3
- Cobalt API + Invidious API + Piped API
4
- يحل مشكلة حظر IPs السيرفرات السحابية بدون كوكيز
 
 
 
 
 
5
  """
6
 
7
  import os
@@ -10,8 +15,10 @@ import asyncio
10
  import logging
11
  import random
12
  import time
 
 
13
  from typing import Optional, Dict, Any, List
14
- from dataclasses import dataclass
15
 
16
  import httpx
17
 
@@ -19,45 +26,20 @@ logger = logging.getLogger(__name__)
19
 
20
 
21
  # ═══════════════════════════════════════════════════
22
- # 1. Cobalt API - أفضل بديل لـ yt-dlp على السحابة
23
  # ═══════════════════════════════════════════════════
24
 
25
- COBALT_INSTANCES = [
26
- "https://api.cobalt.tools",
27
- "https://cobalt-api.kwiatekmiki.com",
28
- "https://cobalt.api.timelessnesses.me",
29
  ]
30
 
 
 
31
 
32
- # ═══════════════════════════════════════════════════
33
- # 2. Invidious - واجهة يوتيوب بديلة مفتوحة المصدر
34
- # ═══════════════════════════════════════════════════
35
-
36
- INVIDIOUS_INSTANCES = [
37
- "https://inv.tux.pizza",
38
- "https://invidious.privacyredirect.com",
39
- "https://vid.puffyan.us",
40
- "https://invidious.nerdvpn.de",
41
- "https://inv.nadeko.net",
42
- "https://invidious.protokolla.fi",
43
- "https://iv.ggtyler.dev",
44
- "https://invidious.lunar.icu",
45
- "https://yt.cdaut.de",
46
- "https://invidious.perennialte.ch",
47
- ]
48
-
49
-
50
- # ═══════════════════════════════════════════════════
51
- # 3. Piped - واجهة يوتيوب بديلة أخرى
52
- # ═══════════════════════════════════════════════════
53
-
54
- PIPED_INSTANCES = [
55
- "https://pipedapi.kavin.rocks",
56
- "https://pipedapi.adminforge.de",
57
- "https://api.piped.projectsegfault.com",
58
- "https://pipedapi.in.projectsegfault.com",
59
- "https://pipedapi.leptons.xyz",
60
- ]
61
 
62
 
63
  @dataclass
@@ -71,10 +53,19 @@ class FallbackVideoInfo:
71
  view_count: int = 0
72
  description: str = ""
73
  available_subtitles: list = None
 
 
 
74
 
75
  def __post_init__(self):
76
  if self.available_subtitles is None:
77
  self.available_subtitles = []
 
 
 
 
 
 
78
 
79
 
80
  def _extract_video_id(url: str) -> Optional[str]:
@@ -90,143 +81,124 @@ def _extract_video_id(url: str) -> Optional[str]:
90
  return None
91
 
92
 
93
- def _shuffle_instances(instances: List[str]) -> List[str]:
94
- """ترتيب عشوائي للسيرفرات مع تفضيل آخر سيرفر ناجح"""
95
- shuffled = instances.copy()
96
- random.shuffle(shuffled)
97
- return shuffled
98
-
99
-
100
- class CobaltDownloader:
101
- """
102
- محمل عبر Cobalt API
103
- مصمم خصيصاً للتحميل بدون حظر - يستخدم سيرفرات وسيطة
104
- """
105
-
106
- def __init__(self):
107
- self._working_instance: Optional[str] = None
108
- self._failed_instances: List[str] = []
109
-
110
- def _get_next_instance(self) -> Optional[str]:
111
- """اختيار سيرفر Cobalt متاح"""
112
- if self._working_instance and self._working_instance not in self._failed_instances:
113
- # 70% فرصة نستخدم السيرفر اللي اشتغل قبل كده
114
- if random.random() < 0.7:
115
- return self._working_instance
116
-
117
- available = [i for i in COBALT_INSTANCES if i not in self._failed_instances]
118
- if not available:
119
- self._failed_instances.clear()
120
- available = COBALT_INSTANCES
121
 
122
- return random.choice(available)
 
123
 
124
- async def get_download_url(
125
- self,
126
- url: str,
127
- quality: str = "best",
128
- ) -> Optional[Dict[str, Any]]:
129
- """
130
- الحصول على رابط التحميل المباشر من Cobalt
131
- """
132
- instances = _shuffle_instances(COBALT_INSTANCES)
133
- # لو فيه سيرفر شغال، نبدأ بيه
134
- if self._working_instance:
135
- instances.insert(0, self._working_instance)
136
 
137
- quality_map = {
138
- "best": "1080",
139
- "medium": "720",
140
- "low": "480",
141
- }
142
- cobalt_quality = quality_map.get(quality, "1080")
143
 
144
- for instance in instances:
145
- try:
146
- async with httpx.AsyncClient(timeout=15) as client:
147
- response = await client.post(
148
- f"{instance}/",
149
- json={
150
- "url": url,
151
- "videoQuality": cobalt_quality,
152
- "filenameStyle": "basic",
153
- "downloadMode": "auto",
154
- },
155
- headers={
156
- "Accept": "application/json",
157
- "Content-Type": "application/json",
158
- },
159
- )
160
 
161
- if response.status_code == 200:
162
- data = response.json()
 
163
 
164
- if data.get("status") == "redirect" or data.get("status") == "stream":
165
- download_url = data.get("url")
166
- if download_url:
167
- self._working_instance = instance
168
- logger.info(f"Cobalt success with {instance}")
169
- return {
170
- "url": download_url,
171
- "filename": data.get("filename", "video.mp4"),
172
- "source": "cobalt",
173
- }
174
-
175
- elif data.get("status") == "picker":
176
- # فيديو فيه خيارات متعددة
177
- picker = data.get("picker", [])
178
- if picker:
179
- download_url = picker[0].get("url")
180
- if download_url:
181
- self._working_instance = instance
182
- return {
183
- "url": download_url,
184
- "filename": data.get("filename", "video.mp4"),
185
- "source": "cobalt",
186
- }
187
 
188
- elif response.status_code == 429:
189
- logger.warning(f"Cobalt rate limited on {instance}")
190
- if instance not in self._failed_instances:
191
- self._failed_instances.append(instance)
 
 
 
 
 
 
 
 
192
  continue
 
 
 
 
 
193
 
194
- logger.warning(f"Cobalt returned status {response.status_code} from {instance}")
 
 
 
195
 
196
- except (httpx.TimeoutException, httpx.ConnectError) as e:
197
- logger.warning(f"Cobalt timeout/connection error on {instance}: {e}")
198
- if instance not in self._failed_instances:
199
- self._failed_instances.append(instance)
200
- continue
201
- except Exception as e:
202
- logger.warning(f"Cobalt error on {instance}: {e}")
203
- continue
204
 
205
- logger.error("All Cobalt instances failed")
206
- return None
 
 
207
 
208
 
209
  class InvidiousDownloader:
210
  """
211
- محمل عبر Invidious API
212
- يوفر معلومات الفيديو + روابط تحميل مباشرة
213
  """
214
 
215
  def __init__(self):
216
  self._working_instance: Optional[str] = None
217
  self._failed_instances: List[str] = []
 
 
 
 
 
218
 
219
- def _get_next_instance(self) -> Optional[str]:
 
 
220
  if self._working_instance and self._working_instance not in self._failed_instances:
221
- if random.random() < 0.7:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  return self._working_instance
 
 
223
 
224
- available = [i for i in INVIDIOUS_INSTANCES if i not in self._failed_instances]
225
- if not available:
226
- self._failed_instances.clear()
227
- available = INVIDIOUS_INSTANCES
 
 
228
 
229
- return random.choice(available)
 
230
 
231
  async def get_video_info(self, url: str) -> Optional[FallbackVideoInfo]:
232
  """جلب معلومات الفيديو من Invidious"""
@@ -235,22 +207,22 @@ class InvidiousDownloader:
235
  logger.error(f"Cannot extract video ID from: {url}")
236
  return None
237
 
238
- instances = _shuffle_instances(INVIDIOUS_INSTANCES)
239
- if self._working_instance:
240
- instances.insert(0, self._working_instance)
241
 
242
  for instance in instances:
243
  try:
244
- async with httpx.AsyncClient(timeout=10) as client:
245
  response = await client.get(
246
  f"{instance}/api/v1/videos/{video_id}",
247
- params={"fields": "title,lengthSeconds,videoThumbnails,author,viewCount,description,subtitles,formatStreams,adaptiveFormats"},
 
 
248
  )
249
 
250
  if response.status_code == 200:
251
  data = response.json()
252
  self._working_instance = instance
253
- logger.info(f"Invidious success with {instance}")
254
 
255
  # استخراج الصورة المصغرة
256
  thumbnail = ""
@@ -262,7 +234,7 @@ class InvidiousDownloader:
262
  if not thumbnail and thumbnails:
263
  thumbnail = thumbnails[0].get("url", "")
264
 
265
- # استخراج الترجمات
266
  subtitles = []
267
  for sub in data.get("subtitles", []):
268
  subtitles.append({
@@ -272,6 +244,16 @@ class InvidiousDownloader:
272
  "url": sub.get("url", ""),
273
  })
274
 
 
 
 
 
 
 
 
 
 
 
275
  return FallbackVideoInfo(
276
  title=data.get("title", ""),
277
  video_id=video_id,
@@ -281,6 +263,9 @@ class InvidiousDownloader:
281
  view_count=data.get("viewCount", 0),
282
  description=data.get("description", "")[:500] if data.get("description") else "",
283
  available_subtitles=subtitles,
 
 
 
284
  )
285
 
286
  elif response.status_code == 429:
@@ -288,8 +273,14 @@ class InvidiousDownloader:
288
  if instance not in self._failed_instances:
289
  self._failed_instances.append(instance)
290
  continue
 
 
 
 
 
291
 
292
- except (httpx.TimeoutException, httpx.ConnectError):
 
293
  if instance not in self._failed_instances:
294
  self._failed_instances.append(instance)
295
  continue
@@ -297,6 +288,61 @@ class InvidiousDownloader:
297
  logger.warning(f"Invidious error on {instance}: {e}")
298
  continue
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  logger.error("All Invidious instances failed for video info")
301
  return None
302
 
@@ -305,16 +351,19 @@ class InvidiousDownloader:
305
  url: str,
306
  quality: str = "best",
307
  ) -> Optional[Dict[str, Any]]:
308
- """الحصول على رابط تحميل مباشر من Invidious"""
 
 
 
309
  video_id = _extract_video_id(url)
310
  if not video_id:
311
  return None
312
 
313
- instances = _shuffle_instances(INVIDIOUS_INSTANCES)
314
 
315
  for instance in instances:
316
  try:
317
- async with httpx.AsyncClient(timeout=10) as client:
318
  response = await client.get(
319
  f"{instance}/api/v1/videos/{video_id}",
320
  params={"fields": "formatStreams,adaptiveFormats,title"},
@@ -324,38 +373,9 @@ class InvidiousDownloader:
324
  data = response.json()
325
  self._working_instance = instance
326
 
327
- # البحث عن أفضل صيغة
328
- formats = data.get("formatStreams", [])
329
-
330
- # ترتيب حسب الجودة
331
- quality_order = {
332
- "best": ["1080p", "720p", "480p", "360p"],
333
- "medium": ["720p", "480p", "360p"],
334
- "low": ["480p", "360p"],
335
- }
336
- preferred = quality_order.get(quality, ["1080p", "720p", "480p", "360p"])
337
-
338
- download_url = None
339
- for q in preferred:
340
- for fmt in formats:
341
- if fmt.get("qualityLabel", "").startswith(q) and fmt.get("type", "").startswith("video/mp4"):
342
- download_url = fmt.get("url")
343
- break
344
- if download_url:
345
- break
346
-
347
- # لو ملقيناش mp4، نأخذ أي حاجة
348
- if not download_url and formats:
349
- download_url = formats[0].get("url")
350
-
351
- if download_url:
352
- title = data.get("title", "video")
353
- safe_title = re.sub(r'[^\w\s-]', '', title)[:50]
354
- return {
355
- "url": download_url,
356
- "filename": f"{safe_title}.mp4",
357
- "source": "invidious",
358
- }
359
 
360
  elif response.status_code == 429:
361
  if instance not in self._failed_instances:
@@ -370,169 +390,205 @@ class InvidiousDownloader:
370
 
371
  return None
372
 
373
- async def get_subtitle_url(self, url: str, lang: str = "ar") -> Optional[str]:
374
- """الحصول على رابط الترجمة من Invidious"""
375
- video_id = _extract_video_id(url)
376
- if not video_id:
377
- return None
378
-
379
- instances = _shuffle_instances(INVIDIOUS_INSTANCES)
380
-
381
- for instance in instances:
382
- try:
383
- async with httpx.AsyncClient(timeout=10) as client:
384
- response = await client.get(
385
- f"{instance}/api/v1/videos/{video_id}",
386
- params={"fields": "subtitles"},
387
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
- if response.status_code == 200:
390
- data = response.json()
391
- subtitles = data.get("subtitles", [])
392
-
393
- # البحث عن اللغة المطلوبة
394
- for sub in subtitles:
395
- if sub.get("language_code") == lang:
396
- sub_url = sub.get("url", "")
397
- if sub_url and not sub_url.startswith("http"):
398
- sub_url = f"{instance}{sub_url}"
399
- return sub_url
400
-
401
- # لو ملقيناش اللغة، نرجع أول ترجمة متاحة
402
- if subtitles:
403
- sub_url = subtitles[0].get("url", "")
404
- if sub_url and not sub_url.startswith("http"):
405
- sub_url = f"{instance}{sub_url}"
406
- return sub_url
407
-
408
- except Exception:
409
- continue
410
 
411
  return None
412
 
413
-
414
- class PipedDownloader:
415
- """
416
- محمل عبر Piped API
417
- واجهة بديلة ليوتيوب بروكسيات
418
- """
419
-
420
- def __init__(self):
421
- self._working_instance: Optional[str] = None
422
- self._failed_instances: List[str] = []
423
-
424
- async def get_video_info(self, url: str) -> Optional[FallbackVideoInfo]:
425
- """جلب معلومات الفيديو من Piped"""
426
- video_id = _extract_video_id(url)
427
- if not video_id:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  return None
429
 
430
- instances = _shuffle_instances(PIPED_INSTANCES)
431
-
432
- for instance in instances:
433
- try:
434
- async with httpx.AsyncClient(timeout=10) as client:
435
- response = await client.get(f"{instance}/streams/{video_id}")
436
-
437
- if response.status_code == 200:
438
- data = response.json()
439
- self._working_instance = instance
440
- logger.info(f"Piped success with {instance}")
441
-
442
- # استخراج الترجمات
443
- subtitles = []
444
- for sub in data.get("subtitles", []):
445
- subtitles.append({
446
- "language": sub.get("name", sub.get("code", "")),
447
- "language_code": sub.get("code", ""),
448
- "auto_generated": sub.get("auto", False),
449
- "url": sub.get("url", ""),
450
- })
451
 
452
- return FallbackVideoInfo(
453
- title=data.get("title", ""),
454
- video_id=video_id,
455
- duration=data.get("duration", 0),
456
- thumbnail=data.get("thumbnailUrl", ""),
457
- uploader=data.get("uploader", ""),
458
- view_count=data.get("views", 0),
459
- description=data.get("description", "")[:500] if data.get("description") else "",
460
- available_subtitles=subtitles,
461
- )
462
 
463
- elif response.status_code == 429:
464
- if instance not in self._failed_instances:
465
- self._failed_instances.append(instance)
466
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
 
468
- except (httpx.TimeoutException, httpx.ConnectError):
469
- continue
470
- except Exception as e:
471
- logger.warning(f"Piped error on {instance}: {e}")
472
- continue
473
 
474
- return None
475
 
476
- async def get_download_url(
477
- self,
478
- url: str,
479
- quality: str = "best",
480
- ) -> Optional[Dict[str, Any]]:
481
- """الحصول على رابط تحميل مباشر من Piped"""
482
  video_id = _extract_video_id(url)
483
  if not video_id:
484
  return None
485
 
486
- instances = _shuffle_instances(PIPED_INSTANCES)
487
 
488
  for instance in instances:
489
  try:
 
490
  async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
491
- response = await client.get(f"{instance}/streams/{video_id}")
492
-
493
- if response.status_code == 200:
494
- data = response.json()
495
 
496
- # Piped بيرجع videoStreams و audioStreams
497
- video_streams = data.get("videoStreams", [])
498
 
499
- # فلترة mp4 فقط
500
- mp4_streams = [s for s in video_streams if s.get("mimeType", "").startswith("video/mp4")]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
 
502
- if not mp4_streams:
503
- mp4_streams = video_streams
 
504
 
505
- # ترتيب حسب الجودة
506
- quality_order = {"best": 1080, "medium": 720, "low": 480}
507
- max_height = quality_order.get(quality, 1080)
508
 
509
- best_stream = None
510
- for stream in mp4_streams:
511
- height = stream.get("quality", 0)
512
- if isinstance(height, str):
513
- height = int(height.replace("p", "")) if height.endswith("p") else 0
514
- if height <= max_height:
515
- best_stream = stream
516
- break
517
 
518
- if not best_stream and mp4_streams:
519
- best_stream = mp4_streams[0]
520
 
521
- if best_stream:
522
- download_url = best_stream.get("url")
523
- if download_url:
524
- title = data.get("title", "video")
525
- safe_title = re.sub(r'[^\w\s-]', '', title)[:50]
526
- return {
527
- "url": download_url,
528
- "filename": f"{safe_title}.mp4",
529
- "source": "piped",
530
- }
531
 
532
- except (httpx.TimeoutException, httpx.ConnectError):
533
- continue
534
  except Exception as e:
535
- logger.warning(f"Piped download error on {instance}: {e}")
536
  continue
537
 
538
  return None
@@ -540,42 +596,32 @@ class PipedDownloader:
540
 
541
  class FallbackDownloader:
542
  """
543
- مدير التحميل البديل - ينسق بين كل الـ APIs
544
- الترتيب: yt-dlp Cobalt Invidious Piped
545
  """
546
 
547
  def __init__(self, download_dir: str = "/tmp/downloads"):
548
  self.download_dir = download_dir
549
- self.cobalt = CobaltDownloader()
550
  self.invidious = InvidiousDownloader()
551
- self.piped = PipedDownloader()
552
 
553
  # إحصائيات
554
  self.stats = {
555
- "yt_dlp_success": 0,
556
- "cobalt_success": 0,
557
- "invidious_success": 0,
558
- "piped_success": 0,
 
559
  "total_fallbacks": 0,
560
  }
561
 
562
  async def get_video_info(self, url: str) -> Optional[FallbackVideoInfo]:
563
  """
564
- جلب معلومات الفيديو - نبدأ بـ Invidious لأنه يوفر حدود YouTube
565
- الترتيب: Invidious → Piped → (yt-dlp يتولاه downloader الرئيسي)
566
  """
567
- # محاولة 1: Invidious (أفضل مصدر للمعلومات)
568
  info = await self.invidious.get_video_info(url)
569
  if info and info.title:
570
- self.stats["invidious_success"] += 1
571
  return info
572
-
573
- # محاولة 2: Piped
574
- info = await self.piped.get_video_info(url)
575
- if info and info.title:
576
- self.stats["piped_success"] += 1
577
- return info
578
-
579
  return None
580
 
581
  async def get_download_url(
@@ -583,31 +629,15 @@ class FallbackDownloader:
583
  url: str,
584
  quality: str = "best",
585
  ) -> Optional[Dict[str, Any]]:
586
- """
587
- الحصول على رابط تحميل مباشر
588
- الترتيب: Cobalt → Invidious → Piped
589
- """
590
- # محاولة 1: Cobalt (الأسرع والأفضل)
591
- result = await self.cobalt.get_download_url(url, quality)
592
- if result:
593
- self.stats["cobalt_success"] += 1
594
- self.stats["total_fallbacks"] += 1
595
- return result
596
-
597
- # محاولة 2: Invidious
598
  result = await self.invidious.get_download_url(url, quality)
599
  if result:
600
- self.stats["invidious_success"] += 1
 
 
 
601
  self.stats["total_fallbacks"] += 1
602
  return result
603
-
604
- # محاولة 3: Piped
605
- result = await self.piped.get_download_url(url, quality)
606
- if result:
607
- self.stats["piped_success"] += 1
608
- self.stats["total_fallbacks"] += 1
609
- return result
610
-
611
  return None
612
 
613
  async def download_from_url(self, download_url: str, filename: str) -> Optional[str]:
@@ -634,27 +664,144 @@ class FallbackDownloader:
634
 
635
  except Exception as e:
636
  logger.error(f"Download from URL failed: {e}")
637
- # حذف الملف الفارغ أو الناقص
638
  if os.path.exists(filepath):
639
  os.remove(filepath)
640
  return None
641
 
642
- async def get_subtitle_content(self, url: str, lang: str = "ar") -> Optional[str]:
643
- """تحميل محتوى الترجمة من Invidious"""
644
- sub_url = await self.invidious.get_subtitle_url(url, lang)
645
- if not sub_url:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  return None
647
 
 
 
 
 
648
  try:
649
- async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
650
- response = await client.get(sub_url)
651
- if response.status_code == 200:
652
- return response.text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  except Exception as e:
654
- logger.warning(f"Subtitle download from Invidious failed: {e}")
 
655
 
656
- return None
 
 
657
 
658
  def get_stats(self) -> Dict[str, Any]:
659
  """إحصائيات الاستخدام"""
660
- return self.stats.copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ نظام التحميل البديل - Invidious-First Strategy
3
+ تم إعادة كتابته بالكامل بناءً على اختبارات حقيقية يونيو 2026
4
+
5
+ المصادر العاملة:
6
+ - Invidious (inv.thepixora.com): ✅ معلومات + تحميل بجميع الجودات
7
+ - اكتشاف ديناميكي للسيرفرات من api.invidious.io
8
+ - Cobalt: ❌ ميت (API v7 أُغلق)
9
+ - Piped: ❌ كل السيرفرات ميتة
10
  """
11
 
12
  import os
 
15
  import logging
16
  import random
17
  import time
18
+ import subprocess
19
+ import tempfile
20
  from typing import Optional, Dict, Any, List
21
+ from dataclasses import dataclass, field
22
 
23
  import httpx
24
 
 
26
 
27
 
28
  # ═══════════════════════════════════════════════════
29
+ # سيرفرات InvidIOUS المعروفة (يتم تحديثها ديناميكياً)
30
  # ═══════════════════════════════════════════════════
31
 
32
+ # سيرفرات أولية ثابتة (مختبرة وفعّالة)
33
+ SEED_INVIDIOUS_INSTANCES = [
34
+ "https://inv.thepixora.com",
 
35
  ]
36
 
37
+ # سيرفرات إضافية يتم اكتشافها من api.invidious.io
38
+ DISCOVERED_INSTANCES: List[str] = []
39
 
40
+ # فترة تحديث السيرفرات (كل 30 دقيقة)
41
+ INSTANCE_REFRESH_INTERVAL = 1800
42
+ _last_instance_refresh: float = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
 
45
  @dataclass
 
53
  view_count: int = 0
54
  description: str = ""
55
  available_subtitles: list = None
56
+ captions: list = None
57
+ formats: list = None
58
+ adaptive_formats: list = None
59
 
60
  def __post_init__(self):
61
  if self.available_subtitles is None:
62
  self.available_subtitles = []
63
+ if self.captions is None:
64
+ self.captions = []
65
+ if self.formats is None:
66
+ self.formats = []
67
+ if self.adaptive_formats is None:
68
+ self.adaptive_formats = []
69
 
70
 
71
  def _extract_video_id(url: str) -> Optional[str]:
 
81
  return None
82
 
83
 
84
+ def _get_all_instances() -> List[str]:
85
+ """الحصول على قائمة كل السيرفرات المتاحة"""
86
+ global DISCOVERED_INSTANCES, _last_instance_refresh
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
+ now = time.time()
89
+ instances = SEED_INVIDIOUS_INSTANCES.copy()
90
 
91
+ # أضف السيرفرات المكتشفة
92
+ instances.extend([i for i in DISCOVERED_INSTANCES if i not in instances])
 
 
 
 
 
 
 
 
 
 
93
 
94
+ return instances
 
 
 
 
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
+ async def refresh_instances():
98
+ """اكتشاف سيرفرات Invidious نشطة من API الرسمي"""
99
+ global DISCOVERED_INSTANCES, _last_instance_refresh
100
 
101
+ now = time.time()
102
+ if now - _last_instance_refresh < INSTANCE_REFRESH_INTERVAL:
103
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
+ logger.info("Refreshing Invidious instance list from official API...")
106
+ try:
107
+ async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
108
+ response = await client.get("https://api.invidious.io/instances.json")
109
+ if response.status_code == 200:
110
+ data = response.json()
111
+ discovered = []
112
+ for item in data:
113
+ if not isinstance(item, list) or len(item) < 2:
114
+ continue
115
+ info = item[1] if len(item) > 1 else {}
116
+ if not info or not isinstance(info, dict):
117
  continue
118
+ uri = info.get("uri", "")
119
+ monitor = info.get("monitor") or {}
120
+ down = monitor.get("down", True)
121
+ api_enabled = info.get("api", False)
122
+ uptime = monitor.get("uptime", 0)
123
 
124
+ # نأخذ السيرفرات اللي مو down ولها API أو uptime عالي
125
+ if uri and not down and (api_enabled or uptime > 80):
126
+ if uri.startswith("https://") and uri not in SEED_INVIDIOUS_INSTANCES:
127
+ discovered.append(uri)
128
 
129
+ if discovered:
130
+ DISCOVERED_INSTANCES = discovered
131
+ logger.info(f"Discovered {len(discovered)} Invidious instances")
 
 
 
 
 
132
 
133
+ except Exception as e:
134
+ logger.warning(f"Failed to refresh Invidious instances: {e}")
135
+
136
+ _last_instance_refresh = now
137
 
138
 
139
  class InvidiousDownloader:
140
  """
141
+ محمل عبر Invidious API - المصدر الرئيسي للتحميل
142
+ يوفر: معلومات الفيديو + روابط تحميل مباشرة لجميع الجودات + ترجمات
143
  """
144
 
145
  def __init__(self):
146
  self._working_instance: Optional[str] = None
147
  self._failed_instances: List[str] = []
148
+ self._instance_tested: bool = False
149
+
150
+ async def _get_instances(self) -> List[str]:
151
+ """الحصول على قائمة السيرفرات مرتبة"""
152
+ await refresh_instances()
153
 
154
+ instances = _get_all_instances()
155
+
156
+ # نبدأ بالسيرفر الشغال
157
  if self._working_instance and self._working_instance not in self._failed_instances:
158
+ instances = [self._working_instance] + [i for i in instances if i != self._working_instance]
159
+
160
+ # نزيل السيرفرات الفاشلة (بس نحتفظ بيهم كـ fallback أخير)
161
+ available = [i for i in instances if i not in self._failed_instances]
162
+ failed_but_maybe_ok = [i for i in instances if i in self._failed_instances]
163
+
164
+ return available + failed_but_maybe_ok
165
+
166
+ async def _test_instance(self, instance: str) -> bool:
167
+ """اختبار سيرفر Invidious"""
168
+ try:
169
+ async with httpx.AsyncClient(timeout=8, follow_redirects=True) as client:
170
+ r = await client.get(
171
+ f"{instance}/api/v1/videos/dQw4w9WgXcQ",
172
+ params={"fields": "title"},
173
+ )
174
+ if r.status_code == 200:
175
+ try:
176
+ data = r.json()
177
+ return bool(data.get("title"))
178
+ except Exception:
179
+ return False
180
+ return False
181
+ except Exception:
182
+ return False
183
+
184
+ async def _find_working_instance(self) -> Optional[str]:
185
+ """البحث عن سيرفر شغال"""
186
+ if self._working_instance:
187
+ # تحقق أنه لسه شغال
188
+ if await self._test_instance(self._working_instance):
189
  return self._working_instance
190
+ else:
191
+ self._working_instance = None
192
 
193
+ instances = await self._get_instances()
194
+ for instance in instances:
195
+ if await self._test_instance(instance):
196
+ self._working_instance = instance
197
+ logger.info(f"Found working Invidious instance: {instance}")
198
+ return instance
199
 
200
+ logger.error("No working Invidious instance found!")
201
+ return None
202
 
203
  async def get_video_info(self, url: str) -> Optional[FallbackVideoInfo]:
204
  """جلب معلومات الفيديو من Invidious"""
 
207
  logger.error(f"Cannot extract video ID from: {url}")
208
  return None
209
 
210
+ instances = await self._get_instances()
 
 
211
 
212
  for instance in instances:
213
  try:
214
+ async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
215
  response = await client.get(
216
  f"{instance}/api/v1/videos/{video_id}",
217
+ params={
218
+ "fields": "title,lengthSeconds,videoThumbnails,author,viewCount,description,subtitles,captions,formatStreams,adaptiveFormats"
219
+ },
220
  )
221
 
222
  if response.status_code == 200:
223
  data = response.json()
224
  self._working_instance = instance
225
+ logger.info(f"Invidious video info success with {instance}")
226
 
227
  # استخراج الصورة المصغرة
228
  thumbnail = ""
 
234
  if not thumbnail and thumbnails:
235
  thumbnail = thumbnails[0].get("url", "")
236
 
237
+ # استخراج الترجمات (captions = فعّالة أكثر من subtitles)
238
  subtitles = []
239
  for sub in data.get("subtitles", []):
240
  subtitles.append({
 
244
  "url": sub.get("url", ""),
245
  })
246
 
247
+ # استخراج captions (التلقائية واليدوية)
248
+ captions = []
249
+ for cap in data.get("captions", []):
250
+ captions.append({
251
+ "language": cap.get("label", ""),
252
+ "language_code": cap.get("language_code", ""),
253
+ "auto_generated": "(auto" in cap.get("label", "").lower(),
254
+ "url": cap.get("url", ""),
255
+ })
256
+
257
  return FallbackVideoInfo(
258
  title=data.get("title", ""),
259
  video_id=video_id,
 
263
  view_count=data.get("viewCount", 0),
264
  description=data.get("description", "")[:500] if data.get("description") else "",
265
  available_subtitles=subtitles,
266
+ captions=captions,
267
+ formats=data.get("formatStreams", []),
268
+ adaptive_formats=data.get("adaptiveFormats", []),
269
  )
270
 
271
  elif response.status_code == 429:
 
273
  if instance not in self._failed_instances:
274
  self._failed_instances.append(instance)
275
  continue
276
+ else:
277
+ logger.warning(f"Invidious returned {response.status_code} from {instance}")
278
+ if instance not in self._failed_instances:
279
+ self._failed_instances.append(instance)
280
+ continue
281
 
282
+ except (httpx.TimeoutException, httpx.ConnectError) as e:
283
+ logger.warning(f"Invidious connection error on {instance}: {e}")
284
  if instance not in self._failed_instances:
285
  self._failed_instances.append(instance)
286
  continue
 
288
  logger.warning(f"Invidious error on {instance}: {e}")
289
  continue
290
 
291
+ # كل السيرفرات فشلت - حاول البحث عن سيرفر جديد
292
+ logger.warning("All instances failed, trying to find new working instance...")
293
+ new_instance = await self._find_working_instance()
294
+ if new_instance:
295
+ try:
296
+ async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
297
+ response = await client.get(
298
+ f"{new_instance}/api/v1/videos/{video_id}",
299
+ params={"fields": "title,lengthSeconds,videoThumbnails,author,viewCount,description,captions,formatStreams,adaptiveFormats"},
300
+ )
301
+ if response.status_code == 200:
302
+ data = response.json()
303
+ thumbnail = ""
304
+ thumbnails = data.get("videoThumbnails", [])
305
+ for t in thumbnails:
306
+ if t.get("quality") == "medium":
307
+ thumbnail = t.get("url", "")
308
+ break
309
+ if not thumbnail and thumbnails:
310
+ thumbnail = thumbnails[0].get("url", "")
311
+
312
+ subtitles = []
313
+ for sub in data.get("subtitles", []):
314
+ subtitles.append({
315
+ "language": sub.get("label", sub.get("language_code", "")),
316
+ "language_code": sub.get("language_code", ""),
317
+ "auto_generated": False,
318
+ "url": sub.get("url", ""),
319
+ })
320
+
321
+ captions = []
322
+ for cap in data.get("captions", []):
323
+ captions.append({
324
+ "language": cap.get("label", ""),
325
+ "language_code": cap.get("language_code", ""),
326
+ "auto_generated": "(auto" in cap.get("label", "").lower(),
327
+ "url": cap.get("url", ""),
328
+ })
329
+
330
+ return FallbackVideoInfo(
331
+ title=data.get("title", ""),
332
+ video_id=video_id,
333
+ duration=data.get("lengthSeconds", 0),
334
+ thumbnail=thumbnail,
335
+ uploader=data.get("author", ""),
336
+ view_count=data.get("viewCount", 0),
337
+ description=data.get("description", "")[:500] if data.get("description") else "",
338
+ available_subtitles=subtitles,
339
+ captions=captions,
340
+ formats=data.get("formatStreams", []),
341
+ adaptive_formats=data.get("adaptiveFormats", []),
342
+ )
343
+ except Exception as e:
344
+ logger.error(f"Even newly found instance failed: {e}")
345
+
346
  logger.error("All Invidious instances failed for video info")
347
  return None
348
 
 
351
  url: str,
352
  quality: str = "best",
353
  ) -> Optional[Dict[str, Any]]:
354
+ """
355
+ الحصول على روابط تحميل من Invidious
356
+ يرجع روابط فيديو + صوت منفصلة للدمج بـ FFmpeg
357
+ """
358
  video_id = _extract_video_id(url)
359
  if not video_id:
360
  return None
361
 
362
+ instances = await self._get_instances()
363
 
364
  for instance in instances:
365
  try:
366
+ async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
367
  response = await client.get(
368
  f"{instance}/api/v1/videos/{video_id}",
369
  params={"fields": "formatStreams,adaptiveFormats,title"},
 
373
  data = response.json()
374
  self._working_instance = instance
375
 
376
+ result = self._extract_best_format(data, quality, instance)
377
+ if result:
378
+ return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
  elif response.status_code == 429:
381
  if instance not in self._failed_instances:
 
390
 
391
  return None
392
 
393
+ def _extract_best_format(
394
+ self, data: dict, quality: str, instance: str
395
+ ) -> Optional[Dict[str, Any]]:
396
+ """استخراج أفضل صيغة تحميل من بيانات Invidious"""
397
+
398
+ format_streams = data.get("formatStreams", [])
399
+ adaptive_formats = data.get("adaptiveFormats", [])
400
+ title = data.get("title", "video")
401
+
402
+ # ═══ الطريقة 1: Format Streams (فيديو + صوت معاً - أسهل) ═══
403
+ # هذه تحتوي على 360p عادةً فقط، لكنها جاهزة للتحميل مباشرة
404
+ if format_streams:
405
+ quality_order = {
406
+ "best": ["1080p", "720p", "480p", "360p"],
407
+ "medium": ["720p", "480p", "360p"],
408
+ "low": ["480p", "360p"],
409
+ }
410
+ preferred = quality_order.get(quality, ["720p", "480p", "360p"])
411
+
412
+ for q in preferred:
413
+ for fmt in format_streams:
414
+ if fmt.get("qualityLabel", "").startswith(q) and "mp4" in fmt.get("type", ""):
415
+ url = fmt.get("url", "")
416
+ if url:
417
+ safe_title = re.sub(r'[^\w\s-]', '', title)[:50]
418
+ return {
419
+ "url": url,
420
+ "filename": f"{safe_title}.mp4",
421
+ "source": "invidious_direct",
422
+ "quality": q,
423
+ "needs_merge": False,
424
+ }
425
 
426
+ # أي format stream متاح
427
+ if format_streams and format_streams[0].get("url"):
428
+ fmt = format_streams[0]
429
+ safe_title = re.sub(r'[^\w\s-]', '', title)[:50]
430
+ return {
431
+ "url": fmt["url"],
432
+ "filename": f"{safe_title}.mp4",
433
+ "source": "invidious_direct",
434
+ "quality": fmt.get("qualityLabel", "unknown"),
435
+ "needs_merge": False,
436
+ }
437
+
438
+ # ═══ الطريقة 2: Adaptive Formats (فيديو منفصل + صوت منفصل + دمج FFmpeg) ═══
439
+ if adaptive_formats:
440
+ return self._extract_adaptive_format(adaptive_formats, quality, title, instance)
 
 
 
 
 
 
441
 
442
  return None
443
 
444
+ def _extract_adaptive_format(
445
+ self, adaptive_formats: list, quality: str, title: str, instance: str
446
+ ) -> Optional[Dict[str, Any]]:
447
+ """استخراج أفضل فيديو + صوت منفصل للدمج"""
448
+
449
+ # فلترة الفيديو mp4 فقط
450
+ video_formats = [
451
+ f for f in adaptive_formats
452
+ if f.get("type", "").startswith("video/") and f.get("container") == "mp4" and f.get("url")
453
+ ]
454
+ # فلترة الصوت m4a فقط
455
+ audio_formats = [
456
+ f for f in adaptive_formats
457
+ if f.get("type", "").startswith("audio/") and f.get("container") == "m4a" and f.get("url")
458
+ ]
459
+
460
+ if not video_formats:
461
+ # جرّب webm كـ fallback
462
+ video_formats = [
463
+ f for f in adaptive_formats
464
+ if f.get("type", "").startswith("video/") and f.get("url")
465
+ ]
466
+ if not audio_formats:
467
+ audio_formats = [
468
+ f for f in adaptive_formats
469
+ if f.get("type", "").startswith("audio/") and f.get("url")
470
+ ]
471
+
472
+ if not video_formats:
473
  return None
474
 
475
+ # اختيار أفضل جودة فيديو حسب الطلب
476
+ quality_height_map = {"best": 2160, "medium": 720, "low": 480}
477
+ max_height = quality_height_map.get(quality, 2160)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
 
479
+ # ترتيب حسب bitrate (أعلى = أفضل)
480
+ video_formats.sort(key=lambda x: x.get("bitrate", 0), reverse=True)
 
 
 
 
 
 
 
 
481
 
482
+ best_video = None
483
+ for fmt in video_formats:
484
+ res_str = fmt.get("resolution", fmt.get("qualityLabel", "0p"))
485
+ height = 0
486
+ try:
487
+ height = int(re.search(r'(\d+)', str(res_str)).group(1))
488
+ except (AttributeError, ValueError):
489
+ pass
490
+
491
+ if height <= max_height:
492
+ best_video = fmt
493
+ break
494
+
495
+ if not best_video:
496
+ best_video = video_formats[0]
497
+
498
+ # أفضل صوت
499
+ audio_formats.sort(key=lambda x: x.get("bitrate", 0), reverse=True)
500
+ best_audio = audio_formats[0] if audio_formats else None
501
+
502
+ safe_title = re.sub(r'[^\w\s-]', '', title)[:50]
503
+
504
+ result = {
505
+ "filename": f"{safe_title}.mp4",
506
+ "source": "invidious_adaptive",
507
+ "quality": best_video.get("qualityLabel", best_video.get("resolution", "unknown")),
508
+ "needs_merge": True,
509
+ "video_url": best_video.get("url", ""),
510
+ "audio_url": best_audio.get("url", "") if best_audio else "",
511
+ "video_itag": best_video.get("itag", ""),
512
+ "audio_itag": best_audio.get("itag", "") if best_audio else "",
513
+ }
514
 
515
+ # لو مفيش صوت منفصل، نستخدم الفيديو المدمج
516
+ if not best_audio:
517
+ result["needs_merge"] = False
518
+ result["url"] = best_video.get("url", "")
 
519
 
520
+ return result
521
 
522
+ async def get_caption_content(
523
+ self, url: str, lang: str = "en", auto: bool = True
524
+ ) -> Optional[str]:
525
+ """تحميل محتوى الترجمة من Invidious captions"""
 
 
526
  video_id = _extract_video_id(url)
527
  if not video_id:
528
  return None
529
 
530
+ instances = await self._get_instances()
531
 
532
  for instance in instances:
533
  try:
534
+ # أولاً: جلب قائمة الترجمات
535
  async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
536
+ response = await client.get(
537
+ f"{instance}/api/v1/videos/{video_id}",
538
+ params={"fields": "captions,subtitles"},
539
+ )
540
 
541
+ if response.status_code != 200:
542
+ continue
543
 
544
+ data = response.json()
545
+ captions = data.get("captions", [])
546
+ subtitles = data.get("subtitles", [])
547
+
548
+ # البحث عن الترجمة المطلوبة
549
+ target_caption = None
550
+
551
+ # أولاً في subtitles
552
+ for sub in subtitles:
553
+ if sub.get("language_code") == lang:
554
+ target_caption = sub
555
+ break
556
+
557
+ # ثم في captions
558
+ if not target_caption:
559
+ for cap in captions:
560
+ lc = cap.get("language_code", "")
561
+ label = cap.get("label", "")
562
+ if lc.startswith(lang.split("-")[0]):
563
+ is_auto = "(auto" in label.lower()
564
+ if auto and is_auto:
565
+ target_caption = cap
566
+ break
567
+ elif not auto and not is_auto:
568
+ target_caption = cap
569
+ break
570
 
571
+ # أي ترجمة متاحة
572
+ if not target_caption and captions:
573
+ target_caption = captions[0]
574
 
575
+ if not target_caption:
576
+ return None
 
577
 
578
+ # تحميل محتوى الترجمة
579
+ cap_url = target_caption.get("url", "")
580
+ if not cap_url:
581
+ return None
 
 
 
 
582
 
583
+ if cap_url.startswith("/"):
584
+ cap_url = f"{instance}{cap_url}"
585
 
586
+ cap_response = await client.get(cap_url, follow_redirects=True)
587
+ if cap_response.status_code == 200 and cap_response.text.strip():
588
+ return cap_response.text
 
 
 
 
 
 
 
589
 
 
 
590
  except Exception as e:
591
+ logger.warning(f"Caption download from {instance} failed: {e}")
592
  continue
593
 
594
  return None
 
596
 
597
  class FallbackDownloader:
598
  """
599
+ مدير التحميل البديل - Invidious أولاً
600
+ الاستراتيجية: Invidious (معلومات + تحميل)yt-dlp (ترجمات)
601
  """
602
 
603
  def __init__(self, download_dir: str = "/tmp/downloads"):
604
  self.download_dir = download_dir
 
605
  self.invidious = InvidiousDownloader()
 
606
 
607
  # إحصائيات
608
  self.stats = {
609
+ "invidious_info_success": 0,
610
+ "invidious_download_success": 0,
611
+ "invidious_adaptive_success": 0,
612
+ "ffmpeg_merge_success": 0,
613
+ "ffmpeg_merge_fail": 0,
614
  "total_fallbacks": 0,
615
  }
616
 
617
  async def get_video_info(self, url: str) -> Optional[FallbackVideoInfo]:
618
  """
619
+ جلب معلومات الفيديو من Invidious
 
620
  """
 
621
  info = await self.invidious.get_video_info(url)
622
  if info and info.title:
623
+ self.stats["invidious_info_success"] += 1
624
  return info
 
 
 
 
 
 
 
625
  return None
626
 
627
  async def get_download_url(
 
629
  url: str,
630
  quality: str = "best",
631
  ) -> Optional[Dict[str, Any]]:
632
+ """الحصول على رابط تحميل من Invidious"""
 
 
 
 
 
 
 
 
 
 
 
633
  result = await self.invidious.get_download_url(url, quality)
634
  if result:
635
+ if result.get("needs_merge"):
636
+ self.stats["invidious_adaptive_success"] += 1
637
+ else:
638
+ self.stats["invidious_download_success"] += 1
639
  self.stats["total_fallbacks"] += 1
640
  return result
 
 
 
 
 
 
 
 
641
  return None
642
 
643
  async def download_from_url(self, download_url: str, filename: str) -> Optional[str]:
 
664
 
665
  except Exception as e:
666
  logger.error(f"Download from URL failed: {e}")
 
667
  if os.path.exists(filepath):
668
  os.remove(filepath)
669
  return None
670
 
671
+ async def download_and_merge(
672
+ self, video_url: str, audio_url: str, filename: str
673
+ ) -> Optional[str]:
674
+ """تحميل فيديو + صوت منفصلين ودمجهم بـ FFmpeg"""
675
+ output_path = os.path.join(self.download_dir, filename)
676
+
677
+ # مسارات مؤقتة
678
+ video_temp = os.path.join(self.download_dir, f"_temp_video_{int(time.time())}.mp4")
679
+ audio_temp = os.path.join(self.download_dir, f"_temp_audio_{int(time.time())}.m4a")
680
+
681
+ try:
682
+ # تحميل الفيديو
683
+ logger.info(f"Downloading video stream for {filename}...")
684
+ video_path = await self.download_from_url(video_url, os.path.basename(video_temp))
685
+ if not video_path:
686
+ logger.error("Failed to download video stream")
687
+ return None
688
+
689
+ # تحميل الصوت
690
+ logger.info(f"Downloading audio stream for {filename}...")
691
+ audio_path = await self.download_from_url(audio_url, os.path.basename(audio_temp))
692
+ if not audio_path:
693
+ # لو الصوت فشل، نستخدم الفيديو فقط
694
+ logger.warning("Failed to download audio stream, using video only")
695
+ try:
696
+ os.rename(video_path, output_path)
697
+ self.stats["ffmpeg_merge_success"] += 1
698
+ return output_path
699
+ except Exception:
700
+ return video_path
701
+
702
+ # دمج بـ FFmpeg
703
+ logger.info(f"Merging video+audio with FFmpeg for {filename}...")
704
+ merge_result = self._ffmpeg_merge(video_path, audio_path, output_path)
705
+
706
+ # تنظيف الملفات المؤقتة
707
+ for temp_file in [video_path, audio_path]:
708
+ try:
709
+ if os.path.exists(temp_file):
710
+ os.remove(temp_file)
711
+ except Exception:
712
+ pass
713
+
714
+ if merge_result:
715
+ self.stats["ffmpeg_merge_success"] += 1
716
+ return output_path
717
+ else:
718
+ self.stats["ffmpeg_merge_fail"] += 1
719
+ # لو الدمج فشل، نرجع الفيديو بدون صوت
720
+ try:
721
+ os.rename(video_path, output_path)
722
+ return output_path
723
+ except Exception:
724
+ return video_path
725
+
726
+ except Exception as e:
727
+ logger.error(f"Download and merge failed: {e}")
728
+ # تنظيف
729
+ for temp_file in [video_temp, audio_temp]:
730
+ try:
731
+ if os.path.exists(temp_file):
732
+ os.remove(temp_file)
733
+ except Exception:
734
+ pass
735
  return None
736
 
737
+ def _ffmpeg_merge(
738
+ self, video_path: str, audio_path: str, output_path: str
739
+ ) -> bool:
740
+ """دمج فيديو + صوت بـ FFmpeg"""
741
  try:
742
+ cmd = [
743
+ "ffmpeg", "-y",
744
+ "-i", video_path,
745
+ "-i", audio_path,
746
+ "-c:v", "copy",
747
+ "-c:a", "aac",
748
+ "-movflags", "+faststart",
749
+ output_path,
750
+ ]
751
+
752
+ result = subprocess.run(
753
+ cmd,
754
+ capture_output=True,
755
+ text=True,
756
+ timeout=120,
757
+ )
758
+
759
+ if result.returncode == 0 and os.path.exists(output_path):
760
+ file_size = os.path.getsize(output_path)
761
+ logger.info(f"FFmpeg merge success: {output_path} ({file_size} bytes)")
762
+ return True
763
+ else:
764
+ logger.error(f"FFmpeg merge failed: {result.stderr[:300]}")
765
+ return False
766
+
767
+ except subprocess.TimeoutExpired:
768
+ logger.error("FFmpeg merge timed out")
769
+ return False
770
+ except FileNotFoundError:
771
+ logger.error("FFmpeg not found! Cannot merge video+audio")
772
+ return False
773
  except Exception as e:
774
+ logger.error(f"FFmpeg merge error: {e}")
775
+ return False
776
 
777
+ async def get_subtitle_content(self, url: str, lang: str = "ar") -> Optional[str]:
778
+ """تحميل محتوى الترجمة من Invidious"""
779
+ return await self.invidious.get_caption_content(url, lang)
780
 
781
  def get_stats(self) -> Dict[str, Any]:
782
  """إحصائيات الاستخدام"""
783
+ stats = self.stats.copy()
784
+ stats["working_instance"] = self.invidious._working_instance
785
+ stats["failed_instances"] = len(self.invidious._failed_instances)
786
+ stats["discovered_instances"] = len(DISCOVERED_INSTANCES)
787
+ return stats
788
+
789
+ async def get_instance_status(self) -> Dict[str, Any]:
790
+ """حالة السيرفرات"""
791
+ await refresh_instances()
792
+ all_instances = _get_all_instances()
793
+
794
+ status = {
795
+ "working": self.invidious._working_instance,
796
+ "total_instances": len(all_instances),
797
+ "failed_instances": len(self.invidious._failed_instances),
798
+ "seed_instances": SEED_INVIDIOUS_INSTANCES,
799
+ "discovered_instances": DISCOVERED_INSTANCES[:5],
800
+ }
801
+
802
+ # اختبار سريع للسيرفر الشغال
803
+ if self.invidious._working_instance:
804
+ ok = await self.invidious._test_instance(self.invidious._working_instance)
805
+ status["working_instance_healthy"] = ok
806
+
807
+ return status
desktop/main.py CHANGED
@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
31
  app = FastAPI(
32
  title="YouTube Downloader - Desktop",
33
  description="تطبيق تحميل فيديوهات يوتيوب مع الترجمات",
34
- version="1.0.0",
35
  )
36
 
37
  # CORS
@@ -54,6 +54,19 @@ downloader = YouTubeDownloader(download_dir=DOWNLOAD_DIR)
54
  ws_connections: list = []
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  @app.get("/")
58
  async def index():
59
  """الصفحة الرئيسية"""
@@ -63,7 +76,13 @@ async def index():
63
  @app.get("/api/health")
64
  async def health():
65
  """فحص حالة الخادم"""
66
- return {"status": "ok", "version": "1.0.0"}
 
 
 
 
 
 
67
 
68
 
69
  @app.get("/api/video/info")
@@ -79,6 +98,7 @@ async def get_video_info(url: str):
79
  "uploader": info.uploader,
80
  "view_count": info.view_count,
81
  "description": info.description,
 
82
  "available_subtitles": [
83
  {
84
  "language": sub.language,
@@ -89,7 +109,20 @@ async def get_video_info(url: str):
89
  ],
90
  }
91
  except Exception as e:
92
- raise HTTPException(status_code=400, detail=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
 
95
  @app.post("/api/download")
@@ -183,7 +216,7 @@ async def download_video_only(url: str, quality: VideoQuality = VideoQuality.bes
183
  "message": "تم تحميل الفيديو بنجاح"
184
  }
185
  else:
186
- raise HTTPException(status_code=400, detail="فشل تحميل الفيديو")
187
 
188
  except HTTPException:
189
  raise
@@ -209,6 +242,7 @@ async def get_progress():
209
  "eta": p.eta,
210
  "message": p.message,
211
  "filename": p.filename,
 
212
  }
213
 
214
 
@@ -218,7 +252,7 @@ async def list_downloads():
218
  files = []
219
  for filename in os.listdir(DOWNLOAD_DIR):
220
  filepath = os.path.join(DOWNLOAD_DIR, filename)
221
- if os.path.isfile(filepath):
222
  stat = os.stat(filepath)
223
  files.append({
224
  "name": filename,
@@ -264,9 +298,8 @@ async def anti_ban_status():
264
  "request_count": anti_ban._request_count,
265
  "failed_attempts": anti_ban._failed_attempts,
266
  "session_active": anti_ban.check_session_limits(),
267
- "current_user_agent": anti_ban.get_current_user_agent()[:50] + "...",
268
  "current_client": anti_ban.get_current_client(),
269
- "current_accept_lang": anti_ban.get_current_accept_lang()[:20] + "...",
270
  "failed_clients": anti_ban._failed_clients,
271
  "working_client": anti_ban._working_client,
272
  "fallback_stats": fallback_stats,
@@ -286,6 +319,25 @@ async def reset_anti_ban():
286
  return {"status": "reset", "message": "تم إعادة تعيين الجلسة"}
287
 
288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  @app.post("/api/cookies/set")
290
  async def set_cookies(data: dict):
291
  """حفظ الكوكيز عن طريق لصق المحتوى"""
@@ -332,22 +384,6 @@ async def remove_cookies():
332
  raise HTTPException(status_code=400, detail="فشل حذف الكوكيز")
333
 
334
 
335
- @app.get("/api/fallback/status")
336
- async def fallback_status():
337
- """حالة نظام التحميل البديل"""
338
- stats = downloader.fallback.get_stats()
339
- return {
340
- "fallback_stats": stats,
341
- "yt_dlp_clients_tried": anti_ban._failed_clients,
342
- "working_client": anti_ban._working_client,
343
- "available_apis": {
344
- "cobalt": len(downloader.fallback.cobalt._failed_instances) == 0 or bool(downloader.fallback.cobalt._working_instance),
345
- "invidious": len(downloader.fallback.invidious._failed_instances) < len(downloader.fallback.invidious._failed_instances) + 1,
346
- "piped": True,
347
- },
348
- }
349
-
350
-
351
  # WebSocket للتحديثات اللحظية
352
  @app.websocket("/ws")
353
  async def websocket_endpoint(websocket: WebSocket):
@@ -384,6 +420,7 @@ async def _send_progress_ws(websocket: WebSocket, progress):
384
  "eta": progress.eta,
385
  "message": progress.message,
386
  "filename": progress.filename,
 
387
  })
388
  except Exception:
389
  pass
@@ -408,11 +445,11 @@ def main():
408
  """تشغيل الخادم"""
409
  import uvicorn
410
  print("=" * 60)
411
- print(" YouTube Downloader - Desktop Version")
412
  print(" تحميل فيديوهات يوتيوب مع الترجمات")
413
  print("=" * 60)
414
  print(f"\n Download Directory: {DOWNLOAD_DIR}")
415
- print(f" Server: http://localhost:8555")
416
  print(f"\n Open your browser and go to: http://localhost:8555")
417
  print("=" * 60)
418
 
 
31
  app = FastAPI(
32
  title="YouTube Downloader - Desktop",
33
  description="تطبيق تحميل فيديوهات يوتيوب مع الترجمات",
34
+ version="2.0.0",
35
  )
36
 
37
  # CORS
 
54
  ws_connections: list = []
55
 
56
 
57
+ @app.on_event("startup")
58
+ async def startup():
59
+ """تهيئة عند بدء الخادم"""
60
+ logger.info("🚀 Server starting up...")
61
+ # اكتشاف سيرفرات Invidious نشطة
62
+ try:
63
+ from core.fallback_downloader import refresh_instances
64
+ await refresh_instances()
65
+ logger.info("✅ Invidious instances refreshed")
66
+ except Exception as e:
67
+ logger.warning(f"⚠️ Failed to refresh Invidious instances: {e}")
68
+
69
+
70
  @app.get("/")
71
  async def index():
72
  """الصفحة الرئيسية"""
 
76
  @app.get("/api/health")
77
  async def health():
78
  """فحص حالة الخادم"""
79
+ fallback_stats = downloader.fallback.get_stats()
80
+ return {
81
+ "status": "ok",
82
+ "version": "2.0.0",
83
+ "invidious_working": downloader.fallback.invidious._working_instance,
84
+ "fallback_stats": fallback_stats,
85
+ }
86
 
87
 
88
  @app.get("/api/video/info")
 
98
  "uploader": info.uploader,
99
  "view_count": info.view_count,
100
  "description": info.description,
101
+ "info_source": info.info_source,
102
  "available_subtitles": [
103
  {
104
  "language": sub.language,
 
109
  ],
110
  }
111
  except Exception as e:
112
+ error_msg = str(e)
113
+ # رسائل خطأ أكثر وضوحاً
114
+ if "429" in error_msg or "Too Many" in error_msg:
115
+ raise HTTPException(
116
+ status_code=429,
117
+ detail="تم حظر الطلبات مؤقتاً من YouTube. انتظر قليلاً ثم حاول مرة أخرى."
118
+ )
119
+ elif "Video unavailable" in error_msg or "Private video" in error_msg:
120
+ raise HTTPException(
121
+ status_code=400,
122
+ detail="الفيديو غير متاح أو خاص. تأكد من الرابط."
123
+ )
124
+ else:
125
+ raise HTTPException(status_code=400, detail=f"فشل جلب معلومات الفيديو: {error_msg}")
126
 
127
 
128
  @app.post("/api/download")
 
216
  "message": "تم تحميل الفيديو بنجاح"
217
  }
218
  else:
219
+ raise HTTPException(status_code=400, detail="فشل تحميل الفيديو من جميع المصادر. جرب فيديو آخر أو حاول لاحقاً.")
220
 
221
  except HTTPException:
222
  raise
 
242
  "eta": p.eta,
243
  "message": p.message,
244
  "filename": p.filename,
245
+ "source": p.source,
246
  }
247
 
248
 
 
252
  files = []
253
  for filename in os.listdir(DOWNLOAD_DIR):
254
  filepath = os.path.join(DOWNLOAD_DIR, filename)
255
+ if os.path.isfile(filepath) and not filename.startswith('_temp_'):
256
  stat = os.stat(filepath)
257
  files.append({
258
  "name": filename,
 
298
  "request_count": anti_ban._request_count,
299
  "failed_attempts": anti_ban._failed_attempts,
300
  "session_active": anti_ban.check_session_limits(),
301
+ "current_user_agent": (anti_ban.get_current_user_agent() or "")[:50] + "...",
302
  "current_client": anti_ban.get_current_client(),
 
303
  "failed_clients": anti_ban._failed_clients,
304
  "working_client": anti_ban._working_client,
305
  "fallback_stats": fallback_stats,
 
319
  return {"status": "reset", "message": "تم إعادة تعيين الجلسة"}
320
 
321
 
322
+ @app.get("/api/fallback/status")
323
+ async def fallback_status():
324
+ """حالة نظام التحميل البديل"""
325
+ try:
326
+ status = await downloader.fallback.get_instance_status()
327
+ stats = downloader.fallback.get_stats()
328
+ return {
329
+ "fallback_stats": stats,
330
+ "instance_status": status,
331
+ "yt_dlp_clients_tried": anti_ban._failed_clients,
332
+ "working_client": anti_ban._working_client,
333
+ }
334
+ except Exception as e:
335
+ return {
336
+ "error": str(e),
337
+ "fallback_stats": downloader.fallback.get_stats(),
338
+ }
339
+
340
+
341
  @app.post("/api/cookies/set")
342
  async def set_cookies(data: dict):
343
  """حفظ الكوكيز عن طريق لصق المحتوى"""
 
384
  raise HTTPException(status_code=400, detail="فشل حذف الكوكيز")
385
 
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  # WebSocket للتحديثات اللحظية
388
  @app.websocket("/ws")
389
  async def websocket_endpoint(websocket: WebSocket):
 
420
  "eta": progress.eta,
421
  "message": progress.message,
422
  "filename": progress.filename,
423
+ "source": progress.source,
424
  })
425
  except Exception:
426
  pass
 
445
  """تشغيل الخادم"""
446
  import uvicorn
447
  print("=" * 60)
448
+ print(" YouTube Downloader v2.0 - Invidious-First")
449
  print(" تحميل فيديوهات يوتيوب مع الترجمات")
450
  print("=" * 60)
451
  print(f"\n Download Directory: {DOWNLOAD_DIR}")
452
+ print(f" Server: http://0.0.0.0:8555")
453
  print(f"\n Open your browser and go to: http://localhost:8555")
454
  print("=" * 60)
455
 
desktop/static/script.js CHANGED
@@ -351,7 +351,25 @@ class YouTubeDownloaderApp {
351
  this.progressPercent.textContent = `${Math.round(data.percent)}%`;
352
  this.progressSpeed.textContent = data.speed || '';
353
  this.progressEta.textContent = data.eta ? `الوقت المتبقي: ${data.eta}` : '';
354
- this.progressMessage.textContent = data.message || '';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
 
356
  // Update steps based on status
357
  this.updateSteps(data.status);
@@ -363,6 +381,8 @@ class YouTubeDownloaderApp {
363
  'downloading_subtitle': 2,
364
  'waiting_anti_ban': 3,
365
  'downloading_video': 4,
 
 
366
  };
367
 
368
  const currentStep = stepMap[status] || 0;
@@ -473,7 +493,20 @@ class YouTubeDownloaderApp {
473
  this.btnDownloadFull.disabled = false;
474
  this.btnDownloadSubtitleOnly.disabled = false;
475
  this.btnDownloadVideoOnly.disabled = false;
476
- this.showToast(error || 'حدث خطأ أثناء التحميل', 'error');
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  }
478
 
479
  async showDownloadsModal() {
@@ -559,8 +592,16 @@ class YouTubeDownloaderApp {
559
 
560
  async showAntiBanModal() {
561
  try {
562
- const response = await fetch('/api/anti-ban/status');
563
- const data = await response.json();
 
 
 
 
 
 
 
 
564
 
565
  this.antiBanInfo.innerHTML = `
566
  <div class="anti-ban-item">
@@ -578,8 +619,21 @@ class YouTubeDownloaderApp {
578
  </span>
579
  </div>
580
  <div class="anti-ban-item">
581
- <label>User-Agent</label>
582
- <span style="font-size:11px; max-width:200px; overflow:hidden; text-overflow:ellipsis;">${data.current_user_agent}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
583
  </div>
584
  `;
585
 
 
351
  this.progressPercent.textContent = `${Math.round(data.percent)}%`;
352
  this.progressSpeed.textContent = data.speed || '';
353
  this.progressEta.textContent = data.eta ? `الوقت المتبقي: ${data.eta}` : '';
354
+
355
+ // إظهار مصدر التحميل مع الرسالة
356
+ let sourceLabel = '';
357
+ if (data.source) {
358
+ const sourceNames = {
359
+ 'invidious': 'Invidious',
360
+ 'invidious_direct': 'Invidious',
361
+ 'invidious_adaptive': 'Invidious',
362
+ 'yt_dlp': 'YouTube',
363
+ 'cobalt': 'Cobalt',
364
+ 'piped': 'Piped',
365
+ };
366
+ sourceLabel = sourceNames[data.source] || data.source;
367
+ }
368
+
369
+ const messageText = data.message || '';
370
+ this.progressMessage.textContent = sourceLabel
371
+ ? `[${sourceLabel}] ${messageText}`
372
+ : messageText;
373
 
374
  // Update steps based on status
375
  this.updateSteps(data.status);
 
381
  'downloading_subtitle': 2,
382
  'waiting_anti_ban': 3,
383
  'downloading_video': 4,
384
+ 'fallback_download': 4,
385
+ 'merging_video': 4,
386
  };
387
 
388
  const currentStep = stepMap[status] || 0;
 
493
  this.btnDownloadFull.disabled = false;
494
  this.btnDownloadSubtitleOnly.disabled = false;
495
  this.btnDownloadVideoOnly.disabled = false;
496
+
497
+ // رسائل خطأ أكثر وضوحاً
498
+ let errorMsg = error || 'حدث خطأ أثناء التحميل';
499
+ if (typeof errorMsg === 'string') {
500
+ if (errorMsg.includes('429') || errorMsg.includes('Too Many')) {
501
+ errorMsg = 'تم حظر الطلبات مؤقتاً. انتظر بضع دقائق ثم حاول مرة أخرى.';
502
+ } else if (errorMsg.includes('Video unavailable') || errorMsg.includes('Private')) {
503
+ errorMsg = 'الفيديو غير متاح أو خاص.';
504
+ } else if (errorMsg.includes('timeout') || errorMsg.includes('Timeout')) {
505
+ errorMsg = 'انتهت مهلة الاتصال. تحقق من الإنترنت وحاول مرة أخرى.';
506
+ }
507
+ }
508
+
509
+ this.showToast(errorMsg, 'error');
510
  }
511
 
512
  async showDownloadsModal() {
 
592
 
593
  async showAntiBanModal() {
594
  try {
595
+ const [antiBanResponse, fallbackResponse] = await Promise.all([
596
+ fetch('/api/anti-ban/status'),
597
+ fetch('/api/fallback/status'),
598
+ ]);
599
+ const data = await antiBanResponse.json();
600
+ const fallbackData = await fallbackResponse.json();
601
+
602
+ const instanceStatus = fallbackData.instance_status || {};
603
+ const workingInstance = instanceStatus.working || 'غير متاح';
604
+ const isHealthy = instanceStatus.working_instance_healthy !== false;
605
 
606
  this.antiBanInfo.innerHTML = `
607
  <div class="anti-ban-item">
 
619
  </span>
620
  </div>
621
  <div class="anti-ban-item">
622
+ <label>سيرفر Invidious</label>
623
+ <span class="${isHealthy ? 'status-active' : 'status-warning'}" style="font-size:11px; max-width:220px; overflow:hidden; text-overflow:ellipsis;">
624
+ ${isHealthy ? '✅' : '❌'} ${workingInstance.replace('https://', '')}
625
+ </span>
626
+ </div>
627
+ <div class="anti-ban-item">
628
+ <label>العميل الحالي</label>
629
+ <span>${data.current_client || 'غير محدد'}</span>
630
+ </div>
631
+ <div class="anti-ban-item">
632
+ <label>إحصائيات التحميل</label>
633
+ <span style="font-size:12px;">
634
+ Invidious: ${data.fallback_stats?.invidious_download_success || 0} |
635
+ FFmpeg: ${data.fallback_stats?.ffmpeg_merge_success || 0}
636
+ </span>
637
  </div>
638
  `;
639