Files
nfoguard/core/async_nfo_manager.py
T
sbcrumb d0e4182d38
Local Docker Build (Dev) / build-dev (pull_request) Successful in 24s
Fix missing Tuple import in async_nfo_manager.py
- Add Tuple to typing imports to resolve NameError
- Fixes container startup failure after modular refactoring
- Version bump to 2.0.4
2025-10-04 11:06:07 -04:00

423 lines
14 KiB
Python

"""
Async NFO Manager for NFOGuard
High-performance async NFO file operations with concurrent processing
"""
import asyncio
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple
from datetime import datetime
import xml.etree.ElementTree as ET
from utils.logging import _log
from utils.async_file_utils import (
async_read_nfo_file,
async_write_nfo_file,
async_file_exists,
async_set_file_mtime,
async_batch_nfo_operations,
async_concurrent_episode_processing
)
from utils.nfo_patterns import (
create_basic_nfo_structure,
extract_imdb_from_nfo_content,
extract_dates_from_nfo,
extract_imdb_id_from_text
)
from utils.validation import validate_date_string
class AsyncNFOManager:
"""Async NFO file manager with concurrent processing capabilities"""
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
self.manager_brand = manager_brand
self.debug = debug
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
"""
Async extract IMDb ID from directory path or filename
Args:
path: Path to examine
Returns:
IMDb ID if found, None otherwise
"""
# Use the sync version since it's just string processing
return extract_imdb_id_from_text(str(path))
async def async_parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
"""
Async extract IMDb ID from NFO file content
Args:
nfo_path: Path to NFO file
Returns:
IMDb ID if found, None otherwise
"""
root = await async_read_nfo_file(nfo_path)
if root is None:
return None
return extract_imdb_from_nfo_content(root)
async def async_find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
"""
Async find IMDb ID from directory name, filenames, or NFO file
Args:
movie_dir: Path to movie directory
Returns:
IMDb ID if found, None otherwise
"""
if self.debug:
_log("DEBUG", f"Async searching for IMDb ID in: {movie_dir.name}")
# First try directory name
imdb_id = await self.async_parse_imdb_from_path(movie_dir)
if imdb_id:
if self.debug:
_log("DEBUG", f"Found IMDb ID in directory name: {imdb_id}")
return imdb_id
# Try all files in the directory concurrently
if await async_file_exists(movie_dir):
try:
from utils.async_file_utils import aiofiles
entries = await aiofiles.os.listdir(movie_dir)
# Create tasks to check all files
async def check_file(filename: str) -> Optional[str]:
file_path = movie_dir / filename
if await aiofiles.os.path.isfile(file_path):
return await self.async_parse_imdb_from_path(file_path)
return None
# Check all files concurrently
results = await asyncio.gather(
*[check_file(filename) for filename in entries],
return_exceptions=True
)
# Find first valid IMDb ID
for result in results:
if isinstance(result, str) and result:
if self.debug:
_log("DEBUG", f"Found IMDb ID in filename: {result}")
return result
except Exception as e:
_log("WARNING", f"Failed to scan directory {movie_dir}: {e}")
# Finally, try NFO file content
nfo_path = movie_dir / "movie.nfo"
imdb_id = await self.async_parse_imdb_from_nfo(nfo_path)
if imdb_id:
if self.debug:
_log("DEBUG", f"Found IMDb ID in NFO file: {imdb_id}")
return imdb_id
if self.debug:
_log("DEBUG", f"No IMDb ID found for: {movie_dir.name}")
return None
async def async_create_movie_nfo(
self,
movie_dir: Path,
imdb_id: str,
dateadded: str,
premiered: Optional[str] = None,
lock_metadata: bool = True
) -> bool:
"""
Async create movie NFO file
Args:
movie_dir: Path to movie directory
imdb_id: IMDb ID
dateadded: Date added
premiered: Optional premiere date
lock_metadata: Whether to lock metadata
Returns:
True if successful, False otherwise
"""
try:
nfo_path = movie_dir / "movie.nfo"
# Prepare dates
dates = {"dateadded": dateadded}
if premiered and validate_date_string(premiered):
dates["premiered"] = premiered
# Create NFO structure
root = create_basic_nfo_structure(
media_type="movie",
title=movie_dir.name,
imdb_id=imdb_id,
dates=dates,
additional_fields={"source": self.manager_brand}
)
# Write NFO file asynchronously
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
if success and self.debug:
_log("DEBUG", f"Created movie NFO: {nfo_path}")
return success
except Exception as e:
_log("ERROR", f"Failed to create movie NFO for {movie_dir}: {e}")
return False
async def async_create_episode_nfo(
self,
season_dir: Path,
season: int,
episode: int,
aired: Optional[str] = None,
dateadded: Optional[str] = None,
source: str = "unknown",
lock_metadata: bool = True
) -> bool:
"""
Async create episode NFO file
Args:
season_dir: Path to season directory
season: Season number
episode: Episode number
aired: Optional air date
dateadded: Optional date added
source: Source of the data
lock_metadata: Whether to lock metadata
Returns:
True if successful, False otherwise
"""
try:
nfo_filename = f"S{season:02d}E{episode:02d}.nfo"
nfo_path = season_dir / nfo_filename
# Prepare dates
dates = {}
if aired and validate_date_string(aired):
dates["aired"] = aired
if dateadded and validate_date_string(dateadded):
dates["dateadded"] = dateadded
# Create NFO structure
root = create_basic_nfo_structure(
media_type="episodedetails",
title=f"S{season:02d}E{episode:02d}",
dates=dates,
additional_fields={
"season": str(season),
"episode": str(episode),
"source": source
}
)
# Write NFO file asynchronously
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
if success and self.debug:
_log("DEBUG", f"Created episode NFO: {nfo_path}")
return success
except Exception as e:
_log("ERROR", f"Failed to create episode NFO S{season:02d}E{episode:02d}: {e}")
return False
async def async_batch_create_episode_nfos(
self,
episode_data_list: List[Dict[str, Any]],
max_concurrent: int = 5
) -> List[bool]:
"""
Batch create multiple episode NFOs concurrently
Args:
episode_data_list: List of episode data dictionaries
max_concurrent: Maximum concurrent NFO operations
Returns:
List of success/failure results
"""
async def _create_episode_nfo(episode_data: Dict[str, Any]) -> bool:
return await self.async_create_episode_nfo(
season_dir=episode_data.get('season_dir'),
season=episode_data.get('season'),
episode=episode_data.get('episode'),
aired=episode_data.get('aired'),
dateadded=episode_data.get('dateadded'),
source=episode_data.get('source', 'unknown'),
lock_metadata=episode_data.get('lock_metadata', True)
)
return await async_concurrent_episode_processing(
episode_data_list,
_create_episode_nfo,
max_concurrent
)
async def async_set_file_mtime(self, file_path: Path, date_str: str) -> bool:
"""
Async set file modification time from date string
Args:
file_path: Path to file
date_str: Date string in ISO format
Returns:
True if successful, False otherwise
"""
try:
if not validate_date_string(date_str):
_log("WARNING", f"Invalid date format for mtime: {date_str}")
return False
# Parse date string to timestamp
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
timestamp = dt.timestamp()
# Set file mtime asynchronously
success = await async_set_file_mtime(file_path, timestamp)
if success and self.debug:
_log("DEBUG", f"Set mtime for {file_path}: {date_str}")
return success
except Exception as e:
_log("ERROR", f"Failed to set mtime for {file_path}: {e}")
return False
async def async_batch_set_file_mtimes(
self,
file_mtime_pairs: List[Tuple[Path, str]],
max_concurrent: int = 10
) -> List[bool]:
"""
Batch set file modification times concurrently
Args:
file_mtime_pairs: List of (file_path, date_str) tuples
max_concurrent: Maximum concurrent operations
Returns:
List of success/failure results
"""
async def _set_single_mtime(file_path: Path, date_str: str) -> bool:
return await self.async_set_file_mtime(file_path, date_str)
# Create tasks for all mtime operations
semaphore = asyncio.Semaphore(max_concurrent)
async def _set_mtime_with_semaphore(file_path: Path, date_str: str) -> bool:
async with semaphore:
return await _set_single_mtime(file_path, date_str)
tasks = [
_set_mtime_with_semaphore(file_path, date_str)
for file_path, date_str in file_mtime_pairs
]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [result if isinstance(result, bool) else False for result in results]
async def async_validate_nfo_integrity(
self,
nfo_paths: List[Path],
max_concurrent: int = 10
) -> Dict[str, Any]:
"""
Async validate integrity of multiple NFO files
Args:
nfo_paths: List of NFO file paths to validate
max_concurrent: Maximum concurrent validations
Returns:
Dictionary with validation results and statistics
"""
results = {
'total_files': len(nfo_paths),
'valid_files': 0,
'invalid_files': 0,
'missing_files': 0,
'validation_errors': [],
'file_results': {}
}
async def _validate_single_nfo(nfo_path: Path) -> Dict[str, Any]:
file_result = {
'path': str(nfo_path),
'exists': False,
'valid_xml': False,
'has_imdb_id': False,
'has_dates': False,
'error': None
}
try:
# Check if file exists
if not await async_file_exists(nfo_path):
file_result['error'] = 'File does not exist'
return file_result
file_result['exists'] = True
# Try to parse NFO
root = await async_read_nfo_file(nfo_path)
if root is None:
file_result['error'] = 'Failed to parse XML'
return file_result
file_result['valid_xml'] = True
# Check for IMDb ID
imdb_id = extract_imdb_from_nfo_content(root)
file_result['has_imdb_id'] = bool(imdb_id)
# Check for dates
dates = extract_dates_from_nfo(root)
file_result['has_dates'] = any(dates.values())
except Exception as e:
file_result['error'] = str(e)
return file_result
# Validate all files concurrently
semaphore = asyncio.Semaphore(max_concurrent)
async def _validate_with_semaphore(nfo_path: Path) -> Dict[str, Any]:
async with semaphore:
return await _validate_single_nfo(nfo_path)
tasks = [_validate_with_semaphore(nfo_path) for nfo_path in nfo_paths]
file_results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
for i, result in enumerate(file_results):
if isinstance(result, dict):
path_str = str(nfo_paths[i])
results['file_results'][path_str] = result
if not result['exists']:
results['missing_files'] += 1
elif result['valid_xml']:
results['valid_files'] += 1
else:
results['invalid_files'] += 1
if result.get('error'):
results['validation_errors'].append(f"{path_str}: {result['error']}")
return results