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+
- Dockerfile +12 -6
- core/anti_ban.py +55 -214
- core/downloader.py +258 -331
- core/fallback_downloader.py +140 -525
- 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 |
-
#
|
| 20 |
RUN mkdir -p /tmp/downloads && chmod 777 /tmp/downloads
|
| 21 |
|
| 22 |
-
#
|
| 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 |
-
#
|
| 28 |
COPY . .
|
| 29 |
|
| 30 |
-
#
|
| 31 |
EXPOSE 8555
|
| 32 |
|
| 33 |
-
#
|
| 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
|
| 4 |
|
| 5 |
-
ي
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
import random
|
|
@@ -17,246 +19,127 @@ from .cookie_manager import cookie_manager
|
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
| 19 |
|
| 20 |
-
#
|
|
|
|
|
|
|
|
|
|
| 21 |
USER_AGENTS = [
|
| 22 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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.
|
| 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.
|
| 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 |
-
# ن
|
| 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 |
-
|
| 127 |
-
old_ua = self._current_user_agent
|
| 128 |
new_ua = random.choice(USER_AGENTS)
|
| 129 |
attempts = 0
|
| 130 |
-
while new_ua ==
|
| 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
|
| 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 |
-
|
| 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
|
| 247 |
-
|
| 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
|
| 282 |
client = self.get_current_client()
|
| 283 |
-
has_cookies =
|
| 284 |
-
logger.info(f"Using YouTube client: {client}
|
| 285 |
|
| 286 |
opts = {
|
| 287 |
-
"http_headers": self.
|
| 288 |
-
"extractor_retries":
|
| 289 |
-
"file_access_retries":
|
| 290 |
-
"fragment_retries":
|
| 291 |
-
"socket_timeout":
|
|
|
|
| 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
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
| 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
|
| 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
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
1.
|
| 7 |
-
2.
|
| 8 |
-
3.
|
| 9 |
-
4.
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
محمل فيديوهات يوتيوب -
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
-
|
| 94 |
-
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
os.makedirs(download_dir, exist_ok=True)
|
| 107 |
|
|
@@ -130,87 +149,36 @@ class YouTubeDownloader:
|
|
| 130 |
except Exception:
|
| 131 |
pass
|
| 132 |
|
| 133 |
-
def
|
| 134 |
-
"""
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 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 |
-
جلب معلومات الفيديو -
|
| 173 |
"""
|
| 174 |
self._cancelled = False
|
| 175 |
self._update_progress(
|
| 176 |
status=DownloadStatus.FETCHING_INFO,
|
| 177 |
-
message="جاري جلب معلومات الفيديو
|
| 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 |
-
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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="تم جلب المعلومات بنجاح
|
| 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 !=
|
| 293 |
-
|
|
|
|
| 294 |
await asyncio.sleep(wait)
|
| 295 |
continue
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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})
|
| 318 |
percent=0,
|
| 319 |
-
source="
|
| 320 |
)
|
| 321 |
|
| 322 |
if self._cancelled:
|
| 323 |
return None
|
| 324 |
|
| 325 |
-
|
| 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 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
|
| 338 |
-
|
| 339 |
-
|
| 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 |
-
|
| 349 |
-
self._update_progress(
|
| 350 |
-
message="Invidious فشل للترجمة، جاري المحاولة بـ yt-dlp...",
|
| 351 |
-
source="yt_dlp",
|
| 352 |
-
)
|
| 353 |
|
| 354 |
-
|
|
|
|
| 355 |
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 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 |
-
|
| 387 |
-
status=DownloadStatus.DOWNLOADING_SUBTITLE,
|
| 388 |
-
percent=100,
|
| 389 |
-
message="تم تحميل الترجمة من yt-dlp",
|
| 390 |
-
)
|
| 391 |
-
anti_ban.report_success()
|
| 392 |
|
| 393 |
-
|
|
|
|
|
|
|
| 394 |
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
| 398 |
|
| 399 |
-
|
|
|
|
| 400 |
|
| 401 |
-
|
| 402 |
-
with open(final_path, 'w', encoding='utf-8') as f:
|
| 403 |
-
f.write(content)
|
| 404 |
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
pass
|
| 409 |
|
| 410 |
-
|
| 411 |
|
| 412 |
-
|
|
|
|
|
|
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
)
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
تحميل الفيديو -
|
| 432 |
"""
|
| 433 |
self._update_progress(
|
| 434 |
status=DownloadStatus.DOWNLOADING_VIDEO,
|
| 435 |
-
message="جاري تحميل الفيديو
|
| 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 |
-
|
| 468 |
-
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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="تم تحميل الفيديو بنجاح
|
| 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 !=
|
| 625 |
-
wait = random.uniform(
|
| 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"جاري التحميل
|
| 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
|
| 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_embedded → android_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
|
| 3 |
-
تم
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 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
|
| 22 |
|
| 23 |
import httpx
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
| 27 |
|
| 28 |
-
#
|
| 29 |
-
|
| 30 |
-
# ═══════════════════════════════════════════════════
|
| 31 |
-
|
| 32 |
-
# سيرفرات أولية ثابتة (مختبرة وفعّالة)
|
| 33 |
-
SEED_INVIDIOUS_INSTANCES = [
|
| 34 |
"https://inv.thepixora.com",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
]
|
| 36 |
|
| 37 |
-
#
|
| 38 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
for instance in instances:
|
| 213 |
try:
|
| 214 |
-
async with httpx.AsyncClient(timeout=
|
| 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 |
-
|
| 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.
|
| 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 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 363 |
|
| 364 |
for instance in instances:
|
| 365 |
try:
|
| 366 |
-
async with httpx.AsyncClient(timeout=
|
| 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
|
| 388 |
-
logger.warning(f"Invidious download error on {instance}: {e}")
|
| 389 |
continue
|
| 390 |
|
| 391 |
return None
|
| 392 |
|
| 393 |
-
def
|
| 394 |
-
|
| 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 |
-
#
|
| 403 |
-
# هذه تحتوي على 360p عادةً فقط، لكنها جاهزة للتحميل مباشرة
|
| 404 |
if format_streams:
|
| 405 |
quality_order = {
|
| 406 |
-
"best": ["
|
| 407 |
-
"medium": ["
|
| 408 |
-
"low": ["
|
| 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 |
-
#
|
| 439 |
if adaptive_formats:
|
| 440 |
-
|
|
|
|
| 441 |
|
| 442 |
-
|
|
|
|
|
|
|
| 443 |
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 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 |
-
|
| 477 |
-
max_height = quality_height_map.get(quality, 2160)
|
| 478 |
|
| 479 |
-
|
| 480 |
-
|
| 481 |
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 555 |
break
|
| 556 |
|
| 557 |
-
|
| 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 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 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
|
| 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}
|
| 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 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
|
|
|
| 716 |
return output_path
|
| 717 |
-
|
| 718 |
-
|
| 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 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 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="
|
| 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": "
|
| 83 |
-
"
|
| 84 |
-
"
|
|
|
|
| 85 |
}
|
| 86 |
|
| 87 |
|
| 88 |
@app.get("/api/video/info")
|
| 89 |
async def get_video_info(url: str):
|
| 90 |
-
"""جلب معلومات الفيديو"""
|
| 91 |
try:
|
| 92 |
-
info = await
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 172 |
})
|
| 173 |
|
| 174 |
|
|
@@ -181,11 +189,14 @@ async def download_subtitle_only(
|
|
| 181 |
):
|
| 182 |
"""تحميل الترجمة فقط"""
|
| 183 |
try:
|
| 184 |
-
subtitle_file = await
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 304 |
-
"
|
| 305 |
-
"
|
| 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": "تم رفع الكوكيز بنجاح"
|
| 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
|
| 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)
|