""" NAS Sharing API Module - FolderSharing API calls EXTRACTED từ download_link.py DSMSeleniumLogin methods """ import os import sys import time import requests import urllib3 import logging from typing import Dict, List, Any, Optional, Union, TYPE_CHECKING from urllib.parse import urlencode from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException # Disable SSL warnings urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # Setup logger logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) if TYPE_CHECKING: from ..aria2.download_manager import Aria2DownloadManager # aria2 integration USE_ARIA2 = os.getenv('USE_ARIA2', 'true').lower() == 'true' # None, False (unavailable), or Aria2DownloadManager _aria2_manager: Optional[Union[bool, "Aria2DownloadManager"]] = None def get_aria2_manager() -> "Aria2DownloadManager": """ Get or create aria2 manager instance for sharing downloads. Raises: RuntimeError: If aria2 is not available """ global _aria2_manager if _aria2_manager is None and USE_ARIA2: try: from ..aria2.download_manager import get_aria2_manager as _get_manager _aria2_manager = _get_manager() if not _aria2_manager: raise RuntimeError("aria2 manager returned None") logger.debug("✅ aria2 manager initialized for Sharing downloads") except Exception as e: raise RuntimeError(f"aria2 is required but not available: {e}") if _aria2_manager is False or _aria2_manager is None: raise RuntimeError("aria2 is required but not initialized") return _aria2_manager # type: ignore def get_file_list( driver: webdriver.Chrome, sharing_id: str, folder_path: str = "/" ) -> List[Dict[str, Any]]: """ Lấy danh sách file/folder từ sharing link qua FolderSharing.List API Args: driver: Selenium WebDriver có cookies hợp lệ sharing_id: Sharing ID (trích xuất từ URL) folder_path: Đường dẫn folder cần list (mặc định: "/") Returns: List of file/folder dicts với các key: - name: Tên file/folder - is_folder: True nếu là folder - size: Chuỗi size đã format - size_bytes: Size tính bằng bytes - path: Đường dẫn đầy đủ - additional: Metadata bổ sung từ API Raises: RuntimeError: Khi session hết hạn (error 101) hoặc lỗi API khác """ # Tạo session với cookies từ Selenium session = requests.Session() for cookie in driver.get_cookies(): session.cookies.set( cookie['name'], cookie['value'], domain=cookie['domain']) # Endpoint FolderSharing.List API url = "https://disk.lezhin.com:5001/sharing/webapi/entry.cgi" params = { 'api': 'SYNO.FolderSharing.List', 'method': 'list', 'version': '2', 'offset': '0', 'limit': '1000', 'sort_by': '"name"', 'sort_direction': '"ASC"', 'action': '"enum"', 'additional': '["size","owner","time","perm","type","mount_point_type"]', 'filetype': '"all"', 'folder_path': f'"{folder_path}"', '_sharing_id': f'"{sharing_id}"' } # Log với context rõ ràng hơn import inspect caller = inspect.stack()[1].function if len( inspect.stack()) > 1 else "unknown" print(f"\n🔍 [{caller}] Lấy danh sách (FolderSharing API): {folder_path}") # NO RETRY - Throw error ngay để phát hiện vấn đề sớm try: response = session.post(url, data=params, verify=False, timeout=30) result = response.json() # Kiểm tra response if not result: raise RuntimeError("API không trả về dữ liệu") # Kiểm tra success if not result.get("success"): error_code = result.get('error', {}).get('code') error_detail = result.get('error', {}) print(f"❌ API lỗi {error_code}: {error_detail}") # Error 101: Session hết hạn if error_code == 101: raise RuntimeError("SESSION_EXPIRED") # Error 407: Rate limit - KHÔNG RETRY, throw ngay # (Frontend có debounce 300ms + Backend có rate limit 500ms) if error_code == 407: raise RuntimeError(f"API_ERROR_407_RATE_LIMIT: {error_detail}") # Các lỗi API khác raise RuntimeError(f"API_ERROR_{error_code}: {error_detail}") # Parse file list files = result['data']['files'] print(f"✅ Thành công! Tìm thấy {len(files)} item(s).") # Format file list formatted = [] for f in files: is_folder = f.get('isdir', False) # Lấy size (chỉ cho files, không có cho folders) size_bytes = 0 if not is_folder and f.get('additional') and f['additional'].get('size'): size_bytes = f['additional']['size'] # Format size string (rỗng cho folders) size_str = "" if is_folder else _format_size(size_bytes) formatted.append({ 'name': f.get('name', ''), 'is_folder': is_folder, 'size': size_str, 'size_bytes': size_bytes, 'path': f.get('path', ''), 'additional': f.get('additional', {}) }) return formatted except RuntimeError: # RuntimeError (SESSION_EXPIRED, API_ERROR_xxx) → raise ngay raise except requests.exceptions.Timeout as e: # Network timeout - KHÔNG RETRY, throw ngay print(f"❌ Timeout khi gọi API: {e}") raise RuntimeError(f"API_TIMEOUT: {e}") from e except requests.exceptions.RequestException as e: # Network/Request errors - KHÔNG RETRY, throw ngay print(f"❌ Network error khi gọi API: {e}") raise RuntimeError(f"API_NETWORK_ERROR: {e}") from e def encode_path_to_dlink(path: str) -> str: """ Encode path to dlink token (hex encoding) EXACT COPY từ download_link.py DSMSeleniumLogin.encode_path_to_dlink() Args: path: File path, e.g., "/수조(북극여우)/001화_PSD_JPG.zip" Returns: Hex-encoded path string for dlink parameter """ # Encode path to bytes (UTF-8) then convert to hex path_bytes = path.encode('utf-8') dlink_hex = path_bytes.hex() return dlink_hex def download_file_direct( driver: webdriver.Chrome, sharing_id: str, remote_path: str, is_dir: bool = False, save_path: Optional[str] = None, progress_callback=None ) -> bool: """ ⚠️ DEPRECATED - DO NOT USE This function uses requests library instead of aria2. All downloads MUST use aria2 for parallel connections. Use prepare_download_url() + aria2_manager.download_file() instead. Kept for reference only - will be removed in future versions. """ raise NotImplementedError( "download_file_direct() is deprecated. " "Use prepare_download_url() + aria2_manager.download_file() instead. " "All downloads MUST use aria2." ) # Old implementation removed - see git history if needed if not save_path: raise ValueError("save_path is required") def prepare_download_url( driver: webdriver.Chrome, sharing_id: str, remote_path: str, file_name: str ) -> tuple[str, str]: """ Extract cookies and build download URL from Selenium driver. This function MUST be called with driver_lock held. Returns URL and cookies that can be used for aria2 download WITHOUT driver. Args: driver: Selenium WebDriver with valid cookies sharing_id: Sharing ID remote_path: File/folder path in NAS file_name: Filename for URL path Returns: (download_url, cookie_string) tuple """ # Extract cookies from Selenium cookie_string = "; ".join([ f"{c['name']}={c['value']}" for c in driver.get_cookies() ]) # Convert path to dlink (hex-encoded) for BOTH files and folders dlink = encode_path_to_dlink(remote_path) # Build GET request URL (same for files and folders) url = f"https://disk.lezhin.com:5001/fsdownload/webapi/file_download.cgi/{file_name}" params = { 'dlink': f'"{dlink}"', 'noCache': str(int(time.time() * 1000)), '_sharing_id': f'"{sharing_id}"', 'api': 'SYNO.FolderSharing.Download', 'version': '2', 'method': 'download', 'mode': 'download', 'stdhtml': 'false' } download_url = f"{url}?{urlencode(params)}" return download_url, cookie_string def validate_download_link( download_url: str, cookie_string: str, timeout: int = 10 ) -> tuple[bool, Optional[str], Optional[int]]: """ Validate download link bằng HEAD request để phát hiện link chết TRƯỚC KHI tải. Giải pháp cho vấn đề: Files pending lâu → Link expire → Download 38B HTML error page Args: download_url: URL download đã build từ prepare_download_url() cookie_string: Cookie string từ prepare_download_url() timeout: Timeout cho HEAD request (giây) Returns: (is_valid, error_message, content_length) tuple: - is_valid: True nếu link OK, False nếu chết - error_message: None nếu OK, error string nếu failed - content_length: File size (bytes) nếu có, None nếu không xác định Raises: RuntimeError: Nếu có lỗi network/timeout """ import requests try: # Prepare headers headers = { 'Cookie': cookie_string, 'Referer': 'https://disk.lezhin.com:5001/', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } logger.debug( f"[validate_link] Sending HEAD request to validate link...") # Send HEAD request (lightweight, chỉ lấy headers) response = requests.head( download_url, headers=headers, timeout=timeout, verify=False, # NAS self-signed cert allow_redirects=True ) # Check status code if response.status_code == 200: # Link OK! content_length = response.headers.get('Content-Length') size_bytes = int(content_length) if content_length else None # Check Content-Type để phát hiện HTML error page content_type = response.headers.get('Content-Type', '') if 'text/html' in content_type.lower(): # NAS trả về HTML thay vì file → Link có vấn đề error_msg = f"Link trả về HTML (possibly expired or error page). Content-Type: {content_type}" logger.warning(f"[validate_link] ❌ {error_msg}") return False, error_msg, None # Link hợp lệ size_str = f"{size_bytes:,} bytes" if size_bytes else "unknown size" logger.debug(f"[validate_link] ✅ Link valid ({size_str})") return True, None, size_bytes elif response.status_code == 401: # Unauthorized → Session expired error_msg = "Session expired (401 Unauthorized)" logger.warning(f"[validate_link] ❌ {error_msg}") return False, error_msg, None elif response.status_code == 403: # Forbidden → Permission denied error_msg = "Permission denied (403 Forbidden)" logger.warning(f"[validate_link] ❌ {error_msg}") return False, error_msg, None elif response.status_code == 404: # Not found → File không tồn tại error_msg = "File not found (404)" logger.warning(f"[validate_link] ❌ {error_msg}") return False, error_msg, None else: # Unexpected status code error_msg = f"Unexpected HTTP status: {response.status_code}" logger.warning(f"[validate_link] ⚠️ {error_msg}") return False, error_msg, None except requests.exceptions.Timeout as e: error_msg = f"Timeout after {timeout}s: {e}" logger.error(f"[validate_link] ❌ {error_msg}") raise RuntimeError(error_msg) from e except requests.exceptions.RequestException as e: error_msg = f"Network error: {e}" logger.error(f"[validate_link] ❌ {error_msg}") raise RuntimeError(error_msg) from e except Exception as e: error_msg = f"Unexpected error: {e}" logger.error(f"[validate_link] ❌ {error_msg}") raise RuntimeError(error_msg) from e def get_initial_path(driver: webdriver.Chrome) -> str: """ Lấy folder path hiện tại từ thanh điều hướng (path bar) trên trang sharing. Workflow: 1. Chờ path bar xuất hiện (tối đa 15s) 2. Nếu timeout → Check login dialog 3. Nếu có login dialog → raise NEEDS_LOGIN 4. Nếu không có cả 2 → raise error Args: driver: Selenium WebDriver đang ở trang sharing Returns: Path của folder hiện tại từ thanh điều hướng Raises: RuntimeError: - "NEEDS_LOGIN" nếu phát hiện login dialog - Error khác nếu không tìm thấy path bar """ if not driver: raise RuntimeError("Driver không tồn tại") # BƯỚC 1: Chờ folder path bar xuất hiện (tối đa 15s) print("🔍 Đang chờ folder path bar xuất hiện (timeout 15s)...") try: path_btn = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, "li table.x-btn button.x-btn-text[aria-label]")) ) folder_name = path_btn.get_attribute('aria-label') if folder_name: path = f"/{folder_name}" print(f"✅ Phát hiện folder path: {path}") return path # aria-label rỗng → fallback check login print("⚠️ Path bar không có aria-label, kiểm tra login dialog...") except TimeoutException: # Timeout 15s - không tìm thấy path bar print("⚠️ Timeout: Không tìm thấy folder path bar sau 15 giây") # BƯỚC 2: Path bar không có → Check login dialog print("🔍 Kiểm tra login dialog...") try: login_dialog = driver.find_element( By.CSS_SELECTOR, "div#webfm-access-dialog") if login_dialog and login_dialog.is_displayed(): print("⚠️ Phát hiện login dialog - cần đăng nhập File Station") raise RuntimeError("NEEDS_LOGIN") except Exception as e: # Không tìm thấy login dialog hoặc lỗi khác if "NEEDS_LOGIN" in str(e): raise print(f"⚠️ Không tìm thấy login dialog: {e}") # BƯỚC 3: Không có path bar và không có login dialog → THROW ERROR current_url = driver.current_url if driver else "unknown" raise RuntimeError( f"Không tìm thấy folder path bar trên trang sharing sau 15 giây. " f"URL: {current_url}" ) def extract_sharing_id(url: str) -> Optional[str]: """ Extract sharing ID from sharing link URL Args: url: Sharing link URL (e.g., "https://disk.lezhin.com:5001/sharing/ABC123/...") Returns: Sharing ID string, or None if invalid format """ try: parts = url.split('/sharing/') if len(parts) < 2: return None sharing_id = parts[1].split('/')[0].split('?')[0] return sharing_id except Exception as e: print(f"❌ Lỗi extract_sharing_id: {e}") return None def _format_size(size_bytes: int) -> str: """ Helper: Format bytes to human-readable size Args: size_bytes: Size in bytes Returns: Formatted string (e.g., "1.5 MB") """ if size_bytes == 0: return "" elif size_bytes < 1024: return f"{size_bytes} B" elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.2f} KB" elif size_bytes < 1024 * 1024 * 1024: return f"{size_bytes / (1024 * 1024):.2f} MB" else: return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"