82eb29fd63
Local Docker Build (Dev) / build-dev (pull_request) Successful in 20s
- Add utils/async_file_utils.py with aiofiles-based operations - Create core/async_nfo_manager.py for concurrent NFO processing - Add async methods to TVProcessor for concurrent episode handling - Implement async_find_episodes_on_disk() for faster scanning - Add async_batch_create_episode_nfos() for concurrent NFO creation - Support async_process_multiple_series() for parallel processing - Optimize I/O performance for large media libraries - Enable controlled concurrency with semaphores and rate limiting Performance improvements: - Concurrent file operations reduce I/O wait times - Batch processing minimizes individual file access overhead - Configurable concurrency limits prevent system overload - Async directory scanning for faster episode discovery
468 lines
14 KiB
Python
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 |