""" NFO parsing patterns and utilities for NFOGuard Consolidates common NFO parsing logic and patterns """ import re import xml.etree.ElementTree as ET from pathlib import Path from typing import Optional, Dict, Any, List from datetime import datetime from utils.logging import _log from utils.exceptions import NFOCreationError, FileOperationError from utils.validation import validate_imdb_id, validate_date_string # Common IMDb ID patterns used across the application IMDB_PATTERNS = [ r'\[imdb-?(tt\d+)\]', # [imdb-tt1234567] or [imdb-1234567] r'\[(tt\d+)\]', # [tt1234567] r'\{imdb-?(tt\d+)\}', # {imdb-tt1234567} or {imdb-1234567} r'\(imdb-?(tt\d+)\)', # (imdb-tt1234567) or (imdb-1234567) r'[-_\s](tt\d+)$', # tt1234567 at end of string r'imdb[_-]?(tt\d+)', # imdb_tt1234567 or imdb-tt1234567 ] # Episode filename patterns EPISODE_PATTERNS = [ r'.*[sS](\d{1,2})[eE](\d{1,3}).*', # S01E01 r'.*(\d{1,2})x(\d{1,3}).*', # 1x01 r'.*[sS](\d{1,2})\.?[eE](\d{1,3}).*', # S01.E01 r'.*Season[_\s]?(\d{1,2})[_\s]?Episode[_\s]?(\d{1,3}).*', # Season 1 Episode 1 ] # Common NFO XML namespaces NFO_NAMESPACES = { 'kodi': 'http://kodi.tv/moviedb', 'tmdb': 'https://www.themoviedb.org', 'imdb': 'https://www.imdb.com' } def extract_imdb_id_from_text(text: str) -> Optional[str]: """ Extract IMDb ID from text using consolidated patterns Args: text: Text to search for IMDb ID Returns: IMDb ID if found, None otherwise """ if not text: return None text_lower = text.lower() for pattern in IMDB_PATTERNS: match = re.search(pattern, text_lower) if match: imdb_id = match.group(1) # Ensure it starts with 'tt' if not imdb_id.startswith('tt'): imdb_id = f'tt{imdb_id}' if validate_imdb_id(imdb_id): return imdb_id return None def extract_episode_info_from_filename(filename: str) -> Optional[Dict[str, int]]: """ Extract season and episode information from filename Args: filename: Filename to parse Returns: Dictionary with 'season' and 'episode' keys if found, None otherwise """ for pattern in EPISODE_PATTERNS: match = re.search(pattern, filename, re.IGNORECASE) if match: try: season = int(match.group(1)) episode = int(match.group(2)) # Validate reasonable ranges if 0 <= season <= 99 and 1 <= episode <= 999: return {"season": season, "episode": episode} except (ValueError, IndexError): continue return None def parse_nfo_with_tolerance(nfo_path: Path) -> Optional[ET.Element]: """ Parse NFO file with error tolerance Args: nfo_path: Path to NFO file Returns: XML root element if successful, None otherwise """ if not nfo_path.exists(): return None try: # Try normal parsing first tree = ET.parse(nfo_path) return tree.getroot() except ET.ParseError as e: _log("WARNING", f"NFO parse error for {nfo_path}: {e}. Trying with tolerance...") try: # Try reading and cleaning the content content = nfo_path.read_text(encoding='utf-8', errors='ignore') # Basic cleanup for common issues content = content.replace('&', '&') # Fix unescaped ampersands content = re.sub(r'<(\w+)([^>]*?)(?', r'<\1\2/>', content) # Fix unclosed tags root = ET.fromstring(content) return root except Exception as e: _log("ERROR", f"Failed to parse NFO file {nfo_path} even with tolerance: {e}") return None def extract_text_from_nfo_element(root: ET.Element, xpath: str, namespaces: Optional[Dict] = None) -> Optional[str]: """ Extract text content from NFO element using XPath Args: root: XML root element xpath: XPath expression namespaces: Optional namespace dictionary Returns: Text content if found, None otherwise """ try: if namespaces: elements = root.findall(xpath, namespaces) else: elements = root.findall(xpath) if elements and elements[0].text: return elements[0].text.strip() except Exception as e: _log("DEBUG", f"Failed to extract text from XPath {xpath}: {e}") return None def extract_imdb_from_nfo_content(root: ET.Element) -> Optional[str]: """ Extract IMDb ID from NFO XML content Args: root: XML root element Returns: IMDb ID if found, None otherwise """ # Common XPath patterns for IMDb ID imdb_xpaths = [ './/imdb', './/imdbid', './/id[@type="imdb"]', './/uniqueid[@type="imdb"]', './/uniqueid[@default="true"]', './/id', './/uniqueid' ] for xpath in imdb_xpaths: imdb_text = extract_text_from_nfo_element(root, xpath) if imdb_text: imdb_id = extract_imdb_id_from_text(imdb_text) if imdb_id: return imdb_id # Check in plot/overview text as fallback plot_xpaths = ['.//plot', './/overview', './/summary'] for xpath in plot_xpaths: plot_text = extract_text_from_nfo_element(root, xpath) if plot_text: imdb_id = extract_imdb_id_from_text(plot_text) if imdb_id: return imdb_id return None def extract_dates_from_nfo(root: ET.Element) -> Dict[str, Optional[str]]: """ Extract various date fields from NFO content Args: root: XML root element Returns: Dictionary with date fields (premiered, aired, dateadded, etc.) """ date_fields = { 'premiered': ['.//premiered', './/releasedate', './/year'], 'aired': ['.//aired', './/firstaired'], 'dateadded': ['.//dateadded', './/added'], 'lastplayed': ['.//lastplayed'], 'filelastmodified': ['.//filelastmodified'] } result = {} for field_name, xpaths in date_fields.items(): for xpath in xpaths: date_text = extract_text_from_nfo_element(root, xpath) if date_text and validate_date_string(date_text): result[field_name] = date_text break else: result[field_name] = None return result def create_basic_nfo_structure( media_type: str, title: str, imdb_id: Optional[str] = None, dates: Optional[Dict[str, str]] = None, additional_fields: Optional[Dict[str, str]] = None ) -> ET.Element: """ Create basic NFO XML structure Args: media_type: Type of media ('movie', 'tvshow', 'episode') title: Media title imdb_id: Optional IMDb ID dates: Optional dictionary of date fields additional_fields: Optional additional fields to include Returns: XML root element """ root = ET.Element(media_type) # Add title title_elem = ET.SubElement(root, 'title') title_elem.text = title # Add IMDb ID if provided if imdb_id and validate_imdb_id(imdb_id): imdb_elem = ET.SubElement(root, 'imdb') imdb_elem.text = imdb_id # Also add as uniqueid uniqueid_elem = ET.SubElement(root, 'uniqueid', type='imdb', default='true') uniqueid_elem.text = imdb_id # Add dates if provided if dates: for field_name, date_value in dates.items(): if date_value and validate_date_string(date_value): date_elem = ET.SubElement(root, field_name) date_elem.text = date_value # Add additional fields if additional_fields: for field_name, field_value in additional_fields.items(): if field_value: field_elem = ET.SubElement(root, field_name) field_elem.text = str(field_value) return root def write_nfo_file( nfo_path: Path, root: ET.Element, lock_metadata: bool = True ) -> None: """ Write NFO XML content to file Args: nfo_path: Path where to write the NFO file root: XML root element to write lock_metadata: Whether to add file locking attributes Raises: NFOCreationError: If writing fails """ try: # Ensure parent directory exists nfo_path.parent.mkdir(parents=True, exist_ok=True) # Add file locking if requested if lock_metadata: root.set('nfoguard_managed', 'true') root.set('last_updated', datetime.now().isoformat()) # Create tree and write tree = ET.ElementTree(root) ET.indent(tree, space=" ", level=0) # Pretty formatting # Write with proper XML declaration with open(nfo_path, 'wb') as f: tree.write(f, encoding='utf-8', xml_declaration=True) _log("DEBUG", f"Successfully wrote NFO file: {nfo_path}") except (OSError, ET.ParseError) as e: raise NFOCreationError( str(nfo_path), f"Failed to write NFO file: {e}", "unknown" ) def is_nfo_managed_by_nfoguard(nfo_path: Path) -> bool: """ Check if NFO file is managed by NFOGuard Args: nfo_path: Path to NFO file Returns: True if managed by NFOGuard, False otherwise """ root = parse_nfo_with_tolerance(nfo_path) if root is None: return False return root.get('nfoguard_managed') == 'true' def extract_title_from_directory_name(directory_name: str) -> Optional[str]: """ Extract clean title from directory name Args: directory_name: Directory name to parse Returns: Cleaned title or None if no title found """ name = directory_name # Remove IMDb ID patterns for pattern in IMDB_PATTERNS: name = re.sub(pattern, '', name, flags=re.IGNORECASE) # Remove year in parentheses: (YYYY) name = re.sub(r'\s*\(\d{4}\)', '', name) # Remove common release info patterns release_patterns = [ r'\s*\[.*?\]', # [1080p], [BluRay], etc. r'\s*\{.*?\}', # {edition info} r'\s*\(.*?\)', # (additional info) ] for pattern in release_patterns: name = re.sub(pattern, '', name, flags=re.IGNORECASE) # Clean up extra spaces and special characters name = re.sub(r'[._-]+', ' ', name) # Replace dots, underscores, dashes with spaces name = ' '.join(name.split()) # Normalize whitespace return name.strip() if name.strip() else None