Z User commited on
Commit
3fde47e
·
1 Parent(s): acd3e1c

v3.0: Radical fix - yt-dlp first strategy with working clients

Browse files

- Rewrote downloader.py: yt-dlp with tv_embedded/android_vr/mediaconnect/android as primary
- Removed dead clients: mweb, ios, web, web_creator (all fail from datacenter IPs)
- Fixed format specs: bestvideo+bestaudio/best (tested and working)
- Simplified anti_ban.py: removed unnecessary delays, focused on working clients
- Updated fallback_downloader.py: Invidious as backup only with short timeouts
- Updated main.py: better error handling, shorter timeouts, clear Arabic messages
- Updated Dockerfile: added Deno JS runtime for yt-dlp 2025+

Files changed (5) hide show
  1. Dockerfile +12 -6
  2. core/anti_ban.py +55 -214
  3. core/downloader.py +258 -331
  4. core/fallback_downloader.py +140 -525
  5. desktop/main.py +64 -72
Dockerfile CHANGED
@@ -7,28 +7,34 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
7
  curl \
8
  git \
9
  ffmpeg \
 
10
  && rm -rf /var/lib/apt/lists/*
11
 
12
  # 3. تثبيت لغة Rust داخل الحاوية لتخطي مشكلة بناء مكتبة pydantic-core
13
  RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
14
  ENV PATH="/root/.cargo/bin:${PATH}"
15
 
16
- # 4. تحديد مجلد العمل الافتراضي داخل السيرفر السحابي
 
 
 
 
 
17
  WORKDIR /app
18
 
19
- # 5. تهيئة بيئة المجلد المؤقت للتحميلات
20
  RUN mkdir -p /tmp/downloads && chmod 777 /tmp/downloads
21
 
22
- # 6. نسخ ملف المكتبات وتحديث أدوات التثبيت
23
  COPY requirements.txt .
24
  RUN pip install --no-cache-dir --upgrade pip setuptools wheel
25
  RUN pip install --no-cache-dir -r requirements.txt
26
 
27
- # 7. نسخ باقي ملفات المشروع بالكامل إلى داخل السيرفر
28
  COPY . .
29
 
30
- # 8. فتح المنفذ (Port) الافتراضي المتوافق مع الكود الخاص بك
31
  EXPOSE 8555
32
 
33
- # 9. أمر تشغيل خادم FastAPI
34
  CMD ["python", "desktop/main.py"]
 
7
  curl \
8
  git \
9
  ffmpeg \
10
+ unzip \
11
  && rm -rf /var/lib/apt/lists/*
12
 
13
  # 3. تثبيت لغة Rust داخل الحاوية لتخطي مشكلة بناء مكتبة pydantic-core
14
  RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
15
  ENV PATH="/root/.cargo/bin:${PATH}"
16
 
17
+ # 4. تثبيت Deno (JavaScript runtime مطلوب لـ yt-dlp 2025+)
18
+ RUN curl -fsSL https://deno.land/install.sh | sh
19
+ ENV DENO_INSTALL="/root/.deno"
20
+ ENV PATH="${DENO_INSTALL}/bin:${PATH}"
21
+
22
+ # 5. تحديد مجلد العمل الافتراضي داخل السيرفر السحابي
23
  WORKDIR /app
24
 
25
+ # 6. تهيئة بيئة المجلد المؤقت للتحميلات
26
  RUN mkdir -p /tmp/downloads && chmod 777 /tmp/downloads
27
 
28
+ # 7. نسخ ملف المكتبات وتحديث أدوات التثبيت
29
  COPY requirements.txt .
30
  RUN pip install --no-cache-dir --upgrade pip setuptools wheel
31
  RUN pip install --no-cache-dir -r requirements.txt
32
 
33
+ # 8. نسخ باقي ملفات المشروع بالكامل إلى داخل السيرفر
34
  COPY . .
35
 
36
+ # 9. فتح المنفذ (Port) الافتراضي المتوافق مع الكود الخاص بك
37
  EXPOSE 8555
38
 
39
+ # 10. أمر تشغيل خادم FastAPI
40
  CMD ["python", "desktop/main.py"]
core/anti_ban.py CHANGED
@@ -1,9 +1,11 @@
1
  """
2
- استراتيجيات تجنب حظر يوتيوب - نسخة محسنة للنشر السحابي
3
- Anti-Ban Strategies for YouTube Downloads - Cloud-Enhanced Version
4
 
5
- يدعم: mweb, ios, android, tv_embedded, web_creator, web
6
- مع نظام Fallback للعملاء وتتبع الناجحين والفاشلين
 
 
7
  """
8
 
9
  import random
@@ -17,246 +19,127 @@ from .cookie_manager import cookie_manager
17
  logger = logging.getLogger(__name__)
18
 
19
 
20
- # قائمة User-Agents متعددة للتدوير
 
 
 
21
  USER_AGENTS = [
22
- # Android
 
 
 
 
23
  "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.113 Mobile Safari/537.36",
 
24
  "Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.179 Mobile Safari/537.36",
25
  "Mozilla/5.0 (Linux; Android 13; SM-S908B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.118 Mobile Safari/537.36",
26
- "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.179 Mobile Safari/537.36",
27
- # iOS
28
- "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
29
- "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1",
30
- "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
31
- "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1",
32
- # Chrome - Windows
33
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
34
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
35
- # Chrome - macOS
36
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
37
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
38
- # Firefox
39
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0",
40
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0",
41
- # Smart TV
42
- "Mozilla/5.0 (CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.0 Safari/537.36",
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
-
53
  ACCEPT_LANGUAGES = [
54
  "en-US,en;q=0.9,ar;q=0.8",
55
  "ar-SA,ar;q=0.9,en;q=0.8",
56
- "en-GB,en;q=0.9,ar;q=0.7",
57
  "fr-FR,fr;q=0.9,en;q=0.8",
58
- "de-DE,de;q=0.9,en;q=0.8",
59
- "es-ES,es;q=0.9,en;q=0.8",
60
  ]
61
 
62
 
63
  class AntiBanManager:
64
  """
65
- مدير استراتيجيات تجنب الحظر - نسخة محسنة للسحابة
 
66
  """
67
 
68
  def __init__(self):
69
- self._last_request_time: float = 0
70
- self._request_count: int = 0
71
- self._session_start: float = time.time()
72
  self._current_user_agent: Optional[str] = None
73
  self._current_client: Optional[str] = None
74
  self._current_accept_lang: Optional[str] = None
75
- self._cooldown_until: float = 0
76
  self._failed_attempts: int = 0
77
  self._consecutive_429: int = 0
 
78
 
79
- # تتبع العملاء الفاشلين والناجحين
80
  self._failed_clients: List[str] = []
81
  self._working_client: Optional[str] = None
82
 
83
- self._has_cookies_cache = cookie_manager.is_active()
84
-
85
- # إعدادات الحد الأقصى
86
- self.max_requests_per_session = 50 if self._has_cookies_cache else 25
87
- self.max_requests_per_hour = 30 if self._has_cookies_cache else 15
88
- self.session_duration_limit = 7200 if self._has_cookies_cache else 3600
89
-
90
- # تأخيرات
91
- self.min_delay = 3.0 if self._has_cookies_cache else 5.0
92
- self.max_delay = 6.0 if self._has_cookies_cache else 12.0
93
- self.subtitle_to_video_delay = 2.0 if self._has_cookies_cache else 8.0
94
-
95
- def _has_cookies(self) -> bool:
96
- """هل توجد كوكيز نشطة؟"""
97
- return cookie_manager.is_active()
98
-
99
  def get_random_user_agent(self) -> str:
100
- """الحصول على User-Agent عشوائي"""
101
  self._current_user_agent = random.choice(USER_AGENTS)
102
  return self._current_user_agent
103
 
104
  def get_current_user_agent(self) -> str:
105
- """الحصول على User-Agent الحالي"""
106
  if not self._current_user_agent:
107
  return self.get_random_user_agent()
108
  return self._current_user_agent
109
 
110
  def _get_next_client(self) -> str:
111
- """الحصول على العميل التالي - يفضل العملاء الناجحين"""
112
- if self._working_client and random.random() < 0.7:
113
  return self._working_client
114
 
115
  available = [c for c in CLIENT_PRIORITY if c not in self._failed_clients]
116
  if not available:
117
  self._failed_clients.clear()
118
- available = CLIENT_PRIORITY
119
 
120
- # نفضل العملاء الأوائل (mweb, ios) لأنهم أقل حظراً
121
- weights = [len(available) - i for i in range(len(available))]
122
- client = random.choices(available, weights=weights, k=1)[0]
123
- return client
124
 
125
  def rotate_user_agent(self) -> str:
126
- """تدوير User-Agent و Client جديد"""
127
- old_ua = self._current_user_agent
128
  new_ua = random.choice(USER_AGENTS)
129
  attempts = 0
130
- while new_ua == old_ua and attempts < 10:
131
  new_ua = random.choice(USER_AGENTS)
132
  attempts += 1
133
  self._current_user_agent = new_ua
134
  self._current_client = self._get_next_client()
135
  self._current_accept_lang = random.choice(ACCEPT_LANGUAGES)
136
- logger.info(
137
- f"Rotated: UA={new_ua[:30]}... Client={self._current_client} Lang={self._current_accept_lang[:15]}..."
138
- )
139
  return new_ua
140
 
141
  def get_current_client(self) -> str:
142
- """الحصول على YouTube Client الحالي"""
143
  if not self._current_client:
144
  self._current_client = self._get_next_client()
145
  return self._current_client
146
 
147
- def get_current_accept_lang(self) -> str:
148
- """الحصول على Accept-Language الحالي"""
149
- if not self._current_accept_lang:
150
- self._current_accept_lang = random.choice(ACCEPT_LANGUAGES)
151
- return self._current_accept_lang
152
-
153
  async def wait_before_request(self):
154
- """انتظار قبل الطلب لتجنب كشف البوت"""
155
  self.rotate_user_agent()
156
 
157
  now = time.time()
158
-
159
  if now < self._cooldown_until:
160
  wait_time = self._cooldown_until - now
161
- logger.info(f"In cooldown period. Waiting {wait_time:.1f}s")
162
  await asyncio.sleep(wait_time)
163
  self.rotate_user_agent()
164
 
165
- time_since_last = now - self._last_request_time
166
- delay = self._calculate_delay()
167
-
168
- if time_since_last < delay:
169
- wait_time = delay - time_since_last
170
- jitter = random.uniform(0, 3.0)
171
- total_wait = wait_time + jitter
172
- logger.info(f"Rate limiting: waiting {total_wait:.1f}s before next request")
173
- await asyncio.sleep(total_wait)
174
-
175
- self._last_request_time = time.time()
176
  self._request_count += 1
177
 
178
- async def wait_between_subtitle_and_video(self):
179
- """انتظار بين تحميل الترجمة والفيديو"""
180
- base_delay = self.subtitle_to_video_delay
181
- jitter = random.uniform(3.0, 8.0)
182
- total_delay = base_delay + jitter
183
-
184
- logger.info(f"Waiting {total_delay:.1f}s between subtitle and video download (anti-ban)")
185
- await asyncio.sleep(total_delay)
186
- self.rotate_user_agent()
187
-
188
- def _calculate_delay(self) -> float:
189
- """حساب التأخير بناءً على عدد الطلبات السابقة"""
190
- base_delay = random.uniform(self.min_delay, self.max_delay)
191
-
192
- if self._request_count > 3:
193
- base_delay *= 1.3
194
- if self._request_count > 8:
195
- base_delay *= 1.5
196
- if self._request_count > 15:
197
- base_delay *= 2.0
198
-
199
- if self._failed_attempts > 0:
200
- base_delay *= (1 + self._failed_attempts * 0.5)
201
-
202
- return min(base_delay, 60.0)
203
-
204
  def check_session_limits(self) -> bool:
205
- """التحقق من حدود الجلسة"""
206
- now = time.time()
207
- session_duration = now - self._session_start
208
-
209
- if self._consecutive_429 >= 3:
210
- logger.warning(f"Too many 429 errors ({self._consecutive_429}). Blocking session.")
211
  return False
212
-
213
- if self._request_count >= self.max_requests_per_session:
214
- logger.warning("Session request limit reached. Need cooldown.")
215
- return False
216
-
217
- if session_duration >= self.session_duration_limit:
218
- logger.warning("Session duration limit reached. Need cooldown.")
219
  return False
220
-
221
  return True
222
 
223
- def apply_cooldown(self, duration: Optional[float] = None):
224
- """تطبيق فترة تبريد"""
225
- has_cookies = self._has_cookies()
226
- if duration is None:
227
- duration = random.uniform(30, 60) if has_cookies else random.uniform(60, 180)
228
- self._cooldown_until = time.time() + duration
229
- logger.info(f"Applied cooldown for {duration:.1f}s")
230
- self.rotate_user_agent()
231
-
232
  def report_failure(self, status_code: Optional[int] = None):
233
- """الإبلاغ عن فشل في الطلب"""
234
- has_cookies = self._has_cookies()
235
  self._failed_attempts += 1
236
-
237
- # إضافة العميل الحالي لقائمة الفاشلين
238
  current = self._current_client
239
  if current and current not in self._failed_clients:
240
  self._failed_clients.append(current)
241
- logger.warning(f"Client '{current}' failed. Failed clients: {self._failed_clients}")
242
- self._working_client = None
243
 
244
  if status_code == 429:
245
  self._consecutive_429 += 1
246
- logger.warning(f"HTTP 429 Too Many Requests! Total: {self._consecutive_429}")
247
- cooldown_time = random.uniform(10, 30) if has_cookies else random.uniform(30, 120) * self._consecutive_429
248
- self.apply_cooldown(cooldown_time)
249
  else:
250
  self._consecutive_429 = max(0, self._consecutive_429 - 1)
251
- logger.warning(f"Request failed (status={status_code}). Total failures: {self._failed_attempts}")
252
-
253
- max_fails = 5 if has_cookies else 3
254
- if self._failed_attempts >= max_fails:
255
- self.apply_cooldown(random.uniform(30, 60) if has_cookies else random.uniform(60, 180))
256
- self.rotate_user_agent()
257
 
258
  def report_success(self):
259
- """الإبلاغ عن نجاح الطلب"""
260
  self._failed_attempts = max(0, self._failed_attempts - 1)
261
  self._consecutive_429 = max(0, self._consecutive_429 - 1)
262
  if self._current_client:
@@ -264,88 +147,46 @@ class AntiBanManager:
264
  if self._current_client in self._failed_clients:
265
  self._failed_clients.remove(self._current_client)
266
 
267
- def get_ydl_headers(self) -> dict:
268
- """الحصول على headers لـ yt-dlp"""
269
- ua = self.get_current_user_agent()
270
- accept_lang = self._current_accept_lang or random.choice(ACCEPT_LANGUAGES)
271
- headers = {
272
- "User-Agent": ua,
273
- "Accept-Language": accept_lang,
274
- }
275
- if self._has_cookies():
276
- headers["Origin"] = "https://www.youtube.com"
277
- headers["Referer"] = "https://www.youtube.com/"
278
- return headers
279
-
280
  def get_ydl_opts_additions(self) -> dict:
281
- """الحصول على خيارات yt-dlp إضافية مع تبديل الـ Client"""
282
  client = self.get_current_client()
283
- has_cookies = self._has_cookies()
284
- logger.info(f"Using YouTube client: {client} (cookies={has_cookies})")
285
 
286
  opts = {
287
- "http_headers": self.get_ydl_headers(),
288
- "extractor_retries": 5,
289
- "file_access_retries": 5,
290
- "fragment_retries": 5,
291
- "socket_timeout": 60,
 
292
  }
293
 
294
- if has_cookies:
295
- opts["retry_sleep_functions"] = {
296
- "http": lambda n: random.uniform(1, 3) * n,
297
- "fragment": lambda n: random.uniform(1, 2) * n,
298
- }
299
- else:
300
- opts["retry_sleep_functions"] = {
301
- "http": lambda n: random.uniform(3, 8) * n,
302
- "fragment": lambda n: random.uniform(2, 5) * n,
303
- }
304
-
305
  if has_cookies:
306
  cookies_path = cookie_manager.get_cookies_path()
307
  if cookies_path:
308
  opts["cookiefile"] = cookies_path
309
- logger.info(f"Using cookies from: {cookies_path}")
310
-
311
- # إضافة extractor_args حسب نوع العميل
312
- if client == "mweb":
313
- opts["extractor_args"] = {"youtube": {"player_client": ["mweb"]}}
314
- elif client == "ios":
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
 
332
- def get_fallback_clients(self) -> List[str]:
333
- """الحصول على قائمة العملاء البديلين"""
334
- current = self._current_client
335
- fallback = [c for c in CLIENT_PRIORITY if c != current and c not in self._failed_clients]
336
- return fallback
 
 
337
 
338
  def reset_session(self):
339
  """إعادة تعيين الجلسة"""
340
  self._request_count = 0
341
- self._session_start = time.time()
342
  self._failed_attempts = 0
343
  self._consecutive_429 = 0
344
  self._cooldown_until = 0
345
  self._failed_clients.clear()
346
  self._working_client = None
347
  self.rotate_user_agent()
348
- logger.info("Session reset with new User-Agent and Client")
349
 
350
 
351
  anti_ban = AntiBanManager()
 
1
  """
2
+ استراتيجيات تجنب حظر يوتيوب - نسخة مبسطة وفعالة يونيو 2026
3
+ Anti-Ban Strategies - Simplified & Effective
4
 
5
+ التغييرات الرئيسية:
6
+ - إزالة التأخيرات غير الضرورية (yt-dlp شغال مباشرة!)
7
+ - العملاء الشغالين فقط: tv_embedded, android_vr, mediaconnect, android
8
+ - إزالة العملاء الفاشلين: mweb, ios, web, web_creator
9
  """
10
 
11
  import random
 
19
  logger = logging.getLogger(__name__)
20
 
21
 
22
+ # العملاء الشغالين فعلياً من datacenter IPs (مختبرين يونيو 2026)
23
+ CLIENT_PRIORITY = ["tv_embedded", "android_vr", "mediaconnect", "android"]
24
+
25
+ # User-Agents للتدوير
26
  USER_AGENTS = [
27
+ # Smart TV (مناسب لـ tv_embedded)
28
+ "Mozilla/5.0 (CrKey armv7l 1.5.16041) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.0 Safari/537.36",
29
+ "Mozilla/5.0 (Web0S; Linux/SmartTV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.34 Safari/537.36",
30
+ # Android VR (مناسب لـ android_vr)
31
+ "Mozilla/5.0 (Linux; Android 14; Quest 3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.113 Safari/537.36",
32
  "Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.113 Mobile Safari/537.36",
33
+ # Android
34
  "Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.179 Mobile Safari/537.36",
35
  "Mozilla/5.0 (Linux; Android 13; SM-S908B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.6312.118 Mobile Safari/537.36",
36
+ # Chrome
 
 
 
 
 
 
37
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
 
 
38
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
 
 
 
 
 
 
 
39
  ]
40
 
 
 
 
 
 
 
 
41
  ACCEPT_LANGUAGES = [
42
  "en-US,en;q=0.9,ar;q=0.8",
43
  "ar-SA,ar;q=0.9,en;q=0.8",
44
+ "en-GB,en;q=0.9",
45
  "fr-FR,fr;q=0.9,en;q=0.8",
 
 
46
  ]
47
 
48
 
49
  class AntiBanManager:
50
  """
51
+ مدير استراتيجيات تجنب الحظر - نسخة مبسطة
52
+ yt-dlp مع العملاء الشغالين لا يحتاج تأخيرات كبيرة
53
  """
54
 
55
  def __init__(self):
 
 
 
56
  self._current_user_agent: Optional[str] = None
57
  self._current_client: Optional[str] = None
58
  self._current_accept_lang: Optional[str] = None
59
+ self._request_count: int = 0
60
  self._failed_attempts: int = 0
61
  self._consecutive_429: int = 0
62
+ self._cooldown_until: float = 0
63
 
64
+ # تتبع العملاء
65
  self._failed_clients: List[str] = []
66
  self._working_client: Optional[str] = None
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def get_random_user_agent(self) -> str:
 
69
  self._current_user_agent = random.choice(USER_AGENTS)
70
  return self._current_user_agent
71
 
72
  def get_current_user_agent(self) -> str:
 
73
  if not self._current_user_agent:
74
  return self.get_random_user_agent()
75
  return self._current_user_agent
76
 
77
  def _get_next_client(self) -> str:
78
+ """الحصول على العميل التالي - نفضل الشغال"""
79
+ if self._working_client and random.random() < 0.8:
80
  return self._working_client
81
 
82
  available = [c for c in CLIENT_PRIORITY if c not in self._failed_clients]
83
  if not available:
84
  self._failed_clients.clear()
85
+ available = CLIENT_PRIORITY.copy()
86
 
87
+ return available[0] # نبدأ بالأول (tv_embedded)
 
 
 
88
 
89
  def rotate_user_agent(self) -> str:
90
+ old = self._current_user_agent
 
91
  new_ua = random.choice(USER_AGENTS)
92
  attempts = 0
93
+ while new_ua == old and attempts < 5:
94
  new_ua = random.choice(USER_AGENTS)
95
  attempts += 1
96
  self._current_user_agent = new_ua
97
  self._current_client = self._get_next_client()
98
  self._current_accept_lang = random.choice(ACCEPT_LANGUAGES)
 
 
 
99
  return new_ua
100
 
101
  def get_current_client(self) -> str:
 
102
  if not self._current_client:
103
  self._current_client = self._get_next_client()
104
  return self._current_client
105
 
 
 
 
 
 
 
106
  async def wait_before_request(self):
107
+ """انتظار قصير جداً قبل الطلب - yt-dlp شغال مباشرة"""
108
  self.rotate_user_agent()
109
 
110
  now = time.time()
 
111
  if now < self._cooldown_until:
112
  wait_time = self._cooldown_until - now
113
+ logger.info(f"In cooldown. Waiting {wait_time:.1f}s")
114
  await asyncio.sleep(wait_time)
115
  self.rotate_user_agent()
116
 
 
 
 
 
 
 
 
 
 
 
 
117
  self._request_count += 1
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  def check_session_limits(self) -> bool:
120
+ """التحقق من حدود الجلسة - متساهل أكثر"""
121
+ if self._consecutive_429 >= 5:
 
 
 
 
122
  return False
123
+ if self._request_count >= 100:
 
 
 
 
 
 
124
  return False
 
125
  return True
126
 
 
 
 
 
 
 
 
 
 
127
  def report_failure(self, status_code: Optional[int] = None):
128
+ """الإبلاغ عن فشل"""
 
129
  self._failed_attempts += 1
 
 
130
  current = self._current_client
131
  if current and current not in self._failed_clients:
132
  self._failed_clients.append(current)
 
 
133
 
134
  if status_code == 429:
135
  self._consecutive_429 += 1
136
+ logger.warning(f"HTTP 429! Total: {self._consecutive_429}")
137
+ self._cooldown_until = time.time() + random.uniform(10, 30)
 
138
  else:
139
  self._consecutive_429 = max(0, self._consecutive_429 - 1)
 
 
 
 
 
 
140
 
141
  def report_success(self):
142
+ """الإبلاغ عن نجاح"""
143
  self._failed_attempts = max(0, self._failed_attempts - 1)
144
  self._consecutive_429 = max(0, self._consecutive_429 - 1)
145
  if self._current_client:
 
147
  if self._current_client in self._failed_clients:
148
  self._failed_clients.remove(self._current_client)
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  def get_ydl_opts_additions(self) -> dict:
151
+ """الحصول على خيارات yt-dlp مع العميل المناسب"""
152
  client = self.get_current_client()
153
+ has_cookies = cookie_manager.is_active()
154
+ logger.info(f"Using YouTube client: {client}")
155
 
156
  opts = {
157
+ "http_headers": self._get_headers(),
158
+ "extractor_retries": 3,
159
+ "file_access_retries": 3,
160
+ "fragment_retries": 3,
161
+ "socket_timeout": 30,
162
+ "extractor_args": {"youtube": {"player_client": [client]}},
163
  }
164
 
 
 
 
 
 
 
 
 
 
 
 
165
  if has_cookies:
166
  cookies_path = cookie_manager.get_cookies_path()
167
  if cookies_path:
168
  opts["cookiefile"] = cookies_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  return opts
171
 
172
+ def _get_headers(self) -> dict:
173
+ ua = self.get_current_user_agent()
174
+ lang = self._current_accept_lang or random.choice(ACCEPT_LANGUAGES)
175
+ return {
176
+ "User-Agent": ua,
177
+ "Accept-Language": lang,
178
+ }
179
 
180
  def reset_session(self):
181
  """إعادة تعيين الجلسة"""
182
  self._request_count = 0
 
183
  self._failed_attempts = 0
184
  self._consecutive_429 = 0
185
  self._cooldown_until = 0
186
  self._failed_clients.clear()
187
  self._working_client = None
188
  self.rotate_user_agent()
189
+ logger.info("Session reset")
190
 
191
 
192
  anti_ban = AntiBanManager()
core/downloader.py CHANGED
@@ -1,15 +1,19 @@
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
 
13
  import asyncio
14
  import logging
15
  import time
@@ -20,7 +24,8 @@ from enum import Enum
20
 
21
  from .anti_ban import anti_ban
22
  from .subtitle_handler import SubtitleConverter, SubtitleInfo
23
- from .fallback_downloader import FallbackDownloader, FallbackVideoInfo
 
24
 
25
 
26
  class CancelledError(Exception):
@@ -28,9 +33,6 @@ class CancelledError(Exception):
28
  pass
29
 
30
 
31
- logger = logging.getLogger(__name__)
32
-
33
-
34
  def _extract_429_status(e: Exception) -> Optional[int]:
35
  """استخراج status_code 429 من رسالة الخطأ"""
36
  msg = str(e)
@@ -44,9 +46,7 @@ class DownloadStatus(Enum):
44
  IDLE = "idle"
45
  FETCHING_INFO = "fetching_info"
46
  DOWNLOADING_SUBTITLE = "downloading_subtitle"
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"
@@ -65,10 +65,7 @@ class VideoInfo:
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,16 +79,35 @@ class DownloadProgress:
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"):
@@ -101,7 +117,10 @@ class YouTubeDownloader:
101
  self.progress = DownloadProgress()
102
  self._progress_callback: Optional[Callable] = None
103
  self.subtitle_converter = SubtitleConverter()
104
- self.fallback = FallbackDownloader(download_dir=download_dir)
 
 
 
105
 
106
  os.makedirs(download_dir, exist_ok=True)
107
 
@@ -130,87 +149,36 @@ class YouTubeDownloader:
130
  except Exception:
131
  pass
132
 
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,
159
- video_id=fallback_info.video_id,
160
- duration=fallback_info.duration,
161
- thumbnail=fallback_info.thumbnail,
162
- description=fallback_info.description,
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:
214
  if self._cancelled:
215
  raise CancelledError("تم إلغاء التحميل")
216
 
@@ -218,6 +186,10 @@ class YouTubeDownloader:
218
  import yt_dlp
219
 
220
  anti_ban._current_client = client
 
 
 
 
221
  logger.info(f"Trying yt-dlp client: {client} for video info")
222
 
223
  ydl_opts = {
@@ -233,11 +205,20 @@ class YouTubeDownloader:
233
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
234
  return ydl.extract_info(url, download=False)
235
 
236
- info = await loop.run_in_executor(None, _extract_info)
 
 
 
237
 
238
  if not info:
239
  raise Exception("لم يتم العثور على معلومات الفيديو")
240
 
 
 
 
 
 
 
241
  video_info = VideoInfo(
242
  title=info.get('title', 'غير معروف'),
243
  video_id=info.get('id', ''),
@@ -274,27 +255,59 @@ class YouTubeDownloader:
274
  ))
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:
285
  raise
 
286
  except Exception as e:
287
  last_error = e
288
  status_code = _extract_429_status(e)
289
  anti_ban.report_failure(status_code=status_code)
 
290
  logger.warning(f"yt-dlp client '{client}' failed: {e}")
291
 
292
- if client != clients_to_try[-1]:
293
- wait = random.uniform(2, 5) if status_code != 429 else random.uniform(5, 15)
 
294
  await asyncio.sleep(wait)
295
  continue
296
- else:
297
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
 
299
  # ═══ كل الطرق فشلت ═══
300
  error_msg = str(last_error) if last_error else "فشل جلب المعلومات"
@@ -314,112 +327,114 @@ class YouTubeDownloader:
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}")
335
- with open(final_path, 'w', encoding='utf-8') as f:
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:
357
- import yt_dlp
358
-
359
- sub_format = "vtt" if subtitle_format == "vtt" else "srt"
360
- subtitle_path = os.path.join(self.download_dir, "subtitle_temp")
361
-
362
- ydl_opts = {
363
- **anti_ban.get_ydl_opts_additions(),
364
- 'quiet': True,
365
- 'no_warnings': True,
366
- 'skip_download': True,
367
- 'writesubtitles': not auto_generated,
368
- 'writeautomaticsub': auto_generated,
369
- 'subtitleslangs': [language_code],
370
- 'subtitlesformat': sub_format,
371
- 'outtmpl': subtitle_path,
372
- 'postprocessors': [{
373
- 'key': 'FFmpegSubtitlesConvertor',
374
- 'format': sub_format,
375
- }] if sub_format != 'vtt' else [],
376
- }
377
-
378
- loop = asyncio.get_event_loop()
379
-
380
- def _download_sub():
381
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
382
- return ydl.download([url])
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)
 
 
394
 
395
- if subtitle_file and os.path.exists(subtitle_file):
396
- with open(subtitle_file, 'r', encoding='utf-8') as f:
397
- content = f.read()
 
398
 
399
- content = self.subtitle_converter.format_subtitle(content, subtitle_format)
 
400
 
401
- final_path = os.path.join(self.download_dir, f"subtitle_{language_code}.{sub_format}")
402
- with open(final_path, 'w', encoding='utf-8') as f:
403
- f.write(content)
404
 
405
- try:
406
- os.remove(subtitle_file)
407
- except Exception:
408
- pass
409
 
410
- return final_path
411
 
412
- return None
 
 
413
 
414
- except Exception as e:
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(
425
  self,
@@ -428,134 +443,22 @@ class YouTubeDownloader:
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(
549
- self,
550
- url: str,
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:
560
  return None
561
 
@@ -570,25 +473,11 @@ class YouTubeDownloader:
570
  else:
571
  outtmpl = os.path.join(self.download_dir, '%(title)s.%(ext)s')
572
 
573
- # تحديد الجودة
574
- if quality == "best":
575
- format_spec = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best'
576
- elif quality == "medium":
577
- format_spec = 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720]/best'
578
- elif quality == "low":
579
- format_spec = 'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480]/best'
580
- else:
581
- format_spec = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best'
582
-
583
  ydl_opts = {
584
  **anti_ban.get_ydl_opts_additions(),
585
  'format': format_spec,
586
  'outtmpl': outtmpl,
587
  'merge_output_format': 'mp4',
588
- 'postprocessors': [{
589
- 'key': 'FFmpegVideoConvertor',
590
- 'preferedformat': 'mp4',
591
- }],
592
  'progress_hooks': [self._progress_hook],
593
  }
594
 
@@ -598,7 +487,10 @@ class YouTubeDownloader:
598
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
599
  return ydl.download([url])
600
 
601
- await loop.run_in_executor(None, _download_video)
 
 
 
602
 
603
  if self._cancelled:
604
  return None
@@ -606,26 +498,66 @@ class YouTubeDownloader:
606
  video_file = self._find_video_file()
607
 
608
  if video_file:
 
 
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()
616
  return video_file
617
 
 
 
 
 
 
618
  except CancelledError:
619
  return None
 
620
  except Exception as e:
621
  anti_ban.report_failure(status_code=_extract_429_status(e))
 
622
  logger.warning(f"yt-dlp client '{client}' failed for download: {e}")
623
 
624
- if client != clients_to_try[-1] and not self._cancelled:
625
- wait = random.uniform(2, 5) if _extract_429_status(e) != 429 else random.uniform(5, 15)
626
  await asyncio.sleep(wait)
627
  continue
628
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  return None
630
 
631
  async def download_full(
@@ -644,9 +576,6 @@ class YouTubeDownloader:
644
  }
645
 
646
  try:
647
- if not anti_ban.check_session_limits():
648
- raise Exception("تم تجاوز حد الجلسة. يرجى الانتظار قبل المحاولة مرة أخرى.")
649
-
650
  info = await self.fetch_video_info(url)
651
  results["info"] = info
652
 
@@ -698,7 +627,7 @@ class YouTubeDownloader:
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
 
@@ -707,14 +636,12 @@ class YouTubeDownloader:
707
 
708
  def _find_subtitle_file(self, lang_code: str, fmt: str) -> Optional[str]:
709
  """البحث عن ملف الترجمة المحمل"""
710
- import re
711
- precise_pattern = re.compile(rf'(^|[\W_]){re.escape(lang_code)}[\W_]')
712
  for filename in os.listdir(self.download_dir):
713
- if filename.endswith(f'.{fmt}') and precise_pattern.search(filename):
714
  return os.path.join(self.download_dir, filename)
715
 
716
  for filename in os.listdir(self.download_dir):
717
- if filename.endswith(f'.{fmt}'):
718
  return os.path.join(self.download_dir, filename)
719
 
720
  return None
@@ -726,7 +653,7 @@ class YouTubeDownloader:
726
  latest_time = 0
727
 
728
  for filename in os.listdir(self.download_dir):
729
- if filename.endswith(video_extensions):
730
  filepath = os.path.join(self.download_dir, filename)
731
  mtime = os.path.getmtime(filepath)
732
  if mtime > latest_time:
 
1
  """
2
+ محمل الفيديوهات الرئيسي - نسخة محسنة يونيو 2026
3
+ YouTube Video Downloader - yt-dlp First Strategy
4
+
5
+ الاستراتيجية المُختبرة فعلياً:
6
+ 1. yt-dlp مع tv_embedded (الأسرع - أقل من ثانية)
7
+ 2. yt-dlp مع android_vr (fallback سريع)
8
+ 3. yt-dlp مع mediaconnect
9
+ 4. yt-dlp مع android
10
+ 5. Invidious كـ backup أخير فقط (معظم السيرفرات ميتة)
11
+
12
+ ملاحظة مهمة: العملاء mweb, ios, web, web_creator يفشلون من datacenter IPs
13
  """
14
 
15
  import os
16
+ import re
17
  import asyncio
18
  import logging
19
  import time
 
24
 
25
  from .anti_ban import anti_ban
26
  from .subtitle_handler import SubtitleConverter, SubtitleInfo
27
+
28
+ logger = logging.getLogger(__name__)
29
 
30
 
31
  class CancelledError(Exception):
 
33
  pass
34
 
35
 
 
 
 
36
  def _extract_429_status(e: Exception) -> Optional[int]:
37
  """استخراج status_code 429 من رسالة الخطأ"""
38
  msg = str(e)
 
46
  IDLE = "idle"
47
  FETCHING_INFO = "fetching_info"
48
  DOWNLOADING_SUBTITLE = "downloading_subtitle"
 
49
  DOWNLOADING_VIDEO = "downloading_video"
 
50
  MERGING_VIDEO = "merging_video"
51
  COMPLETED = "completed"
52
  CANCELLED = "cancelled"
 
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
+ info_source: str = "yt_dlp"
 
 
 
69
 
70
 
71
  @dataclass
 
79
  total_bytes: int = 0
80
  filename: str = ""
81
  message: str = ""
82
+ source: str = ""
83
+
84
+
85
+ # العملاء الشغالين فعلياً من datacenter IPs (مختبرين يونيو 2026)
86
+ WORKING_CLIENTS = ["tv_embedded", "android_vr", "mediaconnect", "android"]
87
+
88
+ # عملاء يفشلون من datacenter IPs - لا نستخدمهم
89
+ # mweb: "Requested format is not available"
90
+ # ios: "Requested format is not available"
91
+ # web: "Requested format is not available"
92
+ # web_creator: "Please sign in"
93
+
94
+ # فورمات التحميل حسب الجودة (مُختبرة فعلياً)
95
+ QUALITY_FORMATS = {
96
+ "best": "bestvideo+bestaudio/best",
97
+ "medium": "bestvideo[height<=720]+bestaudio/best[height<=720]/best",
98
+ "low": "bestvideo[height<=480]+bestaudio/best[height<=480]/best",
99
+ }
100
 
101
 
102
  class YouTubeDownloader:
103
  """
104
+ محمل فيديوهات يوتيوب - yt-dlp أولاً
105
+
106
+ الاستراتيجية المُختبرة:
107
+ - معلومات الفيديو: yt-dlp (tv_embeddedandroid_vr → mediaconnect → android)
108
+ - تحميل الفيديو: yt-dlp بنفس العملاء
109
+ - الترجمات: yt-dlp
110
+ - Invidious: backup أخير فقط
111
  """
112
 
113
  def __init__(self, download_dir: str = "/tmp/youtube_downloads"):
 
117
  self.progress = DownloadProgress()
118
  self._progress_callback: Optional[Callable] = None
119
  self.subtitle_converter = SubtitleConverter()
120
+
121
+ # تتبع العميل الشغال
122
+ self._working_client: Optional[str] = None
123
+ self._failed_clients: List[str] = []
124
 
125
  os.makedirs(download_dir, exist_ok=True)
126
 
 
149
  except Exception:
150
  pass
151
 
152
+ def _get_clients_to_try(self) -> List[str]:
153
+ """الحصول على قائمة العملاء للتجربة"""
154
+ # نبدأ بالعميل الشغال لو معروف
155
+ if self._working_client and self._working_client not in self._failed_clients:
156
+ return [self._working_client] + [c for c in WORKING_CLIENTS if c != self._working_client and c not in self._failed_clients]
157
+
158
+ # العملاء اللي ما فشلوش
159
+ available = [c for c in WORKING_CLIENTS if c not in self._failed_clients]
160
+ if not available:
161
+ # لو كلهم فشلوا، نعيد المحاولة مع الكل
162
+ self._failed_clients.clear()
163
+ available = WORKING_CLIENTS.copy()
164
+
165
+ return available
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
  async def fetch_video_info(self, url: str) -> VideoInfo:
168
  """
169
+ جلب معلومات الفيديو - yt-dlp أولاً (سريع جداً)
170
  """
171
  self._cancelled = False
172
  self._update_progress(
173
  status=DownloadStatus.FETCHING_INFO,
174
+ message="جاري جلب معلومات الفيديو...",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  source="yt_dlp",
176
  )
177
 
178
+ clients = self._get_clients_to_try()
 
 
 
179
  last_error = None
180
 
181
+ for client in clients:
182
  if self._cancelled:
183
  raise CancelledError("تم إلغاء التحميل")
184
 
 
186
  import yt_dlp
187
 
188
  anti_ban._current_client = client
189
+ self._update_progress(
190
+ message=f"جاري جلب المعلومات ({client})...",
191
+ source="yt_dlp",
192
+ )
193
  logger.info(f"Trying yt-dlp client: {client} for video info")
194
 
195
  ydl_opts = {
 
205
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
206
  return ydl.extract_info(url, download=False)
207
 
208
+ info = await asyncio.wait_for(
209
+ loop.run_in_executor(None, _extract_info),
210
+ timeout=15,
211
+ )
212
 
213
  if not info:
214
  raise Exception("لم يتم العثور على معلومات الفيديو")
215
 
216
+ # نجاح! نسجل العميل الشغال
217
+ self._working_client = client
218
+ if client in self._failed_clients:
219
+ self._failed_clients.remove(client)
220
+ anti_ban.report_success()
221
+
222
  video_info = VideoInfo(
223
  title=info.get('title', 'غير معروف'),
224
  video_id=info.get('id', ''),
 
255
  ))
256
  break
257
 
 
258
  self._update_progress(
259
  status=DownloadStatus.IDLE,
260
+ message="تم جلب المعلومات بنجاح",
261
  )
262
+ logger.info(f"Got video info from yt-dlp ({client}): {video_info.title}")
263
  return video_info
264
 
265
+ except asyncio.TimeoutError:
266
+ logger.warning(f"yt-dlp client '{client}' timed out for video info")
267
+ last_error = Exception("انتهت مهلة الاتصال. حاول مرة أخرى.")
268
+ self._failed_clients.append(client)
269
+ continue
270
+
271
  except CancelledError:
272
  raise
273
+
274
  except Exception as e:
275
  last_error = e
276
  status_code = _extract_429_status(e)
277
  anti_ban.report_failure(status_code=status_code)
278
+ self._failed_clients.append(client)
279
  logger.warning(f"yt-dlp client '{client}' failed: {e}")
280
 
281
+ if client != clients[-1]:
282
+ # انتظار قصير قبل العميل التالي
283
+ wait = random.uniform(1, 3) if status_code != 429 else random.uniform(5, 10)
284
  await asyncio.sleep(wait)
285
  continue
286
+
287
+ # ═══ كل العملاء فشلوا - نحاول Invidious كـ last resort ═══
288
+ self._update_progress(message="yt-dlp فشل، جاري المحاولة عبر Invidious...")
289
+ try:
290
+ from .fallback_downloader import FallbackDownloader
291
+ fallback = FallbackDownloader(download_dir=self.download_dir)
292
+ fallback_info = await asyncio.wait_for(
293
+ fallback.get_video_info(url),
294
+ timeout=10,
295
+ )
296
+ if fallback_info and fallback_info.title:
297
+ info = VideoInfo(
298
+ title=fallback_info.title,
299
+ video_id=fallback_info.video_id,
300
+ duration=fallback_info.duration,
301
+ thumbnail=fallback_info.thumbnail,
302
+ description=fallback_info.description,
303
+ uploader=fallback_info.uploader,
304
+ view_count=fallback_info.view_count,
305
+ info_source="invidious",
306
+ )
307
+ self._update_progress(status=DownloadStatus.IDLE, message="تم جلب المعلومات من Invidious")
308
+ return info
309
+ except Exception as e:
310
+ logger.warning(f"Invidious also failed: {e}")
311
 
312
  # ═══ كل الطرق فشلت ═══
313
  error_msg = str(last_error) if last_error else "فشل جلب المعلومات"
 
327
  """تحميل الترجمة"""
328
  self._update_progress(
329
  status=DownloadStatus.DOWNLOADING_SUBTITLE,
330
+ message=f"جاري تحميل الترجمة ({language_code})...",
331
  percent=0,
332
+ source="yt_dlp",
333
  )
334
 
335
  if self._cancelled:
336
  return None
337
 
338
+ clients = self._get_clients_to_try()
 
 
 
 
 
 
 
339
 
340
+ for client in clients:
341
+ if self._cancelled:
342
+ return None
343
 
344
+ try:
345
+ import yt_dlp
 
 
 
 
 
 
 
346
 
347
+ anti_ban._current_client = client
 
 
 
 
348
 
349
+ sub_format = "vtt" if subtitle_format == "vtt" else "srt"
350
+ subtitle_path = os.path.join(self.download_dir, "subtitle_temp")
351
 
352
+ ydl_opts = {
353
+ **anti_ban.get_ydl_opts_additions(),
354
+ 'quiet': True,
355
+ 'no_warnings': True,
356
+ 'skip_download': True,
357
+ 'writesubtitles': not auto_generated,
358
+ 'writeautomaticsub': auto_generated,
359
+ 'subtitleslangs': [language_code],
360
+ 'subtitlesformat': sub_format,
361
+ 'outtmpl': subtitle_path,
362
+ 'postprocessors': [{
363
+ 'key': 'FFmpegSubtitlesConvertor',
364
+ 'format': sub_format,
365
+ }] if sub_format != 'vtt' else [],
366
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
+ loop = asyncio.get_event_loop()
 
 
 
 
 
369
 
370
+ def _download_sub():
371
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
372
+ return ydl.download([url])
373
 
374
+ await asyncio.wait_for(
375
+ loop.run_in_executor(None, _download_sub),
376
+ timeout=30,
377
+ )
378
 
379
+ self._working_client = client
380
+ anti_ban.report_success()
381
 
382
+ subtitle_file = self._find_subtitle_file(language_code, sub_format)
 
 
383
 
384
+ if subtitle_file and os.path.exists(subtitle_file):
385
+ with open(subtitle_file, 'r', encoding='utf-8') as f:
386
+ content = f.read()
 
387
 
388
+ content = self.subtitle_converter.format_subtitle(content, subtitle_format)
389
 
390
+ final_path = os.path.join(self.download_dir, f"subtitle_{language_code}.{sub_format}")
391
+ with open(final_path, 'w', encoding='utf-8') as f:
392
+ f.write(content)
393
 
394
+ try:
395
+ os.remove(subtitle_file)
396
+ except Exception:
397
+ pass
398
+
399
+ self._update_progress(
400
+ status=DownloadStatus.DOWNLOADING_SUBTITLE,
401
+ percent=100,
402
+ message="تم تحميل الترجمة",
403
+ )
404
+ return final_path
405
+
406
+ except asyncio.TimeoutError:
407
+ logger.warning(f"Subtitle download timed out with client {client}")
408
+ continue
409
+ except Exception as e:
410
+ if self._cancelled:
411
+ return None
412
+ anti_ban.report_failure(status_code=_extract_429_status(e))
413
+ logger.warning(f"yt-dlp client '{client}' failed for subtitle: {e}")
414
+ continue
415
+
416
+ # لو yt-dlp فشل للترجمة، نحاول Invidious
417
+ try:
418
+ from .fallback_downloader import FallbackDownloader
419
+ fallback = FallbackDownloader(download_dir=self.download_dir)
420
+ sub_content = await asyncio.wait_for(
421
+ fallback.get_subtitle_content(url, language_code),
422
+ timeout=10,
423
  )
424
+ if sub_content:
425
+ content = self.subtitle_converter.format_subtitle(sub_content, subtitle_format)
426
+ final_path = os.path.join(self.download_dir, f"subtitle_{language_code}.{subtitle_format}")
427
+ with open(final_path, 'w', encoding='utf-8') as f:
428
+ f.write(content)
429
+ return final_path
430
+ except Exception as e:
431
+ logger.warning(f"Invidious subtitle also failed: {e}")
432
+
433
+ self._update_progress(
434
+ status=DownloadStatus.FAILED,
435
+ message=f"فشل تحميل الترجمة",
436
+ )
437
+ return None
438
 
439
  async def download_video(
440
  self,
 
443
  output_filename: Optional[str] = None,
444
  ) -> Optional[str]:
445
  """
446
+ تحميل الفيديو - yt-dlp أولاً (سريع وموثوق)
447
  """
448
  self._update_progress(
449
  status=DownloadStatus.DOWNLOADING_VIDEO,
450
+ message="جاري تحميل الفيديو...",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  percent=0,
452
  source="yt_dlp",
453
  )
454
 
 
 
 
 
455
  if self._cancelled:
456
  return None
457
 
458
+ format_spec = QUALITY_FORMATS.get(quality, QUALITY_FORMATS["best"])
459
+ clients = self._get_clients_to_try()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
 
461
+ for client in clients:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
  if self._cancelled:
463
  return None
464
 
 
473
  else:
474
  outtmpl = os.path.join(self.download_dir, '%(title)s.%(ext)s')
475
 
 
 
 
 
 
 
 
 
 
 
476
  ydl_opts = {
477
  **anti_ban.get_ydl_opts_additions(),
478
  'format': format_spec,
479
  'outtmpl': outtmpl,
480
  'merge_output_format': 'mp4',
 
 
 
 
481
  'progress_hooks': [self._progress_hook],
482
  }
483
 
 
487
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
488
  return ydl.download([url])
489
 
490
+ await asyncio.wait_for(
491
+ loop.run_in_executor(None, _download_video),
492
+ timeout=300, # 5 دقائق كحد أقصى
493
+ )
494
 
495
  if self._cancelled:
496
  return None
 
498
  video_file = self._find_video_file()
499
 
500
  if video_file:
501
+ self._working_client = client
502
+ anti_ban.report_success()
503
  self._update_progress(
504
  status=DownloadStatus.COMPLETED,
505
  percent=100,
506
+ message="تم تحميل الفيديو بنجاح!",
507
  filename=video_file,
508
  )
 
509
  return video_file
510
 
511
+ except asyncio.TimeoutError:
512
+ logger.warning(f"Video download timed out with client {client}")
513
+ self._failed_clients.append(client)
514
+ continue
515
+
516
  except CancelledError:
517
  return None
518
+
519
  except Exception as e:
520
  anti_ban.report_failure(status_code=_extract_429_status(e))
521
+ self._failed_clients.append(client)
522
  logger.warning(f"yt-dlp client '{client}' failed for download: {e}")
523
 
524
+ if client != clients[-1] and not self._cancelled:
525
+ wait = random.uniform(1, 3) if _extract_429_status(e) != 429 else random.uniform(5, 10)
526
  await asyncio.sleep(wait)
527
  continue
528
 
529
+ # ═══ yt-dlp فشل - نحاول Invidious كـ last resort ═══
530
+ self._update_progress(message="yt-dlp فشل، جاري المحاولة عبر Invidious...")
531
+ try:
532
+ from .fallback_downloader import FallbackDownloader
533
+ fallback = FallbackDownloader(download_dir=self.download_dir)
534
+ result = await asyncio.wait_for(
535
+ fallback.get_download_url(url, quality),
536
+ timeout=10,
537
+ )
538
+ if result:
539
+ if result.get("needs_merge"):
540
+ filepath = await fallback.download_and_merge(
541
+ result["video_url"], result.get("audio_url", ""), result.get("filename", "video.mp4")
542
+ )
543
+ else:
544
+ filepath = await fallback.download_from_url(result["url"], result.get("filename", "video.mp4"))
545
+
546
+ if filepath:
547
+ self._update_progress(
548
+ status=DownloadStatus.COMPLETED,
549
+ percent=100,
550
+ message="تم تحميل الفيديو من Invidious!",
551
+ filename=filepath,
552
+ )
553
+ return filepath
554
+ except Exception as e:
555
+ logger.warning(f"Invidious download also failed: {e}")
556
+
557
+ self._update_progress(
558
+ status=DownloadStatus.FAILED,
559
+ message="فشل تحميل الفيديو من جميع المصادر. جرب فيديو آخر أو حاول لاحقاً.",
560
+ )
561
  return None
562
 
563
  async def download_full(
 
576
  }
577
 
578
  try:
 
 
 
579
  info = await self.fetch_video_info(url)
580
  results["info"] = info
581
 
 
627
  eta=eta,
628
  downloaded_bytes=d.get('downloaded_bytes', 0),
629
  total_bytes=d.get('total_bytes') or d.get('total_bytes_estimate', 0),
630
+ message=f"جاري التحميل... {percent}%",
631
  source="yt_dlp",
632
  )
633
 
 
636
 
637
  def _find_subtitle_file(self, lang_code: str, fmt: str) -> Optional[str]:
638
  """البحث عن ملف الترجمة المحمل"""
 
 
639
  for filename in os.listdir(self.download_dir):
640
+ if filename.endswith(f'.{fmt}') and lang_code in filename:
641
  return os.path.join(self.download_dir, filename)
642
 
643
  for filename in os.listdir(self.download_dir):
644
+ if filename.endswith(f'.{fmt}') and 'subtitle' in filename:
645
  return os.path.join(self.download_dir, filename)
646
 
647
  return None
 
653
  latest_time = 0
654
 
655
  for filename in os.listdir(self.download_dir):
656
+ if filename.endswith(video_extensions) and not filename.startswith('_temp_'):
657
  filepath = os.path.join(self.download_dir, filename)
658
  mtime = os.path.getmtime(filepath)
659
  if mtime > latest_time:
core/fallback_downloader.py CHANGED
@@ -1,45 +1,36 @@
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
13
  import re
14
  import asyncio
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
 
25
  logger = logging.getLogger(__name__)
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
@@ -81,142 +72,30 @@ 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"""
205
  video_id = _extract_video_id(url)
206
  if not video_id:
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:
@@ -224,7 +103,6 @@ class InvidiousDownloader:
224
  self._working_instance = instance
225
  logger.info(f"Invidious video info success with {instance}")
226
 
227
- # استخراج الصورة المصغرة
228
  thumbnail = ""
229
  thumbnails = data.get("videoThumbnails", [])
230
  for t in thumbnails:
@@ -234,17 +112,6 @@ class InvidiousDownloader:
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({
241
- "language": sub.get("label", sub.get("language_code", "")),
242
- "language_code": sub.get("language_code", ""),
243
- "auto_generated": False,
244
- "url": sub.get("url", ""),
245
- })
246
-
247
- # استخراج captions (التلقائية واليدوية)
248
  captions = []
249
  for cap in data.get("captions", []):
250
  captions.append({
@@ -262,108 +129,33 @@ class InvidiousDownloader:
262
  uploader=data.get("author", ""),
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:
272
- logger.warning(f"Invidious rate limited on {instance}")
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
287
  except Exception as e:
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
 
349
- async def get_download_url(
350
- self,
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"},
@@ -372,40 +164,29 @@ class InvidiousDownloader:
372
  if response.status_code == 200:
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:
382
- self._failed_instances.append(instance)
383
- continue
384
-
385
  except (httpx.TimeoutException, httpx.ConnectError):
386
  continue
387
- except Exception as e:
388
- logger.warning(f"Invidious download error on {instance}: {e}")
389
  continue
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
 
@@ -423,7 +204,7 @@ class InvidiousDownloader:
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]
@@ -435,104 +216,66 @@ class InvidiousDownloader:
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"},
@@ -545,38 +288,26 @@ class InvidiousDownloader:
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
 
@@ -587,58 +318,24 @@ class InvidiousDownloader:
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
595
 
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(
628
- self,
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]:
644
  """تحميل ملف من رابط مباشر"""
@@ -648,18 +345,13 @@ class FallbackDownloader:
648
  async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
649
  async with client.stream("GET", download_url) as response:
650
  if response.status_code != 200:
651
- logger.error(f"Download failed with status {response.status_code}")
652
  return None
653
 
654
- total = int(response.headers.get("content-length", 0))
655
- downloaded = 0
656
-
657
  with open(filepath, "wb") as f:
658
  async for chunk in response.aiter_bytes(chunk_size=8192):
659
  f.write(chunk)
660
- downloaded += len(chunk)
661
 
662
- logger.info(f"Downloaded {filename} ({downloaded} bytes)")
663
  return filepath
664
 
665
  except Exception as e:
@@ -668,64 +360,51 @@ class FallbackDownloader:
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):
@@ -734,74 +413,10 @@ class FallbackDownloader:
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
 
1
  """
2
+ نظام التحميل البديل - Invidious كـ backup فقط
3
+ يُستخدم فقط عندما yt-dlp يفشل (نادراً)
4
+
5
+ ملاحظة يونيو 2026: معظم سيرفرات Invidious ميتة
6
+ نحتفظ به كـ last resort مع timeout قصير
 
 
 
7
  """
8
 
9
  import os
10
  import re
11
  import asyncio
12
  import logging
 
13
  import time
14
  import subprocess
 
15
  from typing import Optional, Dict, Any, List
16
+ from dataclasses import dataclass
17
 
18
  import httpx
19
 
20
  logger = logging.getLogger(__name__)
21
 
22
 
23
+ # سيرفرات Invidious ثابتة - نحاولها بسرعة
24
+ SEED_INSTANCES = [
 
 
 
 
25
  "https://inv.thepixora.com",
26
+ "https://invidious.nerdvpn.de",
27
+ "https://iv.ggtyler.dev",
28
+ "https://vid.puffyan.us",
29
+ "https://invidious.privacyredirect.com",
30
  ]
31
 
32
+ # timeout قصير جداً - لا نضيع وقت المستخدم
33
+ FAST_TIMEOUT = 5.0 # 5 ثواني فقط لكل سيرفر
 
 
 
 
34
 
35
 
36
  @dataclass
 
72
  return None
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  class InvidiousDownloader:
76
+ """محمل عبر Invidious - backup فقط"""
 
 
 
77
 
78
  def __init__(self):
79
  self._working_instance: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  async def get_video_info(self, url: str) -> Optional[FallbackVideoInfo]:
82
+ """جلب معلومات الفيديو من Invidious - محاولة سريعة"""
83
  video_id = _extract_video_id(url)
84
  if not video_id:
 
85
  return None
86
 
87
+ instances = SEED_INSTANCES.copy()
88
+ # نبدأ بالشغال لو معروف
89
+ if self._working_instance and self._working_instance in instances:
90
+ instances.remove(self._working_instance)
91
+ instances.insert(0, self._working_instance)
92
 
93
  for instance in instances:
94
  try:
95
+ async with httpx.AsyncClient(timeout=FAST_TIMEOUT, follow_redirects=True) as client:
96
  response = await client.get(
97
  f"{instance}/api/v1/videos/{video_id}",
98
+ params={"fields": "title,lengthSeconds,videoThumbnails,author,viewCount,description,captions,formatStreams,adaptiveFormats"},
 
 
99
  )
100
 
101
  if response.status_code == 200:
 
103
  self._working_instance = instance
104
  logger.info(f"Invidious video info success with {instance}")
105
 
 
106
  thumbnail = ""
107
  thumbnails = data.get("videoThumbnails", [])
108
  for t in thumbnails:
 
112
  if not thumbnail and thumbnails:
113
  thumbnail = thumbnails[0].get("url", "")
114
 
 
 
 
 
 
 
 
 
 
 
 
115
  captions = []
116
  for cap in data.get("captions", []):
117
  captions.append({
 
129
  uploader=data.get("author", ""),
130
  view_count=data.get("viewCount", 0),
131
  description=data.get("description", "")[:500] if data.get("description") else "",
 
132
  captions=captions,
133
  formats=data.get("formatStreams", []),
134
  adaptive_formats=data.get("adaptiveFormats", []),
135
  )
136
 
137
+ except (httpx.TimeoutException, httpx.ConnectError):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  continue
139
  except Exception as e:
140
+ logger.debug(f"Invidious {instance} failed: {e}")
141
  continue
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  return None
144
 
145
+ async def get_download_url(self, url: str, quality: str = "best") -> Optional[Dict[str, Any]]:
146
+ """الحصول على روابط تحميل من Invidious"""
 
 
 
 
 
 
 
147
  video_id = _extract_video_id(url)
148
  if not video_id:
149
  return None
150
 
151
+ instances = SEED_INSTANCES.copy()
152
+ if self._working_instance and self._working_instance in instances:
153
+ instances.remove(self._working_instance)
154
+ instances.insert(0, self._working_instance)
155
 
156
  for instance in instances:
157
  try:
158
+ async with httpx.AsyncClient(timeout=FAST_TIMEOUT, follow_redirects=True) as client:
159
  response = await client.get(
160
  f"{instance}/api/v1/videos/{video_id}",
161
  params={"fields": "formatStreams,adaptiveFormats,title"},
 
164
  if response.status_code == 200:
165
  data = response.json()
166
  self._working_instance = instance
167
+ result = self._extract_format(data, quality)
 
168
  if result:
169
  return result
170
 
 
 
 
 
 
171
  except (httpx.TimeoutException, httpx.ConnectError):
172
  continue
173
+ except Exception:
 
174
  continue
175
 
176
  return None
177
 
178
+ def _extract_format(self, data: dict, quality: str) -> Optional[Dict[str, Any]]:
179
+ """استخراج أفضل صيغة تحميل"""
 
 
 
180
  format_streams = data.get("formatStreams", [])
181
  adaptive_formats = data.get("adaptiveFormats", [])
182
  title = data.get("title", "video")
183
 
184
+ # Format Streams (فيديو + صوت معاً)
 
185
  if format_streams:
186
  quality_order = {
187
+ "best": ["720p", "480p", "360p"],
188
+ "medium": ["480p", "360p"],
189
+ "low": ["360p"],
190
  }
191
  preferred = quality_order.get(quality, ["720p", "480p", "360p"])
192
 
 
204
  "needs_merge": False,
205
  }
206
 
207
+ # أي format stream
208
  if format_streams and format_streams[0].get("url"):
209
  fmt = format_streams[0]
210
  safe_title = re.sub(r'[^\w\s-]', '', title)[:50]
 
216
  "needs_merge": False,
217
  }
218
 
219
+ # Adaptive Formats
220
  if adaptive_formats:
221
+ video_formats = [f for f in adaptive_formats if f.get("type", "").startswith("video/") and f.get("url")]
222
+ audio_formats = [f for f in adaptive_formats if f.get("type", "").startswith("audio/") and f.get("url")]
223
 
224
+ if video_formats:
225
+ quality_height_map = {"best": 1080, "medium": 720, "low": 480}
226
+ max_height = quality_height_map.get(quality, 1080)
227
 
228
+ video_formats.sort(key=lambda x: x.get("bitrate", 0), reverse=True)
229
+ best_video = None
230
+ for fmt in video_formats:
231
+ res_str = fmt.get("resolution", fmt.get("qualityLabel", "0p"))
232
+ try:
233
+ height = int(re.search(r'(\d+)', str(res_str)).group(1))
234
+ except (AttributeError, ValueError):
235
+ height = 0
236
+ if height <= max_height:
237
+ best_video = fmt
238
+ break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
+ if not best_video:
241
+ best_video = video_formats[0]
 
242
 
243
+ audio_formats.sort(key=lambda x: x.get("bitrate", 0), reverse=True)
244
+ best_audio = audio_formats[0] if audio_formats else None
245
 
246
+ safe_title = re.sub(r'[^\w\s-]', '', title)[:50]
247
+
248
+ result = {
249
+ "filename": f"{safe_title}.mp4",
250
+ "source": "invidious_adaptive",
251
+ "quality": best_video.get("qualityLabel", "unknown"),
252
+ "needs_merge": bool(best_audio),
253
+ "video_url": best_video.get("url", ""),
254
+ "audio_url": best_audio.get("url", "") if best_audio else "",
255
+ }
256
+
257
+ if not best_audio:
258
+ result["needs_merge"] = False
259
+ result["url"] = best_video.get("url", "")
260
+
261
+ return result
262
+
263
+ return None
264
+
265
+ async def get_caption_content(self, url: str, lang: str = "en", auto: bool = True) -> Optional[str]:
266
+ """تحميل محتوى الترجمة من Invidious"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  video_id = _extract_video_id(url)
268
  if not video_id:
269
  return None
270
 
271
+ instances = SEED_INSTANCES.copy()
272
+ if self._working_instance and self._working_instance in instances:
273
+ instances.remove(self._working_instance)
274
+ instances.insert(0, self._working_instance)
275
 
276
  for instance in instances:
277
  try:
278
+ async with httpx.AsyncClient(timeout=FAST_TIMEOUT, follow_redirects=True) as client:
 
279
  response = await client.get(
280
  f"{instance}/api/v1/videos/{video_id}",
281
  params={"fields": "captions,subtitles"},
 
288
  captions = data.get("captions", [])
289
  subtitles = data.get("subtitles", [])
290
 
291
+ target = None
 
 
 
292
  for sub in subtitles:
293
  if sub.get("language_code") == lang:
294
+ target = sub
295
  break
296
 
297
+ if not target:
 
298
  for cap in captions:
299
  lc = cap.get("language_code", "")
 
300
  if lc.startswith(lang.split("-")[0]):
301
+ target = cap
302
+ break
303
+
304
+ if not target and captions:
305
+ target = captions[0]
306
+
307
+ if not target:
 
 
 
 
 
 
308
  return None
309
 
310
+ cap_url = target.get("url", "")
 
311
  if not cap_url:
312
  return None
313
 
 
318
  if cap_response.status_code == 200 and cap_response.text.strip():
319
  return cap_response.text
320
 
321
+ except Exception:
 
322
  continue
323
 
324
  return None
325
 
326
 
327
  class FallbackDownloader:
328
+ """مدير التحميل البديل - Invidious backup"""
 
 
 
329
 
330
  def __init__(self, download_dir: str = "/tmp/downloads"):
331
  self.download_dir = download_dir
332
  self.invidious = InvidiousDownloader()
333
 
 
 
 
 
 
 
 
 
 
 
334
  async def get_video_info(self, url: str) -> Optional[FallbackVideoInfo]:
335
+ return await self.invidious.get_video_info(url)
 
 
 
 
 
 
 
336
 
337
+ async def get_download_url(self, url: str, quality: str = "best") -> Optional[Dict[str, Any]]:
338
+ return await self.invidious.get_download_url(url, quality)
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
340
  async def download_from_url(self, download_url: str, filename: str) -> Optional[str]:
341
  """تحميل ملف من رابط مباشر"""
 
345
  async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client:
346
  async with client.stream("GET", download_url) as response:
347
  if response.status_code != 200:
 
348
  return None
349
 
 
 
 
350
  with open(filepath, "wb") as f:
351
  async for chunk in response.aiter_bytes(chunk_size=8192):
352
  f.write(chunk)
 
353
 
354
+ logger.info(f"Downloaded {filename}")
355
  return filepath
356
 
357
  except Exception as e:
 
360
  os.remove(filepath)
361
  return None
362
 
363
+ async def download_and_merge(self, video_url: str, audio_url: str, filename: str) -> Optional[str]:
 
 
364
  """تحميل فيديو + صوت منفصلين ودمجهم بـ FFmpeg"""
365
  output_path = os.path.join(self.download_dir, filename)
 
 
366
  video_temp = os.path.join(self.download_dir, f"_temp_video_{int(time.time())}.mp4")
367
  audio_temp = os.path.join(self.download_dir, f"_temp_audio_{int(time.time())}.m4a")
368
 
369
  try:
 
 
370
  video_path = await self.download_from_url(video_url, os.path.basename(video_temp))
371
  if not video_path:
 
372
  return None
373
 
374
+ if audio_url:
375
+ audio_path = await self.download_from_url(audio_url, os.path.basename(audio_temp))
376
+ if audio_path:
377
+ # دمج بـ FFmpeg
378
+ cmd = [
379
+ "ffmpeg", "-y",
380
+ "-i", video_path,
381
+ "-i", audio_path,
382
+ "-c:v", "copy",
383
+ "-c:a", "aac",
384
+ "-movflags", "+faststart",
385
+ output_path,
386
+ ]
387
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
388
+
389
+ for temp_file in [video_path, audio_path]:
390
+ try:
391
+ if os.path.exists(temp_file):
392
+ os.remove(temp_file)
393
+ except Exception:
394
+ pass
395
+
396
+ if result.returncode == 0 and os.path.exists(output_path):
397
+ return output_path
398
+
399
+ # لو الصوت فشل أو الدمج فشل، نستخدم الفيديو فقط
400
+ try:
401
+ os.rename(video_path, output_path)
402
  return output_path
403
+ except Exception:
404
+ return video_path
 
 
 
 
 
 
405
 
406
  except Exception as e:
407
  logger.error(f"Download and merge failed: {e}")
 
408
  for temp_file in [video_temp, audio_temp]:
409
  try:
410
  if os.path.exists(temp_file):
 
413
  pass
414
  return None
415
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  async def get_subtitle_content(self, url: str, lang: str = "ar") -> Optional[str]:
 
417
  return await self.invidious.get_caption_content(url, lang)
418
 
419
+
420
+ async def refresh_instances():
421
+ """لا نفعل شيء - السيرفرات تُختبر عند الحاجة"""
422
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
desktop/main.py CHANGED
@@ -1,6 +1,8 @@
1
  """
2
  نسخة الكمبيوتر - خادم FastAPI مع واجهة ويب
3
  Desktop Version - FastAPI Server with Web UI
 
 
4
  """
5
 
6
  import os
@@ -8,7 +10,6 @@ import sys
8
  import json
9
  import asyncio
10
  import logging
11
- import tempfile
12
  from typing import Optional
13
 
14
  # إضافة مسار النواة المشتركة
@@ -19,7 +20,7 @@ from fastapi.staticfiles import StaticFiles
19
  from fastapi.responses import FileResponse, JSONResponse
20
  from fastapi.middleware.cors import CORSMiddleware
21
 
22
- from core.downloader import YouTubeDownloader, DownloadStatus
23
  from core.models import DownloadRequest, SubtitleFormat, SubtitleLanguage, VideoQuality
24
  from core.anti_ban import anti_ban
25
  from core.cookie_manager import cookie_manager
@@ -31,7 +32,7 @@ logger = logging.getLogger(__name__)
31
  app = FastAPI(
32
  title="YouTube Downloader - Desktop",
33
  description="تطبيق تحميل فيديوهات يوتيوب مع الترجمات",
34
- version="2.0.0",
35
  )
36
 
37
  # CORS
@@ -54,19 +55,6 @@ downloader = YouTubeDownloader(download_dir=DOWNLOAD_DIR)
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,20 +64,23 @@ async def index():
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")
89
  async def get_video_info(url: str):
90
- """جلب معلومات الفيديو"""
91
  try:
92
- info = await downloader.fetch_video_info(url)
 
 
 
93
  return {
94
  "title": info.title,
95
  "video_id": info.video_id,
@@ -108,19 +99,28 @@ async def get_video_info(url: str):
108
  for sub in info.available_subtitles
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
 
@@ -129,7 +129,6 @@ async def get_video_info(url: str):
129
  async def start_download(request: DownloadRequest):
130
  """بدء التحميل الكامل (ترجمة + فيديو)"""
131
  try:
132
- # التحقق من حدود الجلسة
133
  if not anti_ban.check_session_limits():
134
  raise HTTPException(
135
  status_code=429,
@@ -166,9 +165,18 @@ async def _download_task(request: DownloadRequest):
166
  })
167
 
168
  except Exception as e:
 
 
 
 
 
 
 
 
 
169
  await _broadcast_ws({
170
  "type": "download_error",
171
- "error": str(e),
172
  })
173
 
174
 
@@ -181,11 +189,14 @@ async def download_subtitle_only(
181
  ):
182
  """تحميل الترجمة فقط"""
183
  try:
184
- subtitle_file = await downloader.download_subtitle(
185
- url=url,
186
- language_code=lang.value,
187
- subtitle_format=format.value,
188
- auto_generated=auto,
 
 
 
189
  )
190
 
191
  if subtitle_file:
@@ -197,6 +208,8 @@ async def download_subtitle_only(
197
  else:
198
  raise HTTPException(status_code=404, detail="لم يتم العثور على ترجمة باللغة المطلوبة")
199
 
 
 
200
  except HTTPException:
201
  raise
202
  except Exception as e:
@@ -207,7 +220,10 @@ async def download_subtitle_only(
207
  async def download_video_only(url: str, quality: VideoQuality = VideoQuality.best):
208
  """تحميل الفيديو فقط"""
209
  try:
210
- video_file = await downloader.download_video(url=url, quality=quality.value)
 
 
 
211
 
212
  if video_file:
213
  return {
@@ -216,8 +232,10 @@ async def download_video_only(url: str, quality: VideoQuality = VideoQuality.bes
216
  "message": "تم تحميل الفيديو بنجاح"
217
  }
218
  else:
219
- raise HTTPException(status_code=400, detail="فشل تحميل الفيديو من جميع المصادر. جرب فيديو آخر أو حاول لاحقاً.")
220
 
 
 
221
  except HTTPException:
222
  raise
223
  except Exception as e:
@@ -268,7 +286,7 @@ async def list_downloads():
268
 
269
  @app.get("/api/download/file")
270
  async def download_file(path: str):
271
- """تحميل ملف (فتح نافذة حفظ باسم)"""
272
  if not os.path.exists(path):
273
  raise HTTPException(status_code=404, detail="الملف غير موجود")
274
 
@@ -292,23 +310,14 @@ async def delete_file(path: str):
292
  @app.get("/api/anti-ban/status")
293
  async def anti_ban_status():
294
  """حالة مضاد الحظر"""
295
- cookies_info = cookie_manager.get_info()
296
- fallback_stats = downloader.fallback.get_stats()
297
  return {
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,
306
- "cookies": {
307
- "active": cookies_info.get("active", False),
308
- "size": cookies_info.get("size", 0),
309
- "lines": cookies_info.get("lines", 0),
310
- "has_youtube": cookies_info.get("has_youtube", False),
311
- },
312
  }
313
 
314
 
@@ -316,31 +325,14 @@ async def anti_ban_status():
316
  async def reset_anti_ban():
317
  """إعادة تعيين مضاد الحظر"""
318
  anti_ban.reset_session()
 
 
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
- """حفظ الكوكيز عن طريق لصق المحتوى"""
344
  content = data.get("content", "")
345
  if not content:
346
  raise HTTPException(status_code=400, detail="المحتوى فارغ")
@@ -348,7 +340,7 @@ async def set_cookies(data: dict):
348
  success = cookie_manager.set_cookies(content)
349
  if success:
350
  return {"status": "ok", "message": "تم حفظ الكوكيز بنجاح", "info": cookie_manager.get_info()}
351
- raise HTTPException(status_code=400, detail="فشل حفظ الكوكيز. تأكد من أن الملف يحتوي على كوكيز يوتيوب")
352
 
353
 
354
  @app.post("/api/cookies/upload")
@@ -365,13 +357,13 @@ async def upload_cookies_file(file: UploadFile = File(...)):
365
 
366
  success = cookie_manager.upload_cookies(text)
367
  if success:
368
- return {"status": "ok", "message": "تم رفع الكوكيز بنجاح", "info": cookie_manager.get_info()}
369
- raise HTTPException(status_code=400, detail="فشل حفظ الكوكيز. تأكد من أن الملف يحتوي على كوكيز يوتيوب")
370
 
371
 
372
  @app.get("/api/cookies/status")
373
  async def cookies_status():
374
- """حالة الكوكيز الحالية"""
375
  return cookie_manager.get_info()
376
 
377
 
@@ -390,7 +382,6 @@ async def websocket_endpoint(websocket: WebSocket):
390
  await websocket.accept()
391
  ws_connections.append(websocket)
392
 
393
- # تعيين callback للتقدم
394
  def on_progress(progress):
395
  asyncio.create_task(_send_progress_ws(websocket, progress))
396
 
@@ -445,10 +436,11 @@ def main():
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)
 
1
  """
2
  نسخة الكمبيوتر - خادم FastAPI مع واجهة ويب
3
  Desktop Version - FastAPI Server with Web UI
4
+
5
+ يونيو 2026 - yt-dlp First Strategy
6
  """
7
 
8
  import os
 
10
  import json
11
  import asyncio
12
  import logging
 
13
  from typing import Optional
14
 
15
  # إضافة مسار النواة المشتركة
 
20
  from fastapi.responses import FileResponse, JSONResponse
21
  from fastapi.middleware.cors import CORSMiddleware
22
 
23
+ from core.downloader import YouTubeDownloader, DownloadStatus, WORKING_CLIENTS
24
  from core.models import DownloadRequest, SubtitleFormat, SubtitleLanguage, VideoQuality
25
  from core.anti_ban import anti_ban
26
  from core.cookie_manager import cookie_manager
 
32
  app = FastAPI(
33
  title="YouTube Downloader - Desktop",
34
  description="تطبيق تحميل فيديوهات يوتيوب مع الترجمات",
35
+ version="3.0.0",
36
  )
37
 
38
  # CORS
 
55
  ws_connections: list = []
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  @app.get("/")
59
  async def index():
60
  """الصفحة الرئيسية"""
 
64
  @app.get("/api/health")
65
  async def health():
66
  """فحص حالة الخادم"""
 
67
  return {
68
  "status": "ok",
69
+ "version": "3.0.0",
70
+ "working_client": downloader._working_client,
71
+ "failed_clients": downloader._failed_clients,
72
+ "request_count": anti_ban._request_count,
73
  }
74
 
75
 
76
  @app.get("/api/video/info")
77
  async def get_video_info(url: str):
78
+ """جلب معلومات الفيديو - yt-dlp أولاً (سريع)"""
79
  try:
80
+ info = await asyncio.wait_for(
81
+ downloader.fetch_video_info(url),
82
+ timeout=20,
83
+ )
84
  return {
85
  "title": info.title,
86
  "video_id": info.video_id,
 
99
  for sub in info.available_subtitles
100
  ],
101
  }
102
+ except asyncio.TimeoutError:
103
+ raise HTTPException(
104
+ status_code=408,
105
+ detail="انتهت مهلة الاتصال. يوتيوب لا يستجيب حالياً. حاول مرة أخرى بعد قليل."
106
+ )
107
  except Exception as e:
108
  error_msg = str(e)
 
109
  if "429" in error_msg or "Too Many" in error_msg:
110
  raise HTTPException(
111
  status_code=429,
112
+ detail="تم حظر الطلبات مؤقتاً من YouTube. انتظر بضع دقائق ثم حاول مرة أخرى."
113
  )
114
  elif "Video unavailable" in error_msg or "Private video" in error_msg:
115
  raise HTTPException(
116
  status_code=400,
117
  detail="الفيديو غير متاح أو خاص. تأكد من الرابط."
118
  )
119
+ elif "Sign in" in error_msg:
120
+ raise HTTPException(
121
+ status_code=403,
122
+ detail="يوتيوب يطلب تسجيل الدخول. جرب إضافة كوكيز أو حاول لاحقاً."
123
+ )
124
  else:
125
  raise HTTPException(status_code=400, detail=f"فشل جلب معلومات الفيديو: {error_msg}")
126
 
 
129
  async def start_download(request: DownloadRequest):
130
  """بدء التحميل الكامل (ترجمة + فيديو)"""
131
  try:
 
132
  if not anti_ban.check_session_limits():
133
  raise HTTPException(
134
  status_code=429,
 
165
  })
166
 
167
  except Exception as e:
168
+ error_msg = str(e)
169
+ # رسائل واضحة للمستخدم
170
+ if "429" in error_msg:
171
+ user_msg = "تم حظر الطلبات مؤقتاً. انتظر بضع دقائق."
172
+ elif "Video unavailable" in error_msg:
173
+ user_msg = "الفيديو غير متاح."
174
+ else:
175
+ user_msg = f"فشل التحميل: {error_msg}"
176
+
177
  await _broadcast_ws({
178
  "type": "download_error",
179
+ "error": user_msg,
180
  })
181
 
182
 
 
189
  ):
190
  """تحميل الترجمة فقط"""
191
  try:
192
+ subtitle_file = await asyncio.wait_for(
193
+ downloader.download_subtitle(
194
+ url=url,
195
+ language_code=lang.value,
196
+ subtitle_format=format.value,
197
+ auto_generated=auto,
198
+ ),
199
+ timeout=30,
200
  )
201
 
202
  if subtitle_file:
 
208
  else:
209
  raise HTTPException(status_code=404, detail="لم يتم العثور على ترجمة باللغة المطلوبة")
210
 
211
+ except asyncio.TimeoutError:
212
+ raise HTTPException(status_code=408, detail="انتهت مهلة تحميل الترجمة")
213
  except HTTPException:
214
  raise
215
  except Exception as e:
 
220
  async def download_video_only(url: str, quality: VideoQuality = VideoQuality.best):
221
  """تحميل الفيديو فقط"""
222
  try:
223
+ video_file = await asyncio.wait_for(
224
+ downloader.download_video(url=url, quality=quality.value),
225
+ timeout=300,
226
+ )
227
 
228
  if video_file:
229
  return {
 
232
  "message": "تم تحميل الفيديو بنجاح"
233
  }
234
  else:
235
+ raise HTTPException(status_code=400, detail="فشل تحميل الفيديو. جرب فيديو آخر أو حاول لاحقاً.")
236
 
237
+ except asyncio.TimeoutError:
238
+ raise HTTPException(status_code=408, detail="انتهت مهلة تحميل الفيديو. قد يكون الملف كبيراً جداً.")
239
  except HTTPException:
240
  raise
241
  except Exception as e:
 
286
 
287
  @app.get("/api/download/file")
288
  async def download_file(path: str):
289
+ """تحميل ملف"""
290
  if not os.path.exists(path):
291
  raise HTTPException(status_code=404, detail="الملف غير موجود")
292
 
 
310
  @app.get("/api/anti-ban/status")
311
  async def anti_ban_status():
312
  """حالة مضاد الحظر"""
 
 
313
  return {
314
  "request_count": anti_ban._request_count,
315
  "failed_attempts": anti_ban._failed_attempts,
316
  "session_active": anti_ban.check_session_limits(),
 
317
  "current_client": anti_ban.get_current_client(),
318
+ "working_client": downloader._working_client,
319
+ "failed_clients": downloader._failed_clients,
320
+ "cookies_active": cookie_manager.is_active(),
 
 
 
 
 
 
321
  }
322
 
323
 
 
325
  async def reset_anti_ban():
326
  """إعادة تعيين مضاد الحظر"""
327
  anti_ban.reset_session()
328
+ downloader._failed_clients.clear()
329
+ downloader._working_client = None
330
  return {"status": "reset", "message": "تم إعادة تعيين الجلسة"}
331
 
332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  @app.post("/api/cookies/set")
334
  async def set_cookies(data: dict):
335
+ """حفظ الكوكيز"""
336
  content = data.get("content", "")
337
  if not content:
338
  raise HTTPException(status_code=400, detail="المحتوى فارغ")
 
340
  success = cookie_manager.set_cookies(content)
341
  if success:
342
  return {"status": "ok", "message": "تم حفظ الكوكيز بنجاح", "info": cookie_manager.get_info()}
343
+ raise HTTPException(status_code=400, detail="فشل حفظ الكوكيز")
344
 
345
 
346
  @app.post("/api/cookies/upload")
 
357
 
358
  success = cookie_manager.upload_cookies(text)
359
  if success:
360
+ return {"status": "ok", "message": "تم رفع الكوكيز بنجاح"}
361
+ raise HTTPException(status_code=400, detail="فشل حفظ الكوكيز")
362
 
363
 
364
  @app.get("/api/cookies/status")
365
  async def cookies_status():
366
+ """حالة الكوكيز"""
367
  return cookie_manager.get_info()
368
 
369
 
 
382
  await websocket.accept()
383
  ws_connections.append(websocket)
384
 
 
385
  def on_progress(progress):
386
  asyncio.create_task(_send_progress_ws(websocket, progress))
387
 
 
436
  """تشغيل الخادم"""
437
  import uvicorn
438
  print("=" * 60)
439
+ print(" YouTube Downloader v3.0 - yt-dlp First")
440
  print(" تحميل فيديوهات يوتيوب مع الترجمات")
441
  print("=" * 60)
442
  print(f"\n Download Directory: {DOWNLOAD_DIR}")
443
+ print(f" Working Clients: {', '.join(WORKING_CLIENTS)}")
444
  print(f" Server: http://0.0.0.0:8555")
445
  print(f"\n Open your browser and go to: http://localhost:8555")
446
  print("=" * 60)