""" Easynews Client Module Provides a client for interacting with the Easynews API to search for and download files. All connections use HTTPS with HTTP Basic Auth. """ import re from dataclasses import dataclass from datetime import datetime from typing import Any, Callable, Dict, List, Optional from urllib.parse import quote, urljoin import requests from requests.auth import HTTPBasicAuth from modules.universal_logger import get_logger logger = get_logger('EasynewsClient') @dataclass class EasynewsResult: """Represents a single search result from Easynews.""" filename: str download_url: str size_bytes: int post_date: Optional[str] subject: Optional[str] poster: Optional[str] newsgroup: Optional[str] extension: Optional[str] def to_dict(self) -> Dict[str, Any]: return { 'filename': self.filename, 'download_url': self.download_url, 'size_bytes': self.size_bytes, 'post_date': self.post_date, 'subject': self.subject, 'poster': self.poster, 'newsgroup': self.newsgroup, 'extension': self.extension, } class EasynewsClient: """ Client for interacting with Easynews search and download APIs. All connections use HTTPS with HTTP Basic Auth. Supports HTTP, HTTPS, SOCKS4, and SOCKS5 proxies. """ BASE_URL = "https://members.easynews.com" SEARCH_URL = "https://members.easynews.com/2.0/search/solr-search/advanced" # Quality patterns for parsing QUALITY_PATTERNS = [ (r'2160p|4k|uhd', '2160p'), (r'1080p|fhd', '1080p'), (r'720p|hd', '720p'), (r'480p|sd', '480p'), (r'360p', '360p'), ] # Audio codec patterns (order matters - check combinations first) AUDIO_PATTERNS = [ (r'truehd.*atmos|atmos.*truehd', 'Atmos'), (r'atmos', 'Atmos'), (r'truehd', 'TrueHD'), (r'dts[\.\-]?hd[\.\-]?ma', 'DTS-HD'), (r'dts[\.\-]?hd', 'DTS-HD'), (r'dts[\.\-]?x', 'DTS:X'), (r'dts', 'DTS'), (r'7[\.\-]?1', '7.1'), (r'ddp[\.\-\s]?5[\.\-]?1|eac3|e[\.\-]?ac[\.\-]?3|dd[\.\-]?5[\.\-]?1|ac3|5[\.\-]?1', '5.1'), (r'ddp|dd\+', '5.1'), (r'aac[\.\-]?5[\.\-]?1', '5.1'), (r'aac', 'AAC'), (r'flac', 'FLAC'), (r'mp3', 'MP3'), ] # Source/release type patterns SOURCE_PATTERNS = [ (r'remux', 'Remux'), (r'blu[\.\-]?ray|bdrip|brrip', 'BluRay'), (r'web[\.\-]?dl', 'WEB-DL'), (r'webrip', 'WEBRip'), (r'web', 'WEB'), (r'hdtv', 'HDTV'), (r'dvdrip', 'DVDRip'), (r'dvd', 'DVD'), (r'hdcam|cam', 'CAM'), ] def __init__( self, username: str, password: str, proxy_enabled: bool = False, proxy_type: str = 'http', proxy_host: Optional[str] = None, proxy_port: Optional[int] = None, proxy_username: Optional[str] = None, proxy_password: Optional[str] = None, ): """ Initialize the Easynews client. Args: username: Easynews username password: Easynews password proxy_enabled: Whether to use a proxy proxy_type: Proxy type (http, https, socks4, socks5) proxy_host: Proxy hostname/IP proxy_port: Proxy port proxy_username: Proxy auth username (optional) proxy_password: Proxy auth password (optional) """ self.username = username self.password = password self.auth = HTTPBasicAuth(username, password) # Set up session with retry logic self.session = requests.Session() self.session.auth = self.auth # Configure proxy if enabled self.proxies = {} if proxy_enabled and proxy_host and proxy_port: proxy_url = self._build_proxy_url( proxy_type, proxy_host, proxy_port, proxy_username, proxy_password ) self.proxies = { 'http': proxy_url, 'https': proxy_url, } self.session.proxies.update(self.proxies) logger.info(f"Easynews client configured with {proxy_type} proxy: {proxy_host}:{proxy_port}") def _build_proxy_url( self, proxy_type: str, host: str, port: int, username: Optional[str] = None, password: Optional[str] = None, ) -> str: """Build a proxy URL with optional authentication.""" scheme = proxy_type.lower() if scheme not in ('http', 'https', 'socks4', 'socks5'): scheme = 'http' if username and password: return f"{scheme}://{quote(username)}:{quote(password)}@{host}:{port}" return f"{scheme}://{host}:{port}" def test_connection(self) -> Dict[str, Any]: """ Test the connection to Easynews with current credentials. Returns: Dict with 'success' bool and 'message' string """ try: # Try to access the members area response = self.session.get( f"{self.BASE_URL}/", timeout=30, ) if response.status_code == 200: # Check if we're actually authenticated (not redirected to login) if 'login' in response.url.lower() or 'sign in' in response.text.lower(): return { 'success': False, 'message': 'Invalid credentials - authentication failed' } return { 'success': True, 'message': 'Successfully connected to Easynews' } elif response.status_code == 401: return { 'success': False, 'message': 'Invalid credentials - authentication failed' } else: return { 'success': False, 'message': f'Unexpected response: HTTP {response.status_code}' } except requests.exceptions.ProxyError as e: return { 'success': False, 'message': f'Proxy connection failed: {str(e)}' } except requests.exceptions.ConnectionError as e: return { 'success': False, 'message': f'Connection failed: {str(e)}' } except requests.exceptions.Timeout: return { 'success': False, 'message': 'Connection timed out' } except Exception as e: logger.error(f"Easynews connection test failed: {e}") return { 'success': False, 'message': f'Connection test failed: {str(e)}' } def search( self, query: str, page: int = 1, results_per_page: int = 50, file_types: Optional[List[str]] = None, ) -> List[EasynewsResult]: """ Search Easynews for files matching the query. Args: query: Search query string page: Page number (1-indexed) results_per_page: Number of results per page (max 250) file_types: Optional list of file extensions to filter (e.g., ['mkv', 'mp4']) Returns: List of EasynewsResult objects """ try: # Build search parameters params = { 'gps': query, 'pby': min(results_per_page, 250), 'pno': page, 'sS': 1, # Safe search off 'saession': '', # Session 'sb': 1, # Sort by date 'sbj': 1, # Subject search 'fly': 2, # File type filter mode 'fex': 'mkv,mp4', # Only mkv and mp4 files } # Add file type filter if specified if file_types: params['fty[]'] = file_types else: # Default to video file types params['fty[]'] = ['VIDEO'] response = self.session.get( self.SEARCH_URL, params=params, timeout=60, ) response.raise_for_status() # Check for empty response if not response.content or not response.content.strip(): logger.warning(f"Easynews search for '{query}' returned empty response (HTTP {response.status_code})") return [] try: data = response.json() except (ValueError, Exception) as json_err: logger.warning(f"Easynews search for '{query}' returned invalid JSON (HTTP {response.status_code}, body: {response.text[:200]}): {json_err}") return [] results = [] # Parse the response if 'data' in data and isinstance(data['data'], list): for item in data['data']: result = self._parse_search_result(item) if result: results.append(result) logger.info(f"Easynews search for '{query}' returned {len(results)} results") return results except requests.exceptions.RequestException as e: logger.error(f"Easynews search failed: {e}") return [] except Exception as e: logger.error(f"Error parsing Easynews search results: {e}") return [] def _parse_search_result(self, item: Dict[str, Any]) -> Optional[EasynewsResult]: """Parse a single search result from the API response.""" try: # Extract the filename filename = item.get('fn', '') or item.get('0', '') if not filename: return None # Build download URL # Format: https://username:password@members.easynews.com/dl/{hash}/{filename} file_hash = item.get('hash', '') or item.get('0', '') sig = item.get('sig', '') if file_hash and sig: # Use the authenticated download URL format download_path = f"/dl/{file_hash}/{quote(filename)}?sig={sig}" download_url = f"https://{quote(self.username)}:{quote(self.password)}@members.easynews.com{download_path}" else: # Fallback to basic URL download_url = item.get('url', '') or item.get('rawURL', '') if download_url and not download_url.startswith('http'): download_url = urljoin(self.BASE_URL, download_url) if not download_url: return None # Parse size size_bytes = 0 size_str = item.get('rawSize', '') or item.get('size', '') if isinstance(size_str, (int, float)): size_bytes = int(size_str) elif isinstance(size_str, str): size_bytes = self._parse_size(size_str) # Parse date post_date = item.get('date', '') or item.get('d', '') if post_date: try: # Try to parse and standardize the date format if isinstance(post_date, str): post_date = post_date.strip() except Exception: pass # Get extension from API field (more reliable than parsing filename) extension = item.get('extension', '') or item.get('11', '') or item.get('2', '') if extension and not extension.startswith('.'): extension = '.' + extension return EasynewsResult( filename=filename, download_url=download_url, size_bytes=size_bytes, post_date=post_date if post_date else None, subject=item.get('subject', '') or item.get('s', ''), poster=item.get('poster', '') or item.get('p', ''), newsgroup=item.get('newsgroup', '') or item.get('ng', ''), extension=extension if extension else self._get_extension(filename), ) except Exception as e: logger.debug(f"Failed to parse search result: {e}") return None def _parse_size(self, size_str: str) -> int: """Parse a size string like '1.5 GB' to bytes.""" try: size_str = size_str.strip().upper() multipliers = { 'B': 1, 'KB': 1024, 'MB': 1024 ** 2, 'GB': 1024 ** 3, 'TB': 1024 ** 4, } for suffix, multiplier in multipliers.items(): if size_str.endswith(suffix): value = float(size_str[:-len(suffix)].strip()) return int(value * multiplier) # Try to parse as plain number return int(float(size_str)) except Exception: return 0 def _get_extension(self, filename: str) -> Optional[str]: """Extract file extension from filename.""" if '.' in filename: return filename.rsplit('.', 1)[-1].lower() return None @staticmethod def detect_quality(filename: str) -> Optional[str]: """Detect video quality from filename.""" filename_lower = filename.lower() for pattern, quality in EasynewsClient.QUALITY_PATTERNS: if re.search(pattern, filename_lower): return quality return None @staticmethod def detect_audio(filename: str) -> Optional[str]: """Detect audio codec from filename.""" filename_lower = filename.lower() for pattern, audio in EasynewsClient.AUDIO_PATTERNS: if re.search(pattern, filename_lower): return audio return None @staticmethod def detect_source(filename: str) -> Optional[str]: """Detect source/release type from filename.""" filename_lower = filename.lower() for pattern, source in EasynewsClient.SOURCE_PATTERNS: if re.search(pattern, filename_lower): return source return None def download_file( self, url: str, dest_path: str, progress_callback: Optional[Callable[[int, int], None]] = None, chunk_size: int = 8192, ) -> Dict[str, Any]: """ Download a file from Easynews. Args: url: Download URL (with authentication embedded or using session) dest_path: Destination file path progress_callback: Optional callback(downloaded_bytes, total_bytes) chunk_size: Download chunk size in bytes Returns: Dict with 'success' bool and 'message' or 'path' """ try: # Start the download with streaming response = self.session.get( url, stream=True, timeout=30, ) response.raise_for_status() total_size = int(response.headers.get('content-length', 0)) downloaded = 0 with open(dest_path, 'wb') as f: for chunk in response.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) downloaded += len(chunk) if progress_callback: progress_callback(downloaded, total_size) logger.info(f"Downloaded file to {dest_path} ({downloaded} bytes)") return { 'success': True, 'path': dest_path, 'size': downloaded, } except requests.exceptions.RequestException as e: logger.error(f"Download failed: {e}") return { 'success': False, 'message': f'Download failed: {str(e)}' } except IOError as e: logger.error(f"Failed to write file: {e}") return { 'success': False, 'message': f'Failed to write file: {str(e)}' } except Exception as e: logger.error(f"Unexpected error during download: {e}") return { 'success': False, 'message': f'Download error: {str(e)}' } def get_file_info(self, url: str) -> Dict[str, Any]: """ Get information about a file without downloading it. Args: url: File URL Returns: Dict with file information (size, content-type, etc.) """ try: response = self.session.head(url, timeout=30) response.raise_for_status() return { 'success': True, 'size': int(response.headers.get('content-length', 0)), 'content_type': response.headers.get('content-type', ''), 'last_modified': response.headers.get('last-modified', ''), } except Exception as e: logger.error(f"Failed to get file info: {e}") return { 'success': False, 'message': str(e) }