@@ -0,0 +1,375 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user