import os import zipfile import requests import cloudscraper import aiohttp import uuid import shutil import asyncio import json import time from config import Config from google.oauth2 import service_account from googleapiclient.discovery import build from googleapiclient.http import MediaFileUpload from googleapiclient.errors import HttpError from providers.manager import ProviderManager from smart_stitch import smart_stitch_from_zip DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive"] def _get_sa_creds(): """يُنشئ Service Account credentials من متغير البيئة.""" raw_json = Config.GOOGLE_SERVICE_ACCOUNT_JSON if not raw_json: raise Exception("GOOGLE_SERVICE_ACCOUNT_JSON غير محدد في متغيرات البيئة") raw_json = raw_json.strip() if raw_json.startswith("'") and raw_json.endswith("'"): raw_json = raw_json[1:-1] info = json.loads(raw_json) if "private_key" in info: info["private_key"] = info["private_key"].replace("\\n", "\n") return service_account.Credentials.from_service_account_info(info, scopes=DRIVE_SCOPES) def get_drive_service(): """يُنشئ Google Drive service باستخدام Service Account.""" creds = _get_sa_creds() return build('drive', 'v3', credentials=creds) class MangaDownloader: def __init__(self): self.provider_manager = ProviderManager() self.scraper = self.provider_manager.generic.scraper self.temp_dir = "temp_downloads" os.makedirs(self.temp_dir, exist_ok=True) # ── شريط التقدم ─────────────────────────────────────────────────────── @staticmethod def create_progress_bar(current, total, length=15, style="modern"): styles = { "modern": ("▰", "▱", "", ""), "dots": ("●", "○", "", ""), "square": ("■", "□", "", ""), "classic": ("#", "-", "[", "]"), } fill, empty, pre, suf = styles.get(style, styles["modern"]) if total <= 0: return f"{pre}{empty * length}{suf} 0%" pct = max(0.0, min(1.0, float(current) / float(total))) filled = int(round(pct * length)) return f"{pre}{fill * filled}{empty * (length - filled)}{suf} {int(round(pct * 100))}%" # ── تحميل فصل ───────────────────────────────────────────────────────── async def download_chapter(self, url: str, chapter_title: str, progress_callback=None, **kwargs): loop = asyncio.get_event_loop() img_urls = await self.provider_manager.get_images(url) if not img_urls: return None job_id = str(uuid.uuid4())[:8] job_dir = os.path.join(self.temp_dir, job_id) os.makedirs(job_dir) downloaded_files = [] completed = 0 def download_single(idx, img_url): try: headers = { "Referer": url, "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } r = self.scraper.get(img_url, stream=True, timeout=30, headers=headers) if r.status_code == 200: raw = r.content ext = img_url.split('.')[-1].split('?')[0][:4] if not ext or "/" in ext: ext = 'jpg' fp = os.path.join(job_dir, f"{idx:03d}.{ext}") with open(fp, 'wb') as f: f.write(raw) return fp except Exception as e: print(f"Image {idx} failed: {e}") return None sem = asyncio.Semaphore(5) # تقليل العدد لتجنب حرق الموارد async def dl_limited(idx, u): async with sem: await asyncio.sleep(0.1) # تأخير بسيط لتقليل الضغط return await loop.run_in_executor(None, download_single, idx, u) tasks = [dl_limited(i, u) for i, u in enumerate(img_urls)] for task in asyncio.as_completed(tasks): fp = await task if fp: downloaded_files.append(fp) completed += 1 if progress_callback and (completed % 2 == 0 or completed == len(img_urls)): await progress_callback(completed, len(img_urls), "📥 تحميل الصور") if not downloaded_files: shutil.rmtree(job_dir) return None downloaded_files.sort() zip_name = f"{chapter_title.replace(' ', '_')}_{job_id}.zip" zip_path = os.path.join(self.temp_dir, zip_name) with zipfile.ZipFile(zip_path, 'w', compression=zipfile.ZIP_DEFLATED) as zf: for f in downloaded_files: zf.write(f, os.path.basename(f)) shutil.rmtree(job_dir) return zip_path # ── SmartStitch ──────────────────────────────────────────────────────── async def download_and_stitch( self, url: str, chapter_title: str, target_height: int = 14500, target_width: int = 800, sensitivity: int = 90, progress_callback=None, upload_dest: str = "Auto", folder_id: str = None, **_ ) -> dict | None: """ يُرجع قاموساً يحتوي على الرابط والنوع (drive_folder, gofile, catbox, local_zip) """ loop = asyncio.get_event_loop() raw_zip = await self.download_chapter(url, chapter_title, progress_callback=progress_callback) if not raw_zip: return None if progress_callback: await progress_callback(0, 1, "🪡 دمج الصور (SmartStitch)...") stitch_dir = os.path.join(self.temp_dir, f"stitched_{uuid.uuid4().hex[:8]}") safe_title = chapter_title.replace(" ", "_") def run_stitch(): return smart_stitch_from_zip( zip_path=raw_zip, output_dir=stitch_dir, chapter_name=safe_title, target_height=target_height, target_width=target_width, sensitivity=sensitivity, output_format="jpg", output_quality=95, ) stitched_files = await loop.run_in_executor(None, run_stitch) self.cleanup(raw_zip) if not stitched_files: shutil.rmtree(stitch_dir, ignore_errors=True) return None # ── الترتيب للرفع ────────────────────────────────────────────────── # إنشاء ملف ZIP محلي final_zip = os.path.join(self.temp_dir, f"{safe_title}_stitched_{uuid.uuid4().hex[:8]}.zip") with zipfile.ZipFile(final_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf: for f in stitched_files: zf.write(f, os.path.basename(f)) if upload_dest == "Local": shutil.rmtree(stitch_dir, ignore_errors=True) return {"link": final_zip, "type": "local_zip"} # 1. محاولة Google Drive أولاً (الأولوية) if upload_dest in ("Auto", "Drive") and Config.GOOGLE_DRIVE_FOLDER_ID: try: if progress_callback: await progress_callback(90, 100, "📂 رفع إلى Google Drive...") drive_link = await self.upload_stitched_to_gdrive(stitched_files, safe_title, progress_callback, parent_folder_id=folder_id) if drive_link: self.cleanup(final_zip) shutil.rmtree(stitch_dir, ignore_errors=True) return {"link": drive_link, "type": "drive_folder"} except Exception as e: print(f"Drive upload failed, falling back: {e}") # 2. محاولة Gofile if upload_dest in ("Auto", "Gofile"): try: if progress_callback: p_msg = "☁️ رفع إلى Gofile..." if upload_dest == "Gofile" else "☁️ Drive فشل، محاولة Gofile..." await progress_callback(95, 100, p_msg) link = await self.upload_to_gofile(final_zip, progress_callback=progress_callback, folder_id=folder_id) if link: shutil.rmtree(stitch_dir, ignore_errors=True) self.cleanup(final_zip) return {"link": link, "type": "gofile"} except Exception: pass # 3. محاولة Catbox if upload_dest in ("Auto", "Catbox"): try: if progress_callback: p_msg = "📦 محاولة Catbox..." if upload_dest == "Catbox" else "📦 فشل السابق، محاولة Catbox..." await progress_callback(98, 100, p_msg) link = await self.upload_to_catbox(final_zip) if link: shutil.rmtree(stitch_dir, ignore_errors=True) self.cleanup(final_zip) return {"link": link, "type": "catbox"} except Exception: pass # تنظيف في حال الفشل التام أو طلب غير مدعوم shutil.rmtree(stitch_dir, ignore_errors=True) return {"link": final_zip, "type": "local_zip"} # ── رفع Gofile ──────────────────────────────────────────────────────── async def create_gofile_folder(self, folder_name: str): """ينشئ مجلداً في Gofile داخل rootFolder للحساب.""" if not Config.GOFILE_TOKEN: return None try: async with aiohttp.ClientSession() as s: hdrs = {"Authorization": f"Bearer {Config.GOFILE_TOKEN}"} # 1. الحصول على accountId account_id = None async with s.get("https://api.gofile.io/accounts/getid", headers=hdrs) as r: if r.status == 200: acc_id_data = await r.json() account_id = acc_id_data.get("data", {}).get("id") if not account_id: print("Gofile: فشل الحصول على accountId") return None # 2. الحصول على rootFolder id root_id = None async with s.get(f"https://api.gofile.io/accounts/{account_id}", headers=hdrs) as r: if r.status == 200: acc_data = await r.json() root_id = acc_data.get("data", {}).get("rootFolder") if not root_id: print("Gofile: فشل الحصول على rootFolder") return None # 3. إنشاء المجلد داخل rootFolder data = {"folderName": folder_name, "parentFolderId": root_id} async with s.post("https://api.gofile.io/contents/createFolder", json=data, headers=hdrs) as r: if r.status == 200: res = await r.json() if res.get("status") == "ok": return res.get("data", {}) else: print(f"Gofile Folder Creation failed: HTTP {r.status}") except Exception as e: print(f"Gofile Folder Creation Error: {e}") return None async def upload_to_gofile( self, file_path: str, progress_callback=None, folder_id: str = None, remote_filename: str = None, ): async def _upload(): try: if progress_callback: await progress_callback(0, 100, "☁️ جلب سيرفر Gofile...") server = "store1" async with aiohttp.ClientSession() as s: async with s.get("https://api.gofile.io/getServer") as r: if r.status == 200: srv_data = await r.json() server = srv_data.get("data", {}).get("server") or "store1" if progress_callback: await progress_callback(10, 100, f"☁️ رفع إلى Gofile ({server})") filename = remote_filename or os.path.basename(file_path) data = aiohttp.FormData() data.add_field("file", open(file_path, "rb"), filename=filename) if folder_id: data.add_field('folderId', folder_id) hdrs = {} if Config.GOFILE_TOKEN: hdrs["Authorization"] = f"Bearer {Config.GOFILE_TOKEN}" async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=1800)) as s: async with s.post(f"https://{server}.gofile.io/uploadFile", data=data, headers=hdrs) as r: txt = await r.text() if r.status == 200: pl = json.loads(txt) if pl.get("status") in ("ok", True): d = pl.get("data", {}) link = d.get("downloadPage") or d.get("pageLink") or d.get("directLink") or d.get("link") if link: return link return None except Exception as e: print(f"Gofile error: {e}") return None for attempt in range(3): link = await _upload() if link: return link await asyncio.sleep(5) return None # ── رفع Catbox (بديل مجاني بلا حساب) ──────────────────────────────── async def upload_to_catbox(self, file_path: str, progress_callback=None): """رفع إلى catbox.moe — مجاني 200MB max.""" try: if progress_callback: await progress_callback(0, 100, "☁️ رفع إلى Catbox") filename = os.path.basename(file_path) async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=600)) as s: with open(file_path, "rb") as f: data = aiohttp.FormData() data.add_field("reqtype", "fileupload") data.add_field("fileToUpload", f, filename=filename, content_type="application/zip") async with s.post("https://catbox.moe/user/api.php", data=data) as r: text = await r.text() if r.status == 200 and text.startswith("https://"): if progress_callback: await progress_callback(100, 100, "☁️ رفع إلى Catbox") return text.strip() print(f"Catbox error: {r.status} {text[:200]}") return None except Exception as e: print(f"Catbox error: {e}") return None # ── رفع Google Drive ────────────────────────────────────────────────── async def create_gdrive_folder(self, folder_name: str, parent_id: str = None): loop = asyncio.get_event_loop() def _create(): try: service = get_drive_service() p_id = parent_id or Config.GOOGLE_DRIVE_FOLDER_ID # Get driveId if parent is in a shared drive p_meta = service.files().get(fileId=p_id, fields="driveId", supportsAllDrives=True).execute() drive_id = p_meta.get("driveId") file_meta = {'name': folder_name, 'mimeType': 'application/vnd.google-apps.folder', 'parents': [p_id]} if drive_id: file_meta['driveId'] = drive_id f = service.files().create(body=file_meta, fields='id,webViewLink', supportsAllDrives=True).execute() # Make public service.permissions().create(fileId=f['id'], body={'type': 'anyone', 'role': 'reader'}, supportsAllDrives=True).execute() return f except Exception as e: print(f"Drive Create Folder Error: {e}") return None return await loop.run_in_executor(None, _create) async def upload_to_gdrive(self, file_path: str, filename: str, progress_callback=None, parent_folder_id: str = None): loop = asyncio.get_event_loop() def _upload(): try: p_id = parent_folder_id or Config.GOOGLE_DRIVE_FOLDER_ID if not p_id: return None service = get_drive_service() p_meta = service.files().get(fileId=p_id, fields="id,name,driveId", supportsAllDrives=True).execute() drive_id = p_meta.get("driveId") file_meta = {'name': filename, 'parents': [p_id]} kwargs = dict(body=file_meta, media_body=MediaFileUpload(file_path, resumable=True, chunksize=5*1024*1024), fields='id,webViewLink,webContentLink', supportsAllDrives=True) if drive_id: kwargs['driveId'] = drive_id req = service.files().create(**kwargs) response = None while response is None: status, response = req.next_chunk() if status and progress_callback: asyncio.run_coroutine_threadsafe( progress_callback(int(status.progress() * 100), 100, "☁️ رفع إلى Google Drive"), loop ) file_id = response.get('id') service.permissions().create( fileId=file_id, body={'type': 'anyone', 'role': 'reader'}, supportsAllDrives=True ).execute() if progress_callback: asyncio.run_coroutine_threadsafe( progress_callback(100, 100, "☁️ رفع إلى Google Drive"), loop ) return (response.get('webViewLink') or response.get('webContentLink') or f"https://drive.google.com/file/d/{file_id}/view?usp=sharing") except HttpError as e: msg = str(e) if "storageQuotaExceeded" in msg: sa_email = json.loads(Config.GOOGLE_SERVICE_ACCOUNT_JSON).get("client_email", "SA") print(f"❌ Drive: تجاوز الحصة — يرجى إنشاء Shared Drive ومشاركته مع: {sa_email}") elif "403" in msg or "forbidden" in msg.lower(): print(f"❌ Drive: صلاحيات غير كافية — {e}") else: print(f"❌ Drive HTTP Error: {e}") return None except Exception as e: print(f"❌ Drive error: {e}") return None for attempt in range(2): link = await loop.run_in_executor(None, _upload) if link: return link await asyncio.sleep(3) return None # ── رفع مجلد صور مدمجة إلى Google Drive ────────────────────────────── async def upload_stitched_to_gdrive(self, file_paths: list, folder_name: str, progress_callback=None, parent_folder_id: str = None): loop = asyncio.get_event_loop() def _upload_all(): try: service = get_drive_service() # 1. إنشاء المجلد p_id = parent_folder_id or Config.GOOGLE_DRIVE_FOLDER_ID file_meta = { 'name': folder_name, 'mimeType': 'application/vnd.google-apps.folder', 'parents': [p_id] } # فحص Shared Drive parent_meta = service.files().get( fileId=p_id, fields="driveId", supportsAllDrives=True ).execute() drive_id = parent_meta.get("driveId") if drive_id: file_meta['driveId'] = drive_id folder = service.files().create( body=file_meta, fields='id,webViewLink', supportsAllDrives=True ).execute() folder_id = folder.get('id') # 2. جعل المجلد عاماً service.permissions().create( fileId=folder_id, body={'type': 'anyone', 'role': 'reader'}, supportsAllDrives=True ).execute() # 3. رفع الصور داخل المجلد total = len(file_paths) for i, fp in enumerate(file_paths): fname = os.path.basename(fp) m_meta = {'name': fname, 'parents': [folder_id]} media = MediaFileUpload(fp, mimetype='image/jpeg') service.files().create( body=m_meta, media_body=media, supportsAllDrives=True ).execute() if progress_callback: asyncio.run_coroutine_threadsafe( progress_callback(i + 1, total, f"📤 رفع قطعة {i+1}/{total} إلى Drive"), loop ) return folder.get('webViewLink') or f"https://drive.google.com/drive/folders/{folder_id}" except Exception as e: print(f"❌ upload_stitched_to_gdrive error: {e}") return None return await loop.run_in_executor(None, _upload_all) # ── تنظيف ───────────────────────────────────────────────────────────── def cleanup(self, file_path: str): try: if file_path and os.path.exists(file_path): os.remove(file_path) except Exception as e: print(f"Cleanup error: {e}")