#!/usr/bin/env python3 """ XML Processing Cache for NFOGuard Optimizes NFO file parsing and manipulation """ import os import time from functools import lru_cache from pathlib import Path from typing import Dict, Optional, Any from xml.etree import ElementTree as ET from core.logging import _log class XMLProcessingCache: """Caches parsed XML trees and NFO data with mtime invalidation""" def __init__(self, max_cache_size: int = 500): self.max_cache_size = max_cache_size self._xml_tree_cache: Dict[str, tuple] = {} # (mtime, parsed_tree) self._nfo_data_cache: Dict[str, tuple] = {} # (mtime, extracted_data) self._cache_hits = 0 self._cache_misses = 0 def get_parsed_nfo(self, nfo_path: Path) -> Optional[ET.Element]: """Get parsed NFO file with caching""" if not nfo_path.exists(): return None cache_key = str(nfo_path) current_mtime = nfo_path.stat().st_mtime # Check cache if cache_key in self._xml_tree_cache: cached_mtime, cached_tree = self._xml_tree_cache[cache_key] if cached_mtime == current_mtime: self._cache_hits += 1 return cached_tree # Parse XML self._cache_misses += 1 try: tree = ET.parse(nfo_path) root = tree.getroot() # Cache the parsed tree self._xml_tree_cache[cache_key] = (current_mtime, root) self._cleanup_cache() return root except (ET.ParseError, OSError) as e: _log("DEBUG", f"Error parsing NFO {nfo_path}: {e}") return None def extract_nfo_dates_cached(self, nfo_path: Path) -> Optional[Dict[str, Any]]: """Extract NFOGuard date fields from NFO with caching""" if not nfo_path.exists(): return None cache_key = f"dates:{nfo_path}" current_mtime = nfo_path.stat().st_mtime # Check cache if cache_key in self._nfo_data_cache: cached_mtime, cached_data = self._nfo_data_cache[cache_key] if cached_mtime == current_mtime: self._cache_hits += 1 return cached_data # Extract data self._cache_misses += 1 root = self.get_parsed_nfo(nfo_path) if not root: return None dates_data = self._extract_nfoguard_dates(root) # Cache the extracted data self._nfo_data_cache[cache_key] = (current_mtime, dates_data) self._cleanup_cache() return dates_data def _extract_nfoguard_dates(self, root: ET.Element) -> Optional[Dict[str, Any]]: """Extract NFOGuard date fields from parsed XML""" try: # Look for NFOGuard date fields dateadded_elem = root.find(".//dateadded") source_elem = root.find(".//nfoguard_source") aired_elem = root.find(".//aired") or root.find(".//premiered") if dateadded_elem is not None and dateadded_elem.text: result = { "dateadded": dateadded_elem.text.strip(), "source": source_elem.text.strip() if source_elem is not None and source_elem.text else "unknown", "aired": aired_elem.text.strip() if aired_elem is not None and aired_elem.text else None } # Check if title exists for episodes title_elem = root.find(".//title") if title_elem is not None and title_elem.text: result["has_title"] = True result["title"] = title_elem.text.strip() else: result["has_title"] = False return result except Exception as e: _log("DEBUG", f"Error extracting NFOGuard dates from XML: {e}") return None def check_nfo_has_nfoguard_data(self, nfo_path: Path) -> bool: """Quickly check if NFO has NFOGuard data""" cache_key = f"has_nfoguard:{nfo_path}" if not nfo_path.exists(): return False current_mtime = nfo_path.stat().st_mtime # Check cache if cache_key in self._nfo_data_cache: cached_mtime, has_data = self._nfo_data_cache[cache_key] if cached_mtime == current_mtime: self._cache_hits += 1 return has_data # Check for NFOGuard markers self._cache_misses += 1 try: # Quick text search first (faster than XML parsing) with open(nfo_path, 'r', encoding='utf-8', errors='ignore') as f: content = f.read() has_data = '' in content or '' in content # Cache result self._nfo_data_cache[cache_key] = (current_mtime, has_data) return has_data except (OSError, UnicodeDecodeError) as e: _log("DEBUG", f"Error checking NFOGuard markers in {nfo_path}: {e}") return False def _cleanup_cache(self): """Clean up caches when they get too large""" if len(self._xml_tree_cache) > self.max_cache_size: # Remove oldest 25% of entries remove_count = self.max_cache_size // 4 old_keys = list(self._xml_tree_cache.keys())[:remove_count] for key in old_keys: del self._xml_tree_cache[key] if len(self._nfo_data_cache) > self.max_cache_size: remove_count = self.max_cache_size // 4 old_keys = list(self._nfo_data_cache.keys())[:remove_count] for key in old_keys: del self._nfo_data_cache[key] def clear_cache(self): """Clear all XML caches""" self._xml_tree_cache.clear() self._nfo_data_cache.clear() _log("INFO", "XML processing cache cleared") def get_cache_stats(self) -> Dict[str, Any]: """Get XML cache performance statistics""" total_requests = self._cache_hits + self._cache_misses hit_rate = (self._cache_hits / total_requests * 100) if total_requests > 0 else 0 return { "xml_cache_hits": self._cache_hits, "xml_cache_misses": self._cache_misses, "xml_hit_rate_percent": round(hit_rate, 2), "xml_tree_cache_size": len(self._xml_tree_cache), "nfo_data_cache_size": len(self._nfo_data_cache) } # Global XML cache instance xml_cache = XMLProcessingCache() @lru_cache(maxsize=1000) def parse_imdb_from_filename(filename: str) -> Optional[str]: """Parse IMDb ID from filename with LRU caching""" import re match = re.search(r'\[imdb-([^]]+)\]', filename, re.IGNORECASE) return match.group(1) if match else None @lru_cache(maxsize=200) def clean_title_for_search(title: str) -> str: """Clean title for searching with LRU caching""" return title.lower().replace(" ", "").replace("-", "").replace("_", "") def clear_xml_caches(): """Clear all XML-related caches""" xml_cache.clear_cache() parse_imdb_from_filename.cache_clear() clean_title_for_search.cache_clear() _log("INFO", "All XML caches cleared")