Files
nfoguard/utils/async_file_utils.py
T
sbcrumb 7ae8ce0024
Local Docker Build (Main) / build (push) Successful in 23s
Local Docker Build (Main) / deploy (push) Successful in 0s
dev (#18)
Reviewed-on: #18
2025-09-27 17:52:46 -04:00

468 lines
14 KiB
Python

"""
Async file utilities for NFOGuard
High-performance async file operations with concurrent processing
"""
import asyncio
import aiofiles
import aiofiles.os
from pathlib import Path
from typing import List, Dict, Optional, Tuple, Any, Set
import xml.etree.ElementTree as ET
from datetime import datetime
from utils.logging import _log
from utils.exceptions import FileOperationError, NFOCreationError
from utils.file_utils import VIDEO_EXTENSIONS, extract_episode_info, extract_imdb_id_from_path
from utils.nfo_patterns import parse_nfo_with_tolerance, write_nfo_file
async def async_read_text_file(file_path: Path, encoding: str = 'utf-8') -> Optional[str]:
"""
Async read text file with error handling
Args:
file_path: Path to file to read
encoding: Text encoding (default: utf-8)
Returns:
File content as string or None if error
"""
try:
async with aiofiles.open(file_path, 'r', encoding=encoding, errors='ignore') as f:
return await f.read()
except Exception as e:
_log("WARNING", f"Failed to read file {file_path}: {e}")
return None
async def async_write_text_file(file_path: Path, content: str, encoding: str = 'utf-8') -> bool:
"""
Async write text file with error handling
Args:
file_path: Path to file to write
content: Content to write
encoding: Text encoding (default: utf-8)
Returns:
True if successful, False otherwise
"""
try:
# Ensure parent directory exists
await aiofiles.os.makedirs(file_path.parent, exist_ok=True)
async with aiofiles.open(file_path, 'w', encoding=encoding) as f:
await f.write(content)
return True
except Exception as e:
_log("ERROR", f"Failed to write file {file_path}: {e}")
return False
async def async_file_exists(file_path: Path) -> bool:
"""
Async check if file exists
Args:
file_path: Path to check
Returns:
True if file exists, False otherwise
"""
try:
return await aiofiles.os.path.exists(file_path)
except Exception:
return False
async def async_get_file_mtime(file_path: Path) -> Optional[float]:
"""
Async get file modification time
Args:
file_path: Path to file
Returns:
Modification time as timestamp or None if error
"""
try:
stat_result = await aiofiles.os.stat(file_path)
return stat_result.st_mtime
except Exception:
return None
async def async_set_file_mtime(file_path: Path, mtime: float) -> bool:
"""
Async set file modification time
Args:
file_path: Path to file
mtime: New modification time as timestamp
Returns:
True if successful, False otherwise
"""
try:
await aiofiles.os.utime(file_path, (mtime, mtime))
return True
except Exception as e:
_log("WARNING", f"Failed to set mtime for {file_path}: {e}")
return False
async def async_find_video_files(directory: Path, recursive: bool = True) -> List[Path]:
"""
Async find all video files in a directory
Args:
directory: Directory to search
recursive: Whether to search recursively
Returns:
List of video file paths
"""
if not await async_file_exists(directory):
return []
video_files = []
try:
if recursive:
# Use os.walk equivalent for async
async def _walk_directory(path: Path):
try:
entries = await aiofiles.os.listdir(path)
for entry in entries:
entry_path = path / entry
if await aiofiles.os.path.isfile(entry_path):
if entry_path.suffix.lower() in VIDEO_EXTENSIONS:
video_files.append(entry_path)
elif await aiofiles.os.path.isdir(entry_path):
await _walk_directory(entry_path)
except Exception as e:
_log("WARNING", f"Failed to scan directory {path}: {e}")
await _walk_directory(directory)
else:
# Non-recursive scan
try:
entries = await aiofiles.os.listdir(directory)
for entry in entries:
entry_path = directory / entry
if await aiofiles.os.path.isfile(entry_path):
if entry_path.suffix.lower() in VIDEO_EXTENSIONS:
video_files.append(entry_path)
except Exception as e:
_log("WARNING", f"Failed to scan directory {directory}: {e}")
except Exception as e:
_log("ERROR", f"Failed to find video files in {directory}: {e}")
return video_files
async def async_find_episodes_on_disk(series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
"""
Async find all episodes on disk with concurrent processing
Args:
series_path: Path to series directory
Returns:
Dictionary mapping (season, episode) tuples to lists of video files
"""
episodes = {}
if not await async_file_exists(series_path):
return episodes
# Get all video files concurrently
video_files = await async_find_video_files(series_path, recursive=True)
# Process files to extract episode information
for video_file in video_files:
episode_info = extract_episode_info(video_file.name)
if episode_info:
season, episode = episode_info["season"], episode_info["episode"]
key = (season, episode)
if key not in episodes:
episodes[key] = []
episodes[key].append(video_file)
return episodes
async def async_read_nfo_file(nfo_path: Path) -> Optional[ET.Element]:
"""
Async read and parse NFO file
Args:
nfo_path: Path to NFO file
Returns:
XML root element if successful, None otherwise
"""
if not await async_file_exists(nfo_path):
return None
try:
content = await async_read_text_file(nfo_path)
if not content:
return None
# Parse XML content
try:
root = ET.fromstring(content)
return root
except ET.ParseError:
# Try with tolerance (sync operation for now)
return parse_nfo_with_tolerance(nfo_path)
except Exception as e:
_log("ERROR", f"Failed to read NFO file {nfo_path}: {e}")
return None
async def async_write_nfo_file(
nfo_path: Path,
root: ET.Element,
lock_metadata: bool = True
) -> bool:
"""
Async 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
Returns:
True if successful, False otherwise
"""
try:
# Ensure parent directory exists
await aiofiles.os.makedirs(nfo_path.parent, 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 format
tree = ET.ElementTree(root)
ET.indent(tree, space=" ", level=0) # Pretty formatting
# Convert to string
xml_str = ET.tostring(root, encoding='unicode', xml_declaration=False)
xml_content = f'<?xml version="1.0" encoding="utf-8"?>\n{xml_str}'
# Write asynchronously
success = await async_write_text_file(nfo_path, xml_content)
if success:
_log("DEBUG", f"Successfully wrote NFO file: {nfo_path}")
return success
except Exception as e:
_log("ERROR", f"Failed to write NFO file {nfo_path}: {e}")
return False
async def async_batch_process_files(
file_paths: List[Path],
process_func,
max_concurrent: int = 10,
progress_callback: Optional[callable] = None
) -> List[Any]:
"""
Process multiple files concurrently with controlled concurrency
Args:
file_paths: List of file paths to process
process_func: Async function to process each file
max_concurrent: Maximum number of concurrent operations
progress_callback: Optional callback for progress updates
Returns:
List of results from processing each file
"""
semaphore = asyncio.Semaphore(max_concurrent)
results = []
async def _process_with_semaphore(file_path: Path, index: int) -> Any:
async with semaphore:
try:
result = await process_func(file_path)
if progress_callback:
progress_callback(index + 1, len(file_paths), file_path)
return result
except Exception as e:
_log("ERROR", f"Failed to process {file_path}: {e}")
return None
# Create tasks for all files
tasks = [
_process_with_semaphore(file_path, i)
for i, file_path in enumerate(file_paths)
]
# Execute all tasks concurrently with controlled concurrency
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
async def async_batch_nfo_operations(
nfo_operations: List[Dict[str, Any]],
max_concurrent: int = 5
) -> List[bool]:
"""
Batch NFO operations (read/write) with controlled concurrency
Args:
nfo_operations: List of operation dictionaries with 'type', 'path', and other params
max_concurrent: Maximum number of concurrent operations
Returns:
List of success/failure results
"""
async def _execute_nfo_operation(operation: Dict[str, Any]) -> bool:
try:
op_type = operation.get('type')
path = operation.get('path')
if op_type == 'read':
result = await async_read_nfo_file(path)
return result is not None
elif op_type == 'write':
root = operation.get('root')
lock_metadata = operation.get('lock_metadata', True)
return await async_write_nfo_file(path, root, lock_metadata)
else:
_log("ERROR", f"Unknown NFO operation type: {op_type}")
return False
except Exception as e:
_log("ERROR", f"Failed to execute NFO operation: {e}")
return False
return await async_batch_process_files(
[op.get('path') for op in nfo_operations],
lambda path: _execute_nfo_operation(next(op for op in nfo_operations if op.get('path') == path)),
max_concurrent
)
async def async_concurrent_episode_processing(
episodes_data: List[Dict[str, Any]],
process_episode_func,
max_concurrent: int = 3
) -> List[Any]:
"""
Process multiple episodes concurrently
Args:
episodes_data: List of episode data dictionaries
process_episode_func: Async function to process each episode
max_concurrent: Maximum number of concurrent episode processes
Returns:
List of processing results
"""
semaphore = asyncio.Semaphore(max_concurrent)
async def _process_episode_with_semaphore(episode_data: Dict[str, Any]) -> Any:
async with semaphore:
try:
return await process_episode_func(episode_data)
except Exception as e:
_log("ERROR", f"Failed to process episode {episode_data}: {e}")
return None
# Create tasks for all episodes
tasks = [
_process_episode_with_semaphore(episode_data)
for episode_data in episodes_data
]
# Execute all tasks concurrently
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
async def async_directory_scan_with_stats(
directories: List[Path],
file_extensions: Optional[Set[str]] = None
) -> Dict[str, Any]:
"""
Async scan multiple directories and gather statistics
Args:
directories: List of directories to scan
file_extensions: Optional set of file extensions to filter by
Returns:
Dictionary with scan statistics and file lists
"""
if file_extensions is None:
file_extensions = VIDEO_EXTENSIONS
stats = {
'total_files': 0,
'total_directories': len(directories),
'files_by_directory': {},
'scan_errors': [],
'total_size_bytes': 0
}
async def _scan_single_directory(directory: Path) -> Dict[str, Any]:
dir_stats = {
'path': str(directory),
'files': [],
'file_count': 0,
'size_bytes': 0,
'error': None
}
try:
if not await async_file_exists(directory):
dir_stats['error'] = 'Directory does not exist'
return dir_stats
files = await async_find_video_files(directory, recursive=True)
dir_stats['files'] = [str(f) for f in files]
dir_stats['file_count'] = len(files)
# Calculate total size
for file_path in files:
try:
stat_result = await aiofiles.os.stat(file_path)
dir_stats['size_bytes'] += stat_result.st_size
except Exception:
pass # Skip files we can't stat
except Exception as e:
dir_stats['error'] = str(e)
stats['scan_errors'].append(f"{directory}: {e}")
return dir_stats
# Scan all directories concurrently
directory_results = await asyncio.gather(
*[_scan_single_directory(directory) for directory in directories],
return_exceptions=True
)
# Aggregate results
for result in directory_results:
if isinstance(result, dict) and not result.get('error'):
stats['files_by_directory'][result['path']] = result
stats['total_files'] += result['file_count']
stats['total_size_bytes'] += result['size_bytes']
return stats