mirror of
https://github.com/pacnpal/Pac-cogs.git
synced 2025-12-20 02:41:06 -05:00
fixed
This commit is contained in:
@@ -4,31 +4,68 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional, TypedDict, ClassVar, List, Set, Union
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import discord
|
||||||
from redbot.core.bot import Red
|
from redbot.core.bot import Red
|
||||||
from redbot.core.commands import GroupCog
|
from redbot.core.commands import GroupCog, Context
|
||||||
|
|
||||||
from .settings import Settings
|
from .settings import Settings
|
||||||
from .lifecycle import LifecycleManager
|
from .lifecycle import LifecycleManager, LifecycleState
|
||||||
from .component_manager import ComponentManager, ComponentState
|
from .component_manager import ComponentManager, ComponentState
|
||||||
from .error_handler import error_manager, handle_command_error
|
from .error_handler import error_manager, handle_command_error
|
||||||
from .response_handler import response_manager
|
from .response_handler import response_manager
|
||||||
from .commands import setup_archiver_commands, setup_database_commands, setup_settings_commands
|
from .commands.archiver_commands import setup_archiver_commands
|
||||||
from .events import setup_events
|
from .commands.database_commands import setup_database_commands
|
||||||
|
from .commands.settings_commands import setup_settings_commands
|
||||||
|
from .events import setup_events, EventManager
|
||||||
|
|
||||||
|
from ..processor.core import Processor
|
||||||
|
from ..queue.manager import QueueManager
|
||||||
|
from ..ffmpeg.ffmpeg_manager import FFmpegManager
|
||||||
|
from ..database.video_archive_db import VideoArchiveDB
|
||||||
|
from ..config_manager import ConfigManager
|
||||||
|
from ..utils.exceptions import (
|
||||||
|
CogError,
|
||||||
|
ErrorContext,
|
||||||
|
ErrorSeverity
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
class CogStatus:
|
class CogHealthCheck(TypedDict):
|
||||||
|
"""Type definition for health check status"""
|
||||||
|
name: str
|
||||||
|
status: bool
|
||||||
|
last_check: str
|
||||||
|
details: Optional[Dict[str, Any]]
|
||||||
|
|
||||||
|
class CogStatus(TypedDict):
|
||||||
|
"""Type definition for cog status"""
|
||||||
|
uptime: float
|
||||||
|
last_error: Optional[str]
|
||||||
|
error_count: int
|
||||||
|
command_count: int
|
||||||
|
last_command: Optional[str]
|
||||||
|
health_checks: Dict[str, CogHealthCheck]
|
||||||
|
state: str
|
||||||
|
ready: bool
|
||||||
|
|
||||||
|
class StatusTracker:
|
||||||
"""Tracks cog status and health"""
|
"""Tracks cog status and health"""
|
||||||
|
|
||||||
def __init__(self):
|
HEALTH_CHECK_INTERVAL: ClassVar[int] = 30 # Seconds between health checks
|
||||||
|
ERROR_THRESHOLD: ClassVar[int] = 100 # Maximum errors before health warning
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
self.start_time = datetime.utcnow()
|
self.start_time = datetime.utcnow()
|
||||||
self.last_error: Optional[str] = None
|
self.last_error: Optional[str] = None
|
||||||
self.error_count = 0
|
self.error_count = 0
|
||||||
self.command_count = 0
|
self.command_count = 0
|
||||||
self.last_command_time: Optional[datetime] = None
|
self.last_command_time: Optional[datetime] = None
|
||||||
self.health_checks: Dict[str, bool] = {}
|
self.health_checks: Dict[str, CogHealthCheck] = {}
|
||||||
|
|
||||||
def record_error(self, error: str) -> None:
|
def record_error(self, error: str) -> None:
|
||||||
"""Record an error occurrence"""
|
"""Record an error occurrence"""
|
||||||
@@ -40,36 +77,70 @@ class CogStatus:
|
|||||||
self.command_count += 1
|
self.command_count += 1
|
||||||
self.last_command_time = datetime.utcnow()
|
self.last_command_time = datetime.utcnow()
|
||||||
|
|
||||||
def update_health_check(self, check: str, status: bool) -> None:
|
def update_health_check(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
status: bool,
|
||||||
|
details: Optional[Dict[str, Any]] = None
|
||||||
|
) -> None:
|
||||||
"""Update health check status"""
|
"""Update health check status"""
|
||||||
self.health_checks[check] = status
|
self.health_checks[name] = CogHealthCheck(
|
||||||
|
name=name,
|
||||||
|
status=status,
|
||||||
|
last_check=datetime.utcnow().isoformat(),
|
||||||
|
details=details
|
||||||
|
)
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> CogStatus:
|
||||||
"""Get current status"""
|
"""Get current status"""
|
||||||
return {
|
return CogStatus(
|
||||||
"uptime": (datetime.utcnow() - self.start_time).total_seconds(),
|
uptime=(datetime.utcnow() - self.start_time).total_seconds(),
|
||||||
"last_error": self.last_error,
|
last_error=self.last_error,
|
||||||
"error_count": self.error_count,
|
error_count=self.error_count,
|
||||||
"command_count": self.command_count,
|
command_count=self.command_count,
|
||||||
"last_command": self.last_command_time.isoformat() if self.last_command_time else None,
|
last_command=self.last_command_time.isoformat() if self.last_command_time else None,
|
||||||
"health_checks": self.health_checks.copy()
|
health_checks=self.health_checks.copy(),
|
||||||
}
|
state="healthy" if self.is_healthy() else "unhealthy",
|
||||||
|
ready=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_healthy(self) -> bool:
|
||||||
|
"""Check if cog is healthy"""
|
||||||
|
if self.error_count > self.ERROR_THRESHOLD:
|
||||||
|
return False
|
||||||
|
return all(check["status"] for check in self.health_checks.values())
|
||||||
|
|
||||||
class ComponentAccessor:
|
class ComponentAccessor:
|
||||||
"""Provides safe access to components"""
|
"""Provides safe access to components"""
|
||||||
|
|
||||||
def __init__(self, component_manager: ComponentManager):
|
def __init__(self, component_manager: ComponentManager) -> None:
|
||||||
self._component_manager = component_manager
|
self._component_manager = component_manager
|
||||||
|
|
||||||
def get_component(self, name: str) -> Optional[Any]:
|
def get_component(self, name: str) -> Optional[Any]:
|
||||||
"""Get a component with state validation"""
|
"""
|
||||||
|
Get a component with state validation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Component name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Component instance if ready, None otherwise
|
||||||
|
"""
|
||||||
component = self._component_manager.get(name)
|
component = self._component_manager.get(name)
|
||||||
if component and component.state == ComponentState.READY:
|
if component and component.state == ComponentState.READY:
|
||||||
return component
|
return component
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_component_status(self, name: str) -> Dict[str, Any]:
|
def get_component_status(self, name: str) -> Dict[str, Any]:
|
||||||
"""Get component status"""
|
"""
|
||||||
|
Get component status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Component name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Component status dictionary
|
||||||
|
"""
|
||||||
return self._component_manager.get_component_status().get(name, {})
|
return self._component_manager.get_component_status().get(name, {})
|
||||||
|
|
||||||
class VideoArchiver(GroupCog, Settings):
|
class VideoArchiver(GroupCog, Settings):
|
||||||
@@ -85,7 +156,19 @@ class VideoArchiver(GroupCog, Settings):
|
|||||||
self.lifecycle_manager = LifecycleManager(self)
|
self.lifecycle_manager = LifecycleManager(self)
|
||||||
self.component_manager = ComponentManager(self)
|
self.component_manager = ComponentManager(self)
|
||||||
self.component_accessor = ComponentAccessor(self.component_manager)
|
self.component_accessor = ComponentAccessor(self.component_manager)
|
||||||
self.status = CogStatus()
|
self.status_tracker = StatusTracker()
|
||||||
|
self.event_manager: Optional[EventManager] = None
|
||||||
|
|
||||||
|
# Initialize task trackers
|
||||||
|
self._init_task: Optional[asyncio.Task] = None
|
||||||
|
self._cleanup_task: Optional[asyncio.Task] = None
|
||||||
|
self._queue_task: Optional[asyncio.Task] = None
|
||||||
|
self._health_tasks: Set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
# Initialize component storage
|
||||||
|
self.components: Dict[int, Dict[str, Any]] = {}
|
||||||
|
self.update_checker = None
|
||||||
|
self._db = None
|
||||||
|
|
||||||
# Set up commands
|
# Set up commands
|
||||||
setup_archiver_commands(self)
|
setup_archiver_commands(self)
|
||||||
@@ -93,42 +176,85 @@ class VideoArchiver(GroupCog, Settings):
|
|||||||
setup_settings_commands(self)
|
setup_settings_commands(self)
|
||||||
|
|
||||||
# Set up events
|
# Set up events
|
||||||
setup_events(self)
|
self.event_manager = setup_events(self)
|
||||||
|
|
||||||
# Register cleanup handlers
|
# Register cleanup handlers
|
||||||
self.lifecycle_manager.register_cleanup_handler(self._cleanup_handler)
|
self.lifecycle_manager.register_cleanup_handler(self._cleanup_handler)
|
||||||
|
|
||||||
async def cog_load(self) -> None:
|
async def cog_load(self) -> None:
|
||||||
"""Handle cog loading"""
|
"""
|
||||||
|
Handle cog loading.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CogError: If loading fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
await self.lifecycle_manager.handle_load()
|
await self.lifecycle_manager.handle_load()
|
||||||
await self._start_health_monitoring()
|
await self._start_health_monitoring()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status.record_error(str(e))
|
error = f"Failed to load cog: {str(e)}"
|
||||||
raise
|
self.status_tracker.record_error(error)
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise CogError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"VideoArchiver",
|
||||||
|
"cog_load",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.CRITICAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def cog_unload(self) -> None:
|
async def cog_unload(self) -> None:
|
||||||
"""Handle cog unloading"""
|
"""
|
||||||
|
Handle cog unloading.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CogError: If unloading fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Cancel health monitoring
|
||||||
|
for task in self._health_tasks:
|
||||||
|
task.cancel()
|
||||||
|
self._health_tasks.clear()
|
||||||
|
|
||||||
await self.lifecycle_manager.handle_unload()
|
await self.lifecycle_manager.handle_unload()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status.record_error(str(e))
|
error = f"Failed to unload cog: {str(e)}"
|
||||||
raise
|
self.status_tracker.record_error(error)
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise CogError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"VideoArchiver",
|
||||||
|
"cog_unload",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.CRITICAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def cog_command_error(self, ctx, error):
|
async def cog_command_error(
|
||||||
|
self,
|
||||||
|
ctx: Context,
|
||||||
|
error: Exception
|
||||||
|
) -> None:
|
||||||
"""Handle command errors"""
|
"""Handle command errors"""
|
||||||
self.status.record_error(str(error))
|
self.status_tracker.record_error(str(error))
|
||||||
await handle_command_error(ctx, error)
|
await handle_command_error(ctx, error)
|
||||||
|
|
||||||
async def cog_before_invoke(self, ctx) -> bool:
|
async def cog_before_invoke(self, ctx: Context) -> bool:
|
||||||
"""Pre-command hook"""
|
"""Pre-command hook"""
|
||||||
self.status.record_command()
|
self.status_tracker.record_command()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _start_health_monitoring(self) -> None:
|
async def _start_health_monitoring(self) -> None:
|
||||||
"""Start health monitoring tasks"""
|
"""Start health monitoring tasks"""
|
||||||
asyncio.create_task(self._monitor_component_health())
|
self._health_tasks.add(
|
||||||
asyncio.create_task(self._monitor_system_health())
|
asyncio.create_task(self._monitor_component_health())
|
||||||
|
)
|
||||||
|
self._health_tasks.add(
|
||||||
|
asyncio.create_task(self._monitor_system_health())
|
||||||
|
)
|
||||||
|
|
||||||
async def _monitor_component_health(self) -> None:
|
async def _monitor_component_health(self) -> None:
|
||||||
"""Monitor component health"""
|
"""Monitor component health"""
|
||||||
@@ -136,98 +262,134 @@ class VideoArchiver(GroupCog, Settings):
|
|||||||
try:
|
try:
|
||||||
component_status = self.component_manager.get_component_status()
|
component_status = self.component_manager.get_component_status()
|
||||||
for name, status in component_status.items():
|
for name, status in component_status.items():
|
||||||
self.status.update_health_check(
|
self.status_tracker.update_health_check(
|
||||||
f"component_{name}",
|
f"component_{name}",
|
||||||
status["state"] == ComponentState.READY.value
|
status["state"] == ComponentState.READY.name,
|
||||||
|
status
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error monitoring component health: {e}")
|
logger.error(f"Error monitoring component health: {e}", exc_info=True)
|
||||||
await asyncio.sleep(60) # Check every minute
|
await asyncio.sleep(self.status_tracker.HEALTH_CHECK_INTERVAL)
|
||||||
|
|
||||||
async def _monitor_system_health(self) -> None:
|
async def _monitor_system_health(self) -> None:
|
||||||
"""Monitor system health metrics"""
|
"""Monitor system health metrics"""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Check queue health
|
# Check queue health
|
||||||
queue_manager = self.queue_manager
|
if queue_manager := self.queue_manager:
|
||||||
if queue_manager:
|
|
||||||
queue_status = await queue_manager.get_queue_status()
|
queue_status = await queue_manager.get_queue_status()
|
||||||
self.status.update_health_check(
|
self.status_tracker.update_health_check(
|
||||||
"queue_health",
|
"queue_health",
|
||||||
queue_status["active"] and not queue_status["stalled"]
|
queue_status["active"] and not queue_status["stalled"],
|
||||||
|
queue_status
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check processor health
|
# Check processor health
|
||||||
processor = self.processor
|
if processor := self.processor:
|
||||||
if processor:
|
|
||||||
processor_status = await processor.get_status()
|
processor_status = await processor.get_status()
|
||||||
self.status.update_health_check(
|
self.status_tracker.update_health_check(
|
||||||
"processor_health",
|
"processor_health",
|
||||||
processor_status["active"]
|
processor_status["active"],
|
||||||
|
processor_status
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check database health
|
# Check database health
|
||||||
db = self.db
|
if db := self.db:
|
||||||
if db:
|
|
||||||
db_status = await db.get_status()
|
db_status = await db.get_status()
|
||||||
self.status.update_health_check(
|
self.status_tracker.update_health_check(
|
||||||
"database_health",
|
"database_health",
|
||||||
db_status["connected"]
|
db_status["connected"],
|
||||||
|
db_status
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check event system health
|
||||||
|
if self.event_manager:
|
||||||
|
event_stats = self.event_manager.get_stats()
|
||||||
|
self.status_tracker.update_health_check(
|
||||||
|
"event_health",
|
||||||
|
event_stats["health"],
|
||||||
|
event_stats
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error monitoring system health: {e}")
|
logger.error(f"Error monitoring system health: {e}", exc_info=True)
|
||||||
await asyncio.sleep(30) # Check every 30 seconds
|
await asyncio.sleep(self.status_tracker.HEALTH_CHECK_INTERVAL)
|
||||||
|
|
||||||
async def _cleanup_handler(self) -> None:
|
async def _cleanup_handler(self) -> None:
|
||||||
"""Custom cleanup handler"""
|
"""Custom cleanup handler"""
|
||||||
try:
|
try:
|
||||||
# Perform any custom cleanup
|
# Cancel health monitoring tasks
|
||||||
pass
|
for task in self._health_tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._health_tasks.clear()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in cleanup handler: {e}")
|
logger.error(f"Error in cleanup handler: {e}", exc_info=True)
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> Dict[str, Any]:
|
||||||
"""Get comprehensive cog status"""
|
"""
|
||||||
|
Get comprehensive cog status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing cog status information
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"cog": self.status.get_status(),
|
"cog": self.status_tracker.get_status(),
|
||||||
"lifecycle": self.lifecycle_manager.get_status(),
|
"lifecycle": self.lifecycle_manager.get_status(),
|
||||||
"components": self.component_manager.get_component_status(),
|
"components": self.component_manager.get_component_status(),
|
||||||
"errors": error_manager.tracker.get_error_stats()
|
"errors": error_manager.tracker.get_error_stats(),
|
||||||
|
"events": self.event_manager.get_stats() if self.event_manager else None
|
||||||
}
|
}
|
||||||
|
|
||||||
# Component property accessors
|
# Component property accessors
|
||||||
@property
|
@property
|
||||||
def processor(self):
|
def processor(self) -> Optional[Processor]:
|
||||||
"""Get the processor component"""
|
"""Get the processor component"""
|
||||||
return self.component_accessor.get_component("processor")
|
return self.component_accessor.get_component("processor")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def queue_manager(self):
|
def queue_manager(self) -> Optional[QueueManager]:
|
||||||
"""Get the queue manager component"""
|
"""Get the queue manager component"""
|
||||||
return self.component_accessor.get_component("queue_manager")
|
return self.component_accessor.get_component("queue_manager")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config_manager(self):
|
def config_manager(self) -> Optional[ConfigManager]:
|
||||||
"""Get the config manager component"""
|
"""Get the config manager component"""
|
||||||
return self.component_accessor.get_component("config_manager")
|
return self.component_accessor.get_component("config_manager")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ffmpeg_mgr(self):
|
def ffmpeg_mgr(self) -> Optional[FFmpegManager]:
|
||||||
"""Get the FFmpeg manager component"""
|
"""Get the FFmpeg manager component"""
|
||||||
return self.component_accessor.get_component("ffmpeg_mgr")
|
return self.component_accessor.get_component("ffmpeg_mgr")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db(self):
|
def db(self) -> Optional[VideoArchiveDB]:
|
||||||
"""Get the database component"""
|
"""Get the database component"""
|
||||||
return self.component_accessor.get_component("db")
|
return self._db
|
||||||
|
|
||||||
|
@db.setter
|
||||||
|
def db(self, value: VideoArchiveDB) -> None:
|
||||||
|
"""Set the database component"""
|
||||||
|
self._db = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data_path(self):
|
def data_path(self) -> Optional[Path]:
|
||||||
"""Get the data path"""
|
"""Get the data path"""
|
||||||
return self.component_accessor.get_component("data_path")
|
return self.component_accessor.get_component("data_path")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def download_path(self):
|
def download_path(self) -> Optional[Path]:
|
||||||
"""Get the download path"""
|
"""Get the download path"""
|
||||||
return self.component_accessor.get_component("download_path")
|
return self.component_accessor.get_component("download_path")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def queue_handler(self):
|
||||||
|
"""Get the queue handler from processor"""
|
||||||
|
if processor := self.processor:
|
||||||
|
return processor.queue_handler
|
||||||
|
return None
|
||||||
|
|||||||
@@ -2,33 +2,61 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Dict, Any, Optional, Set, List
|
from typing import Dict, Any, Optional, Set, List, TypedDict, ClassVar, Type, Union, Protocol
|
||||||
from enum import Enum
|
from enum import Enum, auto
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..utils.exceptions import (
|
||||||
|
ComponentError,
|
||||||
|
ErrorContext,
|
||||||
|
ErrorSeverity
|
||||||
|
)
|
||||||
|
from ..utils.path_manager import ensure_directory
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
class ComponentState(Enum):
|
class ComponentState(Enum):
|
||||||
"""Possible states of a component"""
|
"""Possible states of a component"""
|
||||||
UNREGISTERED = "unregistered"
|
UNREGISTERED = auto()
|
||||||
REGISTERED = "registered"
|
REGISTERED = auto()
|
||||||
INITIALIZING = "initializing"
|
INITIALIZING = auto()
|
||||||
READY = "ready"
|
READY = auto()
|
||||||
ERROR = "error"
|
ERROR = auto()
|
||||||
SHUTDOWN = "shutdown"
|
SHUTDOWN = auto()
|
||||||
|
|
||||||
class ComponentDependencyError(Exception):
|
class ComponentHistory(TypedDict):
|
||||||
"""Raised when component dependencies cannot be satisfied"""
|
"""Type definition for component history entry"""
|
||||||
pass
|
component: str
|
||||||
|
state: str
|
||||||
|
timestamp: str
|
||||||
|
error: Optional[str]
|
||||||
|
duration: float
|
||||||
|
|
||||||
class ComponentLifecycleError(Exception):
|
class ComponentStatus(TypedDict):
|
||||||
"""Raised when component lifecycle operations fail"""
|
"""Type definition for component status"""
|
||||||
pass
|
state: str
|
||||||
|
registration_time: Optional[str]
|
||||||
|
initialization_time: Optional[str]
|
||||||
|
dependencies: Set[str]
|
||||||
|
dependents: Set[str]
|
||||||
|
error: Optional[str]
|
||||||
|
health: bool
|
||||||
|
|
||||||
|
class Initializable(Protocol):
|
||||||
|
"""Protocol for initializable components"""
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Initialize the component"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def shutdown(self) -> None:
|
||||||
|
"""Shutdown the component"""
|
||||||
|
...
|
||||||
|
|
||||||
class Component:
|
class Component:
|
||||||
"""Base class for managed components"""
|
"""Base class for managed components"""
|
||||||
|
|
||||||
def __init__(self, name: str):
|
def __init__(self, name: str) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.state = ComponentState.UNREGISTERED
|
self.state = ComponentState.UNREGISTERED
|
||||||
self.dependencies: Set[str] = set()
|
self.dependencies: Set[str] = set()
|
||||||
@@ -36,33 +64,74 @@ class Component:
|
|||||||
self.registration_time: Optional[datetime] = None
|
self.registration_time: Optional[datetime] = None
|
||||||
self.initialization_time: Optional[datetime] = None
|
self.initialization_time: Optional[datetime] = None
|
||||||
self.error: Optional[str] = None
|
self.error: Optional[str] = None
|
||||||
|
self._health_check_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
"""Initialize the component"""
|
"""
|
||||||
|
Initialize the component.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ComponentError: If initialization fails
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
"""Shutdown the component"""
|
"""
|
||||||
pass
|
Shutdown the component.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ComponentError: If shutdown fails
|
||||||
|
"""
|
||||||
|
if self._health_check_task:
|
||||||
|
self._health_check_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._health_check_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_healthy(self) -> bool:
|
||||||
|
"""Check if component is healthy"""
|
||||||
|
return self.state == ComponentState.READY and not self.error
|
||||||
|
|
||||||
class ComponentTracker:
|
class ComponentTracker:
|
||||||
"""Tracks component states and relationships"""
|
"""Tracks component states and relationships"""
|
||||||
|
|
||||||
def __init__(self):
|
MAX_HISTORY: ClassVar[int] = 1000 # Maximum history entries to keep
|
||||||
self.states: Dict[str, ComponentState] = {}
|
|
||||||
self.history: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
def update_state(self, name: str, state: ComponentState, error: Optional[str] = None) -> None:
|
def __init__(self) -> None:
|
||||||
|
self.states: Dict[str, ComponentState] = {}
|
||||||
|
self.history: List[ComponentHistory] = []
|
||||||
|
|
||||||
|
def update_state(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
state: ComponentState,
|
||||||
|
error: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
"""Update component state"""
|
"""Update component state"""
|
||||||
self.states[name] = state
|
self.states[name] = state
|
||||||
self.history.append({
|
|
||||||
"component": name,
|
# Add history entry
|
||||||
"state": state.value,
|
now = datetime.utcnow()
|
||||||
"timestamp": datetime.utcnow(),
|
duration = 0.0
|
||||||
"error": error
|
if self.history:
|
||||||
})
|
last_entry = self.history[-1]
|
||||||
|
last_time = datetime.fromisoformat(last_entry["timestamp"])
|
||||||
|
duration = (now - last_time).total_seconds()
|
||||||
|
|
||||||
def get_component_history(self, name: str) -> List[Dict[str, Any]]:
|
self.history.append(ComponentHistory(
|
||||||
|
component=name,
|
||||||
|
state=state.name,
|
||||||
|
timestamp=now.isoformat(),
|
||||||
|
error=error,
|
||||||
|
duration=duration
|
||||||
|
))
|
||||||
|
|
||||||
|
# Cleanup old history
|
||||||
|
if len(self.history) > self.MAX_HISTORY:
|
||||||
|
self.history = self.history[-self.MAX_HISTORY:]
|
||||||
|
|
||||||
|
def get_component_history(self, name: str) -> List[ComponentHistory]:
|
||||||
"""Get state history for a component"""
|
"""Get state history for a component"""
|
||||||
return [
|
return [
|
||||||
entry for entry in self.history
|
entry for entry in self.history
|
||||||
@@ -72,12 +141,33 @@ class ComponentTracker:
|
|||||||
class DependencyManager:
|
class DependencyManager:
|
||||||
"""Manages component dependencies"""
|
"""Manages component dependencies"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.dependencies: Dict[str, Set[str]] = {}
|
self.dependencies: Dict[str, Set[str]] = {}
|
||||||
self.dependents: Dict[str, Set[str]] = {}
|
self.dependents: Dict[str, Set[str]] = {}
|
||||||
|
|
||||||
def add_dependency(self, component: str, dependency: str) -> None:
|
def add_dependency(self, component: str, dependency: str) -> None:
|
||||||
"""Add a dependency relationship"""
|
"""
|
||||||
|
Add a dependency relationship.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
component: Component name
|
||||||
|
dependency: Dependency name
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ComponentError: If dependency cycle is detected
|
||||||
|
"""
|
||||||
|
# Check for cycles
|
||||||
|
if self._would_create_cycle(component, dependency):
|
||||||
|
raise ComponentError(
|
||||||
|
f"Dependency cycle detected: {component} -> {dependency}",
|
||||||
|
context=ErrorContext(
|
||||||
|
"DependencyManager",
|
||||||
|
"add_dependency",
|
||||||
|
{"component": component, "dependency": dependency},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if component not in self.dependencies:
|
if component not in self.dependencies:
|
||||||
self.dependencies[component] = set()
|
self.dependencies[component] = set()
|
||||||
self.dependencies[component].add(dependency)
|
self.dependencies[component].add(dependency)
|
||||||
@@ -86,6 +176,23 @@ class DependencyManager:
|
|||||||
self.dependents[dependency] = set()
|
self.dependents[dependency] = set()
|
||||||
self.dependents[dependency].add(component)
|
self.dependents[dependency].add(component)
|
||||||
|
|
||||||
|
def _would_create_cycle(self, component: str, dependency: str) -> bool:
|
||||||
|
"""Check if adding dependency would create a cycle"""
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
def has_path(start: str, end: str) -> bool:
|
||||||
|
if start == end:
|
||||||
|
return True
|
||||||
|
if start in visited:
|
||||||
|
return False
|
||||||
|
visited.add(start)
|
||||||
|
return any(
|
||||||
|
has_path(dep, end)
|
||||||
|
for dep in self.dependencies.get(start, set())
|
||||||
|
)
|
||||||
|
|
||||||
|
return has_path(dependency, component)
|
||||||
|
|
||||||
def get_dependencies(self, component: str) -> Set[str]:
|
def get_dependencies(self, component: str) -> Set[str]:
|
||||||
"""Get dependencies for a component"""
|
"""Get dependencies for a component"""
|
||||||
return self.dependencies.get(component, set())
|
return self.dependencies.get(component, set())
|
||||||
@@ -95,27 +202,72 @@ class DependencyManager:
|
|||||||
return self.dependents.get(component, set())
|
return self.dependents.get(component, set())
|
||||||
|
|
||||||
def get_initialization_order(self) -> List[str]:
|
def get_initialization_order(self) -> List[str]:
|
||||||
"""Get components in dependency order"""
|
"""
|
||||||
visited = set()
|
Get components in dependency order.
|
||||||
order = []
|
|
||||||
|
Returns:
|
||||||
|
List of component names in initialization order
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ComponentError: If dependency cycle is detected
|
||||||
|
"""
|
||||||
|
visited: Set[str] = set()
|
||||||
|
temp_visited: Set[str] = set()
|
||||||
|
order: List[str] = []
|
||||||
|
|
||||||
def visit(component: str) -> None:
|
def visit(component: str) -> None:
|
||||||
|
if component in temp_visited:
|
||||||
|
cycle = " -> ".join(
|
||||||
|
name for name in self.dependencies
|
||||||
|
if name in temp_visited
|
||||||
|
)
|
||||||
|
raise ComponentError(
|
||||||
|
f"Dependency cycle detected: {cycle}",
|
||||||
|
context=ErrorContext(
|
||||||
|
"DependencyManager",
|
||||||
|
"get_initialization_order",
|
||||||
|
{"cycle": cycle},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
if component in visited:
|
if component in visited:
|
||||||
return
|
return
|
||||||
visited.add(component)
|
|
||||||
|
temp_visited.add(component)
|
||||||
for dep in self.dependencies.get(component, set()):
|
for dep in self.dependencies.get(component, set()):
|
||||||
visit(dep)
|
visit(dep)
|
||||||
|
temp_visited.remove(component)
|
||||||
|
visited.add(component)
|
||||||
order.append(component)
|
order.append(component)
|
||||||
|
|
||||||
for component in self.dependencies:
|
try:
|
||||||
visit(component)
|
for component in self.dependencies:
|
||||||
|
if component not in visited:
|
||||||
|
visit(component)
|
||||||
|
except RecursionError:
|
||||||
|
raise ComponentError(
|
||||||
|
"Dependency resolution exceeded maximum recursion depth",
|
||||||
|
context=ErrorContext(
|
||||||
|
"DependencyManager",
|
||||||
|
"get_initialization_order",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return order
|
return order
|
||||||
|
|
||||||
class ComponentManager:
|
class ComponentManager:
|
||||||
"""Manages VideoArchiver components"""
|
"""Manages VideoArchiver components"""
|
||||||
|
|
||||||
def __init__(self, cog):
|
CORE_COMPONENTS: ClassVar[Dict[str, Tuple[Type[Any], Set[str]]]] = {
|
||||||
|
"config_manager": ("..config_manager.ConfigManager", set()),
|
||||||
|
"processor": ("..processor.core.Processor", {"config_manager"}),
|
||||||
|
"queue_manager": ("..queue.manager.EnhancedVideoQueueManager", {"config_manager"}),
|
||||||
|
"ffmpeg_mgr": ("..ffmpeg.ffmpeg_manager.FFmpegManager", set())
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, cog: Any) -> None:
|
||||||
self.cog = cog
|
self.cog = cog
|
||||||
self._components: Dict[str, Component] = {}
|
self._components: Dict[str, Component] = {}
|
||||||
self.tracker = ComponentTracker()
|
self.tracker = ComponentTracker()
|
||||||
@@ -124,21 +276,41 @@ class ComponentManager:
|
|||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
component: Any,
|
component: Union[Component, Any],
|
||||||
dependencies: Optional[Set[str]] = None
|
dependencies: Optional[Set[str]] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register a component with dependencies"""
|
"""
|
||||||
|
Register a component with dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Component name
|
||||||
|
component: Component instance
|
||||||
|
dependencies: Optional set of dependency names
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ComponentError: If registration fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Wrap non-Component objects
|
# Wrap non-Component objects
|
||||||
if not isinstance(component, Component):
|
if not isinstance(component, Component):
|
||||||
component = Component(name)
|
wrapped = Component(name)
|
||||||
|
if isinstance(component, Initializable):
|
||||||
|
wrapped.initialize = component.initialize
|
||||||
|
wrapped.shutdown = component.shutdown
|
||||||
|
component = wrapped
|
||||||
|
|
||||||
# Register dependencies
|
# Register dependencies
|
||||||
if dependencies:
|
if dependencies:
|
||||||
for dep in dependencies:
|
for dep in dependencies:
|
||||||
if dep not in self._components:
|
if dep not in self._components:
|
||||||
raise ComponentDependencyError(
|
raise ComponentError(
|
||||||
f"Dependency {dep} not registered for {name}"
|
f"Dependency {dep} not registered for {name}",
|
||||||
|
context=ErrorContext(
|
||||||
|
"ComponentManager",
|
||||||
|
"register",
|
||||||
|
{"component": name, "dependency": dep},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.dependency_manager.add_dependency(name, dep)
|
self.dependency_manager.add_dependency(name, dep)
|
||||||
|
|
||||||
@@ -149,19 +321,33 @@ class ComponentManager:
|
|||||||
logger.debug(f"Registered component: {name}")
|
logger.debug(f"Registered component: {name}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error registering component {name}: {e}")
|
error = f"Failed to register component {name}: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
self.tracker.update_state(name, ComponentState.ERROR, str(e))
|
self.tracker.update_state(name, ComponentState.ERROR, str(e))
|
||||||
raise ComponentLifecycleError(f"Failed to register component: {str(e)}")
|
raise ComponentError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"ComponentManager",
|
||||||
|
"register",
|
||||||
|
{"component": name},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def initialize_components(self) -> None:
|
async def initialize_components(self) -> None:
|
||||||
"""Initialize all components in dependency order"""
|
"""
|
||||||
|
Initialize all components in dependency order.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ComponentError: If initialization fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get initialization order
|
|
||||||
init_order = self.dependency_manager.get_initialization_order()
|
|
||||||
|
|
||||||
# Initialize core components first
|
# Initialize core components first
|
||||||
await self._initialize_core_components()
|
await self._initialize_core_components()
|
||||||
|
|
||||||
|
# Get initialization order
|
||||||
|
init_order = self.dependency_manager.get_initialization_order()
|
||||||
|
|
||||||
# Initialize remaining components
|
# Initialize remaining components
|
||||||
for name in init_order:
|
for name in init_order:
|
||||||
if name not in self._components:
|
if name not in self._components:
|
||||||
@@ -174,88 +360,172 @@ class ComponentManager:
|
|||||||
component.initialization_time = datetime.utcnow()
|
component.initialization_time = datetime.utcnow()
|
||||||
self.tracker.update_state(name, ComponentState.READY)
|
self.tracker.update_state(name, ComponentState.READY)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error initializing component {name}: {e}")
|
error = f"Failed to initialize component {name}: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
self.tracker.update_state(name, ComponentState.ERROR, str(e))
|
self.tracker.update_state(name, ComponentState.ERROR, str(e))
|
||||||
raise ComponentLifecycleError(
|
raise ComponentError(
|
||||||
f"Failed to initialize component {name}: {str(e)}"
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"ComponentManager",
|
||||||
|
"initialize_components",
|
||||||
|
{"component": name},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during component initialization: {e}")
|
error = f"Component initialization failed: {str(e)}"
|
||||||
raise ComponentLifecycleError(f"Component initialization failed: {str(e)}")
|
logger.error(error, exc_info=True)
|
||||||
|
raise ComponentError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"ComponentManager",
|
||||||
|
"initialize_components",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _initialize_core_components(self) -> None:
|
async def _initialize_core_components(self) -> None:
|
||||||
"""Initialize core system components"""
|
"""
|
||||||
from ..config_manager import ConfigManager
|
Initialize core system components.
|
||||||
from ..processor.core import Processor
|
|
||||||
from ..queue.manager import EnhancedVideoQueueManager
|
Raises:
|
||||||
from ..ffmpeg.ffmpeg_manager import FFmpegManager
|
ComponentError: If core component initialization fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
for name, (component_path, deps) in self.CORE_COMPONENTS.items():
|
||||||
|
module_path, class_name = component_path.rsplit(".", 1)
|
||||||
|
module = __import__(module_path, fromlist=[class_name])
|
||||||
|
component_class = getattr(module, class_name)
|
||||||
|
|
||||||
|
if name == "processor":
|
||||||
|
component = component_class(self.cog)
|
||||||
|
elif name == "ffmpeg_mgr":
|
||||||
|
component = component_class(self.cog)
|
||||||
|
else:
|
||||||
|
component = component_class()
|
||||||
|
|
||||||
core_components = {
|
self.register(name, component, deps)
|
||||||
"config_manager": (ConfigManager(self.cog), set()),
|
|
||||||
"processor": (Processor(self.cog), {"config_manager"}),
|
|
||||||
"queue_manager": (EnhancedVideoQueueManager(), {"config_manager"}),
|
|
||||||
"ffmpeg_mgr": (FFmpegManager(self.cog), set())
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, (component, deps) in core_components.items():
|
# Initialize paths
|
||||||
self.register(name, component, deps)
|
await self._initialize_paths()
|
||||||
|
|
||||||
# Initialize paths
|
except Exception as e:
|
||||||
await self._initialize_paths()
|
error = f"Failed to initialize core components: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise ComponentError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"ComponentManager",
|
||||||
|
"_initialize_core_components",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _initialize_paths(self) -> None:
|
async def _initialize_paths(self) -> None:
|
||||||
"""Initialize required paths"""
|
"""
|
||||||
from pathlib import Path
|
Initialize required paths.
|
||||||
from ..utils.path_manager import ensure_directory
|
|
||||||
|
Raises:
|
||||||
|
ComponentError: If path initialization fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data_dir = Path(self.cog.bot.data_path) / "VideoArchiver"
|
||||||
|
download_dir = data_dir / "downloads"
|
||||||
|
|
||||||
data_dir = Path(self.cog.bot.data_path) / "VideoArchiver"
|
# Ensure directories exist
|
||||||
download_dir = data_dir / "downloads"
|
await ensure_directory(data_dir)
|
||||||
|
await ensure_directory(download_dir)
|
||||||
|
|
||||||
# Ensure directories exist
|
# Register paths
|
||||||
await ensure_directory(data_dir)
|
self.register("data_path", data_dir)
|
||||||
await ensure_directory(download_dir)
|
self.register("download_path", download_dir)
|
||||||
|
|
||||||
# Register paths
|
except Exception as e:
|
||||||
self.register("data_path", data_dir)
|
error = f"Failed to initialize paths: {str(e)}"
|
||||||
self.register("download_path", download_dir)
|
logger.error(error, exc_info=True)
|
||||||
|
raise ComponentError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"ComponentManager",
|
||||||
|
"_initialize_paths",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def get(self, name: str) -> Optional[Any]:
|
def get(self, name: str) -> Optional[Component]:
|
||||||
"""Get a registered component"""
|
"""Get a registered component"""
|
||||||
component = self._components.get(name)
|
return self._components.get(name)
|
||||||
return component if isinstance(component, Component) else None
|
|
||||||
|
|
||||||
async def shutdown_components(self) -> None:
|
async def shutdown_components(self) -> None:
|
||||||
"""Shutdown components in reverse dependency order"""
|
"""
|
||||||
shutdown_order = reversed(self.dependency_manager.get_initialization_order())
|
Shutdown components in reverse dependency order.
|
||||||
|
|
||||||
for name in shutdown_order:
|
Raises:
|
||||||
if name not in self._components:
|
ComponentError: If shutdown fails
|
||||||
continue
|
"""
|
||||||
|
try:
|
||||||
component = self._components[name]
|
shutdown_order = reversed(self.dependency_manager.get_initialization_order())
|
||||||
try:
|
|
||||||
await component.shutdown()
|
for name in shutdown_order:
|
||||||
self.tracker.update_state(name, ComponentState.SHUTDOWN)
|
if name not in self._components:
|
||||||
except Exception as e:
|
continue
|
||||||
logger.error(f"Error shutting down component {name}: {e}")
|
|
||||||
self.tracker.update_state(name, ComponentState.ERROR, str(e))
|
component = self._components[name]
|
||||||
|
try:
|
||||||
|
await component.shutdown()
|
||||||
|
self.tracker.update_state(name, ComponentState.SHUTDOWN)
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Error shutting down component {name}: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
self.tracker.update_state(name, ComponentState.ERROR, str(e))
|
||||||
|
raise ComponentError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"ComponentManager",
|
||||||
|
"shutdown_components",
|
||||||
|
{"component": name},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Component shutdown failed: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise ComponentError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"ComponentManager",
|
||||||
|
"shutdown_components",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear all registered components"""
|
"""Clear all registered components"""
|
||||||
self._components.clear()
|
self._components.clear()
|
||||||
logger.debug("Cleared all components")
|
logger.debug("Cleared all components")
|
||||||
|
|
||||||
def get_component_status(self) -> Dict[str, Any]:
|
def get_component_status(self) -> Dict[str, ComponentStatus]:
|
||||||
"""Get status of all components"""
|
"""
|
||||||
|
Get status of all components.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping component names to their status
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
name: {
|
name: ComponentStatus(
|
||||||
"state": self.tracker.states.get(name, ComponentState.UNREGISTERED).value,
|
state=self.tracker.states.get(name, ComponentState.UNREGISTERED).name,
|
||||||
"registration_time": component.registration_time,
|
registration_time=component.registration_time.isoformat() if component.registration_time else None,
|
||||||
"initialization_time": component.initialization_time,
|
initialization_time=component.initialization_time.isoformat() if component.initialization_time else None,
|
||||||
"dependencies": self.dependency_manager.get_dependencies(name),
|
dependencies=self.dependency_manager.get_dependencies(name),
|
||||||
"dependents": self.dependency_manager.get_dependents(name),
|
dependents=self.dependency_manager.get_dependents(name),
|
||||||
"error": component.error
|
error=component.error,
|
||||||
}
|
health=component.is_healthy()
|
||||||
|
)
|
||||||
for name, component in self._components.items()
|
for name, component in self._components.items()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Dict, Optional, Tuple, Type
|
from typing import Dict, Optional, Tuple, Type, TypedDict, ClassVar
|
||||||
|
from enum import Enum, auto
|
||||||
import discord
|
import discord
|
||||||
from redbot.core.commands import (
|
from redbot.core.commands import (
|
||||||
Context,
|
Context,
|
||||||
@@ -13,98 +14,179 @@ from redbot.core.commands import (
|
|||||||
CommandError
|
CommandError
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..utils.exceptions import VideoArchiverError as ProcessingError, ConfigurationError as ConfigError
|
from ..utils.exceptions import (
|
||||||
|
VideoArchiverError,
|
||||||
|
ErrorSeverity,
|
||||||
|
ErrorContext,
|
||||||
|
ProcessorError,
|
||||||
|
ValidationError,
|
||||||
|
DisplayError,
|
||||||
|
URLExtractionError,
|
||||||
|
MessageHandlerError,
|
||||||
|
QueueHandlerError,
|
||||||
|
QueueProcessorError,
|
||||||
|
FFmpegError,
|
||||||
|
DatabaseError,
|
||||||
|
HealthCheckError,
|
||||||
|
TrackingError,
|
||||||
|
NetworkError,
|
||||||
|
ResourceExhaustedError,
|
||||||
|
ConfigurationError
|
||||||
|
)
|
||||||
from .response_handler import response_manager
|
from .response_handler import response_manager
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
|
class ErrorCategory(Enum):
|
||||||
|
"""Categories of errors"""
|
||||||
|
PERMISSION = auto()
|
||||||
|
ARGUMENT = auto()
|
||||||
|
CONFIGURATION = auto()
|
||||||
|
PROCESSING = auto()
|
||||||
|
NETWORK = auto()
|
||||||
|
RESOURCE = auto()
|
||||||
|
DATABASE = auto()
|
||||||
|
VALIDATION = auto()
|
||||||
|
QUEUE = auto()
|
||||||
|
CLEANUP = auto()
|
||||||
|
HEALTH = auto()
|
||||||
|
UNEXPECTED = auto()
|
||||||
|
|
||||||
|
class ErrorStats(TypedDict):
|
||||||
|
"""Type definition for error statistics"""
|
||||||
|
counts: Dict[str, int]
|
||||||
|
patterns: Dict[str, Dict[str, int]]
|
||||||
|
severities: Dict[str, Dict[str, int]]
|
||||||
|
|
||||||
class ErrorFormatter:
|
class ErrorFormatter:
|
||||||
"""Formats error messages for display"""
|
"""Formats error messages for display"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_permission_error(error: Exception) -> str:
|
def format_error_message(error: Exception, context: Optional[ErrorContext] = None) -> str:
|
||||||
"""Format permission error messages"""
|
"""Format error message with context"""
|
||||||
|
base_message = str(error)
|
||||||
|
if context:
|
||||||
|
return f"{context}: {base_message}"
|
||||||
|
return base_message
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_user_message(error: Exception, category: ErrorCategory) -> str:
|
||||||
|
"""Format user-friendly error message"""
|
||||||
if isinstance(error, MissingPermissions):
|
if isinstance(error, MissingPermissions):
|
||||||
return "You don't have permission to use this command."
|
return "You don't have permission to use this command."
|
||||||
elif isinstance(error, BotMissingPermissions):
|
elif isinstance(error, BotMissingPermissions):
|
||||||
return "I don't have the required permissions to do that."
|
return "I don't have the required permissions to do that."
|
||||||
return str(error)
|
elif isinstance(error, MissingRequiredArgument):
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def format_argument_error(error: Exception) -> str:
|
|
||||||
"""Format argument error messages"""
|
|
||||||
if isinstance(error, MissingRequiredArgument):
|
|
||||||
return f"Missing required argument: {error.param.name}"
|
return f"Missing required argument: {error.param.name}"
|
||||||
elif isinstance(error, BadArgument):
|
elif isinstance(error, BadArgument):
|
||||||
return f"Invalid argument: {str(error)}"
|
return f"Invalid argument: {str(error)}"
|
||||||
|
elif isinstance(error, VideoArchiverError):
|
||||||
|
return str(error)
|
||||||
|
elif category == ErrorCategory.UNEXPECTED:
|
||||||
|
return "An unexpected error occurred. Please check the logs for details."
|
||||||
return str(error)
|
return str(error)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def format_processing_error(error: ProcessingError) -> str:
|
|
||||||
"""Format processing error messages"""
|
|
||||||
return f"Processing error: {str(error)}"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def format_config_error(error: ConfigError) -> str:
|
|
||||||
"""Format configuration error messages"""
|
|
||||||
return f"Configuration error: {str(error)}"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def format_unexpected_error(error: Exception) -> str:
|
|
||||||
"""Format unexpected error messages"""
|
|
||||||
return "An unexpected error occurred. Check the logs for details."
|
|
||||||
|
|
||||||
class ErrorCategorizer:
|
class ErrorCategorizer:
|
||||||
"""Categorizes errors and determines handling strategy"""
|
"""Categorizes errors and determines handling strategy"""
|
||||||
|
|
||||||
ERROR_TYPES = {
|
ERROR_MAPPING: ClassVar[Dict[Type[Exception], Tuple[ErrorCategory, ErrorSeverity]]] = {
|
||||||
MissingPermissions: ("permission", "error"),
|
# Discord command errors
|
||||||
BotMissingPermissions: ("permission", "error"),
|
MissingPermissions: (ErrorCategory.PERMISSION, ErrorSeverity.MEDIUM),
|
||||||
MissingRequiredArgument: ("argument", "warning"),
|
BotMissingPermissions: (ErrorCategory.PERMISSION, ErrorSeverity.HIGH),
|
||||||
BadArgument: ("argument", "warning"),
|
MissingRequiredArgument: (ErrorCategory.ARGUMENT, ErrorSeverity.LOW),
|
||||||
ConfigError: ("configuration", "error"),
|
BadArgument: (ErrorCategory.ARGUMENT, ErrorSeverity.LOW),
|
||||||
ProcessingError: ("processing", "error"),
|
|
||||||
|
# VideoArchiver errors
|
||||||
|
ProcessorError: (ErrorCategory.PROCESSING, ErrorSeverity.HIGH),
|
||||||
|
ValidationError: (ErrorCategory.VALIDATION, ErrorSeverity.MEDIUM),
|
||||||
|
DisplayError: (ErrorCategory.PROCESSING, ErrorSeverity.LOW),
|
||||||
|
URLExtractionError: (ErrorCategory.PROCESSING, ErrorSeverity.MEDIUM),
|
||||||
|
MessageHandlerError: (ErrorCategory.PROCESSING, ErrorSeverity.MEDIUM),
|
||||||
|
QueueHandlerError: (ErrorCategory.QUEUE, ErrorSeverity.HIGH),
|
||||||
|
QueueProcessorError: (ErrorCategory.QUEUE, ErrorSeverity.HIGH),
|
||||||
|
FFmpegError: (ErrorCategory.PROCESSING, ErrorSeverity.HIGH),
|
||||||
|
DatabaseError: (ErrorCategory.DATABASE, ErrorSeverity.HIGH),
|
||||||
|
HealthCheckError: (ErrorCategory.HEALTH, ErrorSeverity.HIGH),
|
||||||
|
TrackingError: (ErrorCategory.PROCESSING, ErrorSeverity.MEDIUM),
|
||||||
|
NetworkError: (ErrorCategory.NETWORK, ErrorSeverity.MEDIUM),
|
||||||
|
ResourceExhaustedError: (ErrorCategory.RESOURCE, ErrorSeverity.HIGH),
|
||||||
|
ConfigurationError: (ErrorCategory.CONFIGURATION, ErrorSeverity.HIGH)
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def categorize_error(cls, error: Exception) -> Tuple[str, str]:
|
def categorize_error(cls, error: Exception) -> Tuple[ErrorCategory, ErrorSeverity]:
|
||||||
"""Categorize an error and determine its severity
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple[str, str]: (Error category, Severity level)
|
|
||||||
"""
|
"""
|
||||||
for error_type, (category, severity) in cls.ERROR_TYPES.items():
|
Categorize an error and determine its severity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error: Exception to categorize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (Error category, Severity level)
|
||||||
|
"""
|
||||||
|
for error_type, (category, severity) in cls.ERROR_MAPPING.items():
|
||||||
if isinstance(error, error_type):
|
if isinstance(error, error_type):
|
||||||
return category, severity
|
return category, severity
|
||||||
return "unexpected", "error"
|
return ErrorCategory.UNEXPECTED, ErrorSeverity.HIGH
|
||||||
|
|
||||||
class ErrorTracker:
|
class ErrorTracker:
|
||||||
"""Tracks error occurrences and patterns"""
|
"""Tracks error occurrences and patterns"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.error_counts: Dict[str, int] = {}
|
self.error_counts: Dict[str, int] = {}
|
||||||
self.error_patterns: Dict[str, Dict[str, int]] = {}
|
self.error_patterns: Dict[str, Dict[str, int]] = {}
|
||||||
|
self.error_severities: Dict[str, Dict[str, int]] = {}
|
||||||
|
|
||||||
def track_error(self, error: Exception, category: str) -> None:
|
def track_error(
|
||||||
"""Track an error occurrence"""
|
self,
|
||||||
|
error: Exception,
|
||||||
|
category: ErrorCategory,
|
||||||
|
severity: ErrorSeverity
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Track an error occurrence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error: Exception that occurred
|
||||||
|
category: Error category
|
||||||
|
severity: Error severity
|
||||||
|
"""
|
||||||
error_type = type(error).__name__
|
error_type = type(error).__name__
|
||||||
|
|
||||||
|
# Track error counts
|
||||||
self.error_counts[error_type] = self.error_counts.get(error_type, 0) + 1
|
self.error_counts[error_type] = self.error_counts.get(error_type, 0) + 1
|
||||||
|
|
||||||
if category not in self.error_patterns:
|
# Track error patterns by category
|
||||||
self.error_patterns[category] = {}
|
if category.value not in self.error_patterns:
|
||||||
self.error_patterns[category][error_type] = self.error_patterns[category].get(error_type, 0) + 1
|
self.error_patterns[category.value] = {}
|
||||||
|
self.error_patterns[category.value][error_type] = (
|
||||||
|
self.error_patterns[category.value].get(error_type, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track error severities
|
||||||
|
if severity.value not in self.error_severities:
|
||||||
|
self.error_severities[severity.value] = {}
|
||||||
|
self.error_severities[severity.value][error_type] = (
|
||||||
|
self.error_severities[severity.value].get(error_type, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
def get_error_stats(self) -> Dict:
|
def get_error_stats(self) -> ErrorStats:
|
||||||
"""Get error statistics"""
|
"""
|
||||||
return {
|
Get error statistics.
|
||||||
"counts": self.error_counts.copy(),
|
|
||||||
"patterns": self.error_patterns.copy()
|
Returns:
|
||||||
}
|
Dictionary containing error statistics
|
||||||
|
"""
|
||||||
|
return ErrorStats(
|
||||||
|
counts=self.error_counts.copy(),
|
||||||
|
patterns=self.error_patterns.copy(),
|
||||||
|
severities=self.error_severities.copy()
|
||||||
|
)
|
||||||
|
|
||||||
class ErrorManager:
|
class ErrorManager:
|
||||||
"""Manages error handling and reporting"""
|
"""Manages error handling and reporting"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.formatter = ErrorFormatter()
|
self.formatter = ErrorFormatter()
|
||||||
self.categorizer = ErrorCategorizer()
|
self.categorizer = ErrorCategorizer()
|
||||||
self.tracker = ErrorTracker()
|
self.tracker = ErrorTracker()
|
||||||
@@ -114,7 +196,8 @@ class ErrorManager:
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
error: Exception
|
error: Exception
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a command error
|
"""
|
||||||
|
Handle a command error.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: Command context
|
ctx: Command context
|
||||||
@@ -124,24 +207,40 @@ class ErrorManager:
|
|||||||
# Categorize error
|
# Categorize error
|
||||||
category, severity = self.categorizer.categorize_error(error)
|
category, severity = self.categorizer.categorize_error(error)
|
||||||
|
|
||||||
# Track error
|
# Create error context
|
||||||
self.tracker.track_error(error, category)
|
context = ErrorContext(
|
||||||
|
component=ctx.command.qualified_name if ctx.command else "unknown",
|
||||||
|
operation="command_execution",
|
||||||
|
details={
|
||||||
|
"guild_id": str(ctx.guild.id) if ctx.guild else "DM",
|
||||||
|
"channel_id": str(ctx.channel.id),
|
||||||
|
"user_id": str(ctx.author.id)
|
||||||
|
},
|
||||||
|
severity=severity
|
||||||
|
)
|
||||||
|
|
||||||
# Format error message
|
# Track error
|
||||||
error_msg = await self._format_error_message(error, category)
|
self.tracker.track_error(error, category, severity)
|
||||||
|
|
||||||
|
# Format error messages
|
||||||
|
log_message = self.formatter.format_error_message(error, context)
|
||||||
|
user_message = self.formatter.format_user_message(error, category)
|
||||||
|
|
||||||
# Log error details
|
# Log error details
|
||||||
self._log_error(ctx, error, category, severity)
|
self._log_error(log_message, severity)
|
||||||
|
|
||||||
# Send response
|
# Send response
|
||||||
await response_manager.send_response(
|
await response_manager.send_response(
|
||||||
ctx,
|
ctx,
|
||||||
content=error_msg,
|
content=user_message,
|
||||||
response_type=severity
|
response_type=severity.name.lower()
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling command error: {str(e)}")
|
logger.error(
|
||||||
|
f"Error handling command error: {str(e)}\n"
|
||||||
|
f"Original error: {traceback.format_exc()}"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await response_manager.send_response(
|
await response_manager.send_response(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -151,46 +250,25 @@ class ErrorManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def _format_error_message(
|
|
||||||
self,
|
|
||||||
error: Exception,
|
|
||||||
category: str
|
|
||||||
) -> str:
|
|
||||||
"""Format error message based on category"""
|
|
||||||
try:
|
|
||||||
if category == "permission":
|
|
||||||
return self.formatter.format_permission_error(error)
|
|
||||||
elif category == "argument":
|
|
||||||
return self.formatter.format_argument_error(error)
|
|
||||||
elif category == "processing":
|
|
||||||
return self.formatter.format_processing_error(error)
|
|
||||||
elif category == "configuration":
|
|
||||||
return self.formatter.format_config_error(error)
|
|
||||||
else:
|
|
||||||
return self.formatter.format_unexpected_error(error)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error formatting error message: {e}")
|
|
||||||
return "An error occurred. Please check the logs."
|
|
||||||
|
|
||||||
def _log_error(
|
def _log_error(
|
||||||
self,
|
self,
|
||||||
ctx: Context,
|
message: str,
|
||||||
error: Exception,
|
severity: ErrorSeverity
|
||||||
category: str,
|
|
||||||
severity: str
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Log error details"""
|
"""
|
||||||
|
Log error details.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Error message to log
|
||||||
|
severity: Error severity
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if severity == "error":
|
if severity in (ErrorSeverity.HIGH, ErrorSeverity.CRITICAL):
|
||||||
logger.error(
|
logger.error(f"{message}\n{traceback.format_exc()}")
|
||||||
f"Command error in {ctx.command} (Category: {category}):\n"
|
elif severity == ErrorSeverity.MEDIUM:
|
||||||
f"{traceback.format_exc()}"
|
logger.warning(message)
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.info(message)
|
||||||
f"Command warning in {ctx.command} (Category: {category}):\n"
|
|
||||||
f"{str(error)}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error logging error details: {e}")
|
logger.error(f"Error logging error details: {e}")
|
||||||
|
|
||||||
@@ -198,5 +276,11 @@ class ErrorManager:
|
|||||||
error_manager = ErrorManager()
|
error_manager = ErrorManager()
|
||||||
|
|
||||||
async def handle_command_error(ctx: Context, error: Exception) -> None:
|
async def handle_command_error(ctx: Context, error: Exception) -> None:
|
||||||
"""Helper function to handle command errors using the error manager"""
|
"""
|
||||||
|
Helper function to handle command errors using the error manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: Command context
|
||||||
|
error: Exception to handle
|
||||||
|
"""
|
||||||
await error_manager.handle_error(ctx, error)
|
await error_manager.handle_error(ctx, error)
|
||||||
|
|||||||
@@ -1,59 +1,147 @@
|
|||||||
"""Event handlers for VideoArchiver"""
|
"""Event handlers for VideoArchiver"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import discord
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
from typing import TYPE_CHECKING, Dict, Any, Optional
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import TYPE_CHECKING, Dict, Any, Optional, TypedDict, ClassVar, List
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
from ..processor.constants import REACTIONS
|
from ..processor.constants import REACTIONS
|
||||||
from ..processor.reactions import handle_archived_reaction
|
from ..processor.reactions import handle_archived_reaction
|
||||||
from .guild import initialize_guild_components, cleanup_guild_components
|
from .guild import initialize_guild_components, cleanup_guild_components
|
||||||
from .error_handler import error_manager
|
from .error_handler import error_manager
|
||||||
from .response_handler import response_manager
|
from .response_handler import response_manager
|
||||||
|
from ..utils.exceptions import EventError, ErrorContext, ErrorSeverity
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .base import VideoArchiver
|
from .base import VideoArchiver
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(Enum):
|
||||||
|
"""Types of Discord events"""
|
||||||
|
GUILD_JOIN = auto()
|
||||||
|
GUILD_REMOVE = auto()
|
||||||
|
MESSAGE = auto()
|
||||||
|
REACTION_ADD = auto()
|
||||||
|
MESSAGE_PROCESSING = auto()
|
||||||
|
REACTION_PROCESSING = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class EventStats(TypedDict):
|
||||||
|
"""Type definition for event statistics"""
|
||||||
|
counts: Dict[str, int]
|
||||||
|
last_events: Dict[str, str]
|
||||||
|
errors: Dict[str, int]
|
||||||
|
error_rate: float
|
||||||
|
health: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EventHistory(TypedDict):
|
||||||
|
"""Type definition for event history entry"""
|
||||||
|
event_type: str
|
||||||
|
timestamp: str
|
||||||
|
guild_id: Optional[int]
|
||||||
|
channel_id: Optional[int]
|
||||||
|
message_id: Optional[int]
|
||||||
|
user_id: Optional[int]
|
||||||
|
error: Optional[str]
|
||||||
|
duration: float
|
||||||
|
|
||||||
|
|
||||||
class EventTracker:
|
class EventTracker:
|
||||||
"""Tracks event occurrences and patterns"""
|
"""Tracks event occurrences and patterns"""
|
||||||
|
|
||||||
def __init__(self):
|
MAX_HISTORY: ClassVar[int] = 1000 # Maximum history entries to keep
|
||||||
|
ERROR_THRESHOLD: ClassVar[float] = 0.1 # 10% error rate threshold
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
self.event_counts: Dict[str, int] = {}
|
self.event_counts: Dict[str, int] = {}
|
||||||
self.last_events: Dict[str, datetime] = {}
|
self.last_events: Dict[str, datetime] = {}
|
||||||
self.error_counts: Dict[str, int] = {}
|
self.error_counts: Dict[str, int] = {}
|
||||||
|
self.history: List[EventHistory] = []
|
||||||
|
|
||||||
def record_event(self, event_type: str) -> None:
|
def record_event(
|
||||||
|
self,
|
||||||
|
event_type: EventType,
|
||||||
|
guild_id: Optional[int] = None,
|
||||||
|
channel_id: Optional[int] = None,
|
||||||
|
message_id: Optional[int] = None,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
"""Record an event occurrence"""
|
"""Record an event occurrence"""
|
||||||
self.event_counts[event_type] = self.event_counts.get(event_type, 0) + 1
|
event_name = event_type.name
|
||||||
self.last_events[event_type] = datetime.utcnow()
|
self.event_counts[event_name] = self.event_counts.get(event_name, 0) + 1
|
||||||
|
self.last_events[event_name] = datetime.utcnow()
|
||||||
|
|
||||||
def record_error(self, event_type: str) -> None:
|
# Add to history
|
||||||
|
self.history.append(
|
||||||
|
EventHistory(
|
||||||
|
event_type=event_name,
|
||||||
|
timestamp=datetime.utcnow().isoformat(),
|
||||||
|
guild_id=guild_id,
|
||||||
|
channel_id=channel_id,
|
||||||
|
message_id=message_id,
|
||||||
|
user_id=user_id,
|
||||||
|
error=None,
|
||||||
|
duration=0.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cleanup old history
|
||||||
|
if len(self.history) > self.MAX_HISTORY:
|
||||||
|
self.history = self.history[-self.MAX_HISTORY:]
|
||||||
|
|
||||||
|
def record_error(
|
||||||
|
self, event_type: EventType, error: str, duration: float = 0.0
|
||||||
|
) -> None:
|
||||||
"""Record an event error"""
|
"""Record an event error"""
|
||||||
self.error_counts[event_type] = self.error_counts.get(event_type, 0) + 1
|
event_name = event_type.name
|
||||||
|
self.error_counts[event_name] = self.error_counts.get(event_name, 0) + 1
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
# Update last history entry with error
|
||||||
|
if self.history:
|
||||||
|
self.history[-1].update({"error": error, "duration": duration})
|
||||||
|
|
||||||
|
def get_stats(self) -> EventStats:
|
||||||
"""Get event statistics"""
|
"""Get event statistics"""
|
||||||
return {
|
total_events = sum(self.event_counts.values())
|
||||||
"counts": self.event_counts.copy(),
|
total_errors = sum(self.error_counts.values())
|
||||||
"last_events": {k: v.isoformat() for k, v in self.last_events.items()},
|
error_rate = total_errors / total_events if total_events > 0 else 0.0
|
||||||
"errors": self.error_counts.copy()
|
|
||||||
}
|
return EventStats(
|
||||||
|
counts=self.event_counts.copy(),
|
||||||
|
last_events={k: v.isoformat() for k, v in self.last_events.items()},
|
||||||
|
errors=self.error_counts.copy(),
|
||||||
|
error_rate=error_rate,
|
||||||
|
health=error_rate < self.ERROR_THRESHOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class GuildEventHandler:
|
class GuildEventHandler:
|
||||||
"""Handles guild-related events"""
|
"""Handles guild-related events"""
|
||||||
|
|
||||||
def __init__(self, cog: "VideoArchiver", tracker: EventTracker):
|
def __init__(self, cog: "VideoArchiver", tracker: EventTracker) -> None:
|
||||||
self.cog = cog
|
self.cog = cog
|
||||||
self.tracker = tracker
|
self.tracker = tracker
|
||||||
|
|
||||||
async def handle_guild_join(self, guild: discord.Guild) -> None:
|
async def handle_guild_join(self, guild: discord.Guild) -> None:
|
||||||
"""Handle bot joining a new guild"""
|
"""
|
||||||
self.tracker.record_event("guild_join")
|
Handle bot joining a new guild.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
guild: Discord guild that was joined
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EventError: If guild initialization fails
|
||||||
|
"""
|
||||||
|
start_time = datetime.utcnow()
|
||||||
|
self.tracker.record_event(EventType.GUILD_JOIN, guild_id=guild.id)
|
||||||
|
|
||||||
if not self.cog.ready.is_set():
|
if not self.cog.ready.is_set():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -61,29 +149,72 @@ class GuildEventHandler:
|
|||||||
await initialize_guild_components(self.cog, guild.id)
|
await initialize_guild_components(self.cog, guild.id)
|
||||||
logger.info(f"Initialized components for new guild {guild.id}")
|
logger.info(f"Initialized components for new guild {guild.id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.tracker.record_error("guild_join")
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
||||||
logger.error(f"Failed to initialize new guild {guild.id}: {str(e)}")
|
self.tracker.record_error(EventType.GUILD_JOIN, str(e), duration)
|
||||||
|
error = f"Failed to initialize new guild {guild.id}: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise EventError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"GuildEventHandler",
|
||||||
|
"handle_guild_join",
|
||||||
|
{"guild_id": guild.id},
|
||||||
|
ErrorSeverity.HIGH,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def handle_guild_remove(self, guild: discord.Guild) -> None:
|
async def handle_guild_remove(self, guild: discord.Guild) -> None:
|
||||||
"""Handle bot leaving a guild"""
|
"""
|
||||||
self.tracker.record_event("guild_remove")
|
Handle bot leaving a guild.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
guild: Discord guild that was left
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EventError: If guild cleanup fails
|
||||||
|
"""
|
||||||
|
start_time = datetime.utcnow()
|
||||||
|
self.tracker.record_event(EventType.GUILD_REMOVE, guild_id=guild.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await cleanup_guild_components(self.cog, guild.id)
|
await cleanup_guild_components(self.cog, guild.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.tracker.record_error("guild_remove")
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
||||||
logger.error(f"Error cleaning up removed guild {guild.id}: {str(e)}")
|
self.tracker.record_error(EventType.GUILD_REMOVE, str(e), duration)
|
||||||
|
error = f"Error cleaning up removed guild {guild.id}: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise EventError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"GuildEventHandler",
|
||||||
|
"handle_guild_remove",
|
||||||
|
{"guild_id": guild.id},
|
||||||
|
ErrorSeverity.HIGH,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MessageEventHandler:
|
class MessageEventHandler:
|
||||||
"""Handles message-related events"""
|
"""Handles message-related events"""
|
||||||
|
|
||||||
def __init__(self, cog: "VideoArchiver", tracker: EventTracker):
|
def __init__(self, cog: "VideoArchiver", tracker: EventTracker) -> None:
|
||||||
self.cog = cog
|
self.cog = cog
|
||||||
self.tracker = tracker
|
self.tracker = tracker
|
||||||
|
|
||||||
async def handle_message(self, message: discord.Message) -> None:
|
async def handle_message(self, message: discord.Message) -> None:
|
||||||
"""Handle new messages for video processing"""
|
"""
|
||||||
self.tracker.record_event("message")
|
Handle new messages for video processing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Discord message to process
|
||||||
|
"""
|
||||||
|
self.tracker.record_event(
|
||||||
|
EventType.MESSAGE,
|
||||||
|
guild_id=message.guild.id if message.guild else None,
|
||||||
|
channel_id=message.channel.id,
|
||||||
|
message_id=message.id,
|
||||||
|
user_id=message.author.id,
|
||||||
|
)
|
||||||
|
|
||||||
# Skip if not ready or if message is from DM/bot
|
# Skip if not ready or if message is from DM/bot
|
||||||
if not self.cog.ready.is_set() or message.guild is None or message.author.bot:
|
if not self.cog.ready.is_set() or message.guild is None or message.author.bot:
|
||||||
@@ -99,21 +230,19 @@ class MessageEventHandler:
|
|||||||
|
|
||||||
async def _process_message_background(self, message: discord.Message) -> None:
|
async def _process_message_background(self, message: discord.Message) -> None:
|
||||||
"""Process message in background to avoid blocking"""
|
"""Process message in background to avoid blocking"""
|
||||||
|
start_time = datetime.utcnow()
|
||||||
try:
|
try:
|
||||||
await self.cog.processor.process_message(message)
|
await self.cog.processor.process_message(message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.tracker.record_error("message_processing")
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
||||||
|
self.tracker.record_error(EventType.MESSAGE_PROCESSING, str(e), duration)
|
||||||
await self._handle_processing_error(message, e)
|
await self._handle_processing_error(message, e)
|
||||||
|
|
||||||
async def _handle_processing_error(
|
async def _handle_processing_error(
|
||||||
self,
|
self, message: discord.Message, error: Exception
|
||||||
message: discord.Message,
|
|
||||||
error: Exception
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle message processing errors"""
|
"""Handle message processing errors"""
|
||||||
logger.error(
|
logger.error(f"Error processing message {message.id}: {traceback.format_exc()}")
|
||||||
f"Error processing message {message.id}: {traceback.format_exc()}"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
log_channel = await self.cog.config_manager.get_channel(
|
log_channel = await self.cog.config_manager.get_channel(
|
||||||
message.guild, "log"
|
message.guild, "log"
|
||||||
@@ -126,24 +255,35 @@ class MessageEventHandler:
|
|||||||
f"Message ID: {message.id}\n"
|
f"Message ID: {message.id}\n"
|
||||||
f"Channel: {message.channel.mention}"
|
f"Channel: {message.channel.mention}"
|
||||||
),
|
),
|
||||||
response_type="error"
|
response_type=ErrorSeverity.HIGH,
|
||||||
)
|
)
|
||||||
except Exception as log_error:
|
except Exception as log_error:
|
||||||
logger.error(f"Failed to log error to guild: {str(log_error)}")
|
logger.error(f"Failed to log error to guild: {str(log_error)}")
|
||||||
|
|
||||||
|
|
||||||
class ReactionEventHandler:
|
class ReactionEventHandler:
|
||||||
"""Handles reaction-related events"""
|
"""Handles reaction-related events"""
|
||||||
|
|
||||||
def __init__(self, cog: "VideoArchiver", tracker: EventTracker):
|
def __init__(self, cog: "VideoArchiver", tracker: EventTracker) -> None:
|
||||||
self.cog = cog
|
self.cog = cog
|
||||||
self.tracker = tracker
|
self.tracker = tracker
|
||||||
|
|
||||||
async def handle_reaction_add(
|
async def handle_reaction_add(
|
||||||
self,
|
self, payload: discord.RawReactionActionEvent
|
||||||
payload: discord.RawReactionActionEvent
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle reactions to messages"""
|
"""
|
||||||
self.tracker.record_event("reaction_add")
|
Handle reactions to messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Reaction event payload
|
||||||
|
"""
|
||||||
|
self.tracker.record_event(
|
||||||
|
EventType.REACTION_ADD,
|
||||||
|
guild_id=payload.guild_id,
|
||||||
|
channel_id=payload.channel_id,
|
||||||
|
message_id=payload.message_id,
|
||||||
|
user_id=payload.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
if payload.user_id == self.cog.bot.user.id:
|
if payload.user_id == self.cog.bot.user.id:
|
||||||
return
|
return
|
||||||
@@ -151,47 +291,80 @@ class ReactionEventHandler:
|
|||||||
try:
|
try:
|
||||||
await self._process_reaction(payload)
|
await self._process_reaction(payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.tracker.record_error("reaction_processing")
|
self.tracker.record_error(EventType.REACTION_PROCESSING, str(e))
|
||||||
logger.error(f"Error handling reaction: {e}")
|
logger.error(f"Error handling reaction: {e}", exc_info=True)
|
||||||
|
|
||||||
async def _process_reaction(
|
async def _process_reaction(self, payload: discord.RawReactionActionEvent) -> None:
|
||||||
self,
|
"""
|
||||||
payload: discord.RawReactionActionEvent
|
Process a reaction event.
|
||||||
) -> None:
|
|
||||||
"""Process a reaction event"""
|
Args:
|
||||||
# Get the channel and message
|
payload: Reaction event payload
|
||||||
channel = self.cog.bot.get_channel(payload.channel_id)
|
|
||||||
if not channel:
|
Raises:
|
||||||
return
|
EventError: If reaction processing fails
|
||||||
|
"""
|
||||||
message = await channel.fetch_message(payload.message_id)
|
try:
|
||||||
if not message:
|
# Get the channel and message
|
||||||
return
|
channel = self.cog.bot.get_channel(payload.channel_id)
|
||||||
|
if not channel:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = await channel.fetch_message(payload.message_id)
|
||||||
|
if not message:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if it's the archived reaction
|
||||||
|
if str(payload.emoji) == REACTIONS["archived"]:
|
||||||
|
# Only process if database is enabled
|
||||||
|
if self.cog.db:
|
||||||
|
user = self.cog.bot.get_user(payload.user_id)
|
||||||
|
asyncio.create_task(
|
||||||
|
handle_archived_reaction(message, user, self.cog.db)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Failed to process reaction: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise EventError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"ReactionEventHandler",
|
||||||
|
"_process_reaction",
|
||||||
|
{
|
||||||
|
"message_id": payload.message_id,
|
||||||
|
"user_id": payload.user_id,
|
||||||
|
"emoji": str(payload.emoji),
|
||||||
|
},
|
||||||
|
ErrorSeverity.MEDIUM,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Check if it's the archived reaction
|
|
||||||
if str(payload.emoji) == REACTIONS["archived"]:
|
|
||||||
# Only process if database is enabled
|
|
||||||
if self.cog.db:
|
|
||||||
user = self.cog.bot.get_user(payload.user_id)
|
|
||||||
asyncio.create_task(
|
|
||||||
handle_archived_reaction(message, user, self.cog.db)
|
|
||||||
)
|
|
||||||
|
|
||||||
class EventManager:
|
class EventManager:
|
||||||
"""Manages Discord event handling"""
|
"""Manages Discord event handling"""
|
||||||
|
|
||||||
def __init__(self, cog: "VideoArchiver"):
|
def __init__(self, cog: "VideoArchiver") -> None:
|
||||||
self.tracker = EventTracker()
|
self.tracker = EventTracker()
|
||||||
self.guild_handler = GuildEventHandler(cog, self.tracker)
|
self.guild_handler = GuildEventHandler(cog, self.tracker)
|
||||||
self.message_handler = MessageEventHandler(cog, self.tracker)
|
self.message_handler = MessageEventHandler(cog, self.tracker)
|
||||||
self.reaction_handler = ReactionEventHandler(cog, self.tracker)
|
self.reaction_handler = ReactionEventHandler(cog, self.tracker)
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
def get_stats(self) -> EventStats:
|
||||||
"""Get event statistics"""
|
"""Get event statistics"""
|
||||||
return self.tracker.get_stats()
|
return self.tracker.get_stats()
|
||||||
|
|
||||||
def setup_events(cog: "VideoArchiver") -> None:
|
|
||||||
"""Set up event handlers for the cog"""
|
def setup_events(cog: "VideoArchiver") -> EventManager:
|
||||||
|
"""
|
||||||
|
Set up event handlers for the cog.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cog: VideoArchiver cog instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured EventManager instance
|
||||||
|
"""
|
||||||
event_manager = EventManager(cog)
|
event_manager = EventManager(cog)
|
||||||
|
|
||||||
@cog.listener()
|
@cog.listener()
|
||||||
@@ -209,3 +382,5 @@ def setup_events(cog: "VideoArchiver") -> None:
|
|||||||
@cog.listener()
|
@cog.listener()
|
||||||
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent) -> None:
|
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent) -> None:
|
||||||
await event_manager.reaction_handler.handle_reaction_add(payload)
|
await event_manager.reaction_handler.handle_reaction_add(payload)
|
||||||
|
|
||||||
|
return event_manager
|
||||||
|
|||||||
@@ -1,16 +1,102 @@
|
|||||||
"""Module for handling VideoArchiver initialization"""
|
"""Module for handling VideoArchiver initialization"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Optional, Dict, Any
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..utils.exceptions import (
|
||||||
|
ComponentError,
|
||||||
|
ErrorContext,
|
||||||
|
ErrorSeverity
|
||||||
|
)
|
||||||
|
from .lifecycle import LifecycleState
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .base import VideoArchiver
|
from .base import VideoArchiver
|
||||||
|
|
||||||
# Re-export initialization functions from lifecycle
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
async def initialize_cog(cog: "VideoArchiver") -> None:
|
async def initialize_cog(cog: "VideoArchiver") -> None:
|
||||||
"""Initialize all components with proper error handling"""
|
"""
|
||||||
await cog.lifecycle_manager.initialize_cog()
|
Initialize all components with proper error handling.
|
||||||
|
|
||||||
|
This is a re-export of lifecycle_manager.initialize_cog with additional
|
||||||
|
error context and logging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cog: VideoArchiver cog instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ComponentError: If initialization fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Starting cog initialization...")
|
||||||
|
await cog.lifecycle_manager.initialize_cog()
|
||||||
|
logger.info("Cog initialization completed successfully")
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Failed to initialize cog: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise ComponentError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"Initialization",
|
||||||
|
"initialize_cog",
|
||||||
|
{"state": cog.lifecycle_manager.state_tracker.state.name},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def init_callback(cog: "VideoArchiver", task: asyncio.Task) -> None:
|
def init_callback(cog: "VideoArchiver", task: asyncio.Task) -> None:
|
||||||
"""Handle initialization task completion"""
|
"""
|
||||||
cog.lifecycle_manager.init_callback(task)
|
Handle initialization task completion.
|
||||||
|
|
||||||
|
This is a re-export of lifecycle_manager.init_callback with additional
|
||||||
|
error context and logging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cog: VideoArchiver cog instance
|
||||||
|
task: Initialization task
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.debug("Processing initialization task completion...")
|
||||||
|
cog.lifecycle_manager.init_callback(task)
|
||||||
|
|
||||||
|
# Log final state
|
||||||
|
state = cog.lifecycle_manager.state_tracker.state
|
||||||
|
if state == LifecycleState.READY:
|
||||||
|
logger.info("Initialization completed successfully")
|
||||||
|
elif state == LifecycleState.ERROR:
|
||||||
|
logger.error("Initialization failed")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unexpected state after initialization: {state.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in initialization callback: {str(e)}", exc_info=True)
|
||||||
|
# We don't raise here since this is a callback
|
||||||
|
|
||||||
|
def get_init_status(cog: "VideoArchiver") -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get initialization status information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cog: VideoArchiver cog instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing initialization status
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"state": cog.lifecycle_manager.state_tracker.state.name,
|
||||||
|
"ready": cog.ready.is_set(),
|
||||||
|
"components_initialized": all(
|
||||||
|
hasattr(cog, attr) and getattr(cog, attr) is not None
|
||||||
|
for attr in [
|
||||||
|
"processor",
|
||||||
|
"queue_manager",
|
||||||
|
"update_checker",
|
||||||
|
"ffmpeg_mgr",
|
||||||
|
"components",
|
||||||
|
"db"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"history": cog.lifecycle_manager.state_tracker.get_state_history()
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,82 +3,195 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Optional, Dict, Any, Set, List, Callable
|
from typing import Optional, Dict, Any, Set, List, Callable, TypedDict, ClassVar, Union
|
||||||
from enum import Enum
|
from enum import Enum, auto
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from .cleanup import cleanup_resources, force_cleanup_resources
|
from .cleanup import cleanup_resources, force_cleanup_resources
|
||||||
from ..utils.exceptions import VideoArchiverError
|
from ..utils.exceptions import (
|
||||||
|
VideoArchiverError,
|
||||||
|
ErrorContext,
|
||||||
|
ErrorSeverity,
|
||||||
|
ComponentError,
|
||||||
|
CleanupError
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
class LifecycleState(Enum):
|
class LifecycleState(Enum):
|
||||||
"""Possible states in the cog lifecycle"""
|
"""Possible states in the cog lifecycle"""
|
||||||
UNINITIALIZED = "uninitialized"
|
UNINITIALIZED = auto()
|
||||||
INITIALIZING = "initializing"
|
INITIALIZING = auto()
|
||||||
READY = "ready"
|
READY = auto()
|
||||||
UNLOADING = "unloading"
|
UNLOADING = auto()
|
||||||
ERROR = "error"
|
ERROR = auto()
|
||||||
|
|
||||||
|
class TaskStatus(Enum):
|
||||||
|
"""Task execution status"""
|
||||||
|
RUNNING = auto()
|
||||||
|
COMPLETED = auto()
|
||||||
|
CANCELLED = auto()
|
||||||
|
FAILED = auto()
|
||||||
|
|
||||||
|
class TaskHistory(TypedDict):
|
||||||
|
"""Type definition for task history entry"""
|
||||||
|
start_time: str
|
||||||
|
end_time: Optional[str]
|
||||||
|
status: str
|
||||||
|
error: Optional[str]
|
||||||
|
duration: float
|
||||||
|
|
||||||
|
class StateHistory(TypedDict):
|
||||||
|
"""Type definition for state history entry"""
|
||||||
|
state: str
|
||||||
|
timestamp: str
|
||||||
|
duration: float
|
||||||
|
details: Optional[Dict[str, Any]]
|
||||||
|
|
||||||
|
class LifecycleStatus(TypedDict):
|
||||||
|
"""Type definition for lifecycle status"""
|
||||||
|
state: str
|
||||||
|
state_history: List[StateHistory]
|
||||||
|
tasks: Dict[str, Any]
|
||||||
|
health: bool
|
||||||
|
|
||||||
class TaskManager:
|
class TaskManager:
|
||||||
"""Manages asyncio tasks"""
|
"""Manages asyncio tasks"""
|
||||||
|
|
||||||
def __init__(self):
|
TASK_TIMEOUT: ClassVar[int] = 30 # Default task timeout in seconds
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
self._tasks: Dict[str, asyncio.Task] = {}
|
self._tasks: Dict[str, asyncio.Task] = {}
|
||||||
self._task_history: Dict[str, Dict[str, Any]] = {}
|
self._task_history: Dict[str, TaskHistory] = {}
|
||||||
|
|
||||||
async def create_task(
|
async def create_task(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
coro,
|
coro: Callable[..., Any],
|
||||||
callback: Optional[Callable] = None
|
callback: Optional[Callable[[asyncio.Task], None]] = None,
|
||||||
|
timeout: Optional[float] = None
|
||||||
) -> asyncio.Task:
|
) -> asyncio.Task:
|
||||||
"""Create and track a task"""
|
"""
|
||||||
task = asyncio.create_task(coro)
|
Create and track a task.
|
||||||
self._tasks[name] = task
|
|
||||||
self._task_history[name] = {
|
Args:
|
||||||
"start_time": datetime.utcnow(),
|
name: Task name
|
||||||
"status": "running"
|
coro: Coroutine to run
|
||||||
}
|
callback: Optional completion callback
|
||||||
|
timeout: Optional timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created task
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ComponentError: If task creation fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
task = asyncio.create_task(coro)
|
||||||
|
self._tasks[name] = task
|
||||||
|
self._task_history[name] = TaskHistory(
|
||||||
|
start_time=datetime.utcnow().isoformat(),
|
||||||
|
end_time=None,
|
||||||
|
status=TaskStatus.RUNNING.name,
|
||||||
|
error=None,
|
||||||
|
duration=0.0
|
||||||
|
)
|
||||||
|
|
||||||
if callback:
|
if timeout:
|
||||||
task.add_done_callback(lambda t: self._handle_completion(name, t, callback))
|
asyncio.create_task(self._handle_timeout(name, task, timeout))
|
||||||
else:
|
|
||||||
task.add_done_callback(lambda t: self._handle_completion(name, t))
|
|
||||||
|
|
||||||
return task
|
if callback:
|
||||||
|
task.add_done_callback(lambda t: self._handle_completion(name, t, callback))
|
||||||
|
else:
|
||||||
|
task.add_done_callback(lambda t: self._handle_completion(name, t))
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Failed to create task {name}: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise ComponentError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"TaskManager",
|
||||||
|
"create_task",
|
||||||
|
{"task_name": name},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_timeout(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
task: asyncio.Task,
|
||||||
|
timeout: float
|
||||||
|
) -> None:
|
||||||
|
"""Handle task timeout"""
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if not task.done():
|
||||||
|
logger.warning(f"Task {name} timed out after {timeout}s")
|
||||||
|
task.cancel()
|
||||||
|
self._update_task_history(
|
||||||
|
name,
|
||||||
|
TaskStatus.FAILED,
|
||||||
|
f"Task timed out after {timeout}s"
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_completion(
|
def _handle_completion(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
task: asyncio.Task,
|
task: asyncio.Task,
|
||||||
callback: Optional[Callable] = None
|
callback: Optional[Callable[[asyncio.Task], None]] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle task completion"""
|
"""Handle task completion"""
|
||||||
try:
|
try:
|
||||||
task.result() # Raises exception if task failed
|
task.result() # Raises exception if task failed
|
||||||
status = "completed"
|
status = TaskStatus.COMPLETED
|
||||||
|
error = None
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
status = "cancelled"
|
status = TaskStatus.CANCELLED
|
||||||
|
error = "Task was cancelled"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status = "failed"
|
status = TaskStatus.FAILED
|
||||||
logger.error(f"Task {name} failed: {e}")
|
error = str(e)
|
||||||
|
logger.error(f"Task {name} failed: {error}", exc_info=True)
|
||||||
|
|
||||||
self._task_history[name].update({
|
self._update_task_history(name, status, error)
|
||||||
"end_time": datetime.utcnow(),
|
|
||||||
"status": status
|
|
||||||
})
|
|
||||||
|
|
||||||
if callback:
|
if callback:
|
||||||
try:
|
try:
|
||||||
callback(task)
|
callback(task)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Task callback error for {name}: {e}")
|
logger.error(f"Task callback error for {name}: {e}", exc_info=True)
|
||||||
|
|
||||||
self._tasks.pop(name, None)
|
self._tasks.pop(name, None)
|
||||||
|
|
||||||
|
def _update_task_history(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
status: TaskStatus,
|
||||||
|
error: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
"""Update task history entry"""
|
||||||
|
if name in self._task_history:
|
||||||
|
end_time = datetime.utcnow()
|
||||||
|
start_time = datetime.fromisoformat(self._task_history[name]["start_time"])
|
||||||
|
self._task_history[name].update({
|
||||||
|
"end_time": end_time.isoformat(),
|
||||||
|
"status": status.name,
|
||||||
|
"error": error,
|
||||||
|
"duration": (end_time - start_time).total_seconds()
|
||||||
|
})
|
||||||
|
|
||||||
async def cancel_task(self, name: str) -> None:
|
async def cancel_task(self, name: str) -> None:
|
||||||
"""Cancel a specific task"""
|
"""
|
||||||
|
Cancel a specific task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Task name to cancel
|
||||||
|
"""
|
||||||
if task := self._tasks.get(name):
|
if task := self._tasks.get(name):
|
||||||
if not task.done():
|
if not task.done():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
@@ -87,7 +200,7 @@ class TaskManager:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cancelling task {name}: {e}")
|
logger.error(f"Error cancelling task {name}: {e}", exc_info=True)
|
||||||
|
|
||||||
async def cancel_all_tasks(self) -> None:
|
async def cancel_all_tasks(self) -> None:
|
||||||
"""Cancel all tracked tasks"""
|
"""Cancel all tracked tasks"""
|
||||||
@@ -95,7 +208,12 @@ class TaskManager:
|
|||||||
await self.cancel_task(name)
|
await self.cancel_task(name)
|
||||||
|
|
||||||
def get_task_status(self) -> Dict[str, Any]:
|
def get_task_status(self) -> Dict[str, Any]:
|
||||||
"""Get status of all tasks"""
|
"""
|
||||||
|
Get status of all tasks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing task status information
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"active_tasks": list(self._tasks.keys()),
|
"active_tasks": list(self._tasks.keys()),
|
||||||
"history": self._task_history.copy()
|
"history": self._task_history.copy()
|
||||||
@@ -104,42 +222,80 @@ class TaskManager:
|
|||||||
class StateTracker:
|
class StateTracker:
|
||||||
"""Tracks lifecycle state and transitions"""
|
"""Tracks lifecycle state and transitions"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.state = LifecycleState.UNINITIALIZED
|
self.state = LifecycleState.UNINITIALIZED
|
||||||
self.state_history: List[Dict[str, Any]] = []
|
self.state_history: List[StateHistory] = []
|
||||||
self._record_state()
|
self._record_state()
|
||||||
|
|
||||||
def set_state(self, state: LifecycleState) -> None:
|
def set_state(
|
||||||
"""Set current state"""
|
self,
|
||||||
|
state: LifecycleState,
|
||||||
|
details: Optional[Dict[str, Any]] = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Set current state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: New state
|
||||||
|
details: Optional state transition details
|
||||||
|
"""
|
||||||
self.state = state
|
self.state = state
|
||||||
self._record_state()
|
self._record_state(details)
|
||||||
|
|
||||||
def _record_state(self) -> None:
|
def _record_state(
|
||||||
|
self,
|
||||||
|
details: Optional[Dict[str, Any]] = None
|
||||||
|
) -> None:
|
||||||
"""Record state transition"""
|
"""Record state transition"""
|
||||||
self.state_history.append({
|
now = datetime.utcnow()
|
||||||
"state": self.state.value,
|
duration = 0.0
|
||||||
"timestamp": datetime.utcnow()
|
if self.state_history:
|
||||||
})
|
last_state = datetime.fromisoformat(self.state_history[-1]["timestamp"])
|
||||||
|
duration = (now - last_state).total_seconds()
|
||||||
|
|
||||||
def get_state_history(self) -> List[Dict[str, Any]]:
|
self.state_history.append(StateHistory(
|
||||||
|
state=self.state.name,
|
||||||
|
timestamp=now.isoformat(),
|
||||||
|
duration=duration,
|
||||||
|
details=details
|
||||||
|
))
|
||||||
|
|
||||||
|
def get_state_history(self) -> List[StateHistory]:
|
||||||
"""Get state transition history"""
|
"""Get state transition history"""
|
||||||
return self.state_history.copy()
|
return self.state_history.copy()
|
||||||
|
|
||||||
class LifecycleManager:
|
class LifecycleManager:
|
||||||
"""Manages the lifecycle of the VideoArchiver cog"""
|
"""Manages the lifecycle of the VideoArchiver cog"""
|
||||||
|
|
||||||
def __init__(self, cog):
|
INIT_TIMEOUT: ClassVar[int] = 60 # 1 minute timeout for initialization
|
||||||
|
UNLOAD_TIMEOUT: ClassVar[int] = 30 # 30 seconds timeout for unloading
|
||||||
|
CLEANUP_TIMEOUT: ClassVar[int] = 15 # 15 seconds timeout for cleanup
|
||||||
|
|
||||||
|
def __init__(self, cog: Any) -> None:
|
||||||
self.cog = cog
|
self.cog = cog
|
||||||
self.task_manager = TaskManager()
|
self.task_manager = TaskManager()
|
||||||
self.state_tracker = StateTracker()
|
self.state_tracker = StateTracker()
|
||||||
self._cleanup_handlers: Set[Callable] = set()
|
self._cleanup_handlers: Set[Callable] = set()
|
||||||
|
|
||||||
def register_cleanup_handler(self, handler: Callable) -> None:
|
def register_cleanup_handler(
|
||||||
"""Register a cleanup handler"""
|
self,
|
||||||
|
handler: Union[Callable[[], None], Callable[[], Any]]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Register a cleanup handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
handler: Cleanup handler function
|
||||||
|
"""
|
||||||
self._cleanup_handlers.add(handler)
|
self._cleanup_handlers.add(handler)
|
||||||
|
|
||||||
async def initialize_cog(self) -> None:
|
async def initialize_cog(self) -> None:
|
||||||
"""Initialize all components with proper error handling"""
|
"""
|
||||||
|
Initialize all components with proper error handling.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ComponentError: If initialization fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Initialize components in sequence
|
# Initialize components in sequence
|
||||||
await self.cog.component_manager.initialize_components()
|
await self.cog.component_manager.initialize_components()
|
||||||
@@ -149,24 +305,47 @@ class LifecycleManager:
|
|||||||
logger.info("VideoArchiver initialization completed successfully")
|
logger.info("VideoArchiver initialization completed successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during initialization: {str(e)}")
|
error = f"Error during initialization: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
await cleanup_resources(self.cog)
|
await cleanup_resources(self.cog)
|
||||||
raise
|
raise ComponentError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"LifecycleManager",
|
||||||
|
"initialize_cog",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def init_callback(self, task: asyncio.Task) -> None:
|
def init_callback(self, task: asyncio.Task) -> None:
|
||||||
"""Handle initialization task completion"""
|
"""Handle initialization task completion"""
|
||||||
try:
|
try:
|
||||||
task.result()
|
task.result()
|
||||||
logger.info("Initialization completed successfully")
|
logger.info("Initialization completed successfully")
|
||||||
|
self.state_tracker.set_state(LifecycleState.READY)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.warning("Initialization was cancelled")
|
logger.warning("Initialization was cancelled")
|
||||||
|
self.state_tracker.set_state(
|
||||||
|
LifecycleState.ERROR,
|
||||||
|
{"reason": "cancelled"}
|
||||||
|
)
|
||||||
asyncio.create_task(cleanup_resources(self.cog))
|
asyncio.create_task(cleanup_resources(self.cog))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Initialization failed: {str(e)}\n{traceback.format_exc()}")
|
logger.error(f"Initialization failed: {str(e)}", exc_info=True)
|
||||||
|
self.state_tracker.set_state(
|
||||||
|
LifecycleState.ERROR,
|
||||||
|
{"error": str(e)}
|
||||||
|
)
|
||||||
asyncio.create_task(cleanup_resources(self.cog))
|
asyncio.create_task(cleanup_resources(self.cog))
|
||||||
|
|
||||||
async def handle_load(self) -> None:
|
async def handle_load(self) -> None:
|
||||||
"""Handle cog loading without blocking"""
|
"""
|
||||||
|
Handle cog loading without blocking.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
VideoArchiverError: If load fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
self.state_tracker.set_state(LifecycleState.INITIALIZING)
|
self.state_tracker.set_state(LifecycleState.INITIALIZING)
|
||||||
|
|
||||||
@@ -174,7 +353,8 @@ class LifecycleManager:
|
|||||||
await self.task_manager.create_task(
|
await self.task_manager.create_task(
|
||||||
"initialization",
|
"initialization",
|
||||||
self.initialize_cog(),
|
self.initialize_cog(),
|
||||||
self.init_callback
|
self.init_callback,
|
||||||
|
timeout=self.INIT_TIMEOUT
|
||||||
)
|
)
|
||||||
logger.info("Initialization started in background")
|
logger.info("Initialization started in background")
|
||||||
|
|
||||||
@@ -184,14 +364,27 @@ class LifecycleManager:
|
|||||||
try:
|
try:
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
force_cleanup_resources(self.cog),
|
force_cleanup_resources(self.cog),
|
||||||
timeout=15 # CLEANUP_TIMEOUT
|
timeout=self.CLEANUP_TIMEOUT
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("Force cleanup during load error timed out")
|
logger.error("Force cleanup during load error timed out")
|
||||||
raise VideoArchiverError(f"Error during cog load: {str(e)}")
|
raise VideoArchiverError(
|
||||||
|
f"Error during cog load: {str(e)}",
|
||||||
|
context=ErrorContext(
|
||||||
|
"LifecycleManager",
|
||||||
|
"handle_load",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def handle_unload(self) -> None:
|
async def handle_unload(self) -> None:
|
||||||
"""Clean up when cog is unloaded"""
|
"""
|
||||||
|
Clean up when cog is unloaded.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CleanupError: If cleanup fails
|
||||||
|
"""
|
||||||
self.state_tracker.set_state(LifecycleState.UNLOADING)
|
self.state_tracker.set_state(LifecycleState.UNLOADING)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -205,9 +398,10 @@ class LifecycleManager:
|
|||||||
try:
|
try:
|
||||||
cleanup_task = await self.task_manager.create_task(
|
cleanup_task = await self.task_manager.create_task(
|
||||||
"cleanup",
|
"cleanup",
|
||||||
cleanup_resources(self.cog)
|
cleanup_resources(self.cog),
|
||||||
|
timeout=self.UNLOAD_TIMEOUT
|
||||||
)
|
)
|
||||||
await asyncio.wait_for(cleanup_task, timeout=30) # UNLOAD_TIMEOUT
|
await cleanup_task
|
||||||
logger.info("Normal cleanup completed")
|
logger.info("Normal cleanup completed")
|
||||||
|
|
||||||
except (asyncio.TimeoutError, Exception) as e:
|
except (asyncio.TimeoutError, Exception) as e:
|
||||||
@@ -220,17 +414,50 @@ class LifecycleManager:
|
|||||||
try:
|
try:
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
force_cleanup_resources(self.cog),
|
force_cleanup_resources(self.cog),
|
||||||
timeout=15 # CLEANUP_TIMEOUT
|
timeout=self.CLEANUP_TIMEOUT
|
||||||
)
|
)
|
||||||
logger.info("Force cleanup completed")
|
logger.info("Force cleanup completed")
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("Force cleanup timed out")
|
error = "Force cleanup timed out"
|
||||||
|
logger.error(error)
|
||||||
|
raise CleanupError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"LifecycleManager",
|
||||||
|
"handle_unload",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.CRITICAL
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during force cleanup: {str(e)}")
|
error = f"Error during force cleanup: {str(e)}"
|
||||||
|
logger.error(error)
|
||||||
|
raise CleanupError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"LifecycleManager",
|
||||||
|
"handle_unload",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.CRITICAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during cog unload: {str(e)}")
|
error = f"Error during cog unload: {str(e)}"
|
||||||
self.state_tracker.set_state(LifecycleState.ERROR)
|
logger.error(error, exc_info=True)
|
||||||
|
self.state_tracker.set_state(
|
||||||
|
LifecycleState.ERROR,
|
||||||
|
{"error": str(e)}
|
||||||
|
)
|
||||||
|
raise CleanupError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"LifecycleManager",
|
||||||
|
"handle_unload",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.CRITICAL
|
||||||
|
)
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
# Clear all references
|
# Clear all references
|
||||||
await self._cleanup_references()
|
await self._cleanup_references()
|
||||||
@@ -244,7 +471,7 @@ class LifecycleManager:
|
|||||||
else:
|
else:
|
||||||
handler()
|
handler()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in cleanup handler: {e}")
|
logger.error(f"Error in cleanup handler: {e}", exc_info=True)
|
||||||
|
|
||||||
async def _cleanup_references(self) -> None:
|
async def _cleanup_references(self) -> None:
|
||||||
"""Clean up all references"""
|
"""Clean up all references"""
|
||||||
@@ -257,10 +484,16 @@ class LifecycleManager:
|
|||||||
self.cog.components.clear()
|
self.cog.components.clear()
|
||||||
self.cog.db = None
|
self.cog.db = None
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> LifecycleStatus:
|
||||||
"""Get current lifecycle status"""
|
"""
|
||||||
return {
|
Get current lifecycle status.
|
||||||
"state": self.state_tracker.state.value,
|
|
||||||
"state_history": self.state_tracker.get_state_history(),
|
Returns:
|
||||||
"tasks": self.task_manager.get_task_status()
|
Dictionary containing lifecycle status information
|
||||||
}
|
"""
|
||||||
|
return LifecycleStatus(
|
||||||
|
state=self.state_tracker.state.name,
|
||||||
|
state_history=self.state_tracker.get_state_history(),
|
||||||
|
tasks=self.task_manager.get_task_status(),
|
||||||
|
health=self.state_tracker.state == LifecycleState.READY
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,57 +1,116 @@
|
|||||||
"""Module for handling command responses"""
|
"""Module for handling command responses"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Optional, Union, Dict, Any, TypedDict, ClassVar
|
||||||
|
from datetime import datetime
|
||||||
import discord
|
import discord
|
||||||
from typing import Optional, Union, Dict, Any
|
|
||||||
from redbot.core.commands import Context
|
from redbot.core.commands import Context
|
||||||
|
|
||||||
|
from ..utils.exceptions import ErrorSeverity
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
|
class ResponseType(Enum):
|
||||||
|
"""Types of responses"""
|
||||||
|
NORMAL = auto()
|
||||||
|
SUCCESS = auto()
|
||||||
|
ERROR = auto()
|
||||||
|
WARNING = auto()
|
||||||
|
INFO = auto()
|
||||||
|
DEBUG = auto()
|
||||||
|
|
||||||
|
class ResponseTheme(TypedDict):
|
||||||
|
"""Type definition for response theme"""
|
||||||
|
emoji: str
|
||||||
|
color: discord.Color
|
||||||
|
|
||||||
|
class ResponseFormat(TypedDict):
|
||||||
|
"""Type definition for formatted response"""
|
||||||
|
content: str
|
||||||
|
color: discord.Color
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
class ResponseFormatter:
|
class ResponseFormatter:
|
||||||
"""Formats responses for consistency"""
|
"""Formats responses for consistency"""
|
||||||
|
|
||||||
@staticmethod
|
THEMES: ClassVar[Dict[ResponseType, ResponseTheme]] = {
|
||||||
def format_success(message: str) -> Dict[str, Any]:
|
ResponseType.SUCCESS: ResponseTheme(emoji="✅", color=discord.Color.green()),
|
||||||
"""Format a success message"""
|
ResponseType.ERROR: ResponseTheme(emoji="❌", color=discord.Color.red()),
|
||||||
return {
|
ResponseType.WARNING: ResponseTheme(emoji="⚠️", color=discord.Color.gold()),
|
||||||
"content": f"✅ {message}",
|
ResponseType.INFO: ResponseTheme(emoji="ℹ️", color=discord.Color.blue()),
|
||||||
"color": discord.Color.green()
|
ResponseType.DEBUG: ResponseTheme(emoji="🔧", color=discord.Color.greyple())
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
SEVERITY_MAPPING: ClassVar[Dict[ErrorSeverity, ResponseType]] = {
|
||||||
def format_error(message: str) -> Dict[str, Any]:
|
ErrorSeverity.LOW: ResponseType.INFO,
|
||||||
"""Format an error message"""
|
ErrorSeverity.MEDIUM: ResponseType.WARNING,
|
||||||
return {
|
ErrorSeverity.HIGH: ResponseType.ERROR,
|
||||||
"content": f"❌ {message}",
|
ErrorSeverity.CRITICAL: ResponseType.ERROR
|
||||||
"color": discord.Color.red()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def format_warning(message: str) -> Dict[str, Any]:
|
def format_response(
|
||||||
"""Format a warning message"""
|
cls,
|
||||||
return {
|
message: str,
|
||||||
"content": f"⚠️ {message}",
|
response_type: ResponseType = ResponseType.NORMAL
|
||||||
"color": discord.Color.yellow()
|
) -> ResponseFormat:
|
||||||
}
|
"""
|
||||||
|
Format a response message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Message to format
|
||||||
|
response_type: Type of response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted response dictionary
|
||||||
|
"""
|
||||||
|
theme = cls.THEMES.get(response_type)
|
||||||
|
if theme:
|
||||||
|
return ResponseFormat(
|
||||||
|
content=f"{theme['emoji']} {message}",
|
||||||
|
color=theme['color'],
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
return ResponseFormat(
|
||||||
|
content=message,
|
||||||
|
color=discord.Color.default(),
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def format_info(message: str) -> Dict[str, Any]:
|
def get_response_type(cls, severity: ErrorSeverity) -> ResponseType:
|
||||||
"""Format an info message"""
|
"""
|
||||||
return {
|
Get response type for error severity.
|
||||||
"content": f"ℹ️ {message}",
|
|
||||||
"color": discord.Color.blue()
|
Args:
|
||||||
}
|
severity: Error severity level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Appropriate response type
|
||||||
|
"""
|
||||||
|
return cls.SEVERITY_MAPPING.get(severity, ResponseType.ERROR)
|
||||||
|
|
||||||
class InteractionHandler:
|
class InteractionHandler:
|
||||||
"""Handles slash command interactions"""
|
"""Handles slash command interactions"""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def send_initial_response(
|
async def send_initial_response(
|
||||||
|
self,
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
content: Optional[str] = None,
|
content: Optional[str] = None,
|
||||||
embed: Optional[discord.Embed] = None
|
embed: Optional[discord.Embed] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send initial interaction response"""
|
"""
|
||||||
|
Send initial interaction response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interaction: Discord interaction
|
||||||
|
content: Optional message content
|
||||||
|
embed: Optional embed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if response was sent successfully
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if not interaction.response.is_done():
|
if not interaction.response.is_done():
|
||||||
if embed:
|
if embed:
|
||||||
@@ -61,16 +120,26 @@ class InteractionHandler:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending initial interaction response: {e}")
|
logger.error(f"Error sending initial interaction response: {e}", exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def send_followup(
|
async def send_followup(
|
||||||
|
self,
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
content: Optional[str] = None,
|
content: Optional[str] = None,
|
||||||
embed: Optional[discord.Embed] = None
|
embed: Optional[discord.Embed] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send interaction followup"""
|
"""
|
||||||
|
Send interaction followup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interaction: Discord interaction
|
||||||
|
content: Optional message content
|
||||||
|
embed: Optional embed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if followup was sent successfully
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if embed:
|
if embed:
|
||||||
await interaction.followup.send(content=content, embed=embed)
|
await interaction.followup.send(content=content, embed=embed)
|
||||||
@@ -78,13 +147,13 @@ class InteractionHandler:
|
|||||||
await interaction.followup.send(content=content)
|
await interaction.followup.send(content=content)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending interaction followup: {e}")
|
logger.error(f"Error sending interaction followup: {e}", exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
class ResponseManager:
|
class ResponseManager:
|
||||||
"""Manages command responses"""
|
"""Manages command responses"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.formatter = ResponseFormatter()
|
self.formatter = ResponseFormatter()
|
||||||
self.interaction_handler = InteractionHandler()
|
self.interaction_handler = InteractionHandler()
|
||||||
|
|
||||||
@@ -93,25 +162,37 @@ class ResponseManager:
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
content: Optional[str] = None,
|
content: Optional[str] = None,
|
||||||
embed: Optional[discord.Embed] = None,
|
embed: Optional[discord.Embed] = None,
|
||||||
response_type: str = "normal"
|
response_type: Union[ResponseType, str, ErrorSeverity] = ResponseType.NORMAL
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a response to a command
|
"""
|
||||||
|
Send a response to a command.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: Command context
|
ctx: Command context
|
||||||
content: Optional message content
|
content: Optional message content
|
||||||
embed: Optional embed
|
embed: Optional embed
|
||||||
response_type: Type of response (normal, success, error, warning, info)
|
response_type: Type of response or error severity
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Format response if type specified
|
# Convert string response type to enum
|
||||||
if response_type != "normal":
|
if isinstance(response_type, str):
|
||||||
format_method = getattr(self.formatter, f"format_{response_type}", None)
|
try:
|
||||||
if format_method and content:
|
response_type = ResponseType[response_type.upper()]
|
||||||
formatted = format_method(content)
|
except KeyError:
|
||||||
content = formatted["content"]
|
response_type = ResponseType.NORMAL
|
||||||
if not embed:
|
# Convert error severity to response type
|
||||||
embed = discord.Embed(color=formatted["color"])
|
elif isinstance(response_type, ErrorSeverity):
|
||||||
|
response_type = self.formatter.get_response_type(response_type)
|
||||||
|
|
||||||
|
# Format response
|
||||||
|
if response_type != ResponseType.NORMAL and content:
|
||||||
|
formatted = self.formatter.format_response(content, response_type)
|
||||||
|
content = formatted["content"]
|
||||||
|
if not embed:
|
||||||
|
embed = discord.Embed(
|
||||||
|
color=formatted["color"],
|
||||||
|
timestamp=datetime.fromisoformat(formatted["timestamp"])
|
||||||
|
)
|
||||||
|
|
||||||
# Handle response
|
# Handle response
|
||||||
if self._is_interaction(ctx):
|
if self._is_interaction(ctx):
|
||||||
@@ -120,7 +201,7 @@ class ResponseManager:
|
|||||||
await self._handle_regular_response(ctx, content, embed)
|
await self._handle_regular_response(ctx, content, embed)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending response: {e}")
|
logger.error(f"Error sending response: {e}", exc_info=True)
|
||||||
await self._send_fallback_response(ctx, content, embed)
|
await self._send_fallback_response(ctx, content, embed)
|
||||||
|
|
||||||
def _is_interaction(self, ctx: Context) -> bool:
|
def _is_interaction(self, ctx: Context) -> bool:
|
||||||
@@ -151,7 +232,7 @@ class ResponseManager:
|
|||||||
await self._handle_regular_response(ctx, content, embed)
|
await self._handle_regular_response(ctx, content, embed)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling interaction response: {e}")
|
logger.error(f"Error handling interaction response: {e}", exc_info=True)
|
||||||
await self._send_fallback_response(ctx, content, embed)
|
await self._send_fallback_response(ctx, content, embed)
|
||||||
|
|
||||||
async def _handle_regular_response(
|
async def _handle_regular_response(
|
||||||
@@ -167,7 +248,7 @@ class ResponseManager:
|
|||||||
else:
|
else:
|
||||||
await ctx.send(content=content)
|
await ctx.send(content=content)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error sending regular response: {e}")
|
logger.error(f"Error sending regular response: {e}", exc_info=True)
|
||||||
await self._send_fallback_response(ctx, content, embed)
|
await self._send_fallback_response(ctx, content, embed)
|
||||||
|
|
||||||
async def _send_fallback_response(
|
async def _send_fallback_response(
|
||||||
@@ -183,7 +264,7 @@ class ResponseManager:
|
|||||||
else:
|
else:
|
||||||
await ctx.send(content=content)
|
await ctx.send(content=content)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send fallback response: {e}")
|
logger.error(f"Failed to send fallback response: {e}", exc_info=True)
|
||||||
|
|
||||||
# Global response manager instance
|
# Global response manager instance
|
||||||
response_manager = ResponseManager()
|
response_manager = ResponseManager()
|
||||||
@@ -192,7 +273,15 @@ async def handle_response(
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
content: Optional[str] = None,
|
content: Optional[str] = None,
|
||||||
embed: Optional[discord.Embed] = None,
|
embed: Optional[discord.Embed] = None,
|
||||||
response_type: str = "normal"
|
response_type: Union[ResponseType, str, ErrorSeverity] = ResponseType.NORMAL
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Helper function to handle responses using the response manager"""
|
"""
|
||||||
|
Helper function to handle responses using the response manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: Command context
|
||||||
|
content: Optional message content
|
||||||
|
embed: Optional embed
|
||||||
|
response_type: Type of response or error severity
|
||||||
|
"""
|
||||||
await response_manager.send_response(ctx, content, embed, response_type)
|
await response_manager.send_response(ctx, content, embed, response_type)
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
"""Module for managing VideoArchiver settings"""
|
"""Module for managing VideoArchiver settings"""
|
||||||
|
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional, Union, TypedDict, ClassVar
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
from ..utils.exceptions import (
|
||||||
|
ConfigurationError,
|
||||||
|
ErrorContext,
|
||||||
|
ErrorSeverity
|
||||||
|
)
|
||||||
|
|
||||||
class VideoFormat(Enum):
|
class VideoFormat(Enum):
|
||||||
"""Supported video formats"""
|
"""Supported video formats"""
|
||||||
@@ -17,133 +23,177 @@ class VideoQuality(Enum):
|
|||||||
HIGH = "high" # 1080p
|
HIGH = "high" # 1080p
|
||||||
ULTRA = "ultra" # 4K
|
ULTRA = "ultra" # 4K
|
||||||
|
|
||||||
|
class SettingCategory(Enum):
|
||||||
|
"""Setting categories"""
|
||||||
|
GENERAL = auto()
|
||||||
|
CHANNELS = auto()
|
||||||
|
PERMISSIONS = auto()
|
||||||
|
VIDEO = auto()
|
||||||
|
MESSAGES = auto()
|
||||||
|
PERFORMANCE = auto()
|
||||||
|
FEATURES = auto()
|
||||||
|
|
||||||
|
class ValidationResult(TypedDict):
|
||||||
|
"""Type definition for validation result"""
|
||||||
|
valid: bool
|
||||||
|
error: Optional[str]
|
||||||
|
details: Dict[str, Any]
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SettingDefinition:
|
class SettingDefinition:
|
||||||
"""Defines a setting's properties"""
|
"""Defines a setting's properties"""
|
||||||
name: str
|
name: str
|
||||||
category: str
|
category: SettingCategory
|
||||||
default_value: Any
|
default_value: Any
|
||||||
description: str
|
description: str
|
||||||
data_type: type
|
data_type: type
|
||||||
required: bool = True
|
required: bool = True
|
||||||
min_value: Optional[int] = None
|
min_value: Optional[Union[int, float]] = None
|
||||||
max_value: Optional[int] = None
|
max_value: Optional[Union[int, float]] = None
|
||||||
choices: Optional[List[Any]] = None
|
choices: Optional[List[Any]] = None
|
||||||
depends_on: Optional[str] = None
|
depends_on: Optional[str] = None
|
||||||
|
validation_func: Optional[callable] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
class SettingCategory(Enum):
|
def __post_init__(self) -> None:
|
||||||
"""Setting categories"""
|
"""Validate setting definition"""
|
||||||
GENERAL = "general"
|
if self.choices and self.default_value not in self.choices:
|
||||||
CHANNELS = "channels"
|
raise ConfigurationError(
|
||||||
PERMISSIONS = "permissions"
|
f"Default value {self.default_value} not in choices {self.choices}",
|
||||||
VIDEO = "video"
|
context=ErrorContext(
|
||||||
MESSAGES = "messages"
|
"Settings",
|
||||||
PERFORMANCE = "performance"
|
"definition_validation",
|
||||||
FEATURES = "features"
|
{"setting": self.name},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.min_value is not None and self.max_value is not None:
|
||||||
|
if self.min_value > self.max_value:
|
||||||
|
raise ConfigurationError(
|
||||||
|
f"Min value {self.min_value} greater than max value {self.max_value}",
|
||||||
|
context=ErrorContext(
|
||||||
|
"Settings",
|
||||||
|
"definition_validation",
|
||||||
|
{"setting": self.name},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
"""Manages VideoArchiver settings"""
|
"""Manages VideoArchiver settings"""
|
||||||
|
|
||||||
# Setting definitions
|
# Setting definitions
|
||||||
SETTINGS = {
|
SETTINGS: ClassVar[Dict[str, SettingDefinition]] = {
|
||||||
"enabled": SettingDefinition(
|
"enabled": SettingDefinition(
|
||||||
name="enabled",
|
name="enabled",
|
||||||
category=SettingCategory.GENERAL.value,
|
category=SettingCategory.GENERAL,
|
||||||
default_value=False,
|
default_value=False,
|
||||||
description="Whether the archiver is enabled for this guild",
|
description="Whether the archiver is enabled for this guild",
|
||||||
data_type=bool
|
data_type=bool
|
||||||
),
|
),
|
||||||
"archive_channel": SettingDefinition(
|
"archive_channel": SettingDefinition(
|
||||||
name="archive_channel",
|
name="archive_channel",
|
||||||
category=SettingCategory.CHANNELS.value,
|
category=SettingCategory.CHANNELS,
|
||||||
default_value=None,
|
default_value=None,
|
||||||
description="Channel where archived videos are posted",
|
description="Channel where archived videos are posted",
|
||||||
data_type=int,
|
data_type=int,
|
||||||
required=False
|
required=False,
|
||||||
|
error_message="Archive channel must be a valid channel ID"
|
||||||
),
|
),
|
||||||
"log_channel": SettingDefinition(
|
"log_channel": SettingDefinition(
|
||||||
name="log_channel",
|
name="log_channel",
|
||||||
category=SettingCategory.CHANNELS.value,
|
category=SettingCategory.CHANNELS,
|
||||||
default_value=None,
|
default_value=None,
|
||||||
description="Channel for logging archiver actions",
|
description="Channel for logging archiver actions",
|
||||||
data_type=int,
|
data_type=int,
|
||||||
required=False
|
required=False,
|
||||||
|
error_message="Log channel must be a valid channel ID"
|
||||||
),
|
),
|
||||||
"enabled_channels": SettingDefinition(
|
"enabled_channels": SettingDefinition(
|
||||||
name="enabled_channels",
|
name="enabled_channels",
|
||||||
category=SettingCategory.CHANNELS.value,
|
category=SettingCategory.CHANNELS,
|
||||||
default_value=[],
|
default_value=[],
|
||||||
description="Channels to monitor (empty means all channels)",
|
description="Channels to monitor (empty means all channels)",
|
||||||
data_type=list
|
data_type=list,
|
||||||
|
error_message="Enabled channels must be a list of valid channel IDs"
|
||||||
),
|
),
|
||||||
"allowed_roles": SettingDefinition(
|
"allowed_roles": SettingDefinition(
|
||||||
name="allowed_roles",
|
name="allowed_roles",
|
||||||
category=SettingCategory.PERMISSIONS.value,
|
category=SettingCategory.PERMISSIONS,
|
||||||
default_value=[],
|
default_value=[],
|
||||||
description="Roles allowed to use archiver (empty means all roles)",
|
description="Roles allowed to use archiver (empty means all roles)",
|
||||||
data_type=list
|
data_type=list,
|
||||||
|
error_message="Allowed roles must be a list of valid role IDs"
|
||||||
),
|
),
|
||||||
"video_format": SettingDefinition(
|
"video_format": SettingDefinition(
|
||||||
name="video_format",
|
name="video_format",
|
||||||
category=SettingCategory.VIDEO.value,
|
category=SettingCategory.VIDEO,
|
||||||
default_value=VideoFormat.MP4.value,
|
default_value=VideoFormat.MP4.value,
|
||||||
description="Format for archived videos",
|
description="Format for archived videos",
|
||||||
data_type=str,
|
data_type=str,
|
||||||
choices=[format.value for format in VideoFormat]
|
choices=[format.value for format in VideoFormat],
|
||||||
|
error_message=f"Video format must be one of: {', '.join(f.value for f in VideoFormat)}"
|
||||||
),
|
),
|
||||||
"video_quality": SettingDefinition(
|
"video_quality": SettingDefinition(
|
||||||
name="video_quality",
|
name="video_quality",
|
||||||
category=SettingCategory.VIDEO.value,
|
category=SettingCategory.VIDEO,
|
||||||
default_value=VideoQuality.HIGH.value,
|
default_value=VideoQuality.HIGH.value,
|
||||||
description="Quality preset for archived videos",
|
description="Quality preset for archived videos",
|
||||||
data_type=str,
|
data_type=str,
|
||||||
choices=[quality.value for quality in VideoQuality]
|
choices=[quality.value for quality in VideoQuality],
|
||||||
|
error_message=f"Video quality must be one of: {', '.join(q.value for q in VideoQuality)}"
|
||||||
),
|
),
|
||||||
"max_file_size": SettingDefinition(
|
"max_file_size": SettingDefinition(
|
||||||
name="max_file_size",
|
name="max_file_size",
|
||||||
category=SettingCategory.VIDEO.value,
|
category=SettingCategory.VIDEO,
|
||||||
default_value=8,
|
default_value=8,
|
||||||
description="Maximum file size in MB",
|
description="Maximum file size in MB",
|
||||||
data_type=int,
|
data_type=int,
|
||||||
min_value=1,
|
min_value=1,
|
||||||
max_value=100
|
max_value=100,
|
||||||
|
error_message="Max file size must be between 1 and 100 MB"
|
||||||
),
|
),
|
||||||
"message_duration": SettingDefinition(
|
"message_duration": SettingDefinition(
|
||||||
name="message_duration",
|
name="message_duration",
|
||||||
category=SettingCategory.MESSAGES.value,
|
category=SettingCategory.MESSAGES,
|
||||||
default_value=30,
|
default_value=30,
|
||||||
description="Duration to show status messages (seconds)",
|
description="Duration to show status messages (seconds)",
|
||||||
data_type=int,
|
data_type=int,
|
||||||
min_value=5,
|
min_value=5,
|
||||||
max_value=300
|
max_value=300,
|
||||||
|
error_message="Message duration must be between 5 and 300 seconds"
|
||||||
),
|
),
|
||||||
"message_template": SettingDefinition(
|
"message_template": SettingDefinition(
|
||||||
name="message_template",
|
name="message_template",
|
||||||
category=SettingCategory.MESSAGES.value,
|
category=SettingCategory.MESSAGES,
|
||||||
default_value="{author} archived a video from {channel}",
|
default_value="{author} archived a video from {channel}",
|
||||||
description="Template for archive messages",
|
description="Template for archive messages",
|
||||||
data_type=str
|
data_type=str,
|
||||||
|
error_message="Message template must contain {author} and {channel} placeholders"
|
||||||
),
|
),
|
||||||
"concurrent_downloads": SettingDefinition(
|
"concurrent_downloads": SettingDefinition(
|
||||||
name="concurrent_downloads",
|
name="concurrent_downloads",
|
||||||
category=SettingCategory.PERFORMANCE.value,
|
category=SettingCategory.PERFORMANCE,
|
||||||
default_value=2,
|
default_value=2,
|
||||||
description="Maximum concurrent downloads",
|
description="Maximum concurrent downloads",
|
||||||
data_type=int,
|
data_type=int,
|
||||||
min_value=1,
|
min_value=1,
|
||||||
max_value=5
|
max_value=5,
|
||||||
|
error_message="Concurrent downloads must be between 1 and 5"
|
||||||
),
|
),
|
||||||
"enabled_sites": SettingDefinition(
|
"enabled_sites": SettingDefinition(
|
||||||
name="enabled_sites",
|
name="enabled_sites",
|
||||||
category=SettingCategory.FEATURES.value,
|
category=SettingCategory.FEATURES,
|
||||||
default_value=None,
|
default_value=None,
|
||||||
description="Sites to enable archiving for (None means all sites)",
|
description="Sites to enable archiving for (None means all sites)",
|
||||||
data_type=list,
|
data_type=list,
|
||||||
required=False
|
required=False,
|
||||||
|
error_message="Enabled sites must be a list of valid site identifiers"
|
||||||
),
|
),
|
||||||
"use_database": SettingDefinition(
|
"use_database": SettingDefinition(
|
||||||
name="use_database",
|
name="use_database",
|
||||||
category=SettingCategory.FEATURES.value,
|
category=SettingCategory.FEATURES,
|
||||||
default_value=False,
|
default_value=False,
|
||||||
description="Enable database tracking of archived videos",
|
description="Enable database tracking of archived videos",
|
||||||
data_type=bool
|
data_type=bool
|
||||||
@@ -152,12 +202,28 @@ class Settings:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_definition(cls, setting: str) -> Optional[SettingDefinition]:
|
def get_setting_definition(cls, setting: str) -> Optional[SettingDefinition]:
|
||||||
"""Get definition for a setting"""
|
"""
|
||||||
|
Get definition for a setting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setting: Setting name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Setting definition or None if not found
|
||||||
|
"""
|
||||||
return cls.SETTINGS.get(setting)
|
return cls.SETTINGS.get(setting)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_settings_by_category(cls, category: str) -> Dict[str, SettingDefinition]:
|
def get_settings_by_category(cls, category: SettingCategory) -> Dict[str, SettingDefinition]:
|
||||||
"""Get all settings in a category"""
|
"""
|
||||||
|
Get all settings in a category.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Setting category
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of settings in the category
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
name: definition
|
name: definition
|
||||||
for name, definition in cls.SETTINGS.items()
|
for name, definition in cls.SETTINGS.items()
|
||||||
@@ -165,36 +231,109 @@ class Settings:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_setting(cls, setting: str, value: Any) -> bool:
|
def validate_setting(cls, setting: str, value: Any) -> ValidationResult:
|
||||||
"""Validate a setting value"""
|
"""
|
||||||
|
Validate a setting value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setting: Setting name
|
||||||
|
value: Value to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validation result dictionary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConfigurationError: If setting definition is not found
|
||||||
|
"""
|
||||||
definition = cls.get_setting_definition(setting)
|
definition = cls.get_setting_definition(setting)
|
||||||
if not definition:
|
if not definition:
|
||||||
return False
|
raise ConfigurationError(
|
||||||
|
f"Unknown setting: {setting}",
|
||||||
|
context=ErrorContext(
|
||||||
|
"Settings",
|
||||||
|
"validation",
|
||||||
|
{"setting": setting},
|
||||||
|
ErrorSeverity.HIGH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
details = {
|
||||||
|
"setting": setting,
|
||||||
|
"value": value,
|
||||||
|
"type": type(value).__name__,
|
||||||
|
"expected_type": definition.data_type.__name__
|
||||||
|
}
|
||||||
|
|
||||||
# Check type
|
# Check type
|
||||||
if not isinstance(value, definition.data_type):
|
if not isinstance(value, definition.data_type):
|
||||||
return False
|
return ValidationResult(
|
||||||
|
valid=False,
|
||||||
|
error=f"Invalid type: expected {definition.data_type.__name__}, got {type(value).__name__}",
|
||||||
|
details=details
|
||||||
|
)
|
||||||
|
|
||||||
# Check required
|
# Check required
|
||||||
if definition.required and value is None:
|
if definition.required and value is None:
|
||||||
return False
|
return ValidationResult(
|
||||||
|
valid=False,
|
||||||
|
error="Required setting cannot be None",
|
||||||
|
details=details
|
||||||
|
)
|
||||||
|
|
||||||
# Check choices
|
# Check choices
|
||||||
if definition.choices and value not in definition.choices:
|
if definition.choices and value not in definition.choices:
|
||||||
return False
|
return ValidationResult(
|
||||||
|
valid=False,
|
||||||
|
error=f"Value must be one of: {', '.join(map(str, definition.choices))}",
|
||||||
|
details=details
|
||||||
|
)
|
||||||
|
|
||||||
# Check numeric bounds
|
# Check numeric bounds
|
||||||
if isinstance(value, (int, float)):
|
if isinstance(value, (int, float)):
|
||||||
if definition.min_value is not None and value < definition.min_value:
|
if definition.min_value is not None and value < definition.min_value:
|
||||||
return False
|
return ValidationResult(
|
||||||
|
valid=False,
|
||||||
|
error=f"Value must be at least {definition.min_value}",
|
||||||
|
details=details
|
||||||
|
)
|
||||||
if definition.max_value is not None and value > definition.max_value:
|
if definition.max_value is not None and value > definition.max_value:
|
||||||
return False
|
return ValidationResult(
|
||||||
|
valid=False,
|
||||||
|
error=f"Value must be at most {definition.max_value}",
|
||||||
|
details=details
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
# Custom validation
|
||||||
|
if definition.validation_func:
|
||||||
|
try:
|
||||||
|
result = definition.validation_func(value)
|
||||||
|
if not result:
|
||||||
|
return ValidationResult(
|
||||||
|
valid=False,
|
||||||
|
error=definition.error_message or "Validation failed",
|
||||||
|
details=details
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return ValidationResult(
|
||||||
|
valid=False,
|
||||||
|
error=str(e),
|
||||||
|
details=details
|
||||||
|
)
|
||||||
|
|
||||||
|
return ValidationResult(
|
||||||
|
valid=True,
|
||||||
|
error=None,
|
||||||
|
details=details
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default_guild_settings(self) -> Dict[str, Any]:
|
def default_guild_settings(self) -> Dict[str, Any]:
|
||||||
"""Default settings for guild configuration"""
|
"""
|
||||||
|
Default settings for guild configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of default settings
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
name: definition.default_value
|
name: definition.default_value
|
||||||
for name, definition in self.SETTINGS.items()
|
for name, definition in self.SETTINGS.items()
|
||||||
@@ -202,14 +341,22 @@ class Settings:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_setting_help(cls, setting: str) -> Optional[str]:
|
def get_setting_help(cls, setting: str) -> Optional[str]:
|
||||||
"""Get help text for a setting"""
|
"""
|
||||||
|
Get help text for a setting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
setting: Setting name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Help text or None if setting not found
|
||||||
|
"""
|
||||||
definition = cls.get_setting_definition(setting)
|
definition = cls.get_setting_definition(setting)
|
||||||
if not definition:
|
if not definition:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
help_text = [
|
help_text = [
|
||||||
f"Setting: {definition.name}",
|
f"Setting: {definition.name}",
|
||||||
f"Category: {definition.category}",
|
f"Category: {definition.category.name}",
|
||||||
f"Description: {definition.description}",
|
f"Description: {definition.description}",
|
||||||
f"Type: {definition.data_type.__name__}",
|
f"Type: {definition.data_type.__name__}",
|
||||||
f"Required: {definition.required}",
|
f"Required: {definition.required}",
|
||||||
@@ -224,5 +371,7 @@ class Settings:
|
|||||||
help_text.append(f"Maximum: {definition.max_value}")
|
help_text.append(f"Maximum: {definition.max_value}")
|
||||||
if definition.depends_on:
|
if definition.depends_on:
|
||||||
help_text.append(f"Depends on: {definition.depends_on}")
|
help_text.append(f"Depends on: {definition.depends_on}")
|
||||||
|
if definition.error_message:
|
||||||
|
help_text.append(f"Error: {definition.error_message}")
|
||||||
|
|
||||||
return "\n".join(help_text)
|
return "\n".join(help_text)
|
||||||
|
|||||||
@@ -3,81 +3,283 @@
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List, Dict, Any, Optional, TypedDict, ClassVar, Union
|
||||||
|
from enum import Enum, auto
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ..utils.exceptions import DatabaseError, ErrorContext, ErrorSeverity
|
||||||
|
|
||||||
logger = logging.getLogger("DBSchemaManager")
|
logger = logging.getLogger("DBSchemaManager")
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaState(Enum):
|
||||||
|
"""Schema states"""
|
||||||
|
|
||||||
|
UNINITIALIZED = auto()
|
||||||
|
INITIALIZING = auto()
|
||||||
|
READY = auto()
|
||||||
|
MIGRATING = auto()
|
||||||
|
ERROR = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationType(Enum):
|
||||||
|
"""Migration types"""
|
||||||
|
|
||||||
|
CREATE = auto()
|
||||||
|
ALTER = auto()
|
||||||
|
INDEX = auto()
|
||||||
|
DATA = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaVersion(TypedDict):
|
||||||
|
"""Type definition for schema version"""
|
||||||
|
|
||||||
|
version: int
|
||||||
|
last_updated: str
|
||||||
|
migrations_applied: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationResult(TypedDict):
|
||||||
|
"""Type definition for migration result"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
error: Optional[str]
|
||||||
|
migration_type: str
|
||||||
|
duration: float
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaStatus(TypedDict):
|
||||||
|
"""Type definition for schema status"""
|
||||||
|
|
||||||
|
state: str
|
||||||
|
current_version: int
|
||||||
|
target_version: int
|
||||||
|
last_migration: Optional[str]
|
||||||
|
error: Optional[str]
|
||||||
|
initialized: bool
|
||||||
|
|
||||||
|
|
||||||
class DatabaseSchemaManager:
|
class DatabaseSchemaManager:
|
||||||
"""Manages database schema creation and updates"""
|
"""Manages database schema creation and updates"""
|
||||||
|
|
||||||
SCHEMA_VERSION = 1 # Increment when schema changes
|
SCHEMA_VERSION: ClassVar[int] = 1 # Increment when schema changes
|
||||||
|
MIGRATION_TIMEOUT: ClassVar[float] = 30.0 # Seconds
|
||||||
|
|
||||||
def __init__(self, db_path: Path):
|
def __init__(self, db_path: Path) -> None:
|
||||||
|
"""
|
||||||
|
Initialize schema manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to SQLite database file
|
||||||
|
"""
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
|
self.state = SchemaState.UNINITIALIZED
|
||||||
|
self.last_error: Optional[str] = None
|
||||||
|
self.last_migration: Optional[str] = None
|
||||||
|
|
||||||
def initialize_schema(self) -> None:
|
def initialize_schema(self) -> None:
|
||||||
"""Initialize or update the database schema"""
|
"""
|
||||||
|
Initialize or update the database schema.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DatabaseError: If schema initialization fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
self.state = SchemaState.INITIALIZING
|
||||||
self._create_schema_version_table()
|
self._create_schema_version_table()
|
||||||
current_version = self._get_schema_version()
|
current_version = self._get_schema_version()
|
||||||
|
|
||||||
if current_version < self.SCHEMA_VERSION:
|
if current_version < self.SCHEMA_VERSION:
|
||||||
|
self.state = SchemaState.MIGRATING
|
||||||
self._apply_migrations(current_version)
|
self._apply_migrations(current_version)
|
||||||
self._update_schema_version()
|
self._update_schema_version()
|
||||||
|
|
||||||
|
self.state = SchemaState.READY
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
logger.error(f"Schema initialization error: {e}")
|
self.state = SchemaState.ERROR
|
||||||
raise
|
self.last_error = str(e)
|
||||||
|
error = f"Schema initialization failed: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise DatabaseError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"SchemaManager",
|
||||||
|
"initialize_schema",
|
||||||
|
{"current_version": current_version},
|
||||||
|
ErrorSeverity.CRITICAL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def _create_schema_version_table(self) -> None:
|
def _create_schema_version_table(self) -> None:
|
||||||
"""Create schema version tracking table"""
|
"""
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
Create schema version tracking table.
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(
|
Raises:
|
||||||
"""
|
DatabaseError: If table creation fails
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
"""
|
||||||
version INTEGER PRIMARY KEY
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
migrations_applied TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
"""
|
# Insert initial version if table is empty
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO schema_version (version, migrations_applied)
|
||||||
|
VALUES (0, '[]')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
error = f"Failed to create schema version table: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise DatabaseError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"SchemaManager",
|
||||||
|
"create_schema_version_table",
|
||||||
|
None,
|
||||||
|
ErrorSeverity.CRITICAL,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
# Insert initial version if table is empty
|
|
||||||
cursor.execute("INSERT OR IGNORE INTO schema_version VALUES (0)")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def _get_schema_version(self) -> int:
|
def _get_schema_version(self) -> int:
|
||||||
"""Get current schema version"""
|
"""
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
Get current schema version.
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("SELECT version FROM schema_version LIMIT 1")
|
Returns:
|
||||||
result = cursor.fetchone()
|
Current schema version
|
||||||
return result[0] if result else 0
|
|
||||||
|
Raises:
|
||||||
|
DatabaseError: If version query fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT version FROM schema_version LIMIT 1")
|
||||||
|
result = cursor.fetchone()
|
||||||
|
return result[0] if result else 0
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
error = f"Failed to get schema version: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise DatabaseError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"SchemaManager", "get_schema_version", None, ErrorSeverity.HIGH
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def _update_schema_version(self) -> None:
|
def _update_schema_version(self) -> None:
|
||||||
"""Update schema version to current"""
|
"""
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
Update schema version to current.
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(
|
Raises:
|
||||||
"UPDATE schema_version SET version = ?", (self.SCHEMA_VERSION,)
|
DatabaseError: If version update fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
UPDATE schema_version
|
||||||
|
SET version = ?, last_updated = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(self.SCHEMA_VERSION,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
error = f"Failed to update schema version: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise DatabaseError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"SchemaManager",
|
||||||
|
"update_schema_version",
|
||||||
|
{"target_version": self.SCHEMA_VERSION},
|
||||||
|
ErrorSeverity.HIGH,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def _apply_migrations(self, current_version: int) -> None:
|
def _apply_migrations(self, current_version: int) -> None:
|
||||||
"""Apply necessary schema migrations"""
|
"""
|
||||||
|
Apply necessary schema migrations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_version: Current schema version
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DatabaseError: If migrations fail
|
||||||
|
"""
|
||||||
migrations = self._get_migrations(current_version)
|
migrations = self._get_migrations(current_version)
|
||||||
|
results: List[MigrationResult] = []
|
||||||
|
|
||||||
with sqlite3.connect(self.db_path) as conn:
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
for migration in migrations:
|
for migration in migrations:
|
||||||
|
start_time = datetime.utcnow()
|
||||||
try:
|
try:
|
||||||
cursor.executescript(migration)
|
cursor.executescript(migration)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
self.last_migration = migration
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
MigrationResult(
|
||||||
|
success=True,
|
||||||
|
error=None,
|
||||||
|
migration_type=MigrationType.ALTER.name,
|
||||||
|
duration=(datetime.utcnow() - start_time).total_seconds(),
|
||||||
|
timestamp=datetime.utcnow().isoformat(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
logger.error(f"Migration failed: {e}")
|
error = f"Migration failed: {str(e)}"
|
||||||
raise
|
logger.error(error, exc_info=True)
|
||||||
|
results.append(
|
||||||
|
MigrationResult(
|
||||||
|
success=False,
|
||||||
|
error=str(e),
|
||||||
|
migration_type=MigrationType.ALTER.name,
|
||||||
|
duration=(datetime.utcnow() - start_time).total_seconds(),
|
||||||
|
timestamp=datetime.utcnow().isoformat(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise DatabaseError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"SchemaManager",
|
||||||
|
"apply_migrations",
|
||||||
|
{
|
||||||
|
"current_version": current_version,
|
||||||
|
"migration": migration,
|
||||||
|
"results": results,
|
||||||
|
},
|
||||||
|
ErrorSeverity.CRITICAL,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def _get_migrations(self, current_version: int) -> List[str]:
|
def _get_migrations(self, current_version: int) -> List[str]:
|
||||||
"""Get list of migrations to apply"""
|
"""
|
||||||
|
Get list of migrations to apply.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_version: Current schema version
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of migration scripts
|
||||||
|
"""
|
||||||
migrations = []
|
migrations = []
|
||||||
|
|
||||||
# Version 0 to 1: Initial schema
|
# Version 0 to 1: Initial schema
|
||||||
@@ -95,7 +297,11 @@ class DatabaseSchemaManager:
|
|||||||
duration INTEGER,
|
duration INTEGER,
|
||||||
format TEXT,
|
format TEXT,
|
||||||
resolution TEXT,
|
resolution TEXT,
|
||||||
bitrate INTEGER
|
bitrate INTEGER,
|
||||||
|
error_count INTEGER DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
last_accessed TIMESTAMP,
|
||||||
|
metadata TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_guild_channel
|
CREATE INDEX IF NOT EXISTS idx_guild_channel
|
||||||
@@ -103,6 +309,9 @@ class DatabaseSchemaManager:
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_archived_at
|
CREATE INDEX IF NOT EXISTS idx_archived_at
|
||||||
ON archived_videos(archived_at);
|
ON archived_videos(archived_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_last_accessed
|
||||||
|
ON archived_videos(last_accessed);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,3 +320,57 @@ class DatabaseSchemaManager:
|
|||||||
# migrations.append(...)
|
# migrations.append(...)
|
||||||
|
|
||||||
return migrations
|
return migrations
|
||||||
|
|
||||||
|
def get_status(self) -> SchemaStatus:
|
||||||
|
"""
|
||||||
|
Get current schema status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schema status information
|
||||||
|
"""
|
||||||
|
return SchemaStatus(
|
||||||
|
state=self.state.name,
|
||||||
|
current_version=self._get_schema_version(),
|
||||||
|
target_version=self.SCHEMA_VERSION,
|
||||||
|
last_migration=self.last_migration,
|
||||||
|
error=self.last_error,
|
||||||
|
initialized=self.state == SchemaState.READY,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_version_info(self) -> SchemaVersion:
|
||||||
|
"""
|
||||||
|
Get detailed version information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schema version information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DatabaseError: If version query fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT version, last_updated, migrations_applied
|
||||||
|
FROM schema_version LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if result:
|
||||||
|
return SchemaVersion(
|
||||||
|
version=result[0],
|
||||||
|
last_updated=result[1],
|
||||||
|
migrations_applied=result[2].split(",") if result[2] else [],
|
||||||
|
)
|
||||||
|
return SchemaVersion(version=0, last_updated="", migrations_applied=[])
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
error = f"Failed to get version info: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise DatabaseError(
|
||||||
|
error,
|
||||||
|
context=ErrorContext(
|
||||||
|
"SchemaManager", "get_version_info", None, ErrorSeverity.HIGH
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,45 +1,212 @@
|
|||||||
"""Video processing module for VideoArchiver"""
|
"""Video processing module for VideoArchiver"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, Optional, Union, List, Tuple
|
||||||
|
import discord
|
||||||
|
|
||||||
from .core import VideoProcessor
|
from .core import VideoProcessor
|
||||||
from .constants import REACTIONS
|
from .constants import (
|
||||||
from .progress_tracker import ProgressTracker
|
REACTIONS,
|
||||||
|
ReactionType,
|
||||||
|
ReactionEmojis,
|
||||||
|
ProgressEmojis,
|
||||||
|
get_reaction,
|
||||||
|
get_progress_emoji
|
||||||
|
)
|
||||||
|
from .url_extractor import (
|
||||||
|
URLExtractor,
|
||||||
|
URLMetadata,
|
||||||
|
URLPattern,
|
||||||
|
URLType,
|
||||||
|
URLPatternManager,
|
||||||
|
URLValidator,
|
||||||
|
URLMetadataExtractor
|
||||||
|
)
|
||||||
|
from .message_validator import (
|
||||||
|
MessageValidator,
|
||||||
|
ValidationContext,
|
||||||
|
ValidationRule,
|
||||||
|
ValidationResult,
|
||||||
|
ValidationRuleManager,
|
||||||
|
ValidationCache,
|
||||||
|
ValidationStats,
|
||||||
|
ValidationCacheEntry,
|
||||||
|
ValidationError
|
||||||
|
)
|
||||||
from .message_handler import MessageHandler
|
from .message_handler import MessageHandler
|
||||||
from .queue_handler import QueueHandler
|
from .queue_handler import QueueHandler
|
||||||
|
from .reactions import (
|
||||||
|
handle_archived_reaction,
|
||||||
|
update_queue_position_reaction,
|
||||||
|
update_progress_reaction,
|
||||||
|
update_download_progress_reaction
|
||||||
|
)
|
||||||
|
|
||||||
# Export public classes and constants
|
# Export public classes and constants
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
# Core components
|
||||||
"VideoProcessor",
|
"VideoProcessor",
|
||||||
"REACTIONS",
|
|
||||||
"ProgressTracker",
|
|
||||||
"MessageHandler",
|
"MessageHandler",
|
||||||
"QueueHandler",
|
"QueueHandler",
|
||||||
|
|
||||||
|
# URL Extraction
|
||||||
|
"URLExtractor",
|
||||||
|
"URLMetadata",
|
||||||
|
"URLPattern",
|
||||||
|
"URLType",
|
||||||
|
"URLPatternManager",
|
||||||
|
"URLValidator",
|
||||||
|
"URLMetadataExtractor",
|
||||||
|
|
||||||
|
# Message Validation
|
||||||
|
"MessageValidator",
|
||||||
|
"ValidationContext",
|
||||||
|
"ValidationRule",
|
||||||
|
"ValidationResult",
|
||||||
|
"ValidationRuleManager",
|
||||||
|
"ValidationCache",
|
||||||
|
"ValidationStats",
|
||||||
|
"ValidationCacheEntry",
|
||||||
|
"ValidationError",
|
||||||
|
|
||||||
|
# Constants and enums
|
||||||
|
"REACTIONS",
|
||||||
|
"ReactionType",
|
||||||
|
"ReactionEmojis",
|
||||||
|
"ProgressEmojis",
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
"get_reaction",
|
||||||
|
"get_progress_emoji",
|
||||||
|
"extract_urls",
|
||||||
|
"validate_message",
|
||||||
|
"update_download_progress",
|
||||||
|
"complete_download",
|
||||||
|
"increment_download_retries",
|
||||||
|
"get_download_progress",
|
||||||
|
"get_active_operations",
|
||||||
|
"get_validation_stats",
|
||||||
|
"clear_caches",
|
||||||
|
|
||||||
|
# Reaction handlers
|
||||||
|
"handle_archived_reaction",
|
||||||
|
"update_queue_position_reaction",
|
||||||
|
"update_progress_reaction",
|
||||||
|
"update_download_progress_reaction",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Create a shared progress tracker instance for module-level access
|
# Version information
|
||||||
progress_tracker = ProgressTracker()
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "VideoArchiver Team"
|
||||||
|
__description__ = "Video processing module for archiving Discord videos"
|
||||||
|
|
||||||
|
# Create shared instances for module-level access
|
||||||
|
url_extractor = URLExtractor()
|
||||||
|
message_validator = MessageValidator()
|
||||||
|
|
||||||
# Export progress tracking functions that wrap the instance methods
|
# URL extraction helper functions
|
||||||
def update_download_progress(url, progress_data):
|
async def extract_urls(
|
||||||
"""Update download progress for a specific URL"""
|
message: discord.Message,
|
||||||
|
enabled_sites: Optional[List[str]] = None
|
||||||
|
) -> List[URLMetadata]:
|
||||||
|
"""
|
||||||
|
Extract video URLs from a Discord message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Discord message to extract URLs from
|
||||||
|
enabled_sites: Optional list of enabled site identifiers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of URLMetadata objects for extracted URLs
|
||||||
|
"""
|
||||||
|
return await url_extractor.extract_urls(message, enabled_sites)
|
||||||
|
|
||||||
|
async def validate_message(
|
||||||
|
message: discord.Message,
|
||||||
|
settings: Dict[str, Any]
|
||||||
|
) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate a Discord message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Discord message to validate
|
||||||
|
settings: Guild settings dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, reason)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If validation fails unexpectedly
|
||||||
|
"""
|
||||||
|
return await message_validator.validate_message(message, settings)
|
||||||
|
|
||||||
|
# Progress tracking helper functions
|
||||||
|
def update_download_progress(url: str, progress_data: Dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
Update download progress for a specific URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL being downloaded
|
||||||
|
progress_data: Dictionary containing progress information
|
||||||
|
"""
|
||||||
progress_tracker.update_download_progress(url, progress_data)
|
progress_tracker.update_download_progress(url, progress_data)
|
||||||
|
|
||||||
|
def complete_download(url: str) -> None:
|
||||||
def complete_download(url):
|
"""
|
||||||
"""Mark a download as complete"""
|
Mark a download as complete.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL that completed downloading
|
||||||
|
"""
|
||||||
progress_tracker.complete_download(url)
|
progress_tracker.complete_download(url)
|
||||||
|
|
||||||
|
def increment_download_retries(url: str) -> None:
|
||||||
def increment_download_retries(url):
|
"""
|
||||||
"""Increment retry count for a download"""
|
Increment retry count for a download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL being retried
|
||||||
|
"""
|
||||||
progress_tracker.increment_download_retries(url)
|
progress_tracker.increment_download_retries(url)
|
||||||
|
|
||||||
|
def get_download_progress(url: Optional[str] = None) -> Union[Dict[str, Any], Dict[str, Dict[str, Any]]]:
|
||||||
def get_download_progress(url=None):
|
"""
|
||||||
"""Get download progress for a specific URL or all downloads"""
|
Get download progress for a specific URL or all downloads.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Optional URL to get progress for. If None, returns all download progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing progress information for one or all downloads
|
||||||
|
"""
|
||||||
return progress_tracker.get_download_progress(url)
|
return progress_tracker.get_download_progress(url)
|
||||||
|
|
||||||
|
def get_active_operations() -> Dict[str, Dict[str, Any]]:
|
||||||
def get_active_operations():
|
"""
|
||||||
"""Get all active operations"""
|
Get all active operations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing information about all active operations
|
||||||
|
"""
|
||||||
return progress_tracker.get_active_operations()
|
return progress_tracker.get_active_operations()
|
||||||
|
|
||||||
|
def get_validation_stats() -> ValidationStats:
|
||||||
|
"""
|
||||||
|
Get message validation statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing validation statistics and rule information
|
||||||
|
"""
|
||||||
|
return message_validator.get_stats()
|
||||||
|
|
||||||
|
def clear_caches(message_id: Optional[int] = None) -> None:
|
||||||
|
"""
|
||||||
|
Clear URL and validation caches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Optional message ID to clear caches for. If None, clears all caches.
|
||||||
|
"""
|
||||||
|
url_extractor.clear_cache(message_id)
|
||||||
|
message_validator.clear_cache(message_id)
|
||||||
|
|
||||||
|
# Initialize shared progress tracker instance
|
||||||
|
progress_tracker = ProgressTracker()
|
||||||
|
|||||||
@@ -2,25 +2,37 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from enum import Enum
|
from enum import Enum, auto
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional, Dict, Any, List, Set
|
from typing import Optional, Dict, Any, List, Set, TypedDict, ClassVar, Callable, Awaitable, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from .queue_handler import QueueHandler
|
||||||
|
from ..ffmpeg.ffmpeg_manager import FFmpegManager
|
||||||
|
from ..utils.exceptions import CleanupError
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
class CleanupStage(Enum):
|
class CleanupStage(Enum):
|
||||||
"""Cleanup stages"""
|
"""Cleanup stages"""
|
||||||
QUEUE = "queue"
|
QUEUE = auto()
|
||||||
FFMPEG = "ffmpeg"
|
FFMPEG = auto()
|
||||||
TASKS = "tasks"
|
TASKS = auto()
|
||||||
RESOURCES = "resources"
|
RESOURCES = auto()
|
||||||
|
|
||||||
class CleanupStrategy(Enum):
|
class CleanupStrategy(Enum):
|
||||||
"""Cleanup strategies"""
|
"""Cleanup strategies"""
|
||||||
NORMAL = "normal"
|
NORMAL = auto()
|
||||||
FORCE = "force"
|
FORCE = auto()
|
||||||
GRACEFUL = "graceful"
|
GRACEFUL = auto()
|
||||||
|
|
||||||
|
class CleanupStats(TypedDict):
|
||||||
|
"""Type definition for cleanup statistics"""
|
||||||
|
total_cleanups: int
|
||||||
|
active_cleanups: int
|
||||||
|
success_rate: float
|
||||||
|
average_duration: float
|
||||||
|
stage_success_rates: Dict[str, float]
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CleanupResult:
|
class CleanupResult:
|
||||||
@@ -29,33 +41,64 @@ class CleanupResult:
|
|||||||
stage: CleanupStage
|
stage: CleanupStage
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
duration: float = 0.0
|
duration: float = 0.0
|
||||||
|
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CleanupOperation:
|
||||||
|
"""Represents a cleanup operation"""
|
||||||
|
stage: CleanupStage
|
||||||
|
func: Callable[[], Awaitable[None]]
|
||||||
|
force_func: Optional[Callable[[], Awaitable[None]]] = None
|
||||||
|
timeout: float = 30.0 # Default timeout in seconds
|
||||||
|
|
||||||
class CleanupTracker:
|
class CleanupTracker:
|
||||||
"""Tracks cleanup operations"""
|
"""Tracks cleanup operations"""
|
||||||
|
|
||||||
def __init__(self):
|
MAX_HISTORY: ClassVar[int] = 1000 # Maximum number of cleanup operations to track
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
self.cleanup_history: List[Dict[str, Any]] = []
|
self.cleanup_history: List[Dict[str, Any]] = []
|
||||||
self.active_cleanups: Set[str] = set()
|
self.active_cleanups: Set[str] = set()
|
||||||
self.start_times: Dict[str, datetime] = {}
|
self.start_times: Dict[str, datetime] = {}
|
||||||
self.stage_results: Dict[str, List[CleanupResult]] = {}
|
self.stage_results: Dict[str, List[CleanupResult]] = {}
|
||||||
|
|
||||||
def start_cleanup(self, cleanup_id: str) -> None:
|
def start_cleanup(self, cleanup_id: str) -> None:
|
||||||
"""Start tracking a cleanup operation"""
|
"""
|
||||||
|
Start tracking a cleanup operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cleanup_id: Unique identifier for the cleanup operation
|
||||||
|
"""
|
||||||
self.active_cleanups.add(cleanup_id)
|
self.active_cleanups.add(cleanup_id)
|
||||||
self.start_times[cleanup_id] = datetime.utcnow()
|
self.start_times[cleanup_id] = datetime.utcnow()
|
||||||
self.stage_results[cleanup_id] = []
|
self.stage_results[cleanup_id] = []
|
||||||
|
|
||||||
|
# Cleanup old history if needed
|
||||||
|
if len(self.cleanup_history) >= self.MAX_HISTORY:
|
||||||
|
self.cleanup_history = self.cleanup_history[-self.MAX_HISTORY:]
|
||||||
|
|
||||||
def record_stage_result(
|
def record_stage_result(
|
||||||
self,
|
self,
|
||||||
cleanup_id: str,
|
cleanup_id: str,
|
||||||
result: CleanupResult
|
result: CleanupResult
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Record result of a cleanup stage"""
|
"""
|
||||||
|
Record result of a cleanup stage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cleanup_id: Cleanup operation identifier
|
||||||
|
result: Result of the cleanup stage
|
||||||
|
"""
|
||||||
if cleanup_id in self.stage_results:
|
if cleanup_id in self.stage_results:
|
||||||
self.stage_results[cleanup_id].append(result)
|
self.stage_results[cleanup_id].append(result)
|
||||||
|
|
||||||
def end_cleanup(self, cleanup_id: str) -> None:
|
def end_cleanup(self, cleanup_id: str) -> None:
|
||||||
"""End tracking a cleanup operation"""
|
"""
|
||||||
|
End tracking a cleanup operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cleanup_id: Cleanup operation identifier
|
||||||
|
"""
|
||||||
if cleanup_id in self.active_cleanups:
|
if cleanup_id in self.active_cleanups:
|
||||||
end_time = datetime.utcnow()
|
end_time = datetime.utcnow()
|
||||||
self.cleanup_history.append({
|
self.cleanup_history.append({
|
||||||
@@ -69,15 +112,20 @@ class CleanupTracker:
|
|||||||
self.start_times.pop(cleanup_id)
|
self.start_times.pop(cleanup_id)
|
||||||
self.stage_results.pop(cleanup_id)
|
self.stage_results.pop(cleanup_id)
|
||||||
|
|
||||||
def get_cleanup_stats(self) -> Dict[str, Any]:
|
def get_cleanup_stats(self) -> CleanupStats:
|
||||||
"""Get cleanup statistics"""
|
"""
|
||||||
return {
|
Get cleanup statistics.
|
||||||
"total_cleanups": len(self.cleanup_history),
|
|
||||||
"active_cleanups": len(self.active_cleanups),
|
Returns:
|
||||||
"success_rate": self._calculate_success_rate(),
|
Dictionary containing cleanup statistics
|
||||||
"average_duration": self._calculate_average_duration(),
|
"""
|
||||||
"stage_success_rates": self._calculate_stage_success_rates()
|
return CleanupStats(
|
||||||
}
|
total_cleanups=len(self.cleanup_history),
|
||||||
|
active_cleanups=len(self.active_cleanups),
|
||||||
|
success_rate=self._calculate_success_rate(),
|
||||||
|
average_duration=self._calculate_average_duration(),
|
||||||
|
stage_success_rates=self._calculate_stage_success_rates()
|
||||||
|
)
|
||||||
|
|
||||||
def _calculate_success_rate(self) -> float:
|
def _calculate_success_rate(self) -> float:
|
||||||
"""Calculate overall cleanup success rate"""
|
"""Calculate overall cleanup success rate"""
|
||||||
@@ -116,20 +164,49 @@ class CleanupTracker:
|
|||||||
class CleanupManager:
|
class CleanupManager:
|
||||||
"""Manages cleanup operations for the video processor"""
|
"""Manages cleanup operations for the video processor"""
|
||||||
|
|
||||||
|
CLEANUP_TIMEOUT: ClassVar[int] = 60 # Default timeout for entire cleanup operation
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
queue_handler,
|
queue_handler: QueueHandler,
|
||||||
ffmpeg_mgr: Optional[object] = None,
|
ffmpeg_mgr: Optional[FFmpegManager] = None,
|
||||||
strategy: CleanupStrategy = CleanupStrategy.NORMAL
|
strategy: CleanupStrategy = CleanupStrategy.NORMAL
|
||||||
):
|
) -> None:
|
||||||
self.queue_handler = queue_handler
|
self.queue_handler = queue_handler
|
||||||
self.ffmpeg_mgr = ffmpeg_mgr
|
self.ffmpeg_mgr = ffmpeg_mgr
|
||||||
self.strategy = strategy
|
self.strategy = strategy
|
||||||
self._queue_task: Optional[asyncio.Task] = None
|
self._queue_task: Optional[asyncio.Task] = None
|
||||||
self.tracker = CleanupTracker()
|
self.tracker = CleanupTracker()
|
||||||
|
|
||||||
|
# Define cleanup operations
|
||||||
|
self.cleanup_operations: List[CleanupOperation] = [
|
||||||
|
CleanupOperation(
|
||||||
|
stage=CleanupStage.QUEUE,
|
||||||
|
func=self._cleanup_queue,
|
||||||
|
force_func=self._force_cleanup_queue,
|
||||||
|
timeout=30.0
|
||||||
|
),
|
||||||
|
CleanupOperation(
|
||||||
|
stage=CleanupStage.FFMPEG,
|
||||||
|
func=self._cleanup_ffmpeg,
|
||||||
|
force_func=self._force_cleanup_ffmpeg,
|
||||||
|
timeout=15.0
|
||||||
|
),
|
||||||
|
CleanupOperation(
|
||||||
|
stage=CleanupStage.TASKS,
|
||||||
|
func=self._cleanup_tasks,
|
||||||
|
force_func=self._force_cleanup_tasks,
|
||||||
|
timeout=15.0
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
async def cleanup(self) -> None:
|
async def cleanup(self) -> None:
|
||||||
"""Perform normal cleanup of resources"""
|
"""
|
||||||
|
Perform normal cleanup of resources.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CleanupError: If cleanup fails
|
||||||
|
"""
|
||||||
cleanup_id = f"cleanup_{datetime.utcnow().timestamp()}"
|
cleanup_id = f"cleanup_{datetime.utcnow().timestamp()}"
|
||||||
self.tracker.start_cleanup(cleanup_id)
|
self.tracker.start_cleanup(cleanup_id)
|
||||||
|
|
||||||
@@ -137,35 +214,45 @@ class CleanupManager:
|
|||||||
logger.info("Starting normal cleanup...")
|
logger.info("Starting normal cleanup...")
|
||||||
|
|
||||||
# Clean up in stages
|
# Clean up in stages
|
||||||
stages = [
|
for operation in self.cleanup_operations:
|
||||||
(CleanupStage.QUEUE, self._cleanup_queue),
|
|
||||||
(CleanupStage.FFMPEG, self._cleanup_ffmpeg),
|
|
||||||
(CleanupStage.TASKS, self._cleanup_tasks)
|
|
||||||
]
|
|
||||||
|
|
||||||
for stage, cleanup_func in stages:
|
|
||||||
try:
|
try:
|
||||||
start_time = datetime.utcnow()
|
start_time = datetime.utcnow()
|
||||||
await cleanup_func()
|
await asyncio.wait_for(
|
||||||
|
operation.func(),
|
||||||
|
timeout=operation.timeout
|
||||||
|
)
|
||||||
duration = (datetime.utcnow() - start_time).total_seconds()
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
||||||
self.tracker.record_stage_result(
|
self.tracker.record_stage_result(
|
||||||
cleanup_id,
|
cleanup_id,
|
||||||
CleanupResult(True, stage, duration=duration)
|
CleanupResult(True, operation.stage, duration=duration)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except asyncio.TimeoutError:
|
||||||
logger.error(f"Error in {stage.value} cleanup: {e}")
|
error = f"Cleanup stage {operation.stage.value} timed out"
|
||||||
|
logger.error(error)
|
||||||
self.tracker.record_stage_result(
|
self.tracker.record_stage_result(
|
||||||
cleanup_id,
|
cleanup_id,
|
||||||
CleanupResult(False, stage, str(e))
|
CleanupResult(False, operation.stage, error)
|
||||||
)
|
)
|
||||||
if self.strategy != CleanupStrategy.GRACEFUL:
|
if self.strategy != CleanupStrategy.GRACEFUL:
|
||||||
raise
|
raise CleanupError(error)
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Error in {operation.stage.value} cleanup: {e}"
|
||||||
|
logger.error(error)
|
||||||
|
self.tracker.record_stage_result(
|
||||||
|
cleanup_id,
|
||||||
|
CleanupResult(False, operation.stage, str(e))
|
||||||
|
)
|
||||||
|
if self.strategy != CleanupStrategy.GRACEFUL:
|
||||||
|
raise CleanupError(error)
|
||||||
|
|
||||||
logger.info("Normal cleanup completed successfully")
|
logger.info("Normal cleanup completed successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except CleanupError:
|
||||||
logger.error(f"Error during normal cleanup: {str(e)}", exc_info=True)
|
|
||||||
raise
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Unexpected error during cleanup: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise CleanupError(error)
|
||||||
finally:
|
finally:
|
||||||
self.tracker.end_cleanup(cleanup_id)
|
self.tracker.end_cleanup(cleanup_id)
|
||||||
|
|
||||||
@@ -178,26 +265,26 @@ class CleanupManager:
|
|||||||
logger.info("Starting force cleanup...")
|
logger.info("Starting force cleanup...")
|
||||||
|
|
||||||
# Force cleanup in stages
|
# Force cleanup in stages
|
||||||
stages = [
|
for operation in self.cleanup_operations:
|
||||||
(CleanupStage.QUEUE, self._force_cleanup_queue),
|
if not operation.force_func:
|
||||||
(CleanupStage.FFMPEG, self._force_cleanup_ffmpeg),
|
continue
|
||||||
(CleanupStage.TASKS, self._force_cleanup_tasks)
|
|
||||||
]
|
|
||||||
|
|
||||||
for stage, cleanup_func in stages:
|
|
||||||
try:
|
try:
|
||||||
start_time = datetime.utcnow()
|
start_time = datetime.utcnow()
|
||||||
await cleanup_func()
|
await asyncio.wait_for(
|
||||||
|
operation.force_func(),
|
||||||
|
timeout=operation.timeout
|
||||||
|
)
|
||||||
duration = (datetime.utcnow() - start_time).total_seconds()
|
duration = (datetime.utcnow() - start_time).total_seconds()
|
||||||
self.tracker.record_stage_result(
|
self.tracker.record_stage_result(
|
||||||
cleanup_id,
|
cleanup_id,
|
||||||
CleanupResult(True, stage, duration=duration)
|
CleanupResult(True, operation.stage, duration=duration)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in force {stage.value} cleanup: {e}")
|
logger.error(f"Error in force {operation.stage.value} cleanup: {e}")
|
||||||
self.tracker.record_stage_result(
|
self.tracker.record_stage_result(
|
||||||
cleanup_id,
|
cleanup_id,
|
||||||
CleanupResult(False, stage, str(e))
|
CleanupResult(False, operation.stage, str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Force cleanup completed")
|
logger.info("Force cleanup completed")
|
||||||
@@ -209,6 +296,8 @@ class CleanupManager:
|
|||||||
|
|
||||||
async def _cleanup_queue(self) -> None:
|
async def _cleanup_queue(self) -> None:
|
||||||
"""Clean up queue handler"""
|
"""Clean up queue handler"""
|
||||||
|
if not self.queue_handler:
|
||||||
|
raise CleanupError("Queue handler not initialized")
|
||||||
await self.queue_handler.cleanup()
|
await self.queue_handler.cleanup()
|
||||||
|
|
||||||
async def _cleanup_ffmpeg(self) -> None:
|
async def _cleanup_ffmpeg(self) -> None:
|
||||||
@@ -224,15 +313,22 @@ class CleanupManager:
|
|||||||
await self._queue_task
|
await self._queue_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
raise CleanupError(f"Error cleaning up queue task: {str(e)}")
|
||||||
|
|
||||||
async def _force_cleanup_queue(self) -> None:
|
async def _force_cleanup_queue(self) -> None:
|
||||||
"""Force clean up queue handler"""
|
"""Force clean up queue handler"""
|
||||||
|
if not self.queue_handler:
|
||||||
|
raise CleanupError("Queue handler not initialized")
|
||||||
await self.queue_handler.force_cleanup()
|
await self.queue_handler.force_cleanup()
|
||||||
|
|
||||||
async def _force_cleanup_ffmpeg(self) -> None:
|
async def _force_cleanup_ffmpeg(self) -> None:
|
||||||
"""Force clean up FFmpeg manager"""
|
"""Force clean up FFmpeg manager"""
|
||||||
if self.ffmpeg_mgr:
|
if self.ffmpeg_mgr:
|
||||||
self.ffmpeg_mgr.kill_all_processes()
|
try:
|
||||||
|
self.ffmpeg_mgr.kill_all_processes()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error force cleaning FFmpeg processes: {e}")
|
||||||
|
|
||||||
async def _force_cleanup_tasks(self) -> None:
|
async def _force_cleanup_tasks(self) -> None:
|
||||||
"""Force clean up tasks"""
|
"""Force clean up tasks"""
|
||||||
@@ -240,13 +336,31 @@ class CleanupManager:
|
|||||||
self._queue_task.cancel()
|
self._queue_task.cancel()
|
||||||
|
|
||||||
def set_queue_task(self, task: asyncio.Task) -> None:
|
def set_queue_task(self, task: asyncio.Task) -> None:
|
||||||
"""Set the queue processing task for cleanup purposes"""
|
"""
|
||||||
|
Set the queue processing task for cleanup purposes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Queue processing task to track
|
||||||
|
"""
|
||||||
self._queue_task = task
|
self._queue_task = task
|
||||||
|
|
||||||
def get_cleanup_stats(self) -> Dict[str, Any]:
|
def get_cleanup_stats(self) -> Dict[str, Any]:
|
||||||
"""Get cleanup statistics"""
|
"""
|
||||||
|
Get cleanup statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing cleanup statistics and status
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"stats": self.tracker.get_cleanup_stats(),
|
"stats": self.tracker.get_cleanup_stats(),
|
||||||
"strategy": self.strategy.value,
|
"strategy": self.strategy.value,
|
||||||
"active_cleanups": len(self.tracker.active_cleanups)
|
"active_cleanups": len(self.tracker.active_cleanups),
|
||||||
|
"operations": [
|
||||||
|
{
|
||||||
|
"stage": op.stage.value,
|
||||||
|
"timeout": op.timeout,
|
||||||
|
"has_force_cleanup": op.force_func is not None
|
||||||
|
}
|
||||||
|
for op in self.cleanup_operations
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,77 @@
|
|||||||
"""Constants for VideoProcessor"""
|
"""Constants for VideoProcessor"""
|
||||||
|
|
||||||
# Reaction emojis
|
from typing import Dict, List, Union
|
||||||
REACTIONS = {
|
from dataclasses import dataclass
|
||||||
'queued': '📹',
|
from enum import Enum
|
||||||
'processing': '⚙️',
|
|
||||||
'success': '✅',
|
class ReactionType(Enum):
|
||||||
'error': '❌',
|
"""Types of reactions used in the processor"""
|
||||||
'archived': '🔄', # New reaction for already archived videos
|
QUEUED = 'queued'
|
||||||
'numbers': ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣'],
|
PROCESSING = 'processing'
|
||||||
'progress': ['⬛', '🟨', '🟩'],
|
SUCCESS = 'success'
|
||||||
'download': ['0️⃣', '2️⃣', '4️⃣', '6️⃣', '8️⃣', '🔟']
|
ERROR = 'error'
|
||||||
|
ARCHIVED = 'archived'
|
||||||
|
NUMBERS = 'numbers'
|
||||||
|
PROGRESS = 'progress'
|
||||||
|
DOWNLOAD = 'download'
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReactionEmojis:
|
||||||
|
"""Emoji constants for different reaction types"""
|
||||||
|
QUEUED: str = '📹'
|
||||||
|
PROCESSING: str = '⚙️'
|
||||||
|
SUCCESS: str = '✅'
|
||||||
|
ERROR: str = '❌'
|
||||||
|
ARCHIVED: str = '🔄'
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProgressEmojis:
|
||||||
|
"""Emoji sequences for progress indicators"""
|
||||||
|
NUMBERS: List[str] = ('1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣')
|
||||||
|
PROGRESS: List[str] = ('⬛', '🟨', '🟩')
|
||||||
|
DOWNLOAD: List[str] = ('0️⃣', '2️⃣', '4️⃣', '6️⃣', '8️⃣', '🔟')
|
||||||
|
|
||||||
|
# Main reactions dictionary with type hints
|
||||||
|
REACTIONS: Dict[str, Union[str, List[str]]] = {
|
||||||
|
ReactionType.QUEUED.value: ReactionEmojis.QUEUED,
|
||||||
|
ReactionType.PROCESSING.value: ReactionEmojis.PROCESSING,
|
||||||
|
ReactionType.SUCCESS.value: ReactionEmojis.SUCCESS,
|
||||||
|
ReactionType.ERROR.value: ReactionEmojis.ERROR,
|
||||||
|
ReactionType.ARCHIVED.value: ReactionEmojis.ARCHIVED,
|
||||||
|
ReactionType.NUMBERS.value: ProgressEmojis.NUMBERS,
|
||||||
|
ReactionType.PROGRESS.value: ProgressEmojis.PROGRESS,
|
||||||
|
ReactionType.DOWNLOAD.value: ProgressEmojis.DOWNLOAD
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_reaction(reaction_type: Union[ReactionType, str]) -> Union[str, List[str]]:
|
||||||
|
"""
|
||||||
|
Get reaction emoji(s) for a given reaction type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reaction_type: The type of reaction to get, either as ReactionType enum or string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Either a single emoji string or a list of emoji strings
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If the reaction type doesn't exist
|
||||||
|
"""
|
||||||
|
key = reaction_type.value if isinstance(reaction_type, ReactionType) else reaction_type
|
||||||
|
return REACTIONS[key]
|
||||||
|
|
||||||
|
def get_progress_emoji(progress: float, emoji_list: List[str]) -> str:
|
||||||
|
"""
|
||||||
|
Get the appropriate progress emoji based on a progress value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress: Progress value between 0 and 1
|
||||||
|
emoji_list: List of emojis to choose from
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The emoji representing the current progress
|
||||||
|
"""
|
||||||
|
if not 0 <= progress <= 1:
|
||||||
|
raise ValueError("Progress must be between 0 and 1")
|
||||||
|
|
||||||
|
index = int(progress * (len(emoji_list) - 1))
|
||||||
|
return emoji_list[index]
|
||||||
|
|||||||
@@ -2,43 +2,76 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from enum import Enum
|
from enum import Enum, auto
|
||||||
from typing import Optional, Tuple, Dict, Any, List
|
from typing import Optional, Tuple, Dict, Any, List, TypedDict, ClassVar
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from .message_handler import MessageHandler
|
from .message_handler import MessageHandler
|
||||||
from .queue_handler import QueueHandler
|
from .queue_handler import QueueHandler
|
||||||
from .progress_tracker import ProgressTracker
|
from ..utils.progress_tracker import ProgressTracker
|
||||||
from .status_display import StatusDisplay
|
from .status_display import StatusDisplay
|
||||||
from .cleanup_manager import CleanupManager
|
from .cleanup_manager import CleanupManager, CleanupStrategy
|
||||||
from .constants import REACTIONS
|
from .constants import REACTIONS
|
||||||
|
from ..queue.manager import EnhancedVideoQueueManager
|
||||||
|
from ..ffmpeg.ffmpeg_manager import FFmpegManager
|
||||||
|
from ..database.video_archive_db import VideoArchiveDB
|
||||||
|
from ..config_manager import ConfigManager
|
||||||
|
from ..utils.exceptions import ProcessorError
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
class ProcessorState(Enum):
|
class ProcessorState(Enum):
|
||||||
"""Possible states of the video processor"""
|
"""Possible states of the video processor"""
|
||||||
INITIALIZING = "initializing"
|
INITIALIZING = auto()
|
||||||
READY = "ready"
|
READY = auto()
|
||||||
PROCESSING = "processing"
|
PROCESSING = auto()
|
||||||
PAUSED = "paused"
|
PAUSED = auto()
|
||||||
ERROR = "error"
|
ERROR = auto()
|
||||||
SHUTDOWN = "shutdown"
|
SHUTDOWN = auto()
|
||||||
|
|
||||||
class OperationType(Enum):
|
class OperationType(Enum):
|
||||||
"""Types of processor operations"""
|
"""Types of processor operations"""
|
||||||
MESSAGE_PROCESSING = "message_processing"
|
MESSAGE_PROCESSING = auto()
|
||||||
VIDEO_PROCESSING = "video_processing"
|
VIDEO_PROCESSING = auto()
|
||||||
QUEUE_MANAGEMENT = "queue_management"
|
QUEUE_MANAGEMENT = auto()
|
||||||
CLEANUP = "cleanup"
|
CLEANUP = auto()
|
||||||
|
|
||||||
|
class OperationDetails(TypedDict):
|
||||||
|
"""Type definition for operation details"""
|
||||||
|
type: str
|
||||||
|
start_time: datetime
|
||||||
|
end_time: Optional[datetime]
|
||||||
|
status: str
|
||||||
|
details: Dict[str, Any]
|
||||||
|
error: Optional[str]
|
||||||
|
|
||||||
|
class OperationStats(TypedDict):
|
||||||
|
"""Type definition for operation statistics"""
|
||||||
|
total_operations: int
|
||||||
|
active_operations: int
|
||||||
|
success_count: int
|
||||||
|
error_count: int
|
||||||
|
success_rate: float
|
||||||
|
|
||||||
|
class ProcessorStatus(TypedDict):
|
||||||
|
"""Type definition for processor status"""
|
||||||
|
state: str
|
||||||
|
health: bool
|
||||||
|
operations: OperationStats
|
||||||
|
active_operations: Dict[str, OperationDetails]
|
||||||
|
last_health_check: Optional[str]
|
||||||
|
health_status: Dict[str, bool]
|
||||||
|
|
||||||
class OperationTracker:
|
class OperationTracker:
|
||||||
"""Tracks processor operations"""
|
"""Tracks processor operations"""
|
||||||
|
|
||||||
def __init__(self):
|
MAX_HISTORY: ClassVar[int] = 1000 # Maximum number of operations to track
|
||||||
self.operations: Dict[str, Dict[str, Any]] = {}
|
|
||||||
self.operation_history: List[Dict[str, Any]] = []
|
def __init__(self) -> None:
|
||||||
|
self.operations: Dict[str, OperationDetails] = {}
|
||||||
|
self.operation_history: List[OperationDetails] = []
|
||||||
self.error_count = 0
|
self.error_count = 0
|
||||||
self.success_count = 0
|
self.success_count = 0
|
||||||
|
|
||||||
@@ -47,14 +80,25 @@ class OperationTracker:
|
|||||||
op_type: OperationType,
|
op_type: OperationType,
|
||||||
details: Dict[str, Any]
|
details: Dict[str, Any]
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Start tracking an operation"""
|
"""
|
||||||
|
Start tracking an operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
op_type: Type of operation
|
||||||
|
details: Operation details
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Operation ID string
|
||||||
|
"""
|
||||||
op_id = f"{op_type.value}_{datetime.utcnow().timestamp()}"
|
op_id = f"{op_type.value}_{datetime.utcnow().timestamp()}"
|
||||||
self.operations[op_id] = {
|
self.operations[op_id] = OperationDetails(
|
||||||
"type": op_type.value,
|
type=op_type.value,
|
||||||
"start_time": datetime.utcnow(),
|
start_time=datetime.utcnow(),
|
||||||
"status": "running",
|
end_time=None,
|
||||||
"details": details
|
status="running",
|
||||||
}
|
details=details,
|
||||||
|
error=None
|
||||||
|
)
|
||||||
return op_id
|
return op_id
|
||||||
|
|
||||||
def end_operation(
|
def end_operation(
|
||||||
@@ -63,7 +107,14 @@ class OperationTracker:
|
|||||||
success: bool,
|
success: bool,
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""End tracking an operation"""
|
"""
|
||||||
|
End tracking an operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
op_id: Operation ID
|
||||||
|
success: Whether operation succeeded
|
||||||
|
error: Optional error message
|
||||||
|
"""
|
||||||
if op_id in self.operations:
|
if op_id in self.operations:
|
||||||
self.operations[op_id].update({
|
self.operations[op_id].update({
|
||||||
"end_time": datetime.utcnow(),
|
"end_time": datetime.utcnow(),
|
||||||
@@ -78,28 +129,43 @@ class OperationTracker:
|
|||||||
else:
|
else:
|
||||||
self.error_count += 1
|
self.error_count += 1
|
||||||
|
|
||||||
def get_active_operations(self) -> Dict[str, Dict[str, Any]]:
|
# Cleanup old history if needed
|
||||||
"""Get currently active operations"""
|
if len(self.operation_history) > self.MAX_HISTORY:
|
||||||
|
self.operation_history = self.operation_history[-self.MAX_HISTORY:]
|
||||||
|
|
||||||
|
def get_active_operations(self) -> Dict[str, OperationDetails]:
|
||||||
|
"""
|
||||||
|
Get currently active operations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of active operations
|
||||||
|
"""
|
||||||
return self.operations.copy()
|
return self.operations.copy()
|
||||||
|
|
||||||
def get_operation_stats(self) -> Dict[str, Any]:
|
def get_operation_stats(self) -> OperationStats:
|
||||||
"""Get operation statistics"""
|
"""
|
||||||
return {
|
Get operation statistics.
|
||||||
"total_operations": len(self.operation_history) + len(self.operations),
|
|
||||||
"active_operations": len(self.operations),
|
Returns:
|
||||||
"success_count": self.success_count,
|
Dictionary containing operation statistics
|
||||||
"error_count": self.error_count,
|
"""
|
||||||
"success_rate": (
|
total = self.success_count + self.error_count
|
||||||
self.success_count / (self.success_count + self.error_count)
|
return OperationStats(
|
||||||
if (self.success_count + self.error_count) > 0
|
total_operations=len(self.operation_history) + len(self.operations),
|
||||||
else 0
|
active_operations=len(self.operations),
|
||||||
)
|
success_count=self.success_count,
|
||||||
}
|
error_count=self.error_count,
|
||||||
|
success_rate=self.success_count / total if total > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
class HealthMonitor:
|
class HealthMonitor:
|
||||||
"""Monitors processor health"""
|
"""Monitors processor health"""
|
||||||
|
|
||||||
def __init__(self, processor: 'VideoProcessor'):
|
HEALTH_CHECK_INTERVAL: ClassVar[int] = 60 # Seconds between health checks
|
||||||
|
ERROR_CHECK_INTERVAL: ClassVar[int] = 30 # Seconds between checks after error
|
||||||
|
SUCCESS_RATE_THRESHOLD: ClassVar[float] = 0.9 # 90% success rate threshold
|
||||||
|
|
||||||
|
def __init__(self, processor: 'VideoProcessor') -> None:
|
||||||
self.processor = processor
|
self.processor = processor
|
||||||
self.last_check: Optional[datetime] = None
|
self.last_check: Optional[datetime] = None
|
||||||
self.health_status: Dict[str, bool] = {}
|
self.health_status: Dict[str, bool] = {}
|
||||||
@@ -117,6 +183,8 @@ class HealthMonitor:
|
|||||||
await self._monitor_task
|
await self._monitor_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping health monitor: {e}")
|
||||||
|
|
||||||
async def _monitor_health(self) -> None:
|
async def _monitor_health(self) -> None:
|
||||||
"""Monitor processor health"""
|
"""Monitor processor health"""
|
||||||
@@ -134,17 +202,22 @@ class HealthMonitor:
|
|||||||
# Check operation health
|
# Check operation health
|
||||||
op_stats = self.processor.operation_tracker.get_operation_stats()
|
op_stats = self.processor.operation_tracker.get_operation_stats()
|
||||||
self.health_status["operations"] = (
|
self.health_status["operations"] = (
|
||||||
op_stats["success_rate"] >= 0.9 # 90% success rate threshold
|
op_stats["success_rate"] >= self.SUCCESS_RATE_THRESHOLD
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.sleep(60) # Check every minute
|
await asyncio.sleep(self.HEALTH_CHECK_INTERVAL)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Health monitoring error: {e}")
|
logger.error(f"Health monitoring error: {e}", exc_info=True)
|
||||||
await asyncio.sleep(30) # Shorter interval on error
|
await asyncio.sleep(self.ERROR_CHECK_INTERVAL)
|
||||||
|
|
||||||
def is_healthy(self) -> bool:
|
def is_healthy(self) -> bool:
|
||||||
"""Check if processor is healthy"""
|
"""
|
||||||
|
Check if processor is healthy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if all components are healthy, False otherwise
|
||||||
|
"""
|
||||||
return all(self.health_status.values())
|
return all(self.health_status.values())
|
||||||
|
|
||||||
class VideoProcessor:
|
class VideoProcessor:
|
||||||
@@ -152,13 +225,13 @@ class VideoProcessor:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
bot,
|
bot: commands.Bot,
|
||||||
config_manager,
|
config_manager: ConfigManager,
|
||||||
components,
|
components: Dict[int, Dict[str, Any]],
|
||||||
queue_manager=None,
|
queue_manager: Optional[EnhancedVideoQueueManager] = None,
|
||||||
ffmpeg_mgr=None,
|
ffmpeg_mgr: Optional[FFmpegManager] = None,
|
||||||
db=None
|
db: Optional[VideoArchiveDB] = None
|
||||||
):
|
) -> None:
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.config = config_manager
|
self.config = config_manager
|
||||||
self.components = components
|
self.components = components
|
||||||
@@ -171,29 +244,61 @@ class VideoProcessor:
|
|||||||
self.operation_tracker = OperationTracker()
|
self.operation_tracker = OperationTracker()
|
||||||
self.health_monitor = HealthMonitor(self)
|
self.health_monitor = HealthMonitor(self)
|
||||||
|
|
||||||
# Initialize handlers
|
try:
|
||||||
self.queue_handler = QueueHandler(bot, config_manager, components)
|
# Initialize handlers
|
||||||
self.message_handler = MessageHandler(bot, config_manager, queue_manager)
|
self.queue_handler = QueueHandler(bot, config_manager, components)
|
||||||
self.progress_tracker = ProgressTracker()
|
self.message_handler = MessageHandler(bot, config_manager, queue_manager)
|
||||||
self.cleanup_manager = CleanupManager(self.queue_handler, ffmpeg_mgr)
|
self.progress_tracker = ProgressTracker()
|
||||||
|
self.cleanup_manager = CleanupManager(
|
||||||
|
self.queue_handler,
|
||||||
|
ffmpeg_mgr,
|
||||||
|
CleanupStrategy.NORMAL
|
||||||
|
)
|
||||||
|
|
||||||
# Pass db to queue handler if it exists
|
# Pass db to queue handler if it exists
|
||||||
if self.db:
|
if self.db:
|
||||||
self.queue_handler.db = self.db
|
self.queue_handler.db = self.db
|
||||||
|
|
||||||
# Store queue task reference
|
# Store queue task reference
|
||||||
self._queue_task = None
|
self._queue_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
# Mark as ready
|
# Mark as ready
|
||||||
self.state = ProcessorState.READY
|
self.state = ProcessorState.READY
|
||||||
logger.info("VideoProcessor initialized successfully")
|
logger.info("VideoProcessor initialized successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.state = ProcessorState.ERROR
|
||||||
|
logger.error(f"Error initializing VideoProcessor: {e}", exc_info=True)
|
||||||
|
raise ProcessorError(f"Failed to initialize processor: {str(e)}")
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start processor operations"""
|
"""
|
||||||
await self.health_monitor.start_monitoring()
|
Start processor operations.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If startup fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self.health_monitor.start_monitoring()
|
||||||
|
logger.info("VideoProcessor started successfully")
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Failed to start processor: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise ProcessorError(error)
|
||||||
|
|
||||||
async def process_video(self, item) -> Tuple[bool, Optional[str]]:
|
async def process_video(self, item: Any) -> Tuple[bool, Optional[str]]:
|
||||||
"""Process a video from the queue"""
|
"""
|
||||||
|
Process a video from the queue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: Queue item to process
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, error_message)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If processing fails
|
||||||
|
"""
|
||||||
op_id = self.operation_tracker.start_operation(
|
op_id = self.operation_tracker.start_operation(
|
||||||
OperationType.VIDEO_PROCESSING,
|
OperationType.VIDEO_PROCESSING,
|
||||||
{"item": str(item)}
|
{"item": str(item)}
|
||||||
@@ -207,13 +312,23 @@ class VideoProcessor:
|
|||||||
self.operation_tracker.end_operation(op_id, success, error)
|
self.operation_tracker.end_operation(op_id, success, error)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.operation_tracker.end_operation(op_id, False, str(e))
|
error = f"Video processing failed: {str(e)}"
|
||||||
raise
|
self.operation_tracker.end_operation(op_id, False, error)
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise ProcessorError(error)
|
||||||
finally:
|
finally:
|
||||||
self.state = ProcessorState.READY
|
self.state = ProcessorState.READY
|
||||||
|
|
||||||
async def process_message(self, message: discord.Message) -> None:
|
async def process_message(self, message: discord.Message) -> None:
|
||||||
"""Process a message for video content"""
|
"""
|
||||||
|
Process a message for video content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Discord message to process
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If processing fails
|
||||||
|
"""
|
||||||
op_id = self.operation_tracker.start_operation(
|
op_id = self.operation_tracker.start_operation(
|
||||||
OperationType.MESSAGE_PROCESSING,
|
OperationType.MESSAGE_PROCESSING,
|
||||||
{"message_id": message.id}
|
{"message_id": message.id}
|
||||||
@@ -223,11 +338,18 @@ class VideoProcessor:
|
|||||||
await self.message_handler.process_message(message)
|
await self.message_handler.process_message(message)
|
||||||
self.operation_tracker.end_operation(op_id, True)
|
self.operation_tracker.end_operation(op_id, True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.operation_tracker.end_operation(op_id, False, str(e))
|
error = f"Message processing failed: {str(e)}"
|
||||||
raise
|
self.operation_tracker.end_operation(op_id, False, error)
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise ProcessorError(error)
|
||||||
|
|
||||||
async def cleanup(self) -> None:
|
async def cleanup(self) -> None:
|
||||||
"""Clean up resources and stop processing"""
|
"""
|
||||||
|
Clean up resources and stop processing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If cleanup fails
|
||||||
|
"""
|
||||||
op_id = self.operation_tracker.start_operation(
|
op_id = self.operation_tracker.start_operation(
|
||||||
OperationType.CLEANUP,
|
OperationType.CLEANUP,
|
||||||
{"type": "normal"}
|
{"type": "normal"}
|
||||||
@@ -239,12 +361,18 @@ class VideoProcessor:
|
|||||||
await self.cleanup_manager.cleanup()
|
await self.cleanup_manager.cleanup()
|
||||||
self.operation_tracker.end_operation(op_id, True)
|
self.operation_tracker.end_operation(op_id, True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.operation_tracker.end_operation(op_id, False, str(e))
|
error = f"Cleanup failed: {str(e)}"
|
||||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
self.operation_tracker.end_operation(op_id, False, error)
|
||||||
raise
|
logger.error(error, exc_info=True)
|
||||||
|
raise ProcessorError(error)
|
||||||
|
|
||||||
async def force_cleanup(self) -> None:
|
async def force_cleanup(self) -> None:
|
||||||
"""Force cleanup of resources"""
|
"""
|
||||||
|
Force cleanup of resources.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If force cleanup fails
|
||||||
|
"""
|
||||||
op_id = self.operation_tracker.start_operation(
|
op_id = self.operation_tracker.start_operation(
|
||||||
OperationType.CLEANUP,
|
OperationType.CLEANUP,
|
||||||
{"type": "force"}
|
{"type": "force"}
|
||||||
@@ -256,11 +384,18 @@ class VideoProcessor:
|
|||||||
await self.cleanup_manager.force_cleanup()
|
await self.cleanup_manager.force_cleanup()
|
||||||
self.operation_tracker.end_operation(op_id, True)
|
self.operation_tracker.end_operation(op_id, True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.operation_tracker.end_operation(op_id, False, str(e))
|
error = f"Force cleanup failed: {str(e)}"
|
||||||
raise
|
self.operation_tracker.end_operation(op_id, False, error)
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise ProcessorError(error)
|
||||||
|
|
||||||
async def show_queue_details(self, ctx: commands.Context) -> None:
|
async def show_queue_details(self, ctx: commands.Context) -> None:
|
||||||
"""Display detailed queue status"""
|
"""
|
||||||
|
Display detailed queue status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: Command context
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if not self.queue_manager:
|
if not self.queue_manager:
|
||||||
await ctx.send("Queue manager is not initialized.")
|
await ctx.send("Queue manager is not initialized.")
|
||||||
@@ -280,25 +415,36 @@ class VideoProcessor:
|
|||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error showing queue details: {e}", exc_info=True)
|
error = f"Failed to show queue details: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
await ctx.send(f"Error getting queue details: {str(e)}")
|
await ctx.send(f"Error getting queue details: {str(e)}")
|
||||||
|
|
||||||
def set_queue_task(self, task: asyncio.Task) -> None:
|
def set_queue_task(self, task: asyncio.Task) -> None:
|
||||||
"""Set the queue processing task"""
|
"""
|
||||||
|
Set the queue processing task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Queue processing task
|
||||||
|
"""
|
||||||
self._queue_task = task
|
self._queue_task = task
|
||||||
self.cleanup_manager.set_queue_task(task)
|
self.cleanup_manager.set_queue_task(task)
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> ProcessorStatus:
|
||||||
"""Get processor status"""
|
"""
|
||||||
return {
|
Get processor status.
|
||||||
"state": self.state.value,
|
|
||||||
"health": self.health_monitor.is_healthy(),
|
Returns:
|
||||||
"operations": self.operation_tracker.get_operation_stats(),
|
Dictionary containing processor status information
|
||||||
"active_operations": self.operation_tracker.get_active_operations(),
|
"""
|
||||||
"last_health_check": (
|
return ProcessorStatus(
|
||||||
|
state=self.state.value,
|
||||||
|
health=self.health_monitor.is_healthy(),
|
||||||
|
operations=self.operation_tracker.get_operation_stats(),
|
||||||
|
active_operations=self.operation_tracker.get_active_operations(),
|
||||||
|
last_health_check=(
|
||||||
self.health_monitor.last_check.isoformat()
|
self.health_monitor.last_check.isoformat()
|
||||||
if self.health_monitor.last_check
|
if self.health_monitor.last_check
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"health_status": self.health_monitor.health_status
|
health_status=self.health_monitor.health_status
|
||||||
}
|
)
|
||||||
|
|||||||
@@ -2,52 +2,85 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from enum import Enum
|
from enum import Enum, auto
|
||||||
from typing import Optional, Dict, Any, List, Tuple
|
from typing import Optional, Dict, Any, List, Tuple, Set, TypedDict, ClassVar
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import discord
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
from .url_extractor import URLExtractor
|
from .url_extractor import URLExtractor, URLMetadata
|
||||||
from .message_validator import MessageValidator
|
from .message_validator import MessageValidator, ValidationError
|
||||||
from .queue_processor import QueueProcessor
|
from .queue_processor import QueueProcessor, QueuePriority
|
||||||
from .constants import REACTIONS
|
from .constants import REACTIONS
|
||||||
|
from ..queue.manager import EnhancedVideoQueueManager
|
||||||
|
from ..config_manager import ConfigManager
|
||||||
|
from ..utils.exceptions import MessageHandlerError
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
class MessageState(Enum):
|
class MessageState(Enum):
|
||||||
"""Possible states of message processing"""
|
"""Possible states of message processing"""
|
||||||
RECEIVED = "received"
|
RECEIVED = auto()
|
||||||
VALIDATING = "validating"
|
VALIDATING = auto()
|
||||||
EXTRACTING = "extracting"
|
EXTRACTING = auto()
|
||||||
PROCESSING = "processing"
|
PROCESSING = auto()
|
||||||
COMPLETED = "completed"
|
COMPLETED = auto()
|
||||||
FAILED = "failed"
|
FAILED = auto()
|
||||||
IGNORED = "ignored"
|
IGNORED = auto()
|
||||||
|
|
||||||
class ProcessingStage(Enum):
|
class ProcessingStage(Enum):
|
||||||
"""Message processing stages"""
|
"""Message processing stages"""
|
||||||
VALIDATION = "validation"
|
VALIDATION = auto()
|
||||||
EXTRACTION = "extraction"
|
EXTRACTION = auto()
|
||||||
QUEUEING = "queueing"
|
QUEUEING = auto()
|
||||||
COMPLETION = "completion"
|
COMPLETION = auto()
|
||||||
|
|
||||||
|
class MessageCacheEntry(TypedDict):
|
||||||
|
"""Type definition for message cache entry"""
|
||||||
|
valid: bool
|
||||||
|
reason: Optional[str]
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
class MessageStatus(TypedDict):
|
||||||
|
"""Type definition for message status"""
|
||||||
|
state: Optional[MessageState]
|
||||||
|
stage: Optional[ProcessingStage]
|
||||||
|
error: Optional[str]
|
||||||
|
start_time: Optional[datetime]
|
||||||
|
end_time: Optional[datetime]
|
||||||
|
duration: Optional[float]
|
||||||
|
|
||||||
class MessageCache:
|
class MessageCache:
|
||||||
"""Caches message validation results"""
|
"""Caches message validation results"""
|
||||||
|
|
||||||
def __init__(self, max_size: int = 1000):
|
def __init__(self, max_size: int = 1000) -> None:
|
||||||
self.max_size = max_size
|
self.max_size = max_size
|
||||||
self._cache: Dict[int, Dict[str, Any]] = {}
|
self._cache: Dict[int, MessageCacheEntry] = {}
|
||||||
self._access_times: Dict[int, datetime] = {}
|
self._access_times: Dict[int, datetime] = {}
|
||||||
|
|
||||||
def add(self, message_id: int, result: Dict[str, Any]) -> None:
|
def add(self, message_id: int, result: MessageCacheEntry) -> None:
|
||||||
"""Add a result to cache"""
|
"""
|
||||||
|
Add a result to cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Discord message ID
|
||||||
|
result: Validation result entry
|
||||||
|
"""
|
||||||
if len(self._cache) >= self.max_size:
|
if len(self._cache) >= self.max_size:
|
||||||
self._cleanup_oldest()
|
self._cleanup_oldest()
|
||||||
self._cache[message_id] = result
|
self._cache[message_id] = result
|
||||||
self._access_times[message_id] = datetime.utcnow()
|
self._access_times[message_id] = datetime.utcnow()
|
||||||
|
|
||||||
def get(self, message_id: int) -> Optional[Dict[str, Any]]:
|
def get(self, message_id: int) -> Optional[MessageCacheEntry]:
|
||||||
"""Get a cached result"""
|
"""
|
||||||
|
Get a cached result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Discord message ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached validation entry or None if not found
|
||||||
|
"""
|
||||||
if message_id in self._cache:
|
if message_id in self._cache:
|
||||||
self._access_times[message_id] = datetime.utcnow()
|
self._access_times[message_id] = datetime.utcnow()
|
||||||
return self._cache[message_id]
|
return self._cache[message_id]
|
||||||
@@ -64,7 +97,9 @@ class MessageCache:
|
|||||||
class ProcessingTracker:
|
class ProcessingTracker:
|
||||||
"""Tracks message processing state and progress"""
|
"""Tracks message processing state and progress"""
|
||||||
|
|
||||||
def __init__(self):
|
MAX_PROCESSING_TIME: ClassVar[int] = 300 # 5 minutes in seconds
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
self.states: Dict[int, MessageState] = {}
|
self.states: Dict[int, MessageState] = {}
|
||||||
self.stages: Dict[int, ProcessingStage] = {}
|
self.stages: Dict[int, ProcessingStage] = {}
|
||||||
self.errors: Dict[int, str] = {}
|
self.errors: Dict[int, str] = {}
|
||||||
@@ -72,7 +107,12 @@ class ProcessingTracker:
|
|||||||
self.end_times: Dict[int, datetime] = {}
|
self.end_times: Dict[int, datetime] = {}
|
||||||
|
|
||||||
def start_processing(self, message_id: int) -> None:
|
def start_processing(self, message_id: int) -> None:
|
||||||
"""Start tracking a message"""
|
"""
|
||||||
|
Start tracking a message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Discord message ID
|
||||||
|
"""
|
||||||
self.states[message_id] = MessageState.RECEIVED
|
self.states[message_id] = MessageState.RECEIVED
|
||||||
self.start_times[message_id] = datetime.utcnow()
|
self.start_times[message_id] = datetime.utcnow()
|
||||||
|
|
||||||
@@ -83,7 +123,15 @@ class ProcessingTracker:
|
|||||||
stage: Optional[ProcessingStage] = None,
|
stage: Optional[ProcessingStage] = None,
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update message state"""
|
"""
|
||||||
|
Update message state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Discord message ID
|
||||||
|
state: New message state
|
||||||
|
stage: Optional processing stage
|
||||||
|
error: Optional error message
|
||||||
|
"""
|
||||||
self.states[message_id] = state
|
self.states[message_id] = state
|
||||||
if stage:
|
if stage:
|
||||||
self.stages[message_id] = stage
|
self.stages[message_id] = stage
|
||||||
@@ -92,25 +140,61 @@ class ProcessingTracker:
|
|||||||
if state in (MessageState.COMPLETED, MessageState.FAILED, MessageState.IGNORED):
|
if state in (MessageState.COMPLETED, MessageState.FAILED, MessageState.IGNORED):
|
||||||
self.end_times[message_id] = datetime.utcnow()
|
self.end_times[message_id] = datetime.utcnow()
|
||||||
|
|
||||||
def get_status(self, message_id: int) -> Dict[str, Any]:
|
def get_status(self, message_id: int) -> MessageStatus:
|
||||||
"""Get processing status for a message"""
|
"""
|
||||||
return {
|
Get processing status for a message.
|
||||||
"state": self.states.get(message_id),
|
|
||||||
"stage": self.stages.get(message_id),
|
Args:
|
||||||
"error": self.errors.get(message_id),
|
message_id: Discord message ID
|
||||||
"start_time": self.start_times.get(message_id),
|
|
||||||
"end_time": self.end_times.get(message_id),
|
Returns:
|
||||||
"duration": (
|
Dictionary containing message status information
|
||||||
(self.end_times[message_id] - self.start_times[message_id]).total_seconds()
|
"""
|
||||||
if message_id in self.end_times and message_id in self.start_times
|
end_time = self.end_times.get(message_id)
|
||||||
|
start_time = self.start_times.get(message_id)
|
||||||
|
|
||||||
|
return MessageStatus(
|
||||||
|
state=self.states.get(message_id),
|
||||||
|
stage=self.stages.get(message_id),
|
||||||
|
error=self.errors.get(message_id),
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
duration=(
|
||||||
|
(end_time - start_time).total_seconds()
|
||||||
|
if end_time and start_time
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
|
|
||||||
|
def is_message_stuck(self, message_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a message is stuck in processing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Discord message ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if message is stuck, False otherwise
|
||||||
|
"""
|
||||||
|
if message_id not in self.states or message_id not in self.start_times:
|
||||||
|
return False
|
||||||
|
|
||||||
|
state = self.states[message_id]
|
||||||
|
if state in (MessageState.COMPLETED, MessageState.FAILED, MessageState.IGNORED):
|
||||||
|
return False
|
||||||
|
|
||||||
|
processing_time = (datetime.utcnow() - self.start_times[message_id]).total_seconds()
|
||||||
|
return processing_time > self.MAX_PROCESSING_TIME
|
||||||
|
|
||||||
class MessageHandler:
|
class MessageHandler:
|
||||||
"""Handles processing of messages for video content"""
|
"""Handles processing of messages for video content"""
|
||||||
|
|
||||||
def __init__(self, bot, config_manager, queue_manager):
|
def __init__(
|
||||||
|
self,
|
||||||
|
bot: discord.Client,
|
||||||
|
config_manager: ConfigManager,
|
||||||
|
queue_manager: EnhancedVideoQueueManager
|
||||||
|
) -> None:
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self.url_extractor = URLExtractor()
|
self.url_extractor = URLExtractor()
|
||||||
@@ -123,7 +207,15 @@ class MessageHandler:
|
|||||||
self._processing_lock = asyncio.Lock()
|
self._processing_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def process_message(self, message: discord.Message) -> None:
|
async def process_message(self, message: discord.Message) -> None:
|
||||||
"""Process a message for video content"""
|
"""
|
||||||
|
Process a message for video content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Discord message to process
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MessageHandlerError: If there's an error during processing
|
||||||
|
"""
|
||||||
# Start tracking
|
# Start tracking
|
||||||
self.tracker.start_processing(message.id)
|
self.tracker.start_processing(message.id)
|
||||||
|
|
||||||
@@ -139,11 +231,19 @@ class MessageHandler:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await message.add_reaction(REACTIONS["error"])
|
await message.add_reaction(REACTIONS["error"])
|
||||||
except:
|
except Exception as react_error:
|
||||||
pass
|
logger.error(f"Failed to add error reaction: {react_error}")
|
||||||
|
|
||||||
async def _process_message_internal(self, message: discord.Message) -> None:
|
async def _process_message_internal(self, message: discord.Message) -> None:
|
||||||
"""Internal message processing logic"""
|
"""
|
||||||
|
Internal message processing logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Discord message to process
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
MessageHandlerError: If there's an error during processing
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get guild settings
|
# Get guild settings
|
||||||
settings = await self.config_manager.get_guild_settings(message.guild.id)
|
settings = await self.config_manager.get_guild_settings(message.guild.id)
|
||||||
@@ -164,15 +264,19 @@ class MessageHandler:
|
|||||||
MessageState.VALIDATING,
|
MessageState.VALIDATING,
|
||||||
ProcessingStage.VALIDATION
|
ProcessingStage.VALIDATION
|
||||||
)
|
)
|
||||||
is_valid, reason = await self.message_validator.validate_message(
|
try:
|
||||||
message,
|
is_valid, reason = await self.message_validator.validate_message(
|
||||||
settings
|
message,
|
||||||
)
|
settings
|
||||||
# Cache result
|
)
|
||||||
self.validation_cache.add(message.id, {
|
# Cache result
|
||||||
"valid": is_valid,
|
self.validation_cache.add(message.id, MessageCacheEntry(
|
||||||
"reason": reason
|
valid=is_valid,
|
||||||
})
|
reason=reason,
|
||||||
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
))
|
||||||
|
except ValidationError as e:
|
||||||
|
raise MessageHandlerError(f"Validation failed: {str(e)}")
|
||||||
|
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
logger.debug(f"Message validation failed: {reason}")
|
logger.debug(f"Message validation failed: {reason}")
|
||||||
@@ -189,14 +293,17 @@ class MessageHandler:
|
|||||||
MessageState.EXTRACTING,
|
MessageState.EXTRACTING,
|
||||||
ProcessingStage.EXTRACTION
|
ProcessingStage.EXTRACTION
|
||||||
)
|
)
|
||||||
urls = await self.url_extractor.extract_urls(
|
try:
|
||||||
message,
|
urls: List[URLMetadata] = await self.url_extractor.extract_urls(
|
||||||
enabled_sites=settings.get("enabled_sites")
|
message,
|
||||||
)
|
enabled_sites=settings.get("enabled_sites")
|
||||||
if not urls:
|
)
|
||||||
logger.debug("No valid URLs found in message")
|
if not urls:
|
||||||
self.tracker.update_state(message.id, MessageState.IGNORED)
|
logger.debug("No valid URLs found in message")
|
||||||
return
|
self.tracker.update_state(message.id, MessageState.IGNORED)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
raise MessageHandlerError(f"URL extraction failed: {str(e)}")
|
||||||
|
|
||||||
# Process URLs
|
# Process URLs
|
||||||
self.tracker.update_state(
|
self.tracker.update_state(
|
||||||
@@ -204,7 +311,14 @@ class MessageHandler:
|
|||||||
MessageState.PROCESSING,
|
MessageState.PROCESSING,
|
||||||
ProcessingStage.QUEUEING
|
ProcessingStage.QUEUEING
|
||||||
)
|
)
|
||||||
await self.queue_processor.process_urls(message, urls)
|
try:
|
||||||
|
await self.queue_processor.process_urls(
|
||||||
|
message,
|
||||||
|
urls,
|
||||||
|
priority=QueuePriority.NORMAL
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise MessageHandlerError(f"Queue processing failed: {str(e)}")
|
||||||
|
|
||||||
# Mark completion
|
# Mark completion
|
||||||
self.tracker.update_state(
|
self.tracker.update_state(
|
||||||
@@ -213,13 +327,10 @@ class MessageHandler:
|
|||||||
ProcessingStage.COMPLETION
|
ProcessingStage.COMPLETION
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except MessageHandlerError:
|
||||||
self.tracker.update_state(
|
|
||||||
message.id,
|
|
||||||
MessageState.FAILED,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
raise
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise MessageHandlerError(f"Unexpected error: {str(e)}")
|
||||||
|
|
||||||
async def format_archive_message(
|
async def format_archive_message(
|
||||||
self,
|
self,
|
||||||
@@ -227,30 +338,49 @@ class MessageHandler:
|
|||||||
channel: discord.TextChannel,
|
channel: discord.TextChannel,
|
||||||
url: str
|
url: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Format message for archive channel"""
|
"""
|
||||||
|
Format message for archive channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
author: Optional message author
|
||||||
|
channel: Channel the message was posted in
|
||||||
|
url: URL being archived
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted message string
|
||||||
|
"""
|
||||||
return await self.queue_processor.format_archive_message(
|
return await self.queue_processor.format_archive_message(
|
||||||
author,
|
author,
|
||||||
channel,
|
channel,
|
||||||
url
|
url
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_message_status(self, message_id: int) -> Dict[str, Any]:
|
def get_message_status(self, message_id: int) -> MessageStatus:
|
||||||
"""Get processing status for a message"""
|
"""
|
||||||
|
Get processing status for a message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Discord message ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing message status information
|
||||||
|
"""
|
||||||
return self.tracker.get_status(message_id)
|
return self.tracker.get_status(message_id)
|
||||||
|
|
||||||
def is_healthy(self) -> bool:
|
def is_healthy(self) -> bool:
|
||||||
"""Check if handler is healthy"""
|
"""
|
||||||
# Check for any stuck messages
|
Check if handler is healthy.
|
||||||
current_time = datetime.utcnow()
|
|
||||||
for message_id, start_time in self.tracker.start_times.items():
|
Returns:
|
||||||
if (
|
True if handler is healthy, False otherwise
|
||||||
message_id in self.tracker.states and
|
"""
|
||||||
self.tracker.states[message_id] not in (
|
try:
|
||||||
MessageState.COMPLETED,
|
# Check for any stuck messages
|
||||||
MessageState.FAILED,
|
for message_id in self.tracker.states:
|
||||||
MessageState.IGNORED
|
if self.tracker.is_message_stuck(message_id):
|
||||||
) and
|
logger.warning(f"Message {message_id} appears to be stuck in processing")
|
||||||
(current_time - start_time).total_seconds() > 300 # 5 minutes timeout
|
return False
|
||||||
):
|
return True
|
||||||
return False
|
except Exception as e:
|
||||||
return True
|
logger.error(f"Error checking health: {e}")
|
||||||
|
return False
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
"""Message validation functionality for video processing"""
|
"""Message validation functionality for video processing"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from enum import Enum
|
from enum import Enum, auto
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, Optional, Tuple, List, Any, Callable, Set
|
from typing import Dict, Optional, Tuple, List, Any, Callable, Set, TypedDict, ClassVar
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
|
from ..utils.exceptions import ValidationError
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
class ValidationResult(Enum):
|
class ValidationResult(Enum):
|
||||||
"""Possible validation results"""
|
"""Possible validation results"""
|
||||||
VALID = "valid"
|
VALID = auto()
|
||||||
INVALID = "invalid"
|
INVALID = auto()
|
||||||
IGNORED = "ignored"
|
IGNORED = auto()
|
||||||
|
|
||||||
|
class ValidationStats(TypedDict):
|
||||||
|
"""Type definition for validation statistics"""
|
||||||
|
total: int
|
||||||
|
valid: int
|
||||||
|
invalid: int
|
||||||
|
ignored: int
|
||||||
|
cached: int
|
||||||
|
|
||||||
|
class ValidationCacheEntry(TypedDict):
|
||||||
|
"""Type definition for validation cache entry"""
|
||||||
|
valid: bool
|
||||||
|
reason: Optional[str]
|
||||||
|
rule: Optional[str]
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ValidationContext:
|
class ValidationContext:
|
||||||
@@ -28,22 +45,43 @@ class ValidationContext:
|
|||||||
attachment_count: int
|
attachment_count: int
|
||||||
is_bot: bool
|
is_bot: bool
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
validation_time: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_message(cls, message: discord.Message, settings: Dict[str, Any]) -> 'ValidationContext':
|
def from_message(cls, message: discord.Message, settings: Dict[str, Any]) -> 'ValidationContext':
|
||||||
"""Create context from message"""
|
"""
|
||||||
return cls(
|
Create context from message.
|
||||||
message=message,
|
|
||||||
settings=settings,
|
Args:
|
||||||
guild_id=message.guild.id,
|
message: Discord message to validate
|
||||||
channel_id=message.channel.id,
|
settings: Guild settings dictionary
|
||||||
author_id=message.author.id,
|
|
||||||
roles={role.id for role in message.author.roles},
|
Returns:
|
||||||
content_length=len(message.content) if message.content else 0,
|
ValidationContext instance
|
||||||
attachment_count=len(message.attachments),
|
|
||||||
is_bot=message.author.bot,
|
Raises:
|
||||||
timestamp=message.created_at
|
ValidationError: If message or settings are invalid
|
||||||
)
|
"""
|
||||||
|
if not message.guild:
|
||||||
|
raise ValidationError("Message must be from a guild")
|
||||||
|
if not settings:
|
||||||
|
raise ValidationError("Settings dictionary cannot be empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cls(
|
||||||
|
message=message,
|
||||||
|
settings=settings,
|
||||||
|
guild_id=message.guild.id,
|
||||||
|
channel_id=message.channel.id,
|
||||||
|
author_id=message.author.id,
|
||||||
|
roles={role.id for role in message.author.roles},
|
||||||
|
content_length=len(message.content) if message.content else 0,
|
||||||
|
attachment_count=len(message.attachments),
|
||||||
|
is_bot=message.author.bot,
|
||||||
|
timestamp=message.created_at
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValidationError(f"Failed to create validation context: {str(e)}")
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ValidationRule:
|
class ValidationRule:
|
||||||
@@ -53,24 +91,48 @@ class ValidationRule:
|
|||||||
validate: Callable[[ValidationContext], Tuple[bool, Optional[str]]]
|
validate: Callable[[ValidationContext], Tuple[bool, Optional[str]]]
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
priority: int = 0
|
priority: int = 0
|
||||||
|
error_count: int = field(default=0)
|
||||||
|
last_error: Optional[str] = field(default=None)
|
||||||
|
last_run: Optional[str] = field(default=None)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate rule after initialization"""
|
||||||
|
if not callable(self.validate):
|
||||||
|
raise ValueError("Validate must be a callable")
|
||||||
|
if self.priority < 0:
|
||||||
|
raise ValueError("Priority must be non-negative")
|
||||||
|
|
||||||
class ValidationCache:
|
class ValidationCache:
|
||||||
"""Caches validation results"""
|
"""Caches validation results"""
|
||||||
|
|
||||||
def __init__(self, max_size: int = 1000):
|
def __init__(self, max_size: int = 1000) -> None:
|
||||||
self.max_size = max_size
|
self.max_size = max_size
|
||||||
self._cache: Dict[int, Dict[str, Any]] = {}
|
self._cache: Dict[int, ValidationCacheEntry] = {}
|
||||||
self._access_times: Dict[int, datetime] = {}
|
self._access_times: Dict[int, datetime] = {}
|
||||||
|
|
||||||
def add(self, message_id: int, result: Dict[str, Any]) -> None:
|
def add(self, message_id: int, result: ValidationCacheEntry) -> None:
|
||||||
"""Add validation result to cache"""
|
"""
|
||||||
|
Add validation result to cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Discord message ID
|
||||||
|
result: Validation result entry
|
||||||
|
"""
|
||||||
if len(self._cache) >= self.max_size:
|
if len(self._cache) >= self.max_size:
|
||||||
self._cleanup_oldest()
|
self._cleanup_oldest()
|
||||||
self._cache[message_id] = result
|
self._cache[message_id] = result
|
||||||
self._access_times[message_id] = datetime.utcnow()
|
self._access_times[message_id] = datetime.utcnow()
|
||||||
|
|
||||||
def get(self, message_id: int) -> Optional[Dict[str, Any]]:
|
def get(self, message_id: int) -> Optional[ValidationCacheEntry]:
|
||||||
"""Get cached validation result"""
|
"""
|
||||||
|
Get cached validation result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Discord message ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached validation entry or None if not found
|
||||||
|
"""
|
||||||
if message_id in self._cache:
|
if message_id in self._cache:
|
||||||
self._access_times[message_id] = datetime.utcnow()
|
self._access_times[message_id] = datetime.utcnow()
|
||||||
return self._cache[message_id]
|
return self._cache[message_id]
|
||||||
@@ -87,33 +149,28 @@ class ValidationCache:
|
|||||||
class ValidationRuleManager:
|
class ValidationRuleManager:
|
||||||
"""Manages validation rules"""
|
"""Manages validation rules"""
|
||||||
|
|
||||||
def __init__(self):
|
DEFAULT_RULES: ClassVar[List[Tuple[str, str, int]]] = [
|
||||||
self.rules: List[ValidationRule] = [
|
("content_check", "Check if message has content to process", 1),
|
||||||
ValidationRule(
|
("guild_enabled", "Check if archiving is enabled for guild", 2),
|
||||||
name="content_check",
|
("channel_enabled", "Check if channel is enabled for archiving", 3),
|
||||||
description="Check if message has content to process",
|
("user_roles", "Check if user has required roles", 4)
|
||||||
validate=self._validate_content,
|
]
|
||||||
priority=1
|
|
||||||
),
|
def __init__(self) -> None:
|
||||||
ValidationRule(
|
self.rules: List[ValidationRule] = []
|
||||||
name="guild_enabled",
|
self._initialize_rules()
|
||||||
description="Check if archiving is enabled for guild",
|
|
||||||
validate=self._validate_guild_enabled,
|
def _initialize_rules(self) -> None:
|
||||||
priority=2
|
"""Initialize default validation rules"""
|
||||||
),
|
for name, description, priority in self.DEFAULT_RULES:
|
||||||
ValidationRule(
|
validate_method = getattr(self, f"_validate_{name}", None)
|
||||||
name="channel_enabled",
|
if validate_method:
|
||||||
description="Check if channel is enabled for archiving",
|
self.rules.append(ValidationRule(
|
||||||
validate=self._validate_channel,
|
name=name,
|
||||||
priority=3
|
description=description,
|
||||||
),
|
validate=validate_method,
|
||||||
ValidationRule(
|
priority=priority
|
||||||
name="user_roles",
|
))
|
||||||
description="Check if user has required roles",
|
|
||||||
validate=self._validate_user_roles,
|
|
||||||
priority=4
|
|
||||||
)
|
|
||||||
]
|
|
||||||
self.rules.sort(key=lambda x: x.priority)
|
self.rules.sort(key=lambda x: x.priority)
|
||||||
|
|
||||||
def _validate_content(self, ctx: ValidationContext) -> Tuple[bool, Optional[str]]:
|
def _validate_content(self, ctx: ValidationContext) -> Tuple[bool, Optional[str]]:
|
||||||
@@ -145,10 +202,10 @@ class ValidationRuleManager:
|
|||||||
class MessageValidator:
|
class MessageValidator:
|
||||||
"""Handles validation of messages for video processing"""
|
"""Handles validation of messages for video processing"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.rule_manager = ValidationRuleManager()
|
self.rule_manager = ValidationRuleManager()
|
||||||
self.cache = ValidationCache()
|
self.cache = ValidationCache()
|
||||||
self.validation_stats: Dict[str, int] = {
|
self.validation_stats: ValidationStats = {
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"valid": 0,
|
"valid": 0,
|
||||||
"invalid": 0,
|
"invalid": 0,
|
||||||
@@ -159,50 +216,80 @@ class MessageValidator:
|
|||||||
async def validate_message(
|
async def validate_message(
|
||||||
self,
|
self,
|
||||||
message: discord.Message,
|
message: discord.Message,
|
||||||
settings: Dict
|
settings: Dict[str, Any]
|
||||||
) -> Tuple[bool, Optional[str]]:
|
) -> Tuple[bool, Optional[str]]:
|
||||||
"""Validate if a message should be processed"""
|
"""
|
||||||
self.validation_stats["total"] += 1
|
Validate if a message should be processed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Discord message to validate
|
||||||
|
settings: Guild settings dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, reason)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If validation fails unexpectedly
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.validation_stats["total"] += 1
|
||||||
|
|
||||||
# Check cache
|
# Check cache
|
||||||
cached = self.cache.get(message.id)
|
cached = self.cache.get(message.id)
|
||||||
if cached:
|
if cached:
|
||||||
self.validation_stats["cached"] += 1
|
self.validation_stats["cached"] += 1
|
||||||
return cached["valid"], cached.get("reason")
|
return cached["valid"], cached.get("reason")
|
||||||
|
|
||||||
# Create validation context
|
# Create validation context
|
||||||
ctx = ValidationContext.from_message(message, settings)
|
ctx = ValidationContext.from_message(message, settings)
|
||||||
|
|
||||||
# Run validation rules
|
# Run validation rules
|
||||||
for rule in self.rule_manager.rules:
|
for rule in self.rule_manager.rules:
|
||||||
if not rule.enabled:
|
if not rule.enabled:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
valid, reason = rule.validate(ctx)
|
rule.last_run = datetime.utcnow().isoformat()
|
||||||
if not valid:
|
valid, reason = rule.validate(ctx)
|
||||||
self.validation_stats["invalid"] += 1
|
if not valid:
|
||||||
# Cache result
|
self.validation_stats["invalid"] += 1
|
||||||
self.cache.add(message.id, {
|
# Cache result
|
||||||
"valid": False,
|
self.cache.add(message.id, ValidationCacheEntry(
|
||||||
"reason": reason,
|
valid=False,
|
||||||
"rule": rule.name
|
reason=reason,
|
||||||
})
|
rule=rule.name,
|
||||||
return False, reason
|
timestamp=datetime.utcnow().isoformat()
|
||||||
except Exception as e:
|
))
|
||||||
logger.error(f"Error in validation rule {rule.name}: {e}")
|
return False, reason
|
||||||
return False, f"Validation error: {str(e)}"
|
except Exception as e:
|
||||||
|
rule.error_count += 1
|
||||||
|
rule.last_error = str(e)
|
||||||
|
logger.error(f"Error in validation rule {rule.name}: {e}", exc_info=True)
|
||||||
|
raise ValidationError(f"Validation rule {rule.name} failed: {str(e)}")
|
||||||
|
|
||||||
# Message passed all rules
|
# Message passed all rules
|
||||||
self.validation_stats["valid"] += 1
|
self.validation_stats["valid"] += 1
|
||||||
self.cache.add(message.id, {
|
self.cache.add(message.id, ValidationCacheEntry(
|
||||||
"valid": True,
|
valid=True,
|
||||||
"reason": None
|
reason=None,
|
||||||
})
|
rule=None,
|
||||||
return True, None
|
timestamp=datetime.utcnow().isoformat()
|
||||||
|
))
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
except ValidationError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in message validation: {e}", exc_info=True)
|
||||||
|
raise ValidationError(f"Validation failed: {str(e)}")
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
"""Get validation statistics"""
|
"""
|
||||||
|
Get validation statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing validation statistics and rule information
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"validation_stats": self.validation_stats.copy(),
|
"validation_stats": self.validation_stats.copy(),
|
||||||
"rules": [
|
"rules": [
|
||||||
@@ -210,16 +297,27 @@ class MessageValidator:
|
|||||||
"name": rule.name,
|
"name": rule.name,
|
||||||
"description": rule.description,
|
"description": rule.description,
|
||||||
"enabled": rule.enabled,
|
"enabled": rule.enabled,
|
||||||
"priority": rule.priority
|
"priority": rule.priority,
|
||||||
|
"error_count": rule.error_count,
|
||||||
|
"last_error": rule.last_error,
|
||||||
|
"last_run": rule.last_run
|
||||||
}
|
}
|
||||||
for rule in self.rule_manager.rules
|
for rule in self.rule_manager.rules
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
def clear_cache(self, message_id: Optional[int] = None) -> None:
|
def clear_cache(self, message_id: Optional[int] = None) -> None:
|
||||||
"""Clear validation cache"""
|
"""
|
||||||
if message_id:
|
Clear validation cache.
|
||||||
self.cache._cache.pop(message_id, None)
|
|
||||||
self.cache._access_times.pop(message_id, None)
|
Args:
|
||||||
else:
|
message_id: Optional message ID to clear cache for. If None, clears all cache.
|
||||||
self.cache = ValidationCache(self.cache.max_size)
|
"""
|
||||||
|
try:
|
||||||
|
if message_id:
|
||||||
|
self.cache._cache.pop(message_id, None)
|
||||||
|
self.cache._access_times.pop(message_id, None)
|
||||||
|
else:
|
||||||
|
self.cache = ValidationCache(self.cache.max_size)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing validation cache: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -1,21 +1,55 @@
|
|||||||
"""Queue processing and video handling operations"""
|
"""Queue handling functionality for video processing"""
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import discord
|
import os
|
||||||
from typing import Dict, Optional, Tuple, Any
|
from enum import Enum, auto
|
||||||
|
from typing import Optional, Dict, Any, List, Tuple, Set, TypedDict, ClassVar, Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from ..utils.progress_tracker import ProgressTracker
|
||||||
|
from ..database.video_archive_db import VideoArchiveDB
|
||||||
|
from ..utils.download_manager import DownloadManager
|
||||||
|
from ..utils.message_manager import MessageManager
|
||||||
|
from ..utils.exceptions import QueueHandlerError
|
||||||
|
from ..queue.models import QueueItem
|
||||||
|
from ..config_manager import ConfigManager
|
||||||
from .constants import REACTIONS
|
from .constants import REACTIONS
|
||||||
from .progress_tracker import ProgressTracker
|
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
|
class QueueItemStatus(Enum):
|
||||||
|
"""Status of a queue item"""
|
||||||
|
PENDING = auto()
|
||||||
|
PROCESSING = auto()
|
||||||
|
COMPLETED = auto()
|
||||||
|
FAILED = auto()
|
||||||
|
CANCELLED = auto()
|
||||||
|
|
||||||
|
class QueueStats(TypedDict):
|
||||||
|
"""Type definition for queue statistics"""
|
||||||
|
active_downloads: int
|
||||||
|
processing_items: int
|
||||||
|
completed_items: int
|
||||||
|
failed_items: int
|
||||||
|
average_processing_time: float
|
||||||
|
last_processed: Optional[str]
|
||||||
|
is_healthy: bool
|
||||||
|
|
||||||
class QueueHandler:
|
class QueueHandler:
|
||||||
"""Handles queue processing and video operations"""
|
"""Handles queue processing and video operations"""
|
||||||
|
|
||||||
def __init__(self, bot, config_manager, components, db=None):
|
DOWNLOAD_TIMEOUT: ClassVar[int] = 3600 # 1 hour in seconds
|
||||||
|
MAX_RETRIES: ClassVar[int] = 3
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bot: discord.Client,
|
||||||
|
config_manager: ConfigManager,
|
||||||
|
components: Dict[int, Dict[str, Any]],
|
||||||
|
db: Optional[VideoArchiveDB] = None
|
||||||
|
) -> None:
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.config_manager = config_manager
|
self.config_manager = config_manager
|
||||||
self.components = components
|
self.components = components
|
||||||
@@ -24,101 +58,240 @@ class QueueHandler:
|
|||||||
self._active_downloads: Dict[str, asyncio.Task] = {}
|
self._active_downloads: Dict[str, asyncio.Task] = {}
|
||||||
self._active_downloads_lock = asyncio.Lock()
|
self._active_downloads_lock = asyncio.Lock()
|
||||||
self.progress_tracker = ProgressTracker()
|
self.progress_tracker = ProgressTracker()
|
||||||
|
self._stats: QueueStats = {
|
||||||
|
"active_downloads": 0,
|
||||||
|
"processing_items": 0,
|
||||||
|
"completed_items": 0,
|
||||||
|
"failed_items": 0,
|
||||||
|
"average_processing_time": 0.0,
|
||||||
|
"last_processed": None,
|
||||||
|
"is_healthy": True
|
||||||
|
}
|
||||||
|
|
||||||
async def process_video(self, item) -> Tuple[bool, Optional[str]]:
|
async def process_video(self, item: QueueItem) -> Tuple[bool, Optional[str]]:
|
||||||
"""Process a video from the queue"""
|
"""
|
||||||
|
Process a video from the queue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: Queue item to process
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, error_message)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
QueueHandlerError: If there's an error during processing
|
||||||
|
"""
|
||||||
if self._unloading:
|
if self._unloading:
|
||||||
return False, "Processor is unloading"
|
return False, "Processor is unloading"
|
||||||
|
|
||||||
file_path = None
|
file_path = None
|
||||||
original_message = None
|
original_message = None
|
||||||
download_task = None
|
download_task = None
|
||||||
|
start_time = datetime.utcnow()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start processing
|
self._stats["processing_items"] += 1
|
||||||
item.start_processing()
|
item.start_processing()
|
||||||
logger.info(f"Started processing video: {item.url}")
|
logger.info(f"Started processing video: {item.url}")
|
||||||
|
|
||||||
# Check if video is already archived
|
# Check if video is already archived
|
||||||
if self.db and self.db.is_url_archived(item.url):
|
if self.db and await self._check_archived_video(item):
|
||||||
logger.info(f"Video already archived: {item.url}")
|
self._update_stats(True, start_time)
|
||||||
if original_message := await self._get_original_message(item):
|
|
||||||
await original_message.add_reaction(REACTIONS["success"])
|
|
||||||
archived_info = self.db.get_archived_video(item.url)
|
|
||||||
if archived_info:
|
|
||||||
await original_message.reply(f"This video was already archived. You can find it here: {archived_info[0]}")
|
|
||||||
item.finish_processing(True)
|
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
guild_id = item.guild_id
|
# Get components
|
||||||
if guild_id not in self.components:
|
components = await self._get_components(item.guild_id)
|
||||||
error = f"No components found for guild {guild_id}"
|
|
||||||
item.finish_processing(False, error)
|
|
||||||
return False, error
|
|
||||||
|
|
||||||
components = self.components[guild_id]
|
|
||||||
downloader = components.get("downloader")
|
downloader = components.get("downloader")
|
||||||
message_manager = components.get("message_manager")
|
message_manager = components.get("message_manager")
|
||||||
|
|
||||||
if not downloader or not message_manager:
|
if not downloader or not message_manager:
|
||||||
error = f"Missing required components for guild {guild_id}"
|
raise QueueHandlerError(f"Missing required components for guild {item.guild_id}")
|
||||||
item.finish_processing(False, error)
|
|
||||||
return False, error
|
|
||||||
|
|
||||||
# Get original message and update reactions
|
# Get original message and update reactions
|
||||||
original_message = await self._get_original_message(item)
|
original_message = await self._get_original_message(item)
|
||||||
if original_message:
|
if original_message:
|
||||||
await original_message.remove_reaction(REACTIONS["queued"], self.bot.user)
|
await self._update_message_reactions(original_message, QueueItemStatus.PROCESSING)
|
||||||
await original_message.add_reaction(REACTIONS["processing"])
|
|
||||||
logger.info(f"Started processing message {item.message_id}")
|
|
||||||
|
|
||||||
# Create progress callback
|
# Download and archive video
|
||||||
progress_callback = self._create_progress_callback(original_message, item.url)
|
file_path = await self._process_video_file(
|
||||||
|
downloader, message_manager, item, original_message
|
||||||
# Download video
|
|
||||||
success, file_path, error = await self._download_video(
|
|
||||||
downloader, item.url, progress_callback
|
|
||||||
)
|
)
|
||||||
if not success:
|
|
||||||
if original_message:
|
|
||||||
await original_message.add_reaction(REACTIONS["error"])
|
|
||||||
logger.error(f"Download failed for message {item.message_id}: {error}")
|
|
||||||
item.finish_processing(False, f"Failed to download video: {error}")
|
|
||||||
return False, f"Failed to download video: {error}"
|
|
||||||
|
|
||||||
# Archive video
|
# Success
|
||||||
success, error = await self._archive_video(
|
self._update_stats(True, start_time)
|
||||||
guild_id, original_message, message_manager, item.url, file_path
|
item.finish_processing(True)
|
||||||
)
|
if original_message:
|
||||||
|
await self._update_message_reactions(original_message, QueueItemStatus.COMPLETED)
|
||||||
# Finish processing
|
return True, None
|
||||||
item.finish_processing(success, error if not success else None)
|
|
||||||
return success, error
|
|
||||||
|
|
||||||
|
except QueueHandlerError as e:
|
||||||
|
logger.error(f"Queue handler error: {str(e)}")
|
||||||
|
self._handle_processing_error(item, original_message, str(e))
|
||||||
|
return False, str(e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing video: {str(e)}", exc_info=True)
|
logger.error(f"Error processing video: {str(e)}", exc_info=True)
|
||||||
item.finish_processing(False, str(e))
|
self._handle_processing_error(item, original_message, str(e))
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
finally:
|
finally:
|
||||||
# Clean up downloaded file
|
await self._cleanup_file(file_path)
|
||||||
if file_path and os.path.exists(file_path):
|
|
||||||
try:
|
|
||||||
os.unlink(file_path)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to clean up file {file_path}: {e}")
|
|
||||||
|
|
||||||
async def _archive_video(self, guild_id: int, original_message: Optional[discord.Message],
|
async def _check_archived_video(self, item: QueueItem) -> bool:
|
||||||
message_manager, url: str, file_path: str) -> Tuple[bool, Optional[str]]:
|
"""Check if video is already archived and handle accordingly"""
|
||||||
"""Archive downloaded video"""
|
if not self.db:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.db.is_url_archived(item.url):
|
||||||
|
logger.info(f"Video already archived: {item.url}")
|
||||||
|
if original_message := await self._get_original_message(item):
|
||||||
|
await self._update_message_reactions(original_message, QueueItemStatus.COMPLETED)
|
||||||
|
archived_info = self.db.get_archived_video(item.url)
|
||||||
|
if archived_info:
|
||||||
|
await original_message.reply(
|
||||||
|
f"This video was already archived. You can find it here: {archived_info[0]}"
|
||||||
|
)
|
||||||
|
item.finish_processing(True)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _get_components(
|
||||||
|
self,
|
||||||
|
guild_id: int
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get required components for processing"""
|
||||||
|
if guild_id not in self.components:
|
||||||
|
raise QueueHandlerError(f"No components found for guild {guild_id}")
|
||||||
|
return self.components[guild_id]
|
||||||
|
|
||||||
|
async def _process_video_file(
|
||||||
|
self,
|
||||||
|
downloader: DownloadManager,
|
||||||
|
message_manager: MessageManager,
|
||||||
|
item: QueueItem,
|
||||||
|
original_message: Optional[discord.Message]
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Download and process video file"""
|
||||||
|
# Create progress callback
|
||||||
|
progress_callback = self._create_progress_callback(original_message, item.url)
|
||||||
|
|
||||||
|
# Download video
|
||||||
|
success, file_path, error = await self._download_video(
|
||||||
|
downloader, item.url, progress_callback
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
raise QueueHandlerError(f"Failed to download video: {error}")
|
||||||
|
|
||||||
|
# Archive video
|
||||||
|
success, error = await self._archive_video(
|
||||||
|
item.guild_id,
|
||||||
|
original_message,
|
||||||
|
message_manager,
|
||||||
|
item.url,
|
||||||
|
file_path
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
raise QueueHandlerError(f"Failed to archive video: {error}")
|
||||||
|
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
def _handle_processing_error(
|
||||||
|
self,
|
||||||
|
item: QueueItem,
|
||||||
|
message: Optional[discord.Message],
|
||||||
|
error: str
|
||||||
|
) -> None:
|
||||||
|
"""Handle processing error"""
|
||||||
|
self._update_stats(False, datetime.utcnow())
|
||||||
|
item.finish_processing(False, error)
|
||||||
|
if message:
|
||||||
|
asyncio.create_task(self._update_message_reactions(message, QueueItemStatus.FAILED))
|
||||||
|
|
||||||
|
def _update_stats(self, success: bool, start_time: datetime) -> None:
|
||||||
|
"""Update queue statistics"""
|
||||||
|
processing_time = (datetime.utcnow() - start_time).total_seconds()
|
||||||
|
self._stats["processing_items"] -= 1
|
||||||
|
if success:
|
||||||
|
self._stats["completed_items"] += 1
|
||||||
|
else:
|
||||||
|
self._stats["failed_items"] += 1
|
||||||
|
|
||||||
|
# Update average processing time
|
||||||
|
total_items = self._stats["completed_items"] + self._stats["failed_items"]
|
||||||
|
if total_items > 0:
|
||||||
|
current_total = self._stats["average_processing_time"] * (total_items - 1)
|
||||||
|
self._stats["average_processing_time"] = (current_total + processing_time) / total_items
|
||||||
|
|
||||||
|
self._stats["last_processed"] = datetime.utcnow().isoformat()
|
||||||
|
|
||||||
|
async def _update_message_reactions(
|
||||||
|
self,
|
||||||
|
message: discord.Message,
|
||||||
|
status: QueueItemStatus
|
||||||
|
) -> None:
|
||||||
|
"""Update message reactions based on status"""
|
||||||
|
try:
|
||||||
|
# Remove existing reactions
|
||||||
|
for reaction in [
|
||||||
|
REACTIONS["queued"],
|
||||||
|
REACTIONS["processing"],
|
||||||
|
REACTIONS["success"],
|
||||||
|
REACTIONS["error"]
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
await message.remove_reaction(reaction, self.bot.user)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add new reaction
|
||||||
|
if status == QueueItemStatus.PROCESSING:
|
||||||
|
await message.add_reaction(REACTIONS["processing"])
|
||||||
|
elif status == QueueItemStatus.COMPLETED:
|
||||||
|
await message.add_reaction(REACTIONS["success"])
|
||||||
|
elif status == QueueItemStatus.FAILED:
|
||||||
|
await message.add_reaction(REACTIONS["error"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating message reactions: {e}")
|
||||||
|
|
||||||
|
async def _cleanup_file(self, file_path: Optional[str]) -> None:
|
||||||
|
"""Clean up downloaded file"""
|
||||||
|
if file_path and os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
os.unlink(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to clean up file {file_path}: {e}")
|
||||||
|
|
||||||
|
async def _archive_video(
|
||||||
|
self,
|
||||||
|
guild_id: int,
|
||||||
|
original_message: Optional[discord.Message],
|
||||||
|
message_manager: MessageManager,
|
||||||
|
url: str,
|
||||||
|
file_path: str
|
||||||
|
) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Archive downloaded video.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
guild_id: Discord guild ID
|
||||||
|
original_message: Original message containing the video
|
||||||
|
message_manager: Message manager instance
|
||||||
|
url: Video URL
|
||||||
|
file_path: Path to downloaded video file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, error_message)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
QueueHandlerError: If archiving fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get archive channel
|
# Get archive channel
|
||||||
guild = self.bot.get_guild(guild_id)
|
guild = self.bot.get_guild(guild_id)
|
||||||
if not guild:
|
if not guild:
|
||||||
return False, f"Guild {guild_id} not found"
|
raise QueueHandlerError(f"Guild {guild_id} not found")
|
||||||
|
|
||||||
archive_channel = await self.config_manager.get_channel(guild, "archive")
|
archive_channel = await self.config_manager.get_channel(guild, "archive")
|
||||||
if not archive_channel:
|
if not archive_channel:
|
||||||
return False, "Archive channel not configured"
|
raise QueueHandlerError("Archive channel not configured")
|
||||||
|
|
||||||
# Format message
|
# Format message
|
||||||
try:
|
try:
|
||||||
@@ -128,13 +301,16 @@ class QueueHandler:
|
|||||||
author=author, channel=channel, url=url
|
author=author, channel=channel, url=url
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Failed to format message: {str(e)}"
|
raise QueueHandlerError(f"Failed to format message: {str(e)}")
|
||||||
|
|
||||||
# Upload to archive channel
|
# Upload to archive channel
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
return False, "Processed file not found"
|
raise QueueHandlerError("Processed file not found")
|
||||||
|
|
||||||
archive_message = await archive_channel.send(content=message, file=discord.File(file_path))
|
archive_message = await archive_channel.send(
|
||||||
|
content=message,
|
||||||
|
file=discord.File(file_path)
|
||||||
|
)
|
||||||
|
|
||||||
# Store in database if available
|
# Store in database if available
|
||||||
if self.db and archive_message.attachments:
|
if self.db and archive_message.attachments:
|
||||||
@@ -148,26 +324,28 @@ class QueueHandler:
|
|||||||
)
|
)
|
||||||
logger.info(f"Added video to archive database: {url} -> {discord_url}")
|
logger.info(f"Added video to archive database: {url} -> {discord_url}")
|
||||||
|
|
||||||
if original_message:
|
|
||||||
await original_message.remove_reaction(REACTIONS["processing"], self.bot.user)
|
|
||||||
await original_message.add_reaction(REACTIONS["success"])
|
|
||||||
logger.info(f"Successfully processed message {original_message.id}")
|
|
||||||
|
|
||||||
return True, None
|
return True, None
|
||||||
|
|
||||||
except discord.HTTPException as e:
|
except discord.HTTPException as e:
|
||||||
if original_message:
|
|
||||||
await original_message.add_reaction(REACTIONS["error"])
|
|
||||||
logger.error(f"Failed to upload to Discord: {str(e)}")
|
logger.error(f"Failed to upload to Discord: {str(e)}")
|
||||||
return False, f"Failed to upload to Discord: {str(e)}"
|
raise QueueHandlerError(f"Failed to upload to Discord: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if original_message:
|
|
||||||
await original_message.add_reaction(REACTIONS["error"])
|
|
||||||
logger.error(f"Failed to archive video: {str(e)}")
|
logger.error(f"Failed to archive video: {str(e)}")
|
||||||
return False, f"Failed to archive video: {str(e)}"
|
raise QueueHandlerError(f"Failed to archive video: {str(e)}")
|
||||||
|
|
||||||
async def _get_original_message(self, item) -> Optional[discord.Message]:
|
async def _get_original_message(
|
||||||
"""Retrieve the original message"""
|
self,
|
||||||
|
item: QueueItem
|
||||||
|
) -> Optional[discord.Message]:
|
||||||
|
"""
|
||||||
|
Retrieve the original message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: Queue item containing message details
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Original Discord message or None if not found
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
channel = self.bot.get_channel(item.channel_id)
|
channel = self.bot.get_channel(item.channel_id)
|
||||||
if not channel:
|
if not channel:
|
||||||
@@ -179,8 +357,21 @@ class QueueHandler:
|
|||||||
logger.error(f"Error fetching original message: {e}")
|
logger.error(f"Error fetching original message: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _create_progress_callback(self, message: Optional[discord.Message], url: str):
|
def _create_progress_callback(
|
||||||
"""Create progress callback function for download tracking"""
|
self,
|
||||||
|
message: Optional[discord.Message],
|
||||||
|
url: str
|
||||||
|
) -> Callable[[float], None]:
|
||||||
|
"""
|
||||||
|
Create progress callback function for download tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Discord message to update with progress
|
||||||
|
url: URL being downloaded
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Callback function for progress updates
|
||||||
|
"""
|
||||||
def progress_callback(progress: float) -> None:
|
def progress_callback(progress: float) -> None:
|
||||||
if message:
|
if message:
|
||||||
try:
|
try:
|
||||||
@@ -204,22 +395,45 @@ class QueueHandler:
|
|||||||
logger.error(f"Error in progress callback: {e}")
|
logger.error(f"Error in progress callback: {e}")
|
||||||
return progress_callback
|
return progress_callback
|
||||||
|
|
||||||
async def _download_video(self, downloader, url: str, progress_callback) -> Tuple[bool, Optional[str], Optional[str]]:
|
async def _download_video(
|
||||||
"""Download video with progress tracking"""
|
self,
|
||||||
|
downloader: DownloadManager,
|
||||||
|
url: str,
|
||||||
|
progress_callback: Callable[[float], None]
|
||||||
|
) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Download video with progress tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
downloader: Download manager instance
|
||||||
|
url: URL to download
|
||||||
|
progress_callback: Callback for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, file_path, error_message)
|
||||||
|
"""
|
||||||
download_task = asyncio.create_task(
|
download_task = asyncio.create_task(
|
||||||
downloader.download_video(url, progress_callback=progress_callback)
|
downloader.download_video(url, progress_callback=progress_callback)
|
||||||
)
|
)
|
||||||
|
|
||||||
async with self._active_downloads_lock:
|
async with self._active_downloads_lock:
|
||||||
self._active_downloads[url] = download_task
|
self._active_downloads[url] = download_task
|
||||||
|
self._stats["active_downloads"] += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success, file_path, error = await download_task
|
success, file_path, error = await asyncio.wait_for(
|
||||||
|
download_task,
|
||||||
|
timeout=self.DOWNLOAD_TIMEOUT
|
||||||
|
)
|
||||||
if success:
|
if success:
|
||||||
self.progress_tracker.complete_download(url)
|
self.progress_tracker.complete_download(url)
|
||||||
else:
|
else:
|
||||||
self.progress_tracker.increment_download_retries(url)
|
self.progress_tracker.increment_download_retries(url)
|
||||||
return success, file_path, error
|
return success, file_path, error
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"Download timed out for {url}")
|
||||||
|
return False, None, "Download timed out"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info(f"Download cancelled for {url}")
|
logger.info(f"Download cancelled for {url}")
|
||||||
return False, None, "Download cancelled"
|
return False, None, "Download cancelled"
|
||||||
@@ -229,9 +443,15 @@ class QueueHandler:
|
|||||||
finally:
|
finally:
|
||||||
async with self._active_downloads_lock:
|
async with self._active_downloads_lock:
|
||||||
self._active_downloads.pop(url, None)
|
self._active_downloads.pop(url, None)
|
||||||
|
self._stats["active_downloads"] -= 1
|
||||||
|
|
||||||
async def cleanup(self):
|
async def cleanup(self) -> None:
|
||||||
"""Clean up resources and stop processing"""
|
"""
|
||||||
|
Clean up resources and stop processing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
QueueHandlerError: If cleanup fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("Starting QueueHandler cleanup...")
|
logger.info("Starting QueueHandler cleanup...")
|
||||||
self._unloading = True
|
self._unloading = True
|
||||||
@@ -248,14 +468,15 @@ class QueueHandler:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error cancelling download task for {url}: {e}")
|
logger.error(f"Error cancelling download task for {url}: {e}")
|
||||||
self._active_downloads.clear()
|
self._active_downloads.clear()
|
||||||
|
self._stats["active_downloads"] = 0
|
||||||
|
|
||||||
logger.info("QueueHandler cleanup completed successfully")
|
logger.info("QueueHandler cleanup completed successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during QueueHandler cleanup: {str(e)}", exc_info=True)
|
logger.error(f"Error during QueueHandler cleanup: {str(e)}", exc_info=True)
|
||||||
raise
|
raise QueueHandlerError(f"Cleanup failed: {str(e)}")
|
||||||
|
|
||||||
async def force_cleanup(self):
|
async def force_cleanup(self) -> None:
|
||||||
"""Force cleanup of resources when normal cleanup fails"""
|
"""Force cleanup of resources when normal cleanup fails"""
|
||||||
try:
|
try:
|
||||||
logger.info("Starting force cleanup of QueueHandler...")
|
logger.info("Starting force cleanup of QueueHandler...")
|
||||||
@@ -266,13 +487,18 @@ class QueueHandler:
|
|||||||
if not task.done():
|
if not task.done():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
self._active_downloads.clear()
|
self._active_downloads.clear()
|
||||||
|
self._stats["active_downloads"] = 0
|
||||||
|
|
||||||
logger.info("QueueHandler force cleanup completed")
|
logger.info("QueueHandler force cleanup completed")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during QueueHandler force cleanup: {str(e)}", exc_info=True)
|
logger.error(f"Error during QueueHandler force cleanup: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
async def _update_download_progress_reaction(self, message: discord.Message, progress: float):
|
async def _update_download_progress_reaction(
|
||||||
|
self,
|
||||||
|
message: discord.Message,
|
||||||
|
progress: float
|
||||||
|
) -> None:
|
||||||
"""Update download progress reaction on message"""
|
"""Update download progress reaction on message"""
|
||||||
if not message:
|
if not message:
|
||||||
return
|
return
|
||||||
@@ -307,12 +533,41 @@ class QueueHandler:
|
|||||||
logger.error(f"Failed to update download progress reaction: {e}")
|
logger.error(f"Failed to update download progress reaction: {e}")
|
||||||
|
|
||||||
def is_healthy(self) -> bool:
|
def is_healthy(self) -> bool:
|
||||||
"""Check if handler is healthy"""
|
"""
|
||||||
# Check if any downloads are stuck
|
Check if handler is healthy.
|
||||||
current_time = datetime.utcnow()
|
|
||||||
for url, task in self._active_downloads.items():
|
Returns:
|
||||||
if not task.done() and task.get_coro().cr_frame.f_locals.get('start_time'):
|
True if handler is healthy, False otherwise
|
||||||
start_time = task.get_coro().cr_frame.f_locals['start_time']
|
"""
|
||||||
if (current_time - start_time).total_seconds() > 3600: # 1 hour timeout
|
try:
|
||||||
|
# Check if any downloads are stuck
|
||||||
|
current_time = datetime.utcnow()
|
||||||
|
for url, task in self._active_downloads.items():
|
||||||
|
if not task.done() and task.get_coro().cr_frame.f_locals.get('start_time'):
|
||||||
|
start_time = task.get_coro().cr_frame.f_locals['start_time']
|
||||||
|
if (current_time - start_time).total_seconds() > self.DOWNLOAD_TIMEOUT:
|
||||||
|
self._stats["is_healthy"] = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check processing metrics
|
||||||
|
if self._stats["processing_items"] > 0:
|
||||||
|
if self._stats["average_processing_time"] > self.DOWNLOAD_TIMEOUT:
|
||||||
|
self._stats["is_healthy"] = False
|
||||||
return False
|
return False
|
||||||
return True
|
|
||||||
|
self._stats["is_healthy"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking health: {e}")
|
||||||
|
self._stats["is_healthy"] = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_stats(self) -> QueueStats:
|
||||||
|
"""
|
||||||
|
Get queue handler statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing queue statistics
|
||||||
|
"""
|
||||||
|
return self._stats.copy()
|
||||||
|
|||||||
@@ -2,21 +2,24 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from enum import Enum
|
from enum import Enum, auto
|
||||||
from typing import List, Optional, Dict, Any, Set
|
from typing import List, Optional, Dict, Any, Set, Union, TypedDict, ClassVar
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
from ..queue.models import QueueItem
|
from ..queue.models import QueueItem
|
||||||
|
from ..queue.manager import EnhancedVideoQueueManager
|
||||||
from .constants import REACTIONS
|
from .constants import REACTIONS
|
||||||
|
from .url_extractor import URLMetadata
|
||||||
|
from ..utils.exceptions import QueueProcessingError
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
class QueuePriority(Enum):
|
class QueuePriority(Enum):
|
||||||
"""Queue item priorities"""
|
"""Queue item priorities"""
|
||||||
HIGH = 0
|
HIGH = auto()
|
||||||
NORMAL = 1
|
NORMAL = auto()
|
||||||
LOW = 2
|
LOW = auto()
|
||||||
|
|
||||||
class ProcessingStrategy(Enum):
|
class ProcessingStrategy(Enum):
|
||||||
"""Available processing strategies"""
|
"""Available processing strategies"""
|
||||||
@@ -24,10 +27,22 @@ class ProcessingStrategy(Enum):
|
|||||||
PRIORITY = "priority" # Process by priority
|
PRIORITY = "priority" # Process by priority
|
||||||
SMART = "smart" # Smart processing based on various factors
|
SMART = "smart" # Smart processing based on various factors
|
||||||
|
|
||||||
|
class QueueStats(TypedDict):
|
||||||
|
"""Type definition for queue statistics"""
|
||||||
|
total_processed: int
|
||||||
|
successful: int
|
||||||
|
failed: int
|
||||||
|
success_rate: float
|
||||||
|
average_processing_time: float
|
||||||
|
error_counts: Dict[str, int]
|
||||||
|
last_processed: Optional[str]
|
||||||
|
|
||||||
class QueueMetrics:
|
class QueueMetrics:
|
||||||
"""Tracks queue processing metrics"""
|
"""Tracks queue processing metrics"""
|
||||||
|
|
||||||
def __init__(self):
|
MAX_PROCESSING_TIME: ClassVar[float] = 3600.0 # 1 hour in seconds
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
self.total_processed = 0
|
self.total_processed = 0
|
||||||
self.successful = 0
|
self.successful = 0
|
||||||
self.failed = 0
|
self.failed = 0
|
||||||
@@ -36,49 +51,67 @@ class QueueMetrics:
|
|||||||
self.last_processed: Optional[datetime] = None
|
self.last_processed: Optional[datetime] = None
|
||||||
|
|
||||||
def record_success(self, processing_time: float) -> None:
|
def record_success(self, processing_time: float) -> None:
|
||||||
"""Record successful processing"""
|
"""
|
||||||
|
Record successful processing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
processing_time: Time taken to process in seconds
|
||||||
|
"""
|
||||||
|
if processing_time > self.MAX_PROCESSING_TIME:
|
||||||
|
logger.warning(f"Unusually long processing time: {processing_time} seconds")
|
||||||
|
|
||||||
self.total_processed += 1
|
self.total_processed += 1
|
||||||
self.successful += 1
|
self.successful += 1
|
||||||
self.processing_times.append(processing_time)
|
self.processing_times.append(processing_time)
|
||||||
self.last_processed = datetime.utcnow()
|
self.last_processed = datetime.utcnow()
|
||||||
|
|
||||||
def record_failure(self, error: str) -> None:
|
def record_failure(self, error: str) -> None:
|
||||||
"""Record processing failure"""
|
"""
|
||||||
|
Record processing failure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error: Error message describing the failure
|
||||||
|
"""
|
||||||
self.total_processed += 1
|
self.total_processed += 1
|
||||||
self.failed += 1
|
self.failed += 1
|
||||||
self.errors[error] = self.errors.get(error, 0) + 1
|
self.errors[error] = self.errors.get(error, 0) + 1
|
||||||
self.last_processed = datetime.utcnow()
|
self.last_processed = datetime.utcnow()
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
def get_stats(self) -> QueueStats:
|
||||||
"""Get queue metrics"""
|
"""
|
||||||
|
Get queue metrics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing queue statistics
|
||||||
|
"""
|
||||||
avg_time = (
|
avg_time = (
|
||||||
sum(self.processing_times) / len(self.processing_times)
|
sum(self.processing_times) / len(self.processing_times)
|
||||||
if self.processing_times
|
if self.processing_times
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
return {
|
return QueueStats(
|
||||||
"total_processed": self.total_processed,
|
total_processed=self.total_processed,
|
||||||
"successful": self.successful,
|
successful=self.successful,
|
||||||
"failed": self.failed,
|
failed=self.failed,
|
||||||
"success_rate": (
|
success_rate=(
|
||||||
self.successful / self.total_processed
|
self.successful / self.total_processed
|
||||||
if self.total_processed > 0
|
if self.total_processed > 0
|
||||||
else 0
|
else 0
|
||||||
),
|
),
|
||||||
"average_processing_time": avg_time,
|
average_processing_time=avg_time,
|
||||||
"error_counts": self.errors.copy(),
|
error_counts=self.errors.copy(),
|
||||||
"last_processed": self.last_processed
|
last_processed=self.last_processed.isoformat() if self.last_processed else None
|
||||||
}
|
)
|
||||||
|
|
||||||
class QueueProcessor:
|
class QueueProcessor:
|
||||||
"""Handles adding videos to the processing queue"""
|
"""Handles adding videos to the processing queue"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
queue_manager,
|
queue_manager: EnhancedVideoQueueManager,
|
||||||
strategy: ProcessingStrategy = ProcessingStrategy.SMART,
|
strategy: ProcessingStrategy = ProcessingStrategy.SMART,
|
||||||
max_retries: int = 3
|
max_retries: int = 3
|
||||||
):
|
) -> None:
|
||||||
self.queue_manager = queue_manager
|
self.queue_manager = queue_manager
|
||||||
self.strategy = strategy
|
self.strategy = strategy
|
||||||
self.max_retries = max_retries
|
self.max_retries = max_retries
|
||||||
@@ -89,16 +122,34 @@ class QueueProcessor:
|
|||||||
async def process_urls(
|
async def process_urls(
|
||||||
self,
|
self,
|
||||||
message: discord.Message,
|
message: discord.Message,
|
||||||
urls: List[str],
|
urls: Union[List[str], Set[str], List[URLMetadata]],
|
||||||
priority: QueuePriority = QueuePriority.NORMAL
|
priority: QueuePriority = QueuePriority.NORMAL
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Process extracted URLs by adding them to the queue"""
|
"""
|
||||||
for url in urls:
|
Process extracted URLs by adding them to the queue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Discord message containing the URLs
|
||||||
|
urls: List or set of URLs or URLMetadata objects to process
|
||||||
|
priority: Priority level for queue processing
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
QueueProcessingError: If there's an error adding URLs to the queue
|
||||||
|
"""
|
||||||
|
processed_urls: Set[str] = set()
|
||||||
|
|
||||||
|
for url_data in urls:
|
||||||
|
url = url_data.url if isinstance(url_data, URLMetadata) else url_data
|
||||||
|
|
||||||
|
if url in processed_urls:
|
||||||
|
logger.debug(f"Skipping duplicate URL: {url}")
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Adding URL to queue: {url}")
|
logger.info(f"Adding URL to queue: {url}")
|
||||||
await message.add_reaction(REACTIONS['queued'])
|
await message.add_reaction(REACTIONS['queued'])
|
||||||
|
|
||||||
# Create queue item using the model from queue.models
|
# Create queue item
|
||||||
item = QueueItem(
|
item = QueueItem(
|
||||||
url=url,
|
url=url,
|
||||||
message_id=message.id,
|
message_id=message.id,
|
||||||
@@ -111,15 +162,24 @@ class QueueProcessor:
|
|||||||
|
|
||||||
# Add to queue with appropriate strategy
|
# Add to queue with appropriate strategy
|
||||||
await self._add_to_queue(item)
|
await self._add_to_queue(item)
|
||||||
|
processed_urls.add(url)
|
||||||
logger.info(f"Successfully added video to queue: {url}")
|
logger.info(f"Successfully added video to queue: {url}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to add video to queue: {str(e)}")
|
logger.error(f"Failed to add video to queue: {str(e)}", exc_info=True)
|
||||||
await message.add_reaction(REACTIONS['error'])
|
await message.add_reaction(REACTIONS['error'])
|
||||||
continue
|
raise QueueProcessingError(f"Failed to add URL to queue: {str(e)}")
|
||||||
|
|
||||||
async def _add_to_queue(self, item: QueueItem) -> None:
|
async def _add_to_queue(self, item: QueueItem) -> None:
|
||||||
"""Add item to queue using current strategy"""
|
"""
|
||||||
|
Add item to queue using current strategy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: Queue item to add
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
QueueProcessingError: If there's an error adding the item
|
||||||
|
"""
|
||||||
async with self._processing_lock:
|
async with self._processing_lock:
|
||||||
if item.url in self._processing:
|
if item.url in self._processing:
|
||||||
logger.debug(f"URL already being processed: {item.url}")
|
logger.debug(f"URL already being processed: {item.url}")
|
||||||
@@ -136,6 +196,9 @@ class QueueProcessor:
|
|||||||
else: # FIFO
|
else: # FIFO
|
||||||
await self._add_fifo(item)
|
await self._add_fifo(item)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding item to queue: {e}", exc_info=True)
|
||||||
|
raise QueueProcessingError(f"Failed to add item to queue: {str(e)}")
|
||||||
finally:
|
finally:
|
||||||
async with self._processing_lock:
|
async with self._processing_lock:
|
||||||
self._processing.remove(item.url)
|
self._processing.remove(item.url)
|
||||||
@@ -153,7 +216,6 @@ class QueueProcessor:
|
|||||||
|
|
||||||
async def _add_with_smart_strategy(self, item: QueueItem) -> None:
|
async def _add_with_smart_strategy(self, item: QueueItem) -> None:
|
||||||
"""Add item using smart processing strategy"""
|
"""Add item using smart processing strategy"""
|
||||||
# Calculate priority based on various factors
|
|
||||||
priority = await self._calculate_smart_priority(item)
|
priority = await self._calculate_smart_priority(item)
|
||||||
|
|
||||||
await self.queue_manager.add_to_queue(
|
await self.queue_manager.add_to_queue(
|
||||||
@@ -177,7 +239,15 @@ class QueueProcessor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _calculate_smart_priority(self, item: QueueItem) -> int:
|
async def _calculate_smart_priority(self, item: QueueItem) -> int:
|
||||||
"""Calculate priority using smart strategy"""
|
"""
|
||||||
|
Calculate priority using smart strategy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: Queue item to calculate priority for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Calculated priority value
|
||||||
|
"""
|
||||||
base_priority = item.priority
|
base_priority = item.priority
|
||||||
|
|
||||||
# Adjust based on queue metrics
|
# Adjust based on queue metrics
|
||||||
@@ -203,7 +273,17 @@ class QueueProcessor:
|
|||||||
channel: discord.TextChannel,
|
channel: discord.TextChannel,
|
||||||
url: str
|
url: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Format message for archive channel"""
|
"""
|
||||||
|
Format message for archive channel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
author: Optional message author
|
||||||
|
channel: Channel the message was posted in
|
||||||
|
url: URL being archived
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted message string
|
||||||
|
"""
|
||||||
author_mention = author.mention if author else "Unknown User"
|
author_mention = author.mention if author else "Unknown User"
|
||||||
channel_mention = channel.mention if channel else "Unknown Channel"
|
channel_mention = channel.mention if channel else "Unknown Channel"
|
||||||
|
|
||||||
@@ -213,7 +293,12 @@ class QueueProcessor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_metrics(self) -> Dict[str, Any]:
|
def get_metrics(self) -> Dict[str, Any]:
|
||||||
"""Get queue processing metrics"""
|
"""
|
||||||
|
Get queue processing metrics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing queue metrics and status
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"metrics": self.metrics.get_stats(),
|
"metrics": self.metrics.get_stats(),
|
||||||
"strategy": self.strategy.value,
|
"strategy": self.strategy.value,
|
||||||
|
|||||||
@@ -2,112 +2,184 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
|
from typing import List, Optional
|
||||||
import discord
|
import discord
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from .constants import REACTIONS
|
from .constants import REACTIONS, ReactionType, get_reaction, get_progress_emoji
|
||||||
|
from ..database.video_archive_db import VideoArchiveDB
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
async def handle_archived_reaction(message: discord.Message, user: discord.User, db) -> None:
|
async def handle_archived_reaction(
|
||||||
"""Handle reaction to archived video message"""
|
message: discord.Message,
|
||||||
|
user: discord.User,
|
||||||
|
db: VideoArchiveDB
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Handle reaction to archived video message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The Discord message that was reacted to
|
||||||
|
user: The user who added the reaction
|
||||||
|
db: Database instance for checking archived videos
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if the reaction is from a user (not the bot) and is the archived reaction
|
# Check if the reaction is from a user (not the bot) and is the archived reaction
|
||||||
if user.bot or str(message.reactions[0].emoji) != REACTIONS['archived']:
|
if user.bot or str(message.reactions[0].emoji) != get_reaction(ReactionType.ARCHIVED):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Extract URLs from the message
|
# Extract URLs from the message using regex
|
||||||
urls = []
|
url_pattern = re.compile(r'https?://[^\s<>"]+|www\.[^\s<>"]+')
|
||||||
if message.content:
|
urls = url_pattern.findall(message.content) if message.content else []
|
||||||
for word in message.content.split():
|
|
||||||
if any(s in word.lower() for s in ['http://', 'https://']):
|
|
||||||
urls.append(word)
|
|
||||||
|
|
||||||
# Check each URL in the database
|
# Check each URL in the database
|
||||||
for url in urls:
|
for url in urls:
|
||||||
|
# Ensure URL has proper scheme
|
||||||
|
if url.startswith('www.'):
|
||||||
|
url = 'http://' + url
|
||||||
|
|
||||||
|
# Validate URL
|
||||||
|
try:
|
||||||
|
result = urlparse(url)
|
||||||
|
if not all([result.scheme, result.netloc]):
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
result = db.get_archived_video(url)
|
result = db.get_archived_video(url)
|
||||||
if result:
|
if result:
|
||||||
discord_url = result[0]
|
discord_url = result[0]
|
||||||
await message.reply(f"This video was already archived. You can find it here: {discord_url}")
|
await message.reply(
|
||||||
|
f"This video was already archived. You can find it here: {discord_url}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling archived reaction: {e}")
|
logger.error(f"Error handling archived reaction: {e}", exc_info=True)
|
||||||
|
|
||||||
async def update_queue_position_reaction(message: discord.Message, position: int, bot_user) -> None:
|
async def update_queue_position_reaction(
|
||||||
"""Update queue position reaction"""
|
message: discord.Message,
|
||||||
|
position: int,
|
||||||
|
bot_user: discord.ClientUser
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update queue position reaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The Discord message to update reactions on
|
||||||
|
position: Queue position (0-based index)
|
||||||
|
bot_user: The bot's user instance for managing reactions
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
for reaction in REACTIONS["numbers"]:
|
numbers = get_reaction(ReactionType.NUMBERS)
|
||||||
|
if not isinstance(numbers, list):
|
||||||
|
logger.error("Numbers reaction is not a list")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove old reactions
|
||||||
|
for reaction in numbers:
|
||||||
try:
|
try:
|
||||||
await message.remove_reaction(reaction, bot_user)
|
await message.remove_reaction(reaction, bot_user)
|
||||||
except:
|
except discord.HTTPException as e:
|
||||||
pass
|
logger.warning(f"Failed to remove number reaction: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error removing number reaction: {e}")
|
||||||
|
|
||||||
|
# Add new reaction if position is valid
|
||||||
|
if 0 <= position < len(numbers):
|
||||||
|
try:
|
||||||
|
await message.add_reaction(numbers[position])
|
||||||
|
logger.info(
|
||||||
|
f"Updated queue position reaction to {position + 1} for message {message.id}"
|
||||||
|
)
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
logger.error(f"Failed to add queue position reaction: {e}")
|
||||||
|
|
||||||
if 0 <= position < len(REACTIONS["numbers"]):
|
|
||||||
await message.add_reaction(REACTIONS["numbers"][position])
|
|
||||||
logger.info(
|
|
||||||
f"Updated queue position reaction to {position + 1} for message {message.id}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update queue position reaction: {e}")
|
logger.error(f"Failed to update queue position reaction: {e}", exc_info=True)
|
||||||
|
|
||||||
async def update_progress_reaction(message: discord.Message, progress: float, bot_user) -> None:
|
async def update_progress_reaction(
|
||||||
"""Update progress reaction based on FFmpeg progress"""
|
message: discord.Message,
|
||||||
|
progress: float,
|
||||||
|
bot_user: discord.ClientUser
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update progress reaction based on FFmpeg progress.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The Discord message to update reactions on
|
||||||
|
progress: Progress value between 0 and 100
|
||||||
|
bot_user: The bot's user instance for managing reactions
|
||||||
|
"""
|
||||||
if not message:
|
if not message:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Remove old reactions in the event loop
|
progress_emojis = get_reaction(ReactionType.PROGRESS)
|
||||||
for reaction in REACTIONS["progress"]:
|
if not isinstance(progress_emojis, list):
|
||||||
|
logger.error("Progress reaction is not a list")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove old reactions
|
||||||
|
for reaction in progress_emojis:
|
||||||
try:
|
try:
|
||||||
await message.remove_reaction(reaction, bot_user)
|
await message.remove_reaction(reaction, bot_user)
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
logger.warning(f"Failed to remove progress reaction: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to remove progress reaction: {e}")
|
logger.error(f"Unexpected error removing progress reaction: {e}")
|
||||||
continue
|
|
||||||
|
|
||||||
# Add new reaction based on progress
|
# Add new reaction based on progress
|
||||||
try:
|
try:
|
||||||
if progress < 33:
|
normalized_progress = progress / 100 # Convert to 0-1 range
|
||||||
await message.add_reaction(REACTIONS["progress"][0])
|
emoji = get_progress_emoji(normalized_progress, progress_emojis)
|
||||||
elif progress < 66:
|
await message.add_reaction(emoji)
|
||||||
await message.add_reaction(REACTIONS["progress"][1])
|
|
||||||
else:
|
|
||||||
await message.add_reaction(REACTIONS["progress"][2])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to add progress reaction: {e}")
|
logger.error(f"Failed to add progress reaction: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update progress reaction: {e}")
|
logger.error(f"Failed to update progress reaction: {e}", exc_info=True)
|
||||||
|
|
||||||
async def update_download_progress_reaction(message: discord.Message, progress: float, bot_user) -> None:
|
async def update_download_progress_reaction(
|
||||||
"""Update download progress reaction"""
|
message: discord.Message,
|
||||||
|
progress: float,
|
||||||
|
bot_user: discord.ClientUser
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update download progress reaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The Discord message to update reactions on
|
||||||
|
progress: Progress value between 0 and 100
|
||||||
|
bot_user: The bot's user instance for managing reactions
|
||||||
|
"""
|
||||||
if not message:
|
if not message:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Remove old reactions in the event loop
|
download_emojis = get_reaction(ReactionType.DOWNLOAD)
|
||||||
for reaction in REACTIONS["download"]:
|
if not isinstance(download_emojis, list):
|
||||||
|
logger.error("Download reaction is not a list")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove old reactions
|
||||||
|
for reaction in download_emojis:
|
||||||
try:
|
try:
|
||||||
await message.remove_reaction(reaction, bot_user)
|
await message.remove_reaction(reaction, bot_user)
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
logger.warning(f"Failed to remove download reaction: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to remove download reaction: {e}")
|
logger.error(f"Unexpected error removing download reaction: {e}")
|
||||||
continue
|
|
||||||
|
|
||||||
# Add new reaction based on progress
|
# Add new reaction based on progress
|
||||||
try:
|
try:
|
||||||
if progress <= 20:
|
normalized_progress = progress / 100 # Convert to 0-1 range
|
||||||
await message.add_reaction(REACTIONS["download"][0])
|
emoji = get_progress_emoji(normalized_progress, download_emojis)
|
||||||
elif progress <= 40:
|
await message.add_reaction(emoji)
|
||||||
await message.add_reaction(REACTIONS["download"][1])
|
|
||||||
elif progress <= 60:
|
|
||||||
await message.add_reaction(REACTIONS["download"][2])
|
|
||||||
elif progress <= 80:
|
|
||||||
await message.add_reaction(REACTIONS["download"][3])
|
|
||||||
elif progress < 100:
|
|
||||||
await message.add_reaction(REACTIONS["download"][4])
|
|
||||||
else:
|
|
||||||
await message.add_reaction(REACTIONS["download"][5])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to add download reaction: {e}")
|
logger.error(f"Failed to add download reaction: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update download progress reaction: {e}")
|
logger.error(f"Failed to update download progress reaction: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -1,23 +1,39 @@
|
|||||||
"""Module for handling queue status display and formatting"""
|
"""Module for handling queue status display and formatting"""
|
||||||
|
|
||||||
import discord
|
|
||||||
from enum import Enum
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Dict, Any, List, Optional
|
|
||||||
import logging
|
import logging
|
||||||
|
from enum import Enum, auto
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, List, Optional, Callable, TypeVar, Union, TypedDict, ClassVar
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from ..utils.exceptions import DisplayError
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
class DisplayTheme:
|
T = TypeVar('T')
|
||||||
"""Defines display themes"""
|
|
||||||
DEFAULT = {
|
class DisplayTheme(TypedDict):
|
||||||
"title_color": discord.Color.blue(),
|
"""Type definition for display theme"""
|
||||||
"success_color": discord.Color.green(),
|
title_color: discord.Color
|
||||||
"warning_color": discord.Color.gold(),
|
success_color: discord.Color
|
||||||
"error_color": discord.Color.red(),
|
warning_color: discord.Color
|
||||||
"info_color": discord.Color.blurple()
|
error_color: discord.Color
|
||||||
}
|
info_color: discord.Color
|
||||||
|
|
||||||
|
class DisplaySection(Enum):
|
||||||
|
"""Available display sections"""
|
||||||
|
QUEUE_STATS = auto()
|
||||||
|
DOWNLOADS = auto()
|
||||||
|
COMPRESSIONS = auto()
|
||||||
|
ERRORS = auto()
|
||||||
|
HARDWARE = auto()
|
||||||
|
|
||||||
|
class DisplayCondition(Enum):
|
||||||
|
"""Display conditions for sections"""
|
||||||
|
HAS_ERRORS = "has_errors"
|
||||||
|
HAS_DOWNLOADS = "has_downloads"
|
||||||
|
HAS_COMPRESSIONS = "has_compressions"
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DisplayTemplate:
|
class DisplayTemplate:
|
||||||
@@ -26,48 +42,116 @@ class DisplayTemplate:
|
|||||||
format_string: str
|
format_string: str
|
||||||
inline: bool = False
|
inline: bool = False
|
||||||
order: int = 0
|
order: int = 0
|
||||||
condition: Optional[str] = None
|
condition: Optional[DisplayCondition] = None
|
||||||
|
formatter: Optional[Callable[[Dict[str, Any]], str]] = None
|
||||||
class DisplaySection(Enum):
|
max_items: int = field(default=5) # Maximum items to display in lists
|
||||||
"""Available display sections"""
|
|
||||||
QUEUE_STATS = "queue_stats"
|
|
||||||
DOWNLOADS = "downloads"
|
|
||||||
COMPRESSIONS = "compressions"
|
|
||||||
ERRORS = "errors"
|
|
||||||
HARDWARE = "hardware"
|
|
||||||
|
|
||||||
class StatusFormatter:
|
class StatusFormatter:
|
||||||
"""Formats status information for display"""
|
"""Formats status information for display"""
|
||||||
|
|
||||||
|
BYTE_UNITS: ClassVar[List[str]] = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
TIME_THRESHOLDS: ClassVar[List[Tuple[float, str]]] = [
|
||||||
|
(60, 's'),
|
||||||
|
(3600, 'm'),
|
||||||
|
(float('inf'), 'h')
|
||||||
|
]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_bytes(bytes: int) -> str:
|
def format_bytes(bytes_value: Union[int, float]) -> str:
|
||||||
"""Format bytes into human readable format"""
|
"""
|
||||||
for unit in ['B', 'KB', 'MB', 'GB']:
|
Format bytes into human readable format.
|
||||||
if bytes < 1024:
|
|
||||||
return f"{bytes:.1f}{unit}"
|
Args:
|
||||||
bytes /= 1024
|
bytes_value: Number of bytes to format
|
||||||
return f"{bytes:.1f}TB"
|
|
||||||
|
Returns:
|
||||||
|
Formatted string with appropriate unit
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If bytes_value is negative
|
||||||
|
"""
|
||||||
|
if bytes_value < 0:
|
||||||
|
raise ValueError("Bytes value cannot be negative")
|
||||||
|
|
||||||
|
bytes_num = float(bytes_value)
|
||||||
|
for unit in StatusFormatter.BYTE_UNITS:
|
||||||
|
if bytes_num < 1024:
|
||||||
|
return f"{bytes_num:.1f}{unit}"
|
||||||
|
bytes_num /= 1024
|
||||||
|
return f"{bytes_num:.1f}TB"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_time(seconds: float) -> str:
|
def format_time(seconds: float) -> str:
|
||||||
"""Format time duration"""
|
"""
|
||||||
if seconds < 60:
|
Format time duration.
|
||||||
return f"{seconds:.1f}s"
|
|
||||||
minutes = seconds / 60
|
Args:
|
||||||
if minutes < 60:
|
seconds: Number of seconds to format
|
||||||
return f"{minutes:.1f}m"
|
|
||||||
hours = minutes / 60
|
Returns:
|
||||||
return f"{hours:.1f}h"
|
Formatted time string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If seconds is negative
|
||||||
|
"""
|
||||||
|
if seconds < 0:
|
||||||
|
raise ValueError("Time value cannot be negative")
|
||||||
|
|
||||||
|
for threshold, unit in StatusFormatter.TIME_THRESHOLDS:
|
||||||
|
if seconds < threshold:
|
||||||
|
return f"{seconds:.1f}{unit}"
|
||||||
|
seconds /= 60
|
||||||
|
return f"{seconds:.1f}h"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_percentage(value: float) -> str:
|
def format_percentage(value: float) -> str:
|
||||||
"""Format percentage value"""
|
"""
|
||||||
|
Format percentage value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Percentage value to format (0-100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted percentage string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If value is outside valid range
|
||||||
|
"""
|
||||||
|
if not 0 <= value <= 100:
|
||||||
|
raise ValueError("Percentage must be between 0 and 100")
|
||||||
return f"{value:.1f}%"
|
return f"{value:.1f}%"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def truncate_url(url: str, max_length: int = 50) -> str:
|
||||||
|
"""
|
||||||
|
Truncate URL to specified length.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to truncate
|
||||||
|
max_length: Maximum length for URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Truncated URL string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If max_length is less than 4
|
||||||
|
"""
|
||||||
|
if max_length < 4: # Need room for "..."
|
||||||
|
raise ValueError("max_length must be at least 4")
|
||||||
|
return f"{url[:max_length]}..." if len(url) > max_length else url
|
||||||
|
|
||||||
class DisplayManager:
|
class DisplayManager:
|
||||||
"""Manages status display configuration"""
|
"""Manages status display configuration"""
|
||||||
|
|
||||||
def __init__(self):
|
DEFAULT_THEME: ClassVar[DisplayTheme] = DisplayTheme(
|
||||||
|
title_color=discord.Color.blue(),
|
||||||
|
success_color=discord.Color.green(),
|
||||||
|
warning_color=discord.Color.gold(),
|
||||||
|
error_color=discord.Color.red(),
|
||||||
|
info_color=discord.Color.blurple()
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
self.templates: Dict[DisplaySection, DisplayTemplate] = {
|
self.templates: Dict[DisplaySection, DisplayTemplate] = {
|
||||||
DisplaySection.QUEUE_STATS: DisplayTemplate(
|
DisplaySection.QUEUE_STATS: DisplayTemplate(
|
||||||
name="Queue Statistics",
|
name="Queue Statistics",
|
||||||
@@ -96,7 +180,8 @@ class DisplayManager:
|
|||||||
"Retries: {retries}\n"
|
"Retries: {retries}\n"
|
||||||
"```"
|
"```"
|
||||||
),
|
),
|
||||||
order=2
|
order=2,
|
||||||
|
condition=DisplayCondition.HAS_DOWNLOADS
|
||||||
),
|
),
|
||||||
DisplaySection.COMPRESSIONS: DisplayTemplate(
|
DisplaySection.COMPRESSIONS: DisplayTemplate(
|
||||||
name="Active Compressions",
|
name="Active Compressions",
|
||||||
@@ -112,12 +197,13 @@ class DisplayManager:
|
|||||||
"Hardware Accel: {hardware_accel}\n"
|
"Hardware Accel: {hardware_accel}\n"
|
||||||
"```"
|
"```"
|
||||||
),
|
),
|
||||||
order=3
|
order=3,
|
||||||
|
condition=DisplayCondition.HAS_COMPRESSIONS
|
||||||
),
|
),
|
||||||
DisplaySection.ERRORS: DisplayTemplate(
|
DisplaySection.ERRORS: DisplayTemplate(
|
||||||
name="Error Statistics",
|
name="Error Statistics",
|
||||||
format_string="```\n{error_stats}```",
|
format_string="```\n{error_stats}```",
|
||||||
condition="has_errors",
|
condition=DisplayCondition.HAS_ERRORS,
|
||||||
order=4
|
order=4
|
||||||
),
|
),
|
||||||
DisplaySection.HARDWARE: DisplayTemplate(
|
DisplaySection.HARDWARE: DisplayTemplate(
|
||||||
@@ -132,63 +218,99 @@ class DisplayManager:
|
|||||||
order=5
|
order=5
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
self.theme = DisplayTheme.DEFAULT
|
self.theme = self.DEFAULT_THEME.copy()
|
||||||
|
|
||||||
class StatusDisplay:
|
class StatusDisplay:
|
||||||
"""Handles formatting and display of queue status information"""
|
"""Handles formatting and display of queue status information"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.display_manager = DisplayManager()
|
self.display_manager = DisplayManager()
|
||||||
self.formatter = StatusFormatter()
|
self.formatter = StatusFormatter()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
async def create_queue_status_embed(
|
async def create_queue_status_embed(
|
||||||
self,
|
cls,
|
||||||
queue_status: Dict[str, Any],
|
queue_status: Dict[str, Any],
|
||||||
active_ops: Dict[str, Any]
|
active_ops: Dict[str, Any]
|
||||||
) -> discord.Embed:
|
) -> discord.Embed:
|
||||||
"""Create an embed displaying queue status and active operations"""
|
"""
|
||||||
embed = discord.Embed(
|
Create an embed displaying queue status and active operations.
|
||||||
title="Queue Status Details",
|
|
||||||
color=self.display_manager.theme["title_color"],
|
Args:
|
||||||
timestamp=datetime.utcnow()
|
queue_status: Dictionary containing queue status information
|
||||||
)
|
active_ops: Dictionary containing active operations information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Discord embed containing formatted status information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DisplayError: If there's an error creating the embed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
display = cls()
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Queue Status Details",
|
||||||
|
color=display.display_manager.theme["title_color"],
|
||||||
|
timestamp=datetime.utcnow()
|
||||||
|
)
|
||||||
|
|
||||||
# Add sections in order
|
# Add sections in order
|
||||||
sections = sorted(
|
sections = sorted(
|
||||||
self.display_manager.templates.items(),
|
display.display_manager.templates.items(),
|
||||||
key=lambda x: x[1].order
|
key=lambda x: x[1].order
|
||||||
)
|
)
|
||||||
|
|
||||||
for section, template in sections:
|
for section, template in sections:
|
||||||
# Check condition if exists
|
try:
|
||||||
if template.condition:
|
# Check condition if exists
|
||||||
if not self._check_condition(template.condition, queue_status, active_ops):
|
if template.condition:
|
||||||
continue
|
if not display._check_condition(
|
||||||
|
template.condition,
|
||||||
|
queue_status,
|
||||||
|
active_ops
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
# Add section based on type
|
# Add section based on type
|
||||||
if section == DisplaySection.QUEUE_STATS:
|
if section == DisplaySection.QUEUE_STATS:
|
||||||
self._add_queue_statistics(embed, queue_status, template)
|
display._add_queue_statistics(embed, queue_status, template)
|
||||||
elif section == DisplaySection.DOWNLOADS:
|
elif section == DisplaySection.DOWNLOADS:
|
||||||
self._add_active_downloads(embed, active_ops.get('downloads', {}), template)
|
display._add_active_downloads(embed, active_ops.get('downloads', {}), template)
|
||||||
elif section == DisplaySection.COMPRESSIONS:
|
elif section == DisplaySection.COMPRESSIONS:
|
||||||
self._add_active_compressions(embed, active_ops.get('compressions', {}), template)
|
display._add_active_compressions(embed, active_ops.get('compressions', {}), template)
|
||||||
elif section == DisplaySection.ERRORS:
|
elif section == DisplaySection.ERRORS:
|
||||||
self._add_error_statistics(embed, queue_status, template)
|
display._add_error_statistics(embed, queue_status, template)
|
||||||
elif section == DisplaySection.HARDWARE:
|
elif section == DisplaySection.HARDWARE:
|
||||||
self._add_hardware_statistics(embed, queue_status, template)
|
display._add_hardware_statistics(embed, queue_status, template)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding section {section.value}: {e}")
|
||||||
|
# Continue with other sections
|
||||||
|
|
||||||
return embed
|
return embed
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error = f"Error creating status embed: {str(e)}"
|
||||||
|
logger.error(error, exc_info=True)
|
||||||
|
raise DisplayError(error)
|
||||||
|
|
||||||
def _check_condition(
|
def _check_condition(
|
||||||
self,
|
self,
|
||||||
condition: str,
|
condition: DisplayCondition,
|
||||||
queue_status: Dict[str, Any],
|
queue_status: Dict[str, Any],
|
||||||
active_ops: Dict[str, Any]
|
active_ops: Dict[str, Any]
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if condition for displaying section is met"""
|
"""Check if condition for displaying section is met"""
|
||||||
if condition == "has_errors":
|
try:
|
||||||
return bool(queue_status["metrics"]["errors_by_type"])
|
if condition == DisplayCondition.HAS_ERRORS:
|
||||||
return True
|
return bool(queue_status.get("metrics", {}).get("errors_by_type"))
|
||||||
|
elif condition == DisplayCondition.HAS_DOWNLOADS:
|
||||||
|
return bool(active_ops.get("downloads"))
|
||||||
|
elif condition == DisplayCondition.HAS_COMPRESSIONS:
|
||||||
|
return bool(active_ops.get("compressions"))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking condition {condition}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def _add_queue_statistics(
|
def _add_queue_statistics(
|
||||||
self,
|
self,
|
||||||
@@ -197,22 +319,31 @@ class StatusDisplay:
|
|||||||
template: DisplayTemplate
|
template: DisplayTemplate
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add queue statistics to the embed"""
|
"""Add queue statistics to the embed"""
|
||||||
embed.add_field(
|
try:
|
||||||
name=template.name,
|
metrics = queue_status.get('metrics', {})
|
||||||
value=template.format_string.format(
|
embed.add_field(
|
||||||
pending=queue_status['pending'],
|
name=template.name,
|
||||||
processing=queue_status['processing'],
|
value=template.format_string.format(
|
||||||
completed=queue_status['completed'],
|
pending=queue_status.get('pending', 0),
|
||||||
failed=queue_status['failed'],
|
processing=queue_status.get('processing', 0),
|
||||||
success_rate=self.formatter.format_percentage(
|
completed=queue_status.get('completed', 0),
|
||||||
queue_status['metrics']['success_rate'] * 100
|
failed=queue_status.get('failed', 0),
|
||||||
|
success_rate=self.formatter.format_percentage(
|
||||||
|
metrics.get('success_rate', 0) * 100
|
||||||
|
),
|
||||||
|
avg_processing_time=self.formatter.format_time(
|
||||||
|
metrics.get('avg_processing_time', 0)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
avg_processing_time=self.formatter.format_time(
|
inline=template.inline
|
||||||
queue_status['metrics']['avg_processing_time']
|
)
|
||||||
)
|
except Exception as e:
|
||||||
),
|
logger.error(f"Error adding queue statistics: {e}")
|
||||||
inline=template.inline
|
embed.add_field(
|
||||||
)
|
name=template.name,
|
||||||
|
value="```\nError displaying queue statistics```",
|
||||||
|
inline=template.inline
|
||||||
|
)
|
||||||
|
|
||||||
def _add_active_downloads(
|
def _add_active_downloads(
|
||||||
self,
|
self,
|
||||||
@@ -221,28 +352,44 @@ class StatusDisplay:
|
|||||||
template: DisplayTemplate
|
template: DisplayTemplate
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add active downloads information to the embed"""
|
"""Add active downloads information to the embed"""
|
||||||
if downloads:
|
try:
|
||||||
content = []
|
if downloads:
|
||||||
for url, progress in downloads.items():
|
content = []
|
||||||
content.append(template.format_string.format(
|
for url, progress in list(downloads.items())[:template.max_items]:
|
||||||
url=url[:50] + "..." if len(url) > 50 else url,
|
try:
|
||||||
percent=self.formatter.format_percentage(progress.get('percent', 0)),
|
content.append(template.format_string.format(
|
||||||
speed=progress.get('speed', 'N/A'),
|
url=self.formatter.truncate_url(url),
|
||||||
eta=progress.get('eta', 'N/A'),
|
percent=self.formatter.format_percentage(progress.get('percent', 0)),
|
||||||
size=f"{self.formatter.format_bytes(progress.get('downloaded_bytes', 0))}/"
|
speed=progress.get('speed', 'N/A'),
|
||||||
f"{self.formatter.format_bytes(progress.get('total_bytes', 0))}",
|
eta=progress.get('eta', 'N/A'),
|
||||||
start_time=progress.get('start_time', 'N/A'),
|
size=f"{self.formatter.format_bytes(progress.get('downloaded_bytes', 0))}/"
|
||||||
retries=progress.get('retries', 0)
|
f"{self.formatter.format_bytes(progress.get('total_bytes', 0))}",
|
||||||
))
|
start_time=progress.get('start_time', 'N/A'),
|
||||||
|
retries=progress.get('retries', 0)
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error formatting download {url}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(downloads) > template.max_items:
|
||||||
|
content.append(f"\n... and {len(downloads) - template.max_items} more")
|
||||||
|
|
||||||
|
embed.add_field(
|
||||||
|
name=template.name,
|
||||||
|
value="".join(content) if content else "```\nNo active downloads```",
|
||||||
|
inline=template.inline
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
embed.add_field(
|
||||||
|
name=template.name,
|
||||||
|
value="```\nNo active downloads```",
|
||||||
|
inline=template.inline
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding active downloads: {e}")
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=template.name,
|
name=template.name,
|
||||||
value="".join(content),
|
value="```\nError displaying downloads```",
|
||||||
inline=template.inline
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
embed.add_field(
|
|
||||||
name=template.name,
|
|
||||||
value="```\nNo active downloads```",
|
|
||||||
inline=template.inline
|
inline=template.inline
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -253,28 +400,44 @@ class StatusDisplay:
|
|||||||
template: DisplayTemplate
|
template: DisplayTemplate
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add active compressions information to the embed"""
|
"""Add active compressions information to the embed"""
|
||||||
if compressions:
|
try:
|
||||||
content = []
|
if compressions:
|
||||||
for file_id, progress in compressions.items():
|
content = []
|
||||||
content.append(template.format_string.format(
|
for file_id, progress in list(compressions.items())[:template.max_items]:
|
||||||
filename=progress.get('filename', 'Unknown'),
|
try:
|
||||||
percent=self.formatter.format_percentage(progress.get('percent', 0)),
|
content.append(template.format_string.format(
|
||||||
elapsed_time=progress.get('elapsed_time', 'N/A'),
|
filename=progress.get('filename', 'Unknown'),
|
||||||
input_size=self.formatter.format_bytes(progress.get('input_size', 0)),
|
percent=self.formatter.format_percentage(progress.get('percent', 0)),
|
||||||
current_size=self.formatter.format_bytes(progress.get('current_size', 0)),
|
elapsed_time=progress.get('elapsed_time', 'N/A'),
|
||||||
target_size=self.formatter.format_bytes(progress.get('target_size', 0)),
|
input_size=self.formatter.format_bytes(progress.get('input_size', 0)),
|
||||||
codec=progress.get('codec', 'Unknown'),
|
current_size=self.formatter.format_bytes(progress.get('current_size', 0)),
|
||||||
hardware_accel=progress.get('hardware_accel', False)
|
target_size=self.formatter.format_bytes(progress.get('target_size', 0)),
|
||||||
))
|
codec=progress.get('codec', 'Unknown'),
|
||||||
|
hardware_accel=progress.get('hardware_accel', False)
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error formatting compression {file_id}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(compressions) > template.max_items:
|
||||||
|
content.append(f"\n... and {len(compressions) - template.max_items} more")
|
||||||
|
|
||||||
|
embed.add_field(
|
||||||
|
name=template.name,
|
||||||
|
value="".join(content) if content else "```\nNo active compressions```",
|
||||||
|
inline=template.inline
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
embed.add_field(
|
||||||
|
name=template.name,
|
||||||
|
value="```\nNo active compressions```",
|
||||||
|
inline=template.inline
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding active compressions: {e}")
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=template.name,
|
name=template.name,
|
||||||
value="".join(content),
|
value="```\nError displaying compressions```",
|
||||||
inline=template.inline
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
embed.add_field(
|
|
||||||
name=template.name,
|
|
||||||
value="```\nNo active compressions```",
|
|
||||||
inline=template.inline
|
inline=template.inline
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -285,14 +448,26 @@ class StatusDisplay:
|
|||||||
template: DisplayTemplate
|
template: DisplayTemplate
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add error statistics to the embed"""
|
"""Add error statistics to the embed"""
|
||||||
if queue_status["metrics"]["errors_by_type"]:
|
try:
|
||||||
error_stats = "\n".join(
|
metrics = queue_status.get('metrics', {})
|
||||||
f"{error_type}: {count}"
|
errors_by_type = metrics.get('errors_by_type', {})
|
||||||
for error_type, count in queue_status["metrics"]["errors_by_type"].items()
|
if errors_by_type:
|
||||||
)
|
error_stats = "\n".join(
|
||||||
|
f"{error_type}: {count}"
|
||||||
|
for error_type, count in list(errors_by_type.items())[:template.max_items]
|
||||||
|
)
|
||||||
|
if len(errors_by_type) > template.max_items:
|
||||||
|
error_stats += f"\n... and {len(errors_by_type) - template.max_items} more"
|
||||||
|
embed.add_field(
|
||||||
|
name=template.name,
|
||||||
|
value=template.format_string.format(error_stats=error_stats),
|
||||||
|
inline=template.inline
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding error statistics: {e}")
|
||||||
embed.add_field(
|
embed.add_field(
|
||||||
name=template.name,
|
name=template.name,
|
||||||
value=template.format_string.format(error_stats=error_stats),
|
value="```\nError displaying error statistics```",
|
||||||
inline=template.inline
|
inline=template.inline
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -303,14 +478,23 @@ class StatusDisplay:
|
|||||||
template: DisplayTemplate
|
template: DisplayTemplate
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add hardware statistics to the embed"""
|
"""Add hardware statistics to the embed"""
|
||||||
embed.add_field(
|
try:
|
||||||
name=template.name,
|
metrics = queue_status.get('metrics', {})
|
||||||
value=template.format_string.format(
|
embed.add_field(
|
||||||
hw_failures=queue_status['metrics']['hardware_accel_failures'],
|
name=template.name,
|
||||||
comp_failures=queue_status['metrics']['compression_failures'],
|
value=template.format_string.format(
|
||||||
memory_usage=self.formatter.format_bytes(
|
hw_failures=metrics.get('hardware_accel_failures', 0),
|
||||||
queue_status['metrics']['peak_memory_usage'] * 1024 * 1024 # Convert MB to bytes
|
comp_failures=metrics.get('compression_failures', 0),
|
||||||
)
|
memory_usage=self.formatter.format_bytes(
|
||||||
),
|
metrics.get('peak_memory_usage', 0) * 1024 * 1024 # Convert MB to bytes
|
||||||
inline=template.inline
|
)
|
||||||
)
|
),
|
||||||
|
inline=template.inline
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding hardware statistics: {e}")
|
||||||
|
embed.add_field(
|
||||||
|
name=template.name,
|
||||||
|
value="```\nError displaying hardware statistics```",
|
||||||
|
inline=template.inline
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Dict, Optional, Set, Pattern
|
from typing import List, Dict, Optional, Set, Pattern, ClassVar
|
||||||
|
from datetime import datetime
|
||||||
import discord
|
import discord
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs, ParseResult
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
@@ -19,6 +20,11 @@ class URLPattern:
|
|||||||
supports_timestamp: bool = False
|
supports_timestamp: bool = False
|
||||||
supports_playlist: bool = False
|
supports_playlist: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate pattern after initialization"""
|
||||||
|
if not isinstance(self.pattern, Pattern):
|
||||||
|
raise ValueError("Pattern must be a compiled regular expression")
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class URLMetadata:
|
class URLMetadata:
|
||||||
"""Metadata about an extracted URL"""
|
"""Metadata about an extracted URL"""
|
||||||
@@ -28,6 +34,7 @@ class URLMetadata:
|
|||||||
playlist_id: Optional[str] = None
|
playlist_id: Optional[str] = None
|
||||||
video_id: Optional[str] = None
|
video_id: Optional[str] = None
|
||||||
quality: Optional[str] = None
|
quality: Optional[str] = None
|
||||||
|
extraction_time: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
||||||
|
|
||||||
class URLType(Enum):
|
class URLType(Enum):
|
||||||
"""Types of video URLs"""
|
"""Types of video URLs"""
|
||||||
@@ -38,84 +45,137 @@ class URLType(Enum):
|
|||||||
class URLPatternManager:
|
class URLPatternManager:
|
||||||
"""Manages URL patterns for different video sites"""
|
"""Manages URL patterns for different video sites"""
|
||||||
|
|
||||||
def __init__(self):
|
YOUTUBE_PATTERN: ClassVar[Pattern] = re.compile(
|
||||||
|
r'(?:https?://)?(?:www\.)?'
|
||||||
|
r'(?:youtube\.com/watch\?v=|youtu\.be/)'
|
||||||
|
r'([a-zA-Z0-9_-]{11})'
|
||||||
|
)
|
||||||
|
VIMEO_PATTERN: ClassVar[Pattern] = re.compile(
|
||||||
|
r'(?:https?://)?(?:www\.)?'
|
||||||
|
r'vimeo\.com/(?:channels/(?:\w+/)?|groups/(?:[^/]*/)*|)'
|
||||||
|
r'(\d+)(?:|/\w+)*'
|
||||||
|
)
|
||||||
|
TWITTER_PATTERN: ClassVar[Pattern] = re.compile(
|
||||||
|
r'(?:https?://)?(?:www\.)?'
|
||||||
|
r'(?:twitter\.com|x\.com)/\w+/status/(\d+)'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
self.patterns: Dict[str, URLPattern] = {
|
self.patterns: Dict[str, URLPattern] = {
|
||||||
"youtube": URLPattern(
|
"youtube": URLPattern(
|
||||||
site="youtube",
|
site="youtube",
|
||||||
pattern=re.compile(
|
pattern=self.YOUTUBE_PATTERN,
|
||||||
r'(?:https?://)?(?:www\.)?'
|
|
||||||
r'(?:youtube\.com/watch\?v=|youtu\.be/)'
|
|
||||||
r'([a-zA-Z0-9_-]{11})'
|
|
||||||
),
|
|
||||||
supports_timestamp=True,
|
supports_timestamp=True,
|
||||||
supports_playlist=True
|
supports_playlist=True
|
||||||
),
|
),
|
||||||
"vimeo": URLPattern(
|
"vimeo": URLPattern(
|
||||||
site="vimeo",
|
site="vimeo",
|
||||||
pattern=re.compile(
|
pattern=self.VIMEO_PATTERN,
|
||||||
r'(?:https?://)?(?:www\.)?'
|
|
||||||
r'vimeo\.com/(?:channels/(?:\w+/)?|groups/(?:[^/]*/)*|)'
|
|
||||||
r'(\d+)(?:|/\w+)*'
|
|
||||||
),
|
|
||||||
supports_timestamp=True
|
supports_timestamp=True
|
||||||
),
|
),
|
||||||
"twitter": URLPattern(
|
"twitter": URLPattern(
|
||||||
site="twitter",
|
site="twitter",
|
||||||
pattern=re.compile(
|
pattern=self.TWITTER_PATTERN,
|
||||||
r'(?:https?://)?(?:www\.)?'
|
|
||||||
r'(?:twitter\.com|x\.com)/\w+/status/(\d+)'
|
|
||||||
),
|
|
||||||
requires_api=True
|
requires_api=True
|
||||||
),
|
)
|
||||||
# Add more patterns as needed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.direct_extensions = {'.mp4', '.mov', '.avi', '.webm', '.mkv'}
|
self.direct_extensions: Set[str] = {'.mp4', '.mov', '.avi', '.webm', '.mkv'}
|
||||||
|
|
||||||
def get_pattern(self, site: str) -> Optional[URLPattern]:
|
def get_pattern(self, site: str) -> Optional[URLPattern]:
|
||||||
"""Get pattern for a site"""
|
"""
|
||||||
|
Get pattern for a site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site: Site identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URLPattern for the site or None if not found
|
||||||
|
"""
|
||||||
return self.patterns.get(site.lower())
|
return self.patterns.get(site.lower())
|
||||||
|
|
||||||
def is_supported_site(self, url: str, enabled_sites: Optional[List[str]]) -> bool:
|
def is_supported_site(self, url: str, enabled_sites: Optional[List[str]]) -> bool:
|
||||||
"""Check if URL is from a supported site"""
|
"""
|
||||||
|
Check if URL is from a supported site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to check
|
||||||
|
enabled_sites: List of enabled site identifiers
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if site is supported, False otherwise
|
||||||
|
"""
|
||||||
if not enabled_sites:
|
if not enabled_sites:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
parsed = urlparse(url.lower())
|
try:
|
||||||
domain = parsed.netloc.replace('www.', '')
|
parsed = urlparse(url.lower())
|
||||||
return any(site.lower() in domain for site in enabled_sites)
|
domain = parsed.netloc.replace('www.', '')
|
||||||
|
return any(site.lower() in domain for site in enabled_sites)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking site support for {url}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
class URLValidator:
|
class URLValidator:
|
||||||
"""Validates extracted URLs"""
|
"""Validates extracted URLs"""
|
||||||
|
|
||||||
def __init__(self, pattern_manager: URLPatternManager):
|
def __init__(self, pattern_manager: URLPatternManager) -> None:
|
||||||
self.pattern_manager = pattern_manager
|
self.pattern_manager = pattern_manager
|
||||||
|
|
||||||
def get_url_type(self, url: str) -> URLType:
|
def get_url_type(self, url: str) -> URLType:
|
||||||
"""Determine URL type"""
|
"""
|
||||||
parsed = urlparse(url)
|
Determine URL type.
|
||||||
if any(parsed.path.lower().endswith(ext) for ext in self.pattern_manager.direct_extensions):
|
|
||||||
return URLType.DIRECT
|
Args:
|
||||||
if any(pattern.pattern.match(url) for pattern in self.pattern_manager.patterns.values()):
|
url: URL to check
|
||||||
return URLType.PLATFORM
|
|
||||||
return URLType.UNKNOWN
|
Returns:
|
||||||
|
URLType indicating the type of URL
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if any(parsed.path.lower().endswith(ext) for ext in self.pattern_manager.direct_extensions):
|
||||||
|
return URLType.DIRECT
|
||||||
|
if any(pattern.pattern.match(url) for pattern in self.pattern_manager.patterns.values()):
|
||||||
|
return URLType.PLATFORM
|
||||||
|
return URLType.UNKNOWN
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error determining URL type for {url}: {e}")
|
||||||
|
return URLType.UNKNOWN
|
||||||
|
|
||||||
def is_valid_url(self, url: str) -> bool:
|
def is_valid_url(self, url: str) -> bool:
|
||||||
"""Validate URL format"""
|
"""
|
||||||
|
Validate URL format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if URL is valid, False otherwise
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = urlparse(url)
|
result = urlparse(url)
|
||||||
return all([result.scheme, result.netloc])
|
return all([result.scheme, result.netloc])
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.error(f"Error validating URL {url}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
class URLMetadataExtractor:
|
class URLMetadataExtractor:
|
||||||
"""Extracts metadata from URLs"""
|
"""Extracts metadata from URLs"""
|
||||||
|
|
||||||
def __init__(self, pattern_manager: URLPatternManager):
|
def __init__(self, pattern_manager: URLPatternManager) -> None:
|
||||||
self.pattern_manager = pattern_manager
|
self.pattern_manager = pattern_manager
|
||||||
|
|
||||||
def extract_metadata(self, url: str) -> Optional[URLMetadata]:
|
def extract_metadata(self, url: str) -> Optional[URLMetadata]:
|
||||||
"""Extract metadata from URL"""
|
"""
|
||||||
|
Extract metadata from URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to extract metadata from
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URLMetadata object or None if extraction fails
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
|
|
||||||
@@ -143,33 +203,41 @@ class URLMetadataExtractor:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error extracting metadata from URL {url}: {e}")
|
logger.error(f"Error extracting metadata from URL {url}: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_timestamp(self, parsed_url: urlparse) -> Optional[int]:
|
def _extract_timestamp(self, parsed_url: ParseResult) -> Optional[int]:
|
||||||
"""Extract timestamp from URL"""
|
"""Extract timestamp from URL"""
|
||||||
try:
|
try:
|
||||||
params = parse_qs(parsed_url.query)
|
params = parse_qs(parsed_url.query)
|
||||||
if 't' in params:
|
if 't' in params:
|
||||||
return int(params['t'][0])
|
return int(params['t'][0])
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except (ValueError, IndexError) as e:
|
||||||
|
logger.debug(f"Error extracting timestamp: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error extracting timestamp: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _extract_playlist_id(self, parsed_url: urlparse) -> Optional[str]:
|
def _extract_playlist_id(self, parsed_url: ParseResult) -> Optional[str]:
|
||||||
"""Extract playlist ID from URL"""
|
"""Extract playlist ID from URL"""
|
||||||
try:
|
try:
|
||||||
params = parse_qs(parsed_url.query)
|
params = parse_qs(parsed_url.query)
|
||||||
if 'list' in params:
|
if 'list' in params:
|
||||||
return params['list'][0]
|
return params['list'][0]
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except (KeyError, IndexError) as e:
|
||||||
|
logger.debug(f"Error extracting playlist ID: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error extracting playlist ID: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class URLExtractor:
|
class URLExtractor:
|
||||||
"""Handles extraction of video URLs from messages"""
|
"""Handles extraction of video URLs from messages"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.pattern_manager = URLPatternManager()
|
self.pattern_manager = URLPatternManager()
|
||||||
self.validator = URLValidator(self.pattern_manager)
|
self.validator = URLValidator(self.pattern_manager)
|
||||||
self.metadata_extractor = URLMetadataExtractor(self.pattern_manager)
|
self.metadata_extractor = URLMetadataExtractor(self.pattern_manager)
|
||||||
@@ -180,85 +248,113 @@ class URLExtractor:
|
|||||||
message: discord.Message,
|
message: discord.Message,
|
||||||
enabled_sites: Optional[List[str]] = None
|
enabled_sites: Optional[List[str]] = None
|
||||||
) -> List[URLMetadata]:
|
) -> List[URLMetadata]:
|
||||||
"""Extract video URLs from message content and attachments"""
|
"""
|
||||||
urls = []
|
Extract video URLs from message content and attachments.
|
||||||
|
|
||||||
# Check cache
|
Args:
|
||||||
cache_key = f"{message.id}_{'-'.join(enabled_sites) if enabled_sites else 'all'}"
|
message: Discord message to extract URLs from
|
||||||
if cache_key in self._url_cache:
|
enabled_sites: Optional list of enabled site identifiers
|
||||||
return [
|
|
||||||
self.metadata_extractor.extract_metadata(url)
|
Returns:
|
||||||
for url in self._url_cache[cache_key]
|
List of URLMetadata objects for extracted URLs
|
||||||
if url # Filter out None values
|
"""
|
||||||
]
|
urls: List[URLMetadata] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check cache
|
||||||
|
cache_key = f"{message.id}_{'-'.join(enabled_sites) if enabled_sites else 'all'}"
|
||||||
|
if cache_key in self._url_cache:
|
||||||
|
return [
|
||||||
|
metadata for url in self._url_cache[cache_key]
|
||||||
|
if (metadata := self.metadata_extractor.extract_metadata(url))
|
||||||
|
]
|
||||||
|
|
||||||
# Extract URLs
|
# Extract URLs
|
||||||
content_urls = await self._extract_from_content(message.content, enabled_sites)
|
content_urls = await self._extract_from_content(message.content, enabled_sites)
|
||||||
attachment_urls = await self._extract_from_attachments(message.attachments)
|
attachment_urls = await self._extract_from_attachments(message.attachments)
|
||||||
|
|
||||||
# Process all URLs
|
# Process all URLs
|
||||||
all_urls = content_urls + attachment_urls
|
all_urls = content_urls + attachment_urls
|
||||||
valid_urls = []
|
valid_urls: Set[str] = set()
|
||||||
|
|
||||||
for url in all_urls:
|
for url in all_urls:
|
||||||
if not self.validator.is_valid_url(url):
|
if not self.validator.is_valid_url(url):
|
||||||
logger.debug(f"Invalid URL format: {url}")
|
logger.debug(f"Invalid URL format: {url}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not self.pattern_manager.is_supported_site(url, enabled_sites):
|
if not self.pattern_manager.is_supported_site(url, enabled_sites):
|
||||||
logger.debug(f"URL {url} doesn't match any enabled sites")
|
logger.debug(f"URL {url} doesn't match any enabled sites")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
metadata = self.metadata_extractor.extract_metadata(url)
|
metadata = self.metadata_extractor.extract_metadata(url)
|
||||||
if metadata:
|
if metadata:
|
||||||
urls.append(metadata)
|
urls.append(metadata)
|
||||||
valid_urls.append(url)
|
valid_urls.add(url)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Could not extract metadata from URL: {url}")
|
logger.debug(f"Could not extract metadata from URL: {url}")
|
||||||
|
|
||||||
# Update cache
|
# Update cache
|
||||||
self._url_cache[cache_key] = set(valid_urls)
|
self._url_cache[cache_key] = valid_urls
|
||||||
|
|
||||||
return urls
|
return urls
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting URLs from message {message.id}: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
async def _extract_from_content(
|
async def _extract_from_content(
|
||||||
self,
|
self,
|
||||||
content: str,
|
content: Optional[str],
|
||||||
enabled_sites: Optional[List[str]]
|
enabled_sites: Optional[List[str]]
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Extract video URLs from message content"""
|
"""Extract video URLs from message content"""
|
||||||
if not content:
|
if not content:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
urls = []
|
try:
|
||||||
for word in content.split():
|
urls = []
|
||||||
if self.validator.get_url_type(word) != URLType.UNKNOWN:
|
for word in content.split():
|
||||||
urls.append(word)
|
if self.validator.get_url_type(word) != URLType.UNKNOWN:
|
||||||
|
urls.append(word)
|
||||||
return urls
|
return urls
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting URLs from content: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
async def _extract_from_attachments(
|
async def _extract_from_attachments(
|
||||||
self,
|
self,
|
||||||
attachments: List[discord.Attachment]
|
attachments: List[discord.Attachment]
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Extract video URLs from message attachments"""
|
"""Extract video URLs from message attachments"""
|
||||||
return [
|
try:
|
||||||
attachment.url
|
return [
|
||||||
for attachment in attachments
|
attachment.url
|
||||||
if any(
|
for attachment in attachments
|
||||||
attachment.filename.lower().endswith(ext)
|
if any(
|
||||||
for ext in self.pattern_manager.direct_extensions
|
attachment.filename.lower().endswith(ext)
|
||||||
)
|
for ext in self.pattern_manager.direct_extensions
|
||||||
]
|
)
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting URLs from attachments: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
def clear_cache(self, message_id: Optional[int] = None) -> None:
|
def clear_cache(self, message_id: Optional[int] = None) -> None:
|
||||||
"""Clear URL cache"""
|
"""
|
||||||
if message_id:
|
Clear URL cache.
|
||||||
keys_to_remove = [
|
|
||||||
key for key in self._url_cache
|
Args:
|
||||||
if key.startswith(f"{message_id}_")
|
message_id: Optional message ID to clear cache for. If None, clears all cache.
|
||||||
]
|
"""
|
||||||
for key in keys_to_remove:
|
try:
|
||||||
self._url_cache.pop(key, None)
|
if message_id:
|
||||||
else:
|
keys_to_remove = [
|
||||||
self._url_cache.clear()
|
key for key in self._url_cache
|
||||||
|
if key.startswith(f"{message_id}_")
|
||||||
|
]
|
||||||
|
for key in keys_to_remove:
|
||||||
|
self._url_cache.pop(key, None)
|
||||||
|
else:
|
||||||
|
self._url_cache.clear()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing URL cache: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Utility functions and classes for VideoArchiver"""
|
"""Utility functions and classes for VideoArchiver"""
|
||||||
|
|
||||||
|
from typing import Dict, Optional, Any, Union, List
|
||||||
|
|
||||||
from .file_ops import (
|
from .file_ops import (
|
||||||
cleanup_downloads,
|
cleanup_downloads,
|
||||||
ensure_directory,
|
ensure_directory,
|
||||||
@@ -12,16 +14,65 @@ from .directory_manager import DirectoryManager
|
|||||||
from .permission_manager import PermissionManager
|
from .permission_manager import PermissionManager
|
||||||
from .download_manager import DownloadManager
|
from .download_manager import DownloadManager
|
||||||
from .compression_manager import CompressionManager
|
from .compression_manager import CompressionManager
|
||||||
from .progress_tracker import ProgressTracker
|
from .progress_tracker import (
|
||||||
|
ProgressTracker,
|
||||||
|
ProgressStatus,
|
||||||
|
DownloadProgress,
|
||||||
|
CompressionProgress,
|
||||||
|
CompressionParams
|
||||||
|
)
|
||||||
from .path_manager import PathManager
|
from .path_manager import PathManager
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
|
# Base exception
|
||||||
|
VideoArchiverError,
|
||||||
|
ErrorSeverity,
|
||||||
|
ErrorContext,
|
||||||
|
|
||||||
|
# File operations
|
||||||
FileOperationError,
|
FileOperationError,
|
||||||
DirectoryError,
|
DirectoryError,
|
||||||
PermissionError,
|
PermissionError,
|
||||||
DownloadError,
|
FileCleanupError,
|
||||||
CompressionError,
|
|
||||||
TrackingError,
|
# Video operations
|
||||||
PathError
|
VideoDownloadError,
|
||||||
|
VideoProcessingError,
|
||||||
|
VideoVerificationError,
|
||||||
|
VideoUploadError,
|
||||||
|
VideoCleanupError,
|
||||||
|
|
||||||
|
# Resource management
|
||||||
|
ResourceError,
|
||||||
|
ResourceExhaustedError,
|
||||||
|
|
||||||
|
# Network and API
|
||||||
|
NetworkError,
|
||||||
|
DiscordAPIError,
|
||||||
|
|
||||||
|
# Component operations
|
||||||
|
ComponentError,
|
||||||
|
ConfigurationError,
|
||||||
|
DatabaseError,
|
||||||
|
FFmpegError,
|
||||||
|
|
||||||
|
# Queue operations
|
||||||
|
QueueError,
|
||||||
|
QueueHandlerError,
|
||||||
|
QueueProcessorError,
|
||||||
|
|
||||||
|
# Processing operations
|
||||||
|
ProcessingError,
|
||||||
|
ProcessorError,
|
||||||
|
ValidationError,
|
||||||
|
DisplayError,
|
||||||
|
URLExtractionError,
|
||||||
|
MessageHandlerError,
|
||||||
|
|
||||||
|
# Cleanup operations
|
||||||
|
CleanupError,
|
||||||
|
|
||||||
|
# Health monitoring
|
||||||
|
HealthCheckError
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -41,16 +92,75 @@ __all__ = [
|
|||||||
'ProgressTracker',
|
'ProgressTracker',
|
||||||
'PathManager',
|
'PathManager',
|
||||||
|
|
||||||
# Exceptions
|
# Progress Tracking Types
|
||||||
|
'ProgressStatus',
|
||||||
|
'DownloadProgress',
|
||||||
|
'CompressionProgress',
|
||||||
|
'CompressionParams',
|
||||||
|
|
||||||
|
# Base Exceptions
|
||||||
|
'VideoArchiverError',
|
||||||
|
'ErrorSeverity',
|
||||||
|
'ErrorContext',
|
||||||
|
|
||||||
|
# File Operation Exceptions
|
||||||
'FileOperationError',
|
'FileOperationError',
|
||||||
'DirectoryError',
|
'DirectoryError',
|
||||||
'PermissionError',
|
'PermissionError',
|
||||||
'DownloadError',
|
'FileCleanupError',
|
||||||
'CompressionError',
|
|
||||||
'TrackingError',
|
# Video Operation Exceptions
|
||||||
'PathError'
|
'VideoDownloadError',
|
||||||
|
'VideoProcessingError',
|
||||||
|
'VideoVerificationError',
|
||||||
|
'VideoUploadError',
|
||||||
|
'VideoCleanupError',
|
||||||
|
|
||||||
|
# Resource Exceptions
|
||||||
|
'ResourceError',
|
||||||
|
'ResourceExhaustedError',
|
||||||
|
|
||||||
|
# Network and API Exceptions
|
||||||
|
'NetworkError',
|
||||||
|
'DiscordAPIError',
|
||||||
|
|
||||||
|
# Component Exceptions
|
||||||
|
'ComponentError',
|
||||||
|
'ConfigurationError',
|
||||||
|
'DatabaseError',
|
||||||
|
'FFmpegError',
|
||||||
|
|
||||||
|
# Queue Exceptions
|
||||||
|
'QueueError',
|
||||||
|
'QueueHandlerError',
|
||||||
|
'QueueProcessorError',
|
||||||
|
|
||||||
|
# Processing Exceptions
|
||||||
|
'ProcessingError',
|
||||||
|
'ProcessorError',
|
||||||
|
'ValidationError',
|
||||||
|
'DisplayError',
|
||||||
|
'URLExtractionError',
|
||||||
|
'MessageHandlerError',
|
||||||
|
|
||||||
|
# Cleanup Exceptions
|
||||||
|
'CleanupError',
|
||||||
|
|
||||||
|
# Health Monitoring Exceptions
|
||||||
|
'HealthCheckError',
|
||||||
|
|
||||||
|
# Helper Functions
|
||||||
|
'get_download_progress',
|
||||||
|
'get_compression_progress',
|
||||||
|
'get_active_downloads',
|
||||||
|
'get_active_compressions'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Version information
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "VideoArchiver Team"
|
||||||
|
__description__ = "Utility functions and classes for VideoArchiver"
|
||||||
|
|
||||||
# Initialize shared instances for module-level access
|
# Initialize shared instances for module-level access
|
||||||
directory_manager = DirectoryManager()
|
directory_manager = DirectoryManager()
|
||||||
permission_manager = PermissionManager()
|
permission_manager = PermissionManager()
|
||||||
@@ -58,3 +168,93 @@ download_manager = DownloadManager()
|
|||||||
compression_manager = CompressionManager()
|
compression_manager = CompressionManager()
|
||||||
progress_tracker = ProgressTracker()
|
progress_tracker = ProgressTracker()
|
||||||
path_manager = PathManager()
|
path_manager = PathManager()
|
||||||
|
|
||||||
|
# Progress tracking helper functions
|
||||||
|
def get_download_progress(url: Optional[str] = None) -> Union[Dict[str, DownloadProgress], Optional[DownloadProgress]]:
|
||||||
|
"""
|
||||||
|
Get progress information for a download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Optional URL to get progress for. If None, returns all progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
If url is provided, returns progress for that URL or None if not found.
|
||||||
|
If url is None, returns dictionary of all download progress.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TrackingError: If there's an error getting progress information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return progress_tracker.get_download_progress(url)
|
||||||
|
except Exception as e:
|
||||||
|
raise TrackingError(f"Failed to get download progress: {str(e)}")
|
||||||
|
|
||||||
|
def get_compression_progress(input_file: Optional[str] = None) -> Union[Dict[str, CompressionProgress], Optional[CompressionProgress]]:
|
||||||
|
"""
|
||||||
|
Get progress information for a compression operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: Optional file to get progress for. If None, returns all progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
If input_file is provided, returns progress for that file or None if not found.
|
||||||
|
If input_file is None, returns dictionary of all compression progress.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TrackingError: If there's an error getting progress information
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return progress_tracker.get_compression_progress(input_file)
|
||||||
|
except Exception as e:
|
||||||
|
raise TrackingError(f"Failed to get compression progress: {str(e)}")
|
||||||
|
|
||||||
|
def get_active_downloads() -> Dict[str, DownloadProgress]:
|
||||||
|
"""
|
||||||
|
Get all active downloads.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping URLs to their download progress information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TrackingError: If there's an error getting active downloads
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return progress_tracker.get_active_downloads()
|
||||||
|
except Exception as e:
|
||||||
|
raise TrackingError(f"Failed to get active downloads: {str(e)}")
|
||||||
|
|
||||||
|
def get_active_compressions() -> Dict[str, CompressionProgress]:
|
||||||
|
"""
|
||||||
|
Get all active compression operations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping file paths to their compression progress information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TrackingError: If there's an error getting active compressions
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return progress_tracker.get_active_compressions()
|
||||||
|
except Exception as e:
|
||||||
|
raise TrackingError(f"Failed to get active compressions: {str(e)}")
|
||||||
|
|
||||||
|
# Error handling helper functions
|
||||||
|
def create_error_context(
|
||||||
|
component: str,
|
||||||
|
operation: str,
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
severity: ErrorSeverity = ErrorSeverity.MEDIUM
|
||||||
|
) -> ErrorContext:
|
||||||
|
"""
|
||||||
|
Create an error context object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
component: Component where error occurred
|
||||||
|
operation: Operation that failed
|
||||||
|
details: Optional error details
|
||||||
|
severity: Error severity level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ErrorContext object
|
||||||
|
"""
|
||||||
|
return ErrorContext(component, operation, details, severity)
|
||||||
|
|||||||
210
videoarchiver/utils/compression_handler.py
Normal file
210
videoarchiver/utils/compression_handler.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""Video compression handling utilities"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Optional, Callable, Set, Tuple
|
||||||
|
|
||||||
|
from videoarchiver.ffmpeg.ffmpeg_manager import FFmpegManager
|
||||||
|
from videoarchiver.ffmpeg.exceptions import CompressionError
|
||||||
|
from videoarchiver.utils.exceptions import VideoVerificationError
|
||||||
|
from videoarchiver.utils.file_operations import FileOperations
|
||||||
|
from videoarchiver.utils.progress_handler import ProgressHandler
|
||||||
|
|
||||||
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
|
class CompressionHandler:
|
||||||
|
"""Handles video compression operations"""
|
||||||
|
|
||||||
|
def __init__(self, ffmpeg_mgr: FFmpegManager, progress_handler: ProgressHandler,
|
||||||
|
file_ops: FileOperations):
|
||||||
|
self.ffmpeg_mgr = ffmpeg_mgr
|
||||||
|
self.progress_handler = progress_handler
|
||||||
|
self.file_ops = file_ops
|
||||||
|
self._active_processes: Set[subprocess.Popen] = set()
|
||||||
|
self._processes_lock = asyncio.Lock()
|
||||||
|
self._shutting_down = False
|
||||||
|
self.max_file_size = 0 # Will be set during compression
|
||||||
|
|
||||||
|
async def cleanup(self) -> None:
|
||||||
|
"""Clean up compression resources"""
|
||||||
|
self._shutting_down = True
|
||||||
|
try:
|
||||||
|
async with self._processes_lock:
|
||||||
|
for process in self._active_processes:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error killing compression process: {e}")
|
||||||
|
self._active_processes.clear()
|
||||||
|
finally:
|
||||||
|
self._shutting_down = False
|
||||||
|
|
||||||
|
async def compress_video(
|
||||||
|
self,
|
||||||
|
input_file: str,
|
||||||
|
output_file: str,
|
||||||
|
max_size_mb: int,
|
||||||
|
progress_callback: Optional[Callable[[float], None]] = None
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""Compress video to target size"""
|
||||||
|
if self._shutting_down:
|
||||||
|
return False, "Compression handler is shutting down"
|
||||||
|
|
||||||
|
self.max_file_size = max_size_mb
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get optimal compression parameters
|
||||||
|
compression_params = self.ffmpeg_mgr.get_compression_params(
|
||||||
|
input_file, max_size_mb
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try hardware acceleration first
|
||||||
|
success = await self._try_compression(
|
||||||
|
input_file,
|
||||||
|
output_file,
|
||||||
|
compression_params,
|
||||||
|
progress_callback,
|
||||||
|
use_hardware=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fall back to CPU if hardware acceleration fails
|
||||||
|
if not success:
|
||||||
|
logger.warning("Hardware acceleration failed, falling back to CPU encoding")
|
||||||
|
success = await self._try_compression(
|
||||||
|
input_file,
|
||||||
|
output_file,
|
||||||
|
compression_params,
|
||||||
|
progress_callback,
|
||||||
|
use_hardware=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return False, "Failed to compress with both hardware and CPU encoding"
|
||||||
|
|
||||||
|
# Verify compressed file
|
||||||
|
if not self.file_ops.verify_video_file(output_file, str(self.ffmpeg_mgr.get_ffprobe_path())):
|
||||||
|
return False, "Compressed file verification failed"
|
||||||
|
|
||||||
|
# Check final size
|
||||||
|
within_limit, final_size = self.file_ops.check_file_size(output_file, max_size_mb)
|
||||||
|
if not within_limit:
|
||||||
|
return False, f"Failed to compress to target size: {final_size} bytes"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
async def _try_compression(
|
||||||
|
self,
|
||||||
|
input_file: str,
|
||||||
|
output_file: str,
|
||||||
|
params: Dict[str, str],
|
||||||
|
progress_callback: Optional[Callable[[float], None]] = None,
|
||||||
|
use_hardware: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
"""Attempt video compression with given parameters"""
|
||||||
|
if self._shutting_down:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build FFmpeg command
|
||||||
|
ffmpeg_path = str(self.ffmpeg_mgr.get_ffmpeg_path())
|
||||||
|
cmd = [ffmpeg_path, "-y", "-i", input_file]
|
||||||
|
|
||||||
|
# Add progress monitoring
|
||||||
|
cmd.extend(["-progress", "pipe:1"])
|
||||||
|
|
||||||
|
# Modify parameters based on hardware acceleration preference
|
||||||
|
if use_hardware:
|
||||||
|
gpu_info = self.ffmpeg_mgr.gpu_info
|
||||||
|
if gpu_info["nvidia"] and params.get("c:v") == "libx264":
|
||||||
|
params["c:v"] = "h264_nvenc"
|
||||||
|
elif gpu_info["amd"] and params.get("c:v") == "libx264":
|
||||||
|
params["c:v"] = "h264_amf"
|
||||||
|
elif gpu_info["intel"] and params.get("c:v") == "libx264":
|
||||||
|
params["c:v"] = "h264_qsv"
|
||||||
|
else:
|
||||||
|
params["c:v"] = "libx264"
|
||||||
|
|
||||||
|
# Add all parameters to command
|
||||||
|
for key, value in params.items():
|
||||||
|
cmd.extend([f"-{key}", str(value)])
|
||||||
|
|
||||||
|
# Add output file
|
||||||
|
cmd.append(output_file)
|
||||||
|
|
||||||
|
# Get video duration for progress calculation
|
||||||
|
duration = self.file_ops.get_video_duration(input_file, str(self.ffmpeg_mgr.get_ffprobe_path()))
|
||||||
|
|
||||||
|
# Initialize compression progress
|
||||||
|
self.progress_handler.update(input_file, {
|
||||||
|
"active": True,
|
||||||
|
"filename": os.path.basename(input_file),
|
||||||
|
"start_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"percent": 0,
|
||||||
|
"elapsed_time": "0:00",
|
||||||
|
"input_size": os.path.getsize(input_file),
|
||||||
|
"current_size": 0,
|
||||||
|
"target_size": self.max_file_size * 1024 * 1024,
|
||||||
|
"codec": params.get("c:v", "unknown"),
|
||||||
|
"hardware_accel": use_hardware,
|
||||||
|
"preset": params.get("preset", "unknown"),
|
||||||
|
"crf": params.get("crf", "unknown"),
|
||||||
|
"duration": duration,
|
||||||
|
"bitrate": params.get("b:v", "unknown"),
|
||||||
|
"audio_codec": params.get("c:a", "unknown"),
|
||||||
|
"audio_bitrate": params.get("b:a", "unknown"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Run compression with progress monitoring
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track the process
|
||||||
|
async with self._processes_lock:
|
||||||
|
self._active_processes.add(process)
|
||||||
|
|
||||||
|
start_time = datetime.utcnow()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if self._shutting_down:
|
||||||
|
process.terminate()
|
||||||
|
return False
|
||||||
|
|
||||||
|
line = await process.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
line = line.decode().strip()
|
||||||
|
if line.startswith("out_time_ms="):
|
||||||
|
current_time = int(line.split("=")[1]) / 1000000
|
||||||
|
self.progress_handler.handle_compression_progress(
|
||||||
|
input_file, current_time, duration,
|
||||||
|
output_file, start_time, progress_callback
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing FFmpeg progress: {e}")
|
||||||
|
|
||||||
|
await process.wait()
|
||||||
|
return os.path.exists(output_file)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during compression process: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
# Remove process from tracking
|
||||||
|
async with self._processes_lock:
|
||||||
|
self._active_processes.discard(process)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Compression attempt failed: {str
|
||||||
271
videoarchiver/utils/download_core.py
Normal file
271
videoarchiver/utils/download_core.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"""Core download functionality for video archiver"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import yt_dlp
|
||||||
|
from typing import Dict, Optional, Callable, Tuple
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from videoarchiver.utils.url_validator import check_url_support
|
||||||
|
from videoarchiver.utils.progress_handler import ProgressHandler, CancellableYTDLLogger
|
||||||
|
from videoarchiver.utils.file_operations import FileOperations
|
||||||
|
from videoarchiver.utils.compression_handler import CompressionHandler
|
||||||
|
from videoarchiver.utils.process_manager import ProcessManager
|
||||||
|
from videoarchiver.ffmpeg.ffmpeg_manager import FFmpegManager
|
||||||
|
|
||||||
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
|
class DownloadCore:
|
||||||
|
"""Core download functionality for video archiver"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
download_path: str,
|
||||||
|
video_format: str,
|
||||||
|
max_quality: int,
|
||||||
|
max_file_size: int,
|
||||||
|
enabled_sites: Optional[list[str]] = None,
|
||||||
|
concurrent_downloads: int = 2,
|
||||||
|
ffmpeg_mgr: Optional[FFmpegManager] = None,
|
||||||
|
):
|
||||||
|
self.download_path = Path(download_path)
|
||||||
|
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.chmod(str(self.download_path), 0o755)
|
||||||
|
|
||||||
|
self.video_format = video_format
|
||||||
|
self.max_quality = max_quality
|
||||||
|
self.max_file_size = max_file_size
|
||||||
|
self.enabled_sites = enabled_sites
|
||||||
|
self.ffmpeg_mgr = ffmpeg_mgr or FFmpegManager()
|
||||||
|
|
||||||
|
# Initialize components
|
||||||
|
self.process_manager = ProcessManager(concurrent_downloads)
|
||||||
|
self.progress_handler = ProgressHandler()
|
||||||
|
self.file_ops = FileOperations()
|
||||||
|
self.compression_handler = CompressionHandler(
|
||||||
|
self.ffmpeg_mgr, self.progress_handler, self.file_ops
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create cancellable logger
|
||||||
|
self.ytdl_logger = CancellableYTDLLogger()
|
||||||
|
|
||||||
|
# Configure yt-dlp options
|
||||||
|
self.ydl_opts = self._configure_ydl_options()
|
||||||
|
|
||||||
|
def _configure_ydl_options(self) -> Dict:
|
||||||
|
"""Configure yt-dlp options"""
|
||||||
|
return {
|
||||||
|
"format": f"bv*[height<={self.max_quality}][ext=mp4]+ba[ext=m4a]/b[height<={self.max_quality}]/best",
|
||||||
|
"outtmpl": "%(title)s.%(ext)s",
|
||||||
|
"merge_output_format": self.video_format,
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
"extract_flat": True,
|
||||||
|
"concurrent_fragment_downloads": 1,
|
||||||
|
"retries": 5,
|
||||||
|
"fragment_retries": 5,
|
||||||
|
"file_access_retries": 3,
|
||||||
|
"extractor_retries": 5,
|
||||||
|
"postprocessor_hooks": [self._check_file_size],
|
||||||
|
"progress_hooks": [self._handle_progress],
|
||||||
|
"ffmpeg_location": str(self.ffmpeg_mgr.get_ffmpeg_path()),
|
||||||
|
"ffprobe_location": str(self.ffmpeg_mgr.get_ffprobe_path()),
|
||||||
|
"paths": {"home": str(self.download_path)},
|
||||||
|
"logger": self.ytdl_logger,
|
||||||
|
"ignoreerrors": True,
|
||||||
|
"no_color": True,
|
||||||
|
"geo_bypass": True,
|
||||||
|
"socket_timeout": 60,
|
||||||
|
"http_chunk_size": 1048576,
|
||||||
|
"external_downloader_args": {"ffmpeg": ["-timeout", "60000000"]},
|
||||||
|
"max_sleep_interval": 5,
|
||||||
|
"sleep_interval": 1,
|
||||||
|
"max_filesize": self.max_file_size * 1024 * 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _check_file_size(self, info: Dict) -> None:
|
||||||
|
"""Check if file size is within limits"""
|
||||||
|
if info.get("filepath") and os.path.exists(info["filepath"]):
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(info["filepath"])
|
||||||
|
if size > (self.max_file_size * 1024 * 1024):
|
||||||
|
logger.info(
|
||||||
|
f"File exceeds size limit, will compress: {info['filepath']}"
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Error checking file size: {str(e)}")
|
||||||
|
|
||||||
|
def _handle_progress(self, d: Dict) -> None:
|
||||||
|
"""Handle download progress updates"""
|
||||||
|
url = d.get("info_dict", {}).get("webpage_url", "unknown")
|
||||||
|
self.progress_handler.handle_download_progress(d, url)
|
||||||
|
|
||||||
|
def is_supported_url(self, url: str) -> bool:
|
||||||
|
"""Check if URL is supported"""
|
||||||
|
return check_url_support(url, self.ydl_opts, self.enabled_sites)
|
||||||
|
|
||||||
|
async def download_video(
|
||||||
|
self, url: str, progress_callback: Optional[Callable[[float], None]] = None
|
||||||
|
) -> Tuple[bool, str, str]:
|
||||||
|
"""Download and process a video"""
|
||||||
|
if self.process_manager.is_shutting_down:
|
||||||
|
return False, "", "Download manager is shutting down"
|
||||||
|
|
||||||
|
# Initialize progress tracking
|
||||||
|
self.progress_handler.initialize_progress(url)
|
||||||
|
original_file = None
|
||||||
|
compressed_file = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Download the video
|
||||||
|
success, file_path, error = await self._safe_download(
|
||||||
|
url, str(self.download_path), progress_callback
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
return False, "", error
|
||||||
|
|
||||||
|
original_file = file_path
|
||||||
|
await self.process_manager.track_download(url, original_file)
|
||||||
|
|
||||||
|
# Check file size and compress if needed
|
||||||
|
within_limit, file_size = self.file_ops.check_file_size(original_file, self.max_file_size)
|
||||||
|
if not within_limit:
|
||||||
|
logger.info(f"Compressing video: {original_file}")
|
||||||
|
try:
|
||||||
|
compressed_file = os.path.join(
|
||||||
|
self.download_path,
|
||||||
|
f"compressed_{os.path.basename(original_file)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attempt compression
|
||||||
|
success, error = await self.compression_handler.compress_video(
|
||||||
|
original_file,
|
||||||
|
compressed_file,
|
||||||
|
self.max_file_size,
|
||||||
|
progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
await self._cleanup_files(original_file, compressed_file)
|
||||||
|
return False, "", error
|
||||||
|
|
||||||
|
# Verify compressed file
|
||||||
|
if not self.file_ops.verify_video_file(
|
||||||
|
compressed_file,
|
||||||
|
str(self.ffmpeg_mgr.get_ffprobe_path())
|
||||||
|
):
|
||||||
|
await self._cleanup_files(original_file, compressed_file)
|
||||||
|
return False, "", "Compressed file verification failed"
|
||||||
|
|
||||||
|
# Delete original and return compressed
|
||||||
|
await self.file_ops.safe_delete_file(original_file)
|
||||||
|
return True, compressed_file, ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Compression failed: {str(e)}"
|
||||||
|
await self._cleanup_files(original_file, compressed_file)
|
||||||
|
return False, "", error_msg
|
||||||
|
else:
|
||||||
|
# Move file to final location if no compression needed
|
||||||
|
final_path = os.path.join(
|
||||||
|
self.download_path,
|
||||||
|
os.path.basename(original_file)
|
||||||
|
)
|
||||||
|
success = await self.file_ops.safe_move_file(original_file, final_path)
|
||||||
|
if not success:
|
||||||
|
await self._cleanup_files(original_file)
|
||||||
|
return False, "", "Failed to move file to final location"
|
||||||
|
return True, final_path, ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Download error: {str(e)}")
|
||||||
|
await self._cleanup_files(original_file, compressed_file)
|
||||||
|
return False, "", str(e)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up tracking
|
||||||
|
await self.process_manager.untrack_download(url)
|
||||||
|
self.progress_handler.complete(url)
|
||||||
|
|
||||||
|
async def _safe_download(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
output_dir: str,
|
||||||
|
progress_callback: Optional[Callable[[float], None]] = None,
|
||||||
|
) -> Tuple[bool, str, str]:
|
||||||
|
"""Safely download video with retries"""
|
||||||
|
if self.process_manager.is_shutting_down:
|
||||||
|
return False, "", "Download manager is shutting down"
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(5): # Max retries
|
||||||
|
try:
|
||||||
|
ydl_opts = self.ydl_opts.copy()
|
||||||
|
ydl_opts["outtmpl"] = os.path.join(output_dir, ydl_opts["outtmpl"])
|
||||||
|
|
||||||
|
# Add progress callback
|
||||||
|
if progress_callback:
|
||||||
|
original_progress_hook = ydl_opts["progress_hooks"][0]
|
||||||
|
|
||||||
|
def combined_progress_hook(d):
|
||||||
|
original_progress_hook(d)
|
||||||
|
if d["status"] == "downloading":
|
||||||
|
try:
|
||||||
|
percent = float(
|
||||||
|
d.get("_percent_str", "0").replace("%", "")
|
||||||
|
)
|
||||||
|
progress_callback(percent)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
|
ydl_opts["progress_hooks"] = [combined_progress_hook]
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
self.process_manager.download_pool,
|
||||||
|
lambda: ydl.extract_info(url, download=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
raise Exception("Failed to extract video information")
|
||||||
|
|
||||||
|
file_path = os.path.join(output_dir, ydl.prepare_filename(info))
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise FileNotFoundError("Download completed but file not found")
|
||||||
|
|
||||||
|
if not self.file_ops.verify_video_file(
|
||||||
|
file_path,
|
||||||
|
str(self.ffmpeg_mgr.get_ffprobe_path())
|
||||||
|
):
|
||||||
|
raise Exception("Downloaded file is not a valid video")
|
||||||
|
|
||||||
|
return True, file_path, ""
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
last_error = str(e)
|
||||||
|
logger.error(f"Download attempt {attempt + 1} failed: {str(e)}")
|
||||||
|
if attempt < 4: # Less than max retries
|
||||||
|
delay = 10 * (2**attempt) + (attempt * 2) # Exponential backoff
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
return False, "", f"All download attempts failed: {last_error}"
|
||||||
|
|
||||||
|
async def _cleanup_files(self, *files: str) -> None:
|
||||||
|
"""Clean up multiple files"""
|
||||||
|
for file in files:
|
||||||
|
if file and os.path.exists(file):
|
||||||
|
await self.file_ops.safe_delete_file(file)
|
||||||
|
|
||||||
|
async def cleanup(self) -> None:
|
||||||
|
"""Clean up resources"""
|
||||||
|
await self.process_manager.cleanup()
|
||||||
|
await self.compression_handler.cleanup()
|
||||||
|
|
||||||
|
async def force_cleanup(self) -> None:
|
||||||
|
"""Force cleanup of all resources"""
|
||||||
|
self.ytdl_logger.cancelled = True
|
||||||
|
await self.process_m
|
||||||
|
self.ytdl_logger.cancelled = True
|
||||||
|
await self.process_manager.force_cleanup()
|
||||||
|
await self.compress
|
||||||
@@ -1,8 +1,44 @@
|
|||||||
"""Custom exceptions for VideoArchiver"""
|
"""Custom exceptions for VideoArchiver"""
|
||||||
|
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
class ErrorSeverity(Enum):
|
||||||
|
"""Severity levels for errors"""
|
||||||
|
LOW = auto()
|
||||||
|
MEDIUM = auto()
|
||||||
|
HIGH = auto()
|
||||||
|
CRITICAL = auto()
|
||||||
|
|
||||||
|
class ErrorContext:
|
||||||
|
"""Context information for errors"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
component: str,
|
||||||
|
operation: str,
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
severity: ErrorSeverity = ErrorSeverity.MEDIUM
|
||||||
|
) -> None:
|
||||||
|
self.component = component
|
||||||
|
self.operation = operation
|
||||||
|
self.details = details or {}
|
||||||
|
self.severity = severity
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"[{self.severity.name}] {self.component}.{self.operation}: "
|
||||||
|
f"{', '.join(f'{k}={v}' for k, v in self.details.items())}"
|
||||||
|
)
|
||||||
|
|
||||||
class VideoArchiverError(Exception):
|
class VideoArchiverError(Exception):
|
||||||
"""Base exception for VideoArchiver errors"""
|
"""Base exception for VideoArchiver errors"""
|
||||||
pass
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
|
self.context = context
|
||||||
|
super().__init__(f"{context}: {message}" if context else message)
|
||||||
|
|
||||||
class VideoDownloadError(VideoArchiverError):
|
class VideoDownloadError(VideoArchiverError):
|
||||||
"""Error downloading video"""
|
"""Error downloading video"""
|
||||||
@@ -38,7 +74,17 @@ class PermissionError(VideoArchiverError):
|
|||||||
|
|
||||||
class NetworkError(VideoArchiverError):
|
class NetworkError(VideoArchiverError):
|
||||||
"""Error with network operations"""
|
"""Error with network operations"""
|
||||||
pass
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
url: Optional[str] = None,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
|
self.url = url
|
||||||
|
self.status_code = status_code
|
||||||
|
details = f" (URL: {url}" + (f", Status: {status_code})" if status_code else ")")
|
||||||
|
super().__init__(message + details, context)
|
||||||
|
|
||||||
class ResourceError(VideoArchiverError):
|
class ResourceError(VideoArchiverError):
|
||||||
"""Error with system resources"""
|
"""Error with system resources"""
|
||||||
@@ -54,15 +100,27 @@ class ComponentError(VideoArchiverError):
|
|||||||
|
|
||||||
class DiscordAPIError(VideoArchiverError):
|
class DiscordAPIError(VideoArchiverError):
|
||||||
"""Error with Discord API operations"""
|
"""Error with Discord API operations"""
|
||||||
def __init__(self, message: str, status_code: int = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
super().__init__(f"Discord API Error: {message} (Status: {status_code if status_code else 'Unknown'})")
|
details = f" (Status: {status_code})" if status_code else ""
|
||||||
|
super().__init__(f"Discord API Error: {message}{details}", context)
|
||||||
|
|
||||||
class ResourceExhaustedError(VideoArchiverError):
|
class ResourceExhaustedError(VideoArchiverError):
|
||||||
"""Error when system resources are exhausted"""
|
"""Error when system resources are exhausted"""
|
||||||
def __init__(self, message: str, resource_type: str = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
resource_type: Optional[str] = None,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
self.resource_type = resource_type
|
self.resource_type = resource_type
|
||||||
super().__init__(f"Resource exhausted: {message} (Type: {resource_type if resource_type else 'Unknown'})")
|
details = f" (Type: {resource_type})" if resource_type else ""
|
||||||
|
super().__init__(f"Resource exhausted: {message}{details}", context)
|
||||||
|
|
||||||
class ProcessingError(VideoArchiverError):
|
class ProcessingError(VideoArchiverError):
|
||||||
"""Error during video processing"""
|
"""Error during video processing"""
|
||||||
@@ -74,4 +132,126 @@ class CleanupError(VideoArchiverError):
|
|||||||
|
|
||||||
class FileOperationError(VideoArchiverError):
|
class FileOperationError(VideoArchiverError):
|
||||||
"""Error during file operations"""
|
"""Error during file operations"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
operation: Optional[str] = None,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.operation = operation
|
||||||
|
details = []
|
||||||
|
if path:
|
||||||
|
details.append(f"Path: {path}")
|
||||||
|
if operation:
|
||||||
|
details.append(f"Operation: {operation}")
|
||||||
|
details_str = f" ({', '.join(details)})" if details else ""
|
||||||
|
super().__init__(f"File operation error: {message}{details_str}", context)
|
||||||
|
|
||||||
|
# New exceptions for processor components
|
||||||
|
class ProcessorError(VideoArchiverError):
|
||||||
|
"""Error in video processor operations"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class ValidationError(VideoArchiverError):
|
||||||
|
"""Error in message or content validation"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DisplayError(VideoArchiverError):
|
||||||
|
"""Error in status display operations"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class URLExtractionError(VideoArchiverError):
|
||||||
|
"""Error extracting URLs from content"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
url: Optional[str] = None,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
|
self.url = url
|
||||||
|
details = f" (URL: {url})" if url else ""
|
||||||
|
super().__init__(f"URL extraction error: {message}{details}", context)
|
||||||
|
|
||||||
|
class MessageHandlerError(VideoArchiverError):
|
||||||
|
"""Error in message handling operations"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
message_id: Optional[int] = None,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
|
self.message_id = message_id
|
||||||
|
details = f" (Message ID: {message_id})" if message_id else ""
|
||||||
|
super().__init__(f"Message handler error: {message}{details}", context)
|
||||||
|
|
||||||
|
class QueueHandlerError(VideoArchiverError):
|
||||||
|
"""Error in queue handling operations"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class QueueProcessorError(VideoArchiverError):
|
||||||
|
"""Error in queue processing operations"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FFmpegError(VideoArchiverError):
|
||||||
|
"""Error in FFmpeg operations"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
command: Optional[str] = None,
|
||||||
|
exit_code: Optional[int] = None,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
|
self.command = command
|
||||||
|
self.exit_code = exit_code
|
||||||
|
details = []
|
||||||
|
if command:
|
||||||
|
details.append(f"Command: {command}")
|
||||||
|
if exit_code is not None:
|
||||||
|
details.append(f"Exit Code: {exit_code}")
|
||||||
|
details_str = f" ({', '.join(details)})" if details else ""
|
||||||
|
super().__init__(f"FFmpeg error: {message}{details_str}", context)
|
||||||
|
|
||||||
|
class DatabaseError(VideoArchiverError):
|
||||||
|
"""Error in database operations"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
query: Optional[str] = None,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
|
self.query = query
|
||||||
|
details = f" (Query: {query})" if query else ""
|
||||||
|
super().__init__(f"Database error: {message}{details}", context)
|
||||||
|
|
||||||
|
class HealthCheckError(VideoArchiverError):
|
||||||
|
"""Error in health check operations"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
component: Optional[str] = None,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
|
self.component = component
|
||||||
|
details = f" (Component: {component})" if component else ""
|
||||||
|
super().__init__(f"Health check error: {message}{details}", context)
|
||||||
|
|
||||||
|
class TrackingError(VideoArchiverError):
|
||||||
|
"""Error in progress tracking operations"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
operation: Optional[str] = None,
|
||||||
|
item_id: Optional[str] = None,
|
||||||
|
context: Optional[ErrorContext] = None
|
||||||
|
) -> None:
|
||||||
|
self.operation = operation
|
||||||
|
self.item_id = item_id
|
||||||
|
details = []
|
||||||
|
if operation:
|
||||||
|
details.append(f"Operation: {operation}")
|
||||||
|
if item_id:
|
||||||
|
details.append(f"Item ID: {item_id}")
|
||||||
|
details_str = f" ({', '.join(details)})" if details else ""
|
||||||
|
super().__init__(f"Progress tracking error: {message}{details_str}", context)
|
||||||
|
|||||||
138
videoarchiver/utils/file_operations.py
Normal file
138
videoarchiver/utils/file_operations.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Safe file operations with retry logic"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from typing import Tuple
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from videoarchiver.utils.exceptions import VideoVerificationError
|
||||||
|
from videoarchiver.utils.file_deletion import secure_delete_file
|
||||||
|
|
||||||
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
|
class FileOperations:
|
||||||
|
"""Handles safe file operations with retries"""
|
||||||
|
|
||||||
|
def __init__(self, max_retries: int = 3, retry_delay: int = 1):
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.retry_delay = retry_delay
|
||||||
|
|
||||||
|
async def safe_delete_file(self, file_path: str) -> bool:
|
||||||
|
"""Safely delete a file with retries"""
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
await secure_delete_file(file_path)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Delete attempt {attempt + 1} failed: {str(e)}")
|
||||||
|
if attempt == self.max_retries - 1:
|
||||||
|
return False
|
||||||
|
await asyncio.sleep(self.retry_delay * (attempt + 1))
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def safe_move_file(self, src: str, dst: str) -> bool:
|
||||||
|
"""Safely move a file with retries"""
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
|
shutil.move(src, dst)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Move attempt {attempt + 1} failed: {str(e)}")
|
||||||
|
if attempt == self.max_retries - 1:
|
||||||
|
return False
|
||||||
|
await asyncio.sleep(self.retry_delay * (attempt + 1))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def verify_video_file(self, file_path: str, ffprobe_path: str) -> bool:
|
||||||
|
"""Verify video file integrity"""
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
ffprobe_path,
|
||||||
|
"-v",
|
||||||
|
"quiet",
|
||||||
|
"-print_format",
|
||||||
|
"json",
|
||||||
|
"-show_format",
|
||||||
|
"-show_streams",
|
||||||
|
file_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise VideoVerificationError(f"FFprobe failed: {result.stderr}")
|
||||||
|
|
||||||
|
probe = json.loads(result.stdout)
|
||||||
|
|
||||||
|
# Verify video stream
|
||||||
|
video_streams = [s for s in probe["streams"] if s["codec_type"] == "video"]
|
||||||
|
if not video_streams:
|
||||||
|
raise VideoVerificationError("No video streams found")
|
||||||
|
|
||||||
|
# Verify duration
|
||||||
|
duration = float(probe["format"].get("duration", 0))
|
||||||
|
if duration <= 0:
|
||||||
|
raise VideoVerificationError("Invalid video duration")
|
||||||
|
|
||||||
|
# Verify file is readable
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
f.seek(0, 2)
|
||||||
|
if f.tell() == 0:
|
||||||
|
raise VideoVerificationError("Empty file")
|
||||||
|
except Exception as e:
|
||||||
|
raise VideoVerificationError(f"File read error: {str(e)}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error(f"FFprobe timed out for {file_path}")
|
||||||
|
return False
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error(f"Invalid FFprobe output for {file_path}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error verifying video file {file_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_video_duration(self, file_path: str, ffprobe_path: str) -> float:
|
||||||
|
"""Get video duration in seconds"""
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
ffprobe_path,
|
||||||
|
"-v",
|
||||||
|
"quiet",
|
||||||
|
"-print_format",
|
||||||
|
"json",
|
||||||
|
"-show_format",
|
||||||
|
file_path,
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise Exception(f"FFprobe failed: {result.stderr}")
|
||||||
|
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
return float(data["format"]["duration"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting video duration: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def check_file_size(self, file_path: str, max_size_mb: int) -> Tuple[bool, int]:
|
||||||
|
"""Check if file size is within limits"""
|
||||||
|
try:
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
size = os.path.getsize(file_path)
|
||||||
|
max_size = max_size_mb * 1024 * 1024
|
||||||
|
|
||||||
111
videoarchiver/utils/process_manager.py
Normal file
111
videoarchiver/utils/process_manager.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""Process management and cleanup utilities"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
from typing import Set, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
|
class ProcessManager:
|
||||||
|
"""Manages processes and resources for video operations"""
|
||||||
|
|
||||||
|
def __init__(self, concurrent_downloads: int = 2):
|
||||||
|
self._active_processes: Set[subprocess.Popen] = set()
|
||||||
|
self._processes_lock = asyncio.Lock()
|
||||||
|
self._shutting_down = False
|
||||||
|
|
||||||
|
# Create thread pool with proper naming
|
||||||
|
self.download_pool = ThreadPoolExecutor(
|
||||||
|
max_workers=max(1, min(3, concurrent_downloads)),
|
||||||
|
thread_name_prefix="videoarchiver_download"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track active downloads
|
||||||
|
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._downloads_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def cleanup(self) -> None:
|
||||||
|
"""Clean up resources with proper shutdown"""
|
||||||
|
self._shutting_down = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Kill any active processes
|
||||||
|
async with self._processes_lock:
|
||||||
|
for process in self._active_processes:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
await asyncio.sleep(0.1) # Give process time to terminate
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill() # Force kill if still running
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error killing process: {e}")
|
||||||
|
self._active_processes.clear()
|
||||||
|
|
||||||
|
# Clean up thread pool
|
||||||
|
self.download_pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
|
# Clean up active downloads
|
||||||
|
async with self._downloads_lock:
|
||||||
|
self.active_downloads.clear()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during process manager cleanup: {e}")
|
||||||
|
finally:
|
||||||
|
self._shutting_down = False
|
||||||
|
|
||||||
|
async def force_cleanup(self) -> None:
|
||||||
|
"""Force cleanup of all resources"""
|
||||||
|
try:
|
||||||
|
# Kill all processes immediately
|
||||||
|
async with self._processes_lock:
|
||||||
|
for process in self._active_processes:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error force killing process: {e}")
|
||||||
|
self._active_processes.clear()
|
||||||
|
|
||||||
|
# Force shutdown thread pool
|
||||||
|
self.download_pool.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
|
# Clear all tracking
|
||||||
|
async with self._downloads_lock:
|
||||||
|
self.active_downloads.clear()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during force cleanup: {e}")
|
||||||
|
|
||||||
|
async def track_download(self, url: str, file_path: str) -> None:
|
||||||
|
"""Track a new download"""
|
||||||
|
async with self._downloads_lock:
|
||||||
|
self.active_downloads[url] = {
|
||||||
|
"file_path": file_path,
|
||||||
|
"start_time": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def untrack_download(self, url: str) -> None:
|
||||||
|
"""Remove download from tracking"""
|
||||||
|
async with self._downloads_lock:
|
||||||
|
self.active_downloads.pop(url, None)
|
||||||
|
|
||||||
|
async def track_process(self, process: subprocess.Popen) -> None:
|
||||||
|
"""Track a new process"""
|
||||||
|
async with self._processes_lock:
|
||||||
|
self._active_processes.add(process)
|
||||||
|
|
||||||
|
async def untrack_process(self, process: subprocess.Popen) -> None:
|
||||||
|
"""Remove process from tracking"""
|
||||||
|
async with self._processes_lock:
|
||||||
|
self._active_processes.discard(process)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_shutting_down(self) -> bool:
|
||||||
|
"""Check if manager is shutting down"""
|
||||||
|
return self._shutting_down
|
||||||
|
|
||||||
|
def get_active_downloads(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Get current active downloads"""
|
||||||
|
return self.acti
|
||||||
126
videoarchiver/utils/progress_handler.py
Normal file
126
videoarchiver/utils/progress_handler.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Progress tracking and logging utilities for video downloads"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, Optional, Callable
|
||||||
|
|
||||||
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
|
class CancellableYTDLLogger:
|
||||||
|
"""Custom yt-dlp logger that can handle cancellation"""
|
||||||
|
def __init__(self):
|
||||||
|
self.cancelled = False
|
||||||
|
|
||||||
|
def debug(self, msg):
|
||||||
|
if self.cancelled:
|
||||||
|
raise yt_dlp.utils.DownloadError("Download cancelled")
|
||||||
|
logger.debug(msg)
|
||||||
|
|
||||||
|
def warning(self, msg):
|
||||||
|
if self.cancelled:
|
||||||
|
raise yt_dlp.utils.DownloadError("Download cancelled")
|
||||||
|
logger.warning(msg)
|
||||||
|
|
||||||
|
def error(self, msg):
|
||||||
|
if self.cancelled:
|
||||||
|
raise yt_dlp.utils.DownloadError("Download cancelled")
|
||||||
|
logger.error(msg)
|
||||||
|
|
||||||
|
class ProgressHandler:
|
||||||
|
"""Handles progress tracking and callbacks for video operations"""
|
||||||
|
def __init__(self):
|
||||||
|
self.progress_data: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def initialize_progress(self, url: str) -> None:
|
||||||
|
"""Initialize progress tracking for a URL"""
|
||||||
|
self.progress_data[url] = {
|
||||||
|
"active": True,
|
||||||
|
"start_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"percent": 0,
|
||||||
|
"speed": "N/A",
|
||||||
|
"eta": "N/A",
|
||||||
|
"downloaded_bytes": 0,
|
||||||
|
"total_bytes": 0,
|
||||||
|
"retries": 0,
|
||||||
|
"fragment_count": 0,
|
||||||
|
"fragment_index": 0,
|
||||||
|
"video_title": "Unknown",
|
||||||
|
"extractor": "Unknown",
|
||||||
|
"format": "Unknown",
|
||||||
|
"resolution": "Unknown",
|
||||||
|
"fps": "Unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
def update(self, key: str, data: Dict[str, Any]) -> None:
|
||||||
|
"""Update progress data for a key"""
|
||||||
|
if key in self.progress_data:
|
||||||
|
self.progress_data[key].update(data)
|
||||||
|
|
||||||
|
def complete(self, key: str) -> None:
|
||||||
|
"""Mark progress as complete for a key"""
|
||||||
|
if key in self.progress_data:
|
||||||
|
self.progress_data[key]["active"] = False
|
||||||
|
self.progress_data[key]["percent"] = 100
|
||||||
|
|
||||||
|
def get_progress(self, key: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get progress data for a key"""
|
||||||
|
return self.progress_data.get(key)
|
||||||
|
|
||||||
|
def handle_download_progress(self, d: Dict[str, Any], url: str,
|
||||||
|
progress_callback: Optional[Callable[[float], None]] = None) -> None:
|
||||||
|
"""Handle download progress updates"""
|
||||||
|
try:
|
||||||
|
if d["status"] == "downloading":
|
||||||
|
progress_data = {
|
||||||
|
"active": True,
|
||||||
|
"percent": float(d.get("_percent_str", "0").replace("%", "")),
|
||||||
|
"speed": d.get("_speed_str", "N/A"),
|
||||||
|
"eta": d.get("_eta_str", "N/A"),
|
||||||
|
"downloaded_bytes": d.get("downloaded_bytes", 0),
|
||||||
|
"total_bytes": d.get("total_bytes", 0) or d.get("total_bytes_estimate", 0),
|
||||||
|
"retries": d.get("retry_count", 0),
|
||||||
|
"fragment_count": d.get("fragment_count", 0),
|
||||||
|
"fragment_index": d.get("fragment_index", 0),
|
||||||
|
"video_title": d.get("info_dict", {}).get("title", "Unknown"),
|
||||||
|
"extractor": d.get("info_dict", {}).get("extractor", "Unknown"),
|
||||||
|
"format": d.get("info_dict", {}).get("format", "Unknown"),
|
||||||
|
"resolution": d.get("info_dict", {}).get("resolution", "Unknown"),
|
||||||
|
"fps": d.get("info_dict", {}).get("fps", "Unknown"),
|
||||||
|
}
|
||||||
|
self.update(url, progress_data)
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(progress_data["percent"])
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Download progress: {progress_data['percent']}% at {progress_data['speed']}, "
|
||||||
|
f"ETA: {progress_data['eta']}, Downloaded: {progress_data['downloaded_bytes']}/"
|
||||||
|
f"{progress_data['total_bytes']} bytes"
|
||||||
|
)
|
||||||
|
elif d["status"] == "finished":
|
||||||
|
logger.info(f"Download completed: {d.get('filename', 'unknown')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in progress handler: {str(e)}")
|
||||||
|
|
||||||
|
def handle_compression_progress(self, input_file: str, current_time: float, duration: float,
|
||||||
|
output_file: str, start_time: datetime,
|
||||||
|
progress_callback: Optional[Callable[[float], None]] = None) -> None:
|
||||||
|
"""Handle compression progress updates"""
|
||||||
|
try:
|
||||||
|
if duration > 0:
|
||||||
|
progress = min(100, (current_time / duration) * 100)
|
||||||
|
elapsed = datetime.utcnow() - start_time
|
||||||
|
|
||||||
|
self.update(input_file, {
|
||||||
|
"percent": progress,
|
||||||
|
"elapsed_time": str(elapsed).split(".")[0],
|
||||||
|
"current_size": os.path.getsize(output_file) if os.path.exists(output_file) else 0,
|
||||||
|
"current_time": current_time,
|
||||||
|
})
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(progress)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error upda
|
||||||
@@ -1,109 +1,205 @@
|
|||||||
"""Module for tracking download and compression progress"""
|
"""Progress tracking module."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
logger = logging.getLogger("ProgressTracker")
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class ProgressTracker:
|
class ProgressTracker:
|
||||||
"""Tracks progress of downloads and compression operations"""
|
"""Progress tracker singleton."""
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._download_progress: Dict[str, Dict[str, Any]] = {}
|
if not hasattr(self, '_initialized'):
|
||||||
self._compression_progress: Dict[str, Dict[str, Any]] = {}
|
self._data: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
def start_download(self, url: str) -> None:
|
def update(self, key: str, data: Dict[str, Any]) -> None:
|
||||||
"""Initialize progress tracking for a download"""
|
"""Update progress for a key."""
|
||||||
self._download_progress[url] = {
|
if key not in self._data:
|
||||||
"active": True,
|
self._data[key] = {
|
||||||
"start_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
'active': True,
|
||||||
"percent": 0,
|
'start_time': datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"speed": "N/A",
|
'percent': 0
|
||||||
"eta": "N/A",
|
}
|
||||||
"downloaded_bytes": 0,
|
self._data[key].update(data)
|
||||||
"total_bytes": 0,
|
self._data[key]['last_update'] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
"retries": 0,
|
logger.debug(f"Progress for {key}: {self._data[key].get('percent', 0)}%")
|
||||||
"fragment_count": 0,
|
|
||||||
"fragment_index": 0,
|
|
||||||
"video_title": "Unknown",
|
|
||||||
"extractor": "Unknown",
|
|
||||||
"format": "Unknown",
|
|
||||||
"resolution": "Unknown",
|
|
||||||
"fps": "Unknown",
|
|
||||||
"last_update": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def update_download_progress(self, data: Dict[str, Any]) -> None:
|
def get(self, key: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Update download progress information"""
|
"""Get progress for a key."""
|
||||||
|
if key is None:
|
||||||
|
return self._data
|
||||||
|
return self._data.get(key, {})
|
||||||
|
|
||||||
|
def complete(self, key: str) -> None:
|
||||||
|
"""Mark progress as complete."""
|
||||||
|
if key in self._data:
|
||||||
|
self._data[key]['active'] = False
|
||||||
|
logger.info(f"Operation completed for {key}")
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all progress data."""
|
||||||
|
self._data.clear()
|
||||||
|
logger.info("Progress data cleared")
|
||||||
|
|
||||||
|
_tracker = ProgressTracker()
|
||||||
|
|
||||||
|
def get_compression(self, file_path: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Get compression progress."""
|
||||||
|
if file_path is None:
|
||||||
|
return self._compressions
|
||||||
|
return self._compressions.get(file_path, {})
|
||||||
|
|
||||||
|
def complete_download(self, url: str) -> None:
|
||||||
|
"""Mark download as complete."""
|
||||||
|
if url in self._downloads:
|
||||||
|
self._downloads[url]['active'] = False
|
||||||
|
logger.info(f"Download completed for {url}")
|
||||||
|
|
||||||
|
def complete_compression(self, file_path: str) -> None:
|
||||||
|
"""Mark compression as complete."""
|
||||||
|
if file_path in self._compressions:
|
||||||
|
self._compressions[file_path]['active'] = False
|
||||||
|
logger.info(f"Compression completed for {file_path}")
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all progress data."""
|
||||||
|
self._downloads.clear()
|
||||||
|
self._compressions.clear()
|
||||||
|
logger.info("Progress data cleared")
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
_tracker = ProgressTrack
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
_tracker = ProgressTracker()
|
||||||
|
|
||||||
|
def get_tracker() -> Progre
|
||||||
|
"""Clear all progress tracking"""
|
||||||
|
self._download_progress.clear()
|
||||||
|
self._compression_progress.clear()
|
||||||
|
logger.info("Cleared all progress tracking data")
|
||||||
|
|
||||||
|
# Create singleton instance
|
||||||
|
progress_tracker = ProgressTracker()
|
||||||
|
|
||||||
|
def get_progress_tracker() -> ProgressTracker:
|
||||||
|
|
||||||
|
def mark_compression_complete(self, file_path: str) -> None:
|
||||||
|
"""Mark a compression operation as complete"""
|
||||||
|
if file_path in self._compression_progress:
|
||||||
|
self._compression_progress[file_path]['active'] = False
|
||||||
|
logger.info(f"Compression completed for {file_path}")
|
||||||
|
|
||||||
|
def clear_progress(self) -> None:
|
||||||
|
"""Clear all progress tracking"""
|
||||||
|
self._download_progress.clear()
|
||||||
|
self._compression_progress.clear()
|
||||||
|
logger.info("Cleared all progress tracking data")
|
||||||
|
|
||||||
|
# Create singleton instance
|
||||||
|
progress_tracker = ProgressTracker()
|
||||||
|
|
||||||
|
# Export the singleton instance
|
||||||
|
def get_progress_tracker() -> ProgressTracker:
|
||||||
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary containing download progress data
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get URL from info dict
|
info_dict = data.get("info_dict", {})
|
||||||
url = data.get("info_dict", {}).get("webpage_url", "unknown")
|
url = info_dict.get("webpage_url")
|
||||||
if url not in self._download_progress:
|
if not url or url not in self._download_progress:
|
||||||
return
|
return
|
||||||
|
|
||||||
if data["status"] == "downloading":
|
if data.get("status") == "downloading":
|
||||||
|
percent_str = data.get("_percent_str", "0").replace("%", "")
|
||||||
|
try:
|
||||||
|
percent = float(percent_str)
|
||||||
|
except ValueError:
|
||||||
|
percent = 0.0
|
||||||
|
|
||||||
|
total_bytes = (
|
||||||
|
data.get("total_bytes", 0) or
|
||||||
|
data.get("total_bytes_estimate", 0)
|
||||||
|
)
|
||||||
|
|
||||||
self._download_progress[url].update({
|
self._download_progress[url].update({
|
||||||
"active": True,
|
"active": True,
|
||||||
"percent": float(data.get("_percent_str", "0").replace("%", "")),
|
"percent": percent,
|
||||||
"speed": data.get("_speed_str", "N/A"),
|
"speed": data.get("_speed_str", "N/A"),
|
||||||
"eta": data.get("_eta_str", "N/A"),
|
"eta": data.get("_eta_str", "N/A"),
|
||||||
"downloaded_bytes": data.get("downloaded_bytes", 0),
|
"downloaded_bytes": data.get("downloaded_bytes", 0),
|
||||||
"total_bytes": data.get("total_bytes", 0) or data.get("total_bytes_estimate", 0),
|
"total_bytes": total_bytes,
|
||||||
"retries": data.get("retry_count", 0),
|
"retries": data.get("retry_count", 0),
|
||||||
"fragment_count": data.get("fragment_count", 0),
|
"fragment_count": data.get("fragment_count", 0),
|
||||||
"fragment_index": data.get("fragment_index", 0),
|
"fragment_index": data.get("fragment_index", 0),
|
||||||
"video_title": data.get("info_dict", {}).get("title", "Unknown"),
|
"video_title": info_dict.get("title", "Unknown"),
|
||||||
"extractor": data.get("info_dict", {}).get("extractor", "Unknown"),
|
"extractor": info_dict.get("extractor", "Unknown"),
|
||||||
"format": data.get("info_dict", {}).get("format", "Unknown"),
|
"format": info_dict.get("format", "Unknown"),
|
||||||
"resolution": data.get("info_dict", {}).get("resolution", "Unknown"),
|
"resolution": info_dict.get("resolution", "Unknown"),
|
||||||
"fps": data.get("info_dict", {}).get("fps", "Unknown"),
|
"fps": info_dict.get("fps", "Unknown"),
|
||||||
"last_update": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
"last_update": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Download progress for {url}: "
|
f"Download progress for {url}: "
|
||||||
f"{self._download_progress[url]['percent']}% at {self._download_progress[url]['speed']}, "
|
f"{percent:.1f}% at {self._download_progress[url]['speed']}, "
|
||||||
f"ETA: {self._download_progress[url]['eta']}"
|
f"ETA: {self._download_progress[url]['eta']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating download progress: {e}")
|
logger.error(f"Error updating download progress: {e}", exc_info=True)
|
||||||
|
|
||||||
def end_download(self, url: str) -> None:
|
def end_download(self, url: str, status: ProgressStatus = ProgressStatus.COMPLETED) -> None:
|
||||||
"""Mark a download as completed"""
|
"""
|
||||||
|
Mark a download as completed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL being downloaded
|
||||||
|
status: The final status of the download
|
||||||
|
"""
|
||||||
if url in self._download_progress:
|
if url in self._download_progress:
|
||||||
self._download_progress[url]["active"] = False
|
self._download_progress[url]["active"] = False
|
||||||
|
logger.info(f"Download {status.value} for {url}")
|
||||||
|
|
||||||
def start_compression(
|
def start_compression(self, params: CompressionParams) -> None:
|
||||||
self,
|
"""
|
||||||
input_file: str,
|
Initialize progress tracking for compression.
|
||||||
params: Dict[str, str],
|
|
||||||
use_hardware: bool,
|
Args:
|
||||||
duration: float,
|
params: Compression parameters
|
||||||
input_size: int,
|
"""
|
||||||
target_size: int
|
current_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
) -> None:
|
self._compression_progress[params.input_file] = CompressionProgress(
|
||||||
"""Initialize progress tracking for compression"""
|
active=True,
|
||||||
self._compression_progress[input_file] = {
|
filename=params.input_file,
|
||||||
"active": True,
|
start_time=current_time,
|
||||||
"filename": input_file,
|
percent=0.0,
|
||||||
"start_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
elapsed_time="0:00",
|
||||||
"percent": 0,
|
input_size=params.input_size,
|
||||||
"elapsed_time": "0:00",
|
current_size=0,
|
||||||
"input_size": input_size,
|
target_size=params.target_size,
|
||||||
"current_size": 0,
|
codec=params.codec_params.get("c:v", "unknown"),
|
||||||
"target_size": target_size,
|
hardware_accel=params.use_hardware,
|
||||||
"codec": params.get("c:v", "unknown"),
|
preset=params.codec_params.get("preset", "unknown"),
|
||||||
"hardware_accel": use_hardware,
|
crf=params.codec_params.get("crf", "unknown"),
|
||||||
"preset": params.get("preset", "unknown"),
|
duration=params.duration,
|
||||||
"crf": params.get("crf", "unknown"),
|
bitrate=params.codec_params.get("b:v", "unknown"),
|
||||||
"duration": duration,
|
audio_codec=params.codec_params.get("c:a", "unknown"),
|
||||||
"bitrate": params.get("b:v", "unknown"),
|
audio_bitrate=params.codec_params.get("b:a", "unknown"),
|
||||||
"audio_codec": params.get("c:a", "unknown"),
|
last_update=current_time,
|
||||||
"audio_bitrate": params.get("b:a", "unknown"),
|
current_time=None
|
||||||
"last_update": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
)
|
||||||
}
|
|
||||||
|
|
||||||
def update_compression_progress(
|
def update_compression_progress(
|
||||||
self,
|
self,
|
||||||
@@ -113,14 +209,23 @@ class ProgressTracker:
|
|||||||
current_size: int,
|
current_size: int,
|
||||||
current_time: float
|
current_time: float
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update compression progress information"""
|
"""
|
||||||
|
Update compression progress information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: The input file being compressed
|
||||||
|
progress: Current progress percentage (0-100)
|
||||||
|
elapsed_time: Time elapsed as string
|
||||||
|
current_size: Current file size in bytes
|
||||||
|
current_time: Current timestamp in seconds
|
||||||
|
"""
|
||||||
if input_file in self._compression_progress:
|
if input_file in self._compression_progress:
|
||||||
self._compression_progress[input_file].update({
|
self._compression_progress[input_file].update({
|
||||||
"percent": progress,
|
"percent": max(0.0, min(100.0, progress)),
|
||||||
"elapsed_time": elapsed_time,
|
"elapsed_time": elapsed_time,
|
||||||
"current_size": current_size,
|
"current_size": current_size,
|
||||||
"current_time": current_time,
|
"current_time": current_time,
|
||||||
"last_update": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
"last_update": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -128,29 +233,73 @@ class ProgressTracker:
|
|||||||
f"{progress:.1f}%, Size: {current_size}/{self._compression_progress[input_file]['target_size']} bytes"
|
f"{progress:.1f}%, Size: {current_size}/{self._compression_progress[input_file]['target_size']} bytes"
|
||||||
)
|
)
|
||||||
|
|
||||||
def end_compression(self, input_file: str) -> None:
|
def end_compression(
|
||||||
"""Mark a compression operation as completed"""
|
self,
|
||||||
|
input_file: str,
|
||||||
|
status: ProgressStatus = ProgressStatus.COMPLETED
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Mark a compression operation as completed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: The input file being compressed
|
||||||
|
status: The final status of the compression
|
||||||
|
"""
|
||||||
if input_file in self._compression_progress:
|
if input_file in self._compression_progress:
|
||||||
self._compression_progress[input_file]["active"] = False
|
self._compression_progress[input_file]["active"] = False
|
||||||
|
logger.info(f"Compression {status.value} for {input_file}")
|
||||||
|
|
||||||
def get_download_progress(self, url: str) -> Optional[Dict[str, Any]]:
|
def get_download_progress(self, url: Optional[str] = None) -> Optional[DownloadProgress]:
|
||||||
"""Get progress information for a download"""
|
"""
|
||||||
|
Get progress information for a download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Optional URL to get progress for. If None, returns all progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Progress information for the specified download or None if not found
|
||||||
|
"""
|
||||||
|
if url is None:
|
||||||
|
return self._download_progress
|
||||||
return self._download_progress.get(url)
|
return self._download_progress.get(url)
|
||||||
|
|
||||||
def get_compression_progress(self, input_file: str) -> Optional[Dict[str, Any]]:
|
def get_compression_progress(
|
||||||
"""Get progress information for a compression operation"""
|
self,
|
||||||
|
input_file: Optional[str] = None
|
||||||
|
) -> Optional[CompressionProgress]:
|
||||||
|
"""
|
||||||
|
Get progress information for a compression operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: Optional file to get progress for. If None, returns all progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Progress information for the specified compression or None if not found
|
||||||
|
"""
|
||||||
|
if input_file is None:
|
||||||
|
return self._compression_progress
|
||||||
return self._compression_progress.get(input_file)
|
return self._compression_progress.get(input_file)
|
||||||
|
|
||||||
def get_active_downloads(self) -> Dict[str, Dict[str, Any]]:
|
def get_active_downloads(self) -> Dict[str, DownloadProgress]:
|
||||||
"""Get all active downloads"""
|
"""
|
||||||
|
Get all active downloads.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of active downloads and their progress
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
url: progress
|
url: progress
|
||||||
for url, progress in self._download_progress.items()
|
for url, progress in self._download_progress.items()
|
||||||
if progress.get("active", False)
|
if progress.get("active", False)
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_active_compressions(self) -> Dict[str, Dict[str, Any]]:
|
def get_active_compressions(self) -> Dict[str, CompressionProgress]:
|
||||||
"""Get all active compression operations"""
|
"""
|
||||||
|
Get all active compression operations.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of active compressions and their progress
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
input_file: progress
|
input_file: progress
|
||||||
for input_file, progress in self._compression_progress.items()
|
for input_file, progress in self._compression_progress.items()
|
||||||
@@ -161,3 +310,4 @@ class ProgressTracker:
|
|||||||
"""Clear all progress tracking"""
|
"""Clear all progress tracking"""
|
||||||
self._download_progress.clear()
|
self._download_progress.clear()
|
||||||
self._compression_progress.clear()
|
self._compression_progress.clear()
|
||||||
|
logger.info("Cleared
|
||||||
76
videoarchiver/utils/url_validator.py
Normal file
76
videoarchiver/utils/url_validator.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""URL validation utilities for video downloads"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import yt_dlp
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("VideoArchiver")
|
||||||
|
|
||||||
|
def is_video_url_pattern(url: str) -> bool:
|
||||||
|
"""Check if URL matches common video platform patterns"""
|
||||||
|
video_patterns = [
|
||||||
|
r"youtube\.com/watch\?v=",
|
||||||
|
r"youtu\.be/",
|
||||||
|
r"vimeo\.com/",
|
||||||
|
r"tiktok\.com/",
|
||||||
|
r"twitter\.com/.*/video/",
|
||||||
|
r"x\.com/.*/video/",
|
||||||
|
r"bsky\.app/",
|
||||||
|
r"facebook\.com/.*/videos/",
|
||||||
|
r"instagram\.com/.*/(tv|reel|p)/",
|
||||||
|
r"twitch\.tv/.*/clip/",
|
||||||
|
r"streamable\.com/",
|
||||||
|
r"v\.redd\.it/",
|
||||||
|
r"clips\.twitch\.tv/",
|
||||||
|
r"dailymotion\.com/video/",
|
||||||
|
r"\.mp4$",
|
||||||
|
r"\.webm$",
|
||||||
|
r"\.mov$",
|
||||||
|
]
|
||||||
|
return any(re.search(pattern, url, re.IGNORECASE) for pattern in video_patterns)
|
||||||
|
|
||||||
|
def check_url_support(url: str, ydl_opts: dict, enabled_sites: Optional[List[str]] = None) -> bool:
|
||||||
|
"""Check if URL is supported by attempting a simulated download"""
|
||||||
|
if not is_video_url_pattern(url):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
simulate_opts = {
|
||||||
|
**ydl_opts,
|
||||||
|
"simulate": True,
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
"extract_flat": True,
|
||||||
|
"skip_download": True,
|
||||||
|
"format": "best",
|
||||||
|
}
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(simulate_opts) as ydl:
|
||||||
|
try:
|
||||||
|
info = ydl.extract_info(url, download=False)
|
||||||
|
if info is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if enabled_sites:
|
||||||
|
extractor = info.get("extractor", "").lower()
|
||||||
|
if not any(
|
||||||
|
site.lower() in extractor for site in enabled_sites
|
||||||
|
):
|
||||||
|
logger.info(f"Site {extractor} not in enabled sites list")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"URL supported: {url} (Extractor: {info.get('extractor', 'unknown')})"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except yt_dlp.utils.UnsupportedError:
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
if "Unsupported URL" not in str(e):
|
||||||
|
logger.error(f"Error checking URL {url}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error
|
||||||
@@ -1,809 +0,0 @@
|
|||||||
"""Video download and processing utilities"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
import ffmpeg
|
|
||||||
import yt_dlp
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import json
|
|
||||||
import signal
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from typing import Dict, List, Optional, Tuple, Callable, Set
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from videoarchiver.ffmpeg.ffmpeg_manager import FFmpegManager
|
|
||||||
from videoarchiver.ffmpeg.exceptions import (
|
|
||||||
FFmpegError,
|
|
||||||
CompressionError,
|
|
||||||
VerificationError,
|
|
||||||
FFprobeError,
|
|
||||||
TimeoutError,
|
|
||||||
handle_ffmpeg_error,
|
|
||||||
)
|
|
||||||
from videoarchiver.utils.exceptions import VideoVerificationError
|
|
||||||
from videoarchiver.utils.file_ops import secure_delete_file
|
|
||||||
from videoarchiver.utils.path_manager import temp_path_context
|
|
||||||
|
|
||||||
logger = logging.getLogger("VideoArchiver")
|
|
||||||
|
|
||||||
|
|
||||||
# Add a custom yt-dlp logger to handle cancellation
|
|
||||||
class CancellableYTDLLogger:
|
|
||||||
def __init__(self):
|
|
||||||
self.cancelled = False
|
|
||||||
|
|
||||||
def debug(self, msg):
|
|
||||||
if self.cancelled:
|
|
||||||
raise Exception("Download cancelled")
|
|
||||||
logger.debug(msg)
|
|
||||||
|
|
||||||
def warning(self, msg):
|
|
||||||
if self.cancelled:
|
|
||||||
raise Exception("Download cancelled")
|
|
||||||
logger.warning(msg)
|
|
||||||
|
|
||||||
def error(self, msg):
|
|
||||||
if self.cancelled:
|
|
||||||
raise Exception("Download cancelled")
|
|
||||||
logger.error(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def is_video_url_pattern(url: str) -> bool:
|
|
||||||
"""Check if URL matches common video platform patterns"""
|
|
||||||
video_patterns = [
|
|
||||||
r"youtube\.com/watch\?v=",
|
|
||||||
r"youtu\.be/",
|
|
||||||
r"vimeo\.com/",
|
|
||||||
r"tiktok\.com/",
|
|
||||||
r"twitter\.com/.*/video/",
|
|
||||||
r"x\.com/.*/video/",
|
|
||||||
r"bsky\.app/",
|
|
||||||
r"facebook\.com/.*/videos/",
|
|
||||||
r"instagram\.com/.*/(tv|reel|p)/",
|
|
||||||
r"twitch\.tv/.*/clip/",
|
|
||||||
r"streamable\.com/",
|
|
||||||
r"v\.redd\.it/",
|
|
||||||
r"clips\.twitch\.tv/",
|
|
||||||
r"dailymotion\.com/video/",
|
|
||||||
r"\.mp4$",
|
|
||||||
r"\.webm$",
|
|
||||||
r"\.mov$",
|
|
||||||
]
|
|
||||||
return any(re.search(pattern, url, re.IGNORECASE) for pattern in video_patterns)
|
|
||||||
|
|
||||||
|
|
||||||
class VideoDownloader:
|
|
||||||
MAX_RETRIES = 5
|
|
||||||
RETRY_DELAY = 10
|
|
||||||
FILE_OP_RETRIES = 3
|
|
||||||
FILE_OP_RETRY_DELAY = 1
|
|
||||||
SHUTDOWN_TIMEOUT = 15 # seconds
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
download_path: str,
|
|
||||||
video_format: str,
|
|
||||||
max_quality: int,
|
|
||||||
max_file_size: int,
|
|
||||||
enabled_sites: Optional[List[str]] = None,
|
|
||||||
concurrent_downloads: int = 2,
|
|
||||||
ffmpeg_mgr: Optional[FFmpegManager] = None,
|
|
||||||
):
|
|
||||||
self.download_path = Path(download_path)
|
|
||||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
os.chmod(str(self.download_path), 0o755)
|
|
||||||
|
|
||||||
self.video_format = video_format
|
|
||||||
self.max_quality = max_quality
|
|
||||||
self.max_file_size = max_file_size
|
|
||||||
self.enabled_sites = enabled_sites
|
|
||||||
self.ffmpeg_mgr = ffmpeg_mgr or FFmpegManager()
|
|
||||||
|
|
||||||
# Create thread pool with proper naming
|
|
||||||
self.download_pool = ThreadPoolExecutor(
|
|
||||||
max_workers=max(1, min(3, concurrent_downloads)),
|
|
||||||
thread_name_prefix="videoarchiver_download",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track active downloads and processes
|
|
||||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
|
||||||
self._downloads_lock = asyncio.Lock()
|
|
||||||
self._active_processes: Set[subprocess.Popen] = set()
|
|
||||||
self._processes_lock = asyncio.Lock()
|
|
||||||
self._shutting_down = False
|
|
||||||
|
|
||||||
# Create cancellable logger
|
|
||||||
self.ytdl_logger = CancellableYTDLLogger()
|
|
||||||
|
|
||||||
# Configure yt-dlp options
|
|
||||||
self.ydl_opts = {
|
|
||||||
"format": f"bv*[height<={max_quality}][ext=mp4]+ba[ext=m4a]/b[height<={max_quality}]/best",
|
|
||||||
"outtmpl": "%(title)s.%(ext)s",
|
|
||||||
"merge_output_format": video_format,
|
|
||||||
"quiet": True,
|
|
||||||
"no_warnings": True,
|
|
||||||
"extract_flat": True,
|
|
||||||
"concurrent_fragment_downloads": 1,
|
|
||||||
"retries": self.MAX_RETRIES,
|
|
||||||
"fragment_retries": self.MAX_RETRIES,
|
|
||||||
"file_access_retries": self.FILE_OP_RETRIES,
|
|
||||||
"extractor_retries": self.MAX_RETRIES,
|
|
||||||
"postprocessor_hooks": [self._check_file_size],
|
|
||||||
"progress_hooks": [self._progress_hook, self._detailed_progress_hook],
|
|
||||||
"ffmpeg_location": str(self.ffmpeg_mgr.get_ffmpeg_path()),
|
|
||||||
"ffprobe_location": str(self.ffmpeg_mgr.get_ffprobe_path()),
|
|
||||||
"paths": {"home": str(self.download_path)},
|
|
||||||
"logger": self.ytdl_logger,
|
|
||||||
"ignoreerrors": True,
|
|
||||||
"no_color": True,
|
|
||||||
"geo_bypass": True,
|
|
||||||
"socket_timeout": 60,
|
|
||||||
"http_chunk_size": 1048576,
|
|
||||||
"external_downloader_args": {"ffmpeg": ["-timeout", "60000000"]},
|
|
||||||
"max_sleep_interval": 5,
|
|
||||||
"sleep_interval": 1,
|
|
||||||
"max_filesize": max_file_size * 1024 * 1024,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def cleanup(self) -> None:
|
|
||||||
"""Clean up resources with proper shutdown"""
|
|
||||||
self._shutting_down = True
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Cancel active downloads
|
|
||||||
self.ytdl_logger.cancelled = True
|
|
||||||
|
|
||||||
# Kill any active FFmpeg processes
|
|
||||||
async with self._processes_lock:
|
|
||||||
for process in self._active_processes:
|
|
||||||
try:
|
|
||||||
process.terminate()
|
|
||||||
await asyncio.sleep(0.1) # Give process time to terminate
|
|
||||||
if process.poll() is None:
|
|
||||||
process.kill() # Force kill if still running
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error killing process: {e}")
|
|
||||||
self._active_processes.clear()
|
|
||||||
|
|
||||||
# Clean up thread pool
|
|
||||||
self.download_pool.shutdown(wait=False, cancel_futures=True)
|
|
||||||
|
|
||||||
# Clean up active downloads
|
|
||||||
async with self._downloads_lock:
|
|
||||||
self.active_downloads.clear()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error during downloader cleanup: {e}")
|
|
||||||
finally:
|
|
||||||
self._shutting_down = False
|
|
||||||
|
|
||||||
async def force_cleanup(self) -> None:
|
|
||||||
"""Force cleanup of all resources"""
|
|
||||||
try:
|
|
||||||
# Force cancel all downloads
|
|
||||||
self.ytdl_logger.cancelled = True
|
|
||||||
|
|
||||||
# Kill all processes immediately
|
|
||||||
async with self._processes_lock:
|
|
||||||
for process in self._active_processes:
|
|
||||||
try:
|
|
||||||
process.kill()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error force killing process: {e}")
|
|
||||||
self._active_processes.clear()
|
|
||||||
|
|
||||||
# Force shutdown thread pool
|
|
||||||
self.download_pool.shutdown(wait=False, cancel_futures=True)
|
|
||||||
|
|
||||||
# Clear all tracking
|
|
||||||
async with self._downloads_lock:
|
|
||||||
self.active_downloads.clear()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error during force cleanup: {e}")
|
|
||||||
|
|
||||||
def _detailed_progress_hook(self, d):
|
|
||||||
"""Handle detailed download progress tracking"""
|
|
||||||
try:
|
|
||||||
if d["status"] == "downloading":
|
|
||||||
# Get URL from info dict
|
|
||||||
url = d.get("info_dict", {}).get("webpage_url", "unknown")
|
|
||||||
|
|
||||||
# Update global progress tracking
|
|
||||||
from videoarchiver.processor import _download_progress
|
|
||||||
|
|
||||||
if url in _download_progress:
|
|
||||||
_download_progress[url].update(
|
|
||||||
{
|
|
||||||
"active": True,
|
|
||||||
"percent": float(
|
|
||||||
d.get("_percent_str", "0").replace("%", "")
|
|
||||||
),
|
|
||||||
"speed": d.get("_speed_str", "N/A"),
|
|
||||||
"eta": d.get("_eta_str", "N/A"),
|
|
||||||
"downloaded_bytes": d.get("downloaded_bytes", 0),
|
|
||||||
"total_bytes": d.get("total_bytes", 0)
|
|
||||||
or d.get("total_bytes_estimate", 0),
|
|
||||||
"retries": d.get("retry_count", 0),
|
|
||||||
"fragment_count": d.get("fragment_count", 0),
|
|
||||||
"fragment_index": d.get("fragment_index", 0),
|
|
||||||
"video_title": d.get("info_dict", {}).get(
|
|
||||||
"title", "Unknown"
|
|
||||||
),
|
|
||||||
"extractor": d.get("info_dict", {}).get(
|
|
||||||
"extractor", "Unknown"
|
|
||||||
),
|
|
||||||
"format": d.get("info_dict", {}).get("format", "Unknown"),
|
|
||||||
"resolution": d.get("info_dict", {}).get(
|
|
||||||
"resolution", "Unknown"
|
|
||||||
),
|
|
||||||
"fps": d.get("info_dict", {}).get("fps", "Unknown"),
|
|
||||||
"last_update": datetime.utcnow().strftime(
|
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"Detailed progress for {url}: "
|
|
||||||
f"{_download_progress[url]['percent']}% at {_download_progress[url]['speed']}, "
|
|
||||||
f"ETA: {_download_progress[url]['eta']}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in detailed progress hook: {str(e)}")
|
|
||||||
|
|
||||||
def _progress_hook(self, d):
|
|
||||||
"""Handle download progress"""
|
|
||||||
if d["status"] == "finished":
|
|
||||||
logger.info(f"Download completed: {d['filename']}")
|
|
||||||
elif d["status"] == "downloading":
|
|
||||||
try:
|
|
||||||
percent = float(d.get("_percent_str", "0").replace("%", ""))
|
|
||||||
speed = d.get("_speed_str", "N/A")
|
|
||||||
eta = d.get("_eta_str", "N/A")
|
|
||||||
downloaded = d.get("downloaded_bytes", 0)
|
|
||||||
total = d.get("total_bytes", 0) or d.get("total_bytes_estimate", 0)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f"Download progress: {percent}% at {speed}, "
|
|
||||||
f"ETA: {eta}, Downloaded: {downloaded}/{total} bytes"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error logging progress: {str(e)}")
|
|
||||||
|
|
||||||
def is_supported_url(self, url: str) -> bool:
|
|
||||||
"""Check if URL is supported by attempting a simulated download"""
|
|
||||||
if not is_video_url_pattern(url):
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
simulate_opts = {
|
|
||||||
**self.ydl_opts,
|
|
||||||
"simulate": True,
|
|
||||||
"quiet": True,
|
|
||||||
"no_warnings": True,
|
|
||||||
"extract_flat": True,
|
|
||||||
"skip_download": True,
|
|
||||||
"format": "best",
|
|
||||||
}
|
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(simulate_opts) as ydl:
|
|
||||||
try:
|
|
||||||
info = ydl.extract_info(url, download=False)
|
|
||||||
if info is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.enabled_sites:
|
|
||||||
extractor = info.get("extractor", "").lower()
|
|
||||||
if not any(
|
|
||||||
site.lower() in extractor for site in self.enabled_sites
|
|
||||||
):
|
|
||||||
logger.info(f"Site {extractor} not in enabled sites list")
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"URL supported: {url} (Extractor: {info.get('extractor', 'unknown')})"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
except yt_dlp.utils.UnsupportedError:
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
if "Unsupported URL" not in str(e):
|
|
||||||
logger.error(f"Error checking URL {url}: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error during URL check: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def download_video(
|
|
||||||
self, url: str, progress_callback: Optional[Callable[[float], None]] = None
|
|
||||||
) -> Tuple[bool, str, str]:
|
|
||||||
"""Download and process a video with improved error handling"""
|
|
||||||
if self._shutting_down:
|
|
||||||
return False, "", "Downloader is shutting down"
|
|
||||||
|
|
||||||
# Initialize progress tracking for this URL
|
|
||||||
from videoarchiver.processor import _download_progress
|
|
||||||
|
|
||||||
_download_progress[url] = {
|
|
||||||
"active": True,
|
|
||||||
"start_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"percent": 0,
|
|
||||||
"speed": "N/A",
|
|
||||||
"eta": "N/A",
|
|
||||||
"downloaded_bytes": 0,
|
|
||||||
"total_bytes": 0,
|
|
||||||
"retries": 0,
|
|
||||||
"fragment_count": 0,
|
|
||||||
"fragment_index": 0,
|
|
||||||
"video_title": "Unknown",
|
|
||||||
"extractor": "Unknown",
|
|
||||||
"format": "Unknown",
|
|
||||||
"resolution": "Unknown",
|
|
||||||
"fps": "Unknown",
|
|
||||||
"last_update": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
}
|
|
||||||
|
|
||||||
original_file = None
|
|
||||||
compressed_file = None
|
|
||||||
temp_dir = None
|
|
||||||
hardware_accel_failed = False
|
|
||||||
compression_params = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with temp_path_context() as temp_dir:
|
|
||||||
# Download the video
|
|
||||||
success, file_path, error = await self._safe_download(
|
|
||||||
url, temp_dir, progress_callback
|
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
return False, "", error
|
|
||||||
|
|
||||||
original_file = file_path
|
|
||||||
|
|
||||||
async with self._downloads_lock:
|
|
||||||
self.active_downloads[url] = {
|
|
||||||
"file_path": original_file,
|
|
||||||
"start_time": datetime.utcnow(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check file size and compress if needed
|
|
||||||
file_size = os.path.getsize(original_file)
|
|
||||||
if file_size > (self.max_file_size * 1024 * 1024):
|
|
||||||
logger.info(f"Compressing video: {original_file}")
|
|
||||||
try:
|
|
||||||
# Get optimal compression parameters
|
|
||||||
compression_params = self.ffmpeg_mgr.get_compression_params(
|
|
||||||
original_file, self.max_file_size
|
|
||||||
)
|
|
||||||
compressed_file = os.path.join(
|
|
||||||
self.download_path,
|
|
||||||
f"compressed_{os.path.basename(original_file)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try hardware acceleration first
|
|
||||||
success = await self._try_compression(
|
|
||||||
original_file,
|
|
||||||
compressed_file,
|
|
||||||
compression_params,
|
|
||||||
progress_callback,
|
|
||||||
use_hardware=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# If hardware acceleration fails, fall back to CPU
|
|
||||||
if not success:
|
|
||||||
hardware_accel_failed = True
|
|
||||||
logger.warning(
|
|
||||||
"Hardware acceleration failed, falling back to CPU encoding"
|
|
||||||
)
|
|
||||||
success = await self._try_compression(
|
|
||||||
original_file,
|
|
||||||
compressed_file,
|
|
||||||
compression_params,
|
|
||||||
progress_callback,
|
|
||||||
use_hardware=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
raise CompressionError(
|
|
||||||
"Failed to compress with both hardware and CPU encoding",
|
|
||||||
file_size,
|
|
||||||
self.max_file_size * 1024 * 1024,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify compressed file
|
|
||||||
if not self._verify_video_file(compressed_file):
|
|
||||||
raise VideoVerificationError(
|
|
||||||
"Compressed file verification failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
compressed_size = os.path.getsize(compressed_file)
|
|
||||||
if compressed_size <= (self.max_file_size * 1024 * 1024):
|
|
||||||
await self._safe_delete_file(original_file)
|
|
||||||
return True, compressed_file, ""
|
|
||||||
else:
|
|
||||||
await self._safe_delete_file(compressed_file)
|
|
||||||
raise CompressionError(
|
|
||||||
"Failed to compress to target size",
|
|
||||||
file_size,
|
|
||||||
self.max_file_size * 1024 * 1024,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = str(e)
|
|
||||||
if hardware_accel_failed:
|
|
||||||
error_msg = f"Hardware acceleration failed, CPU fallback error: {error_msg}"
|
|
||||||
if compressed_file and os.path.exists(compressed_file):
|
|
||||||
await self._safe_delete_file(compressed_file)
|
|
||||||
return False, "", error_msg
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Move file to final location
|
|
||||||
final_path = os.path.join(
|
|
||||||
self.download_path, os.path.basename(original_file)
|
|
||||||
)
|
|
||||||
success = await self._safe_move_file(original_file, final_path)
|
|
||||||
if not success:
|
|
||||||
return False, "", "Failed to move file to final location"
|
|
||||||
return True, final_path, ""
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Download error: {str(e)}")
|
|
||||||
return False, "", str(e)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up
|
|
||||||
async with self._downloads_lock:
|
|
||||||
self.active_downloads.pop(url, None)
|
|
||||||
if url in _download_progress:
|
|
||||||
_download_progress[url]["active"] = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
if original_file and os.path.exists(original_file):
|
|
||||||
await self._safe_delete_file(original_file)
|
|
||||||
if (
|
|
||||||
compressed_file
|
|
||||||
and os.path.exists(compressed_file)
|
|
||||||
and not compressed_file.startswith(self.download_path)
|
|
||||||
):
|
|
||||||
await self._safe_delete_file(compressed_file)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error during file cleanup: {str(e)}")
|
|
||||||
|
|
||||||
async def _try_compression(
|
|
||||||
self,
|
|
||||||
input_file: str,
|
|
||||||
output_file: str,
|
|
||||||
params: Dict[str, str],
|
|
||||||
progress_callback: Optional[Callable[[float], None]] = None,
|
|
||||||
use_hardware: bool = True,
|
|
||||||
) -> bool:
|
|
||||||
"""Attempt video compression with given parameters"""
|
|
||||||
if self._shutting_down:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Build FFmpeg command
|
|
||||||
ffmpeg_path = str(self.ffmpeg_mgr.get_ffmpeg_path())
|
|
||||||
cmd = [ffmpeg_path, "-y", "-i", input_file]
|
|
||||||
|
|
||||||
# Add progress monitoring
|
|
||||||
cmd.extend(["-progress", "pipe:1"])
|
|
||||||
|
|
||||||
# Modify parameters based on hardware acceleration preference
|
|
||||||
if use_hardware:
|
|
||||||
gpu_info = self.ffmpeg_mgr.gpu_info
|
|
||||||
if gpu_info["nvidia"] and params.get("c:v") == "libx264":
|
|
||||||
params["c:v"] = "h264_nvenc"
|
|
||||||
elif gpu_info["amd"] and params.get("c:v") == "libx264":
|
|
||||||
params["c:v"] = "h264_amf"
|
|
||||||
elif gpu_info["intel"] and params.get("c:v") == "libx264":
|
|
||||||
params["c:v"] = "h264_qsv"
|
|
||||||
else:
|
|
||||||
params["c:v"] = "libx264"
|
|
||||||
|
|
||||||
# Add all parameters to command
|
|
||||||
for key, value in params.items():
|
|
||||||
cmd.extend([f"-{key}", str(value)])
|
|
||||||
|
|
||||||
# Add output file
|
|
||||||
cmd.append(output_file)
|
|
||||||
|
|
||||||
# Get video duration for progress calculation
|
|
||||||
duration = self._get_video_duration(input_file)
|
|
||||||
|
|
||||||
# Update compression progress tracking
|
|
||||||
from videoarchiver.processor import _compression_progress
|
|
||||||
|
|
||||||
# Get input file size
|
|
||||||
input_size = os.path.getsize(input_file)
|
|
||||||
|
|
||||||
# Initialize compression progress
|
|
||||||
_compression_progress[input_file] = {
|
|
||||||
"active": True,
|
|
||||||
"filename": os.path.basename(input_file),
|
|
||||||
"start_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"percent": 0,
|
|
||||||
"elapsed_time": "0:00",
|
|
||||||
"input_size": input_size,
|
|
||||||
"current_size": 0,
|
|
||||||
"target_size": self.max_file_size * 1024 * 1024,
|
|
||||||
"codec": params.get("c:v", "unknown"),
|
|
||||||
"hardware_accel": use_hardware,
|
|
||||||
"preset": params.get("preset", "unknown"),
|
|
||||||
"crf": params.get("crf", "unknown"),
|
|
||||||
"duration": duration,
|
|
||||||
"bitrate": params.get("b:v", "unknown"),
|
|
||||||
"audio_codec": params.get("c:a", "unknown"),
|
|
||||||
"audio_bitrate": params.get("b:a", "unknown"),
|
|
||||||
"last_update": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run compression with progress monitoring
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
|
||||||
|
|
||||||
# Track the process
|
|
||||||
async with self._processes_lock:
|
|
||||||
self._active_processes.add(process)
|
|
||||||
|
|
||||||
start_time = datetime.utcnow()
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
if self._shutting_down:
|
|
||||||
process.terminate()
|
|
||||||
return False
|
|
||||||
|
|
||||||
line = await process.stdout.readline()
|
|
||||||
if not line:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
line = line.decode().strip()
|
|
||||||
if line.startswith("out_time_ms="):
|
|
||||||
current_time = (
|
|
||||||
int(line.split("=")[1]) / 1000000
|
|
||||||
) # Convert microseconds to seconds
|
|
||||||
if duration > 0:
|
|
||||||
progress = min(100, (current_time / duration) * 100)
|
|
||||||
|
|
||||||
# Update compression progress
|
|
||||||
elapsed = datetime.utcnow() - start_time
|
|
||||||
_compression_progress[input_file].update(
|
|
||||||
{
|
|
||||||
"percent": progress,
|
|
||||||
"elapsed_time": str(elapsed).split(".")[
|
|
||||||
0
|
|
||||||
], # Remove microseconds
|
|
||||||
"current_size": (
|
|
||||||
os.path.getsize(output_file)
|
|
||||||
if os.path.exists(output_file)
|
|
||||||
else 0
|
|
||||||
),
|
|
||||||
"current_time": current_time,
|
|
||||||
"last_update": datetime.utcnow().strftime(
|
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if progress_callback:
|
|
||||||
# Call the callback directly since it now handles task creation
|
|
||||||
progress_callback(progress)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error parsing FFmpeg progress: {e}")
|
|
||||||
|
|
||||||
await process.wait()
|
|
||||||
success = os.path.exists(output_file)
|
|
||||||
|
|
||||||
# Update final status
|
|
||||||
if success and input_file in _compression_progress:
|
|
||||||
_compression_progress[input_file].update(
|
|
||||||
{
|
|
||||||
"active": False,
|
|
||||||
"percent": 100,
|
|
||||||
"current_size": os.path.getsize(output_file),
|
|
||||||
"last_update": datetime.utcnow().strftime(
|
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return success
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Remove process from tracking
|
|
||||||
async with self._processes_lock:
|
|
||||||
self._active_processes.discard(process)
|
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
logger.error(f"FFmpeg compression failed: {e.stderr.decode()}")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Compression attempt failed: {str(e)}")
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
# Ensure compression progress is marked as inactive
|
|
||||||
if input_file in _compression_progress:
|
|
||||||
_compression_progress[input_file]["active"] = False
|
|
||||||
|
|
||||||
def _get_video_duration(self, file_path: str) -> float:
|
|
||||||
"""Get video duration in seconds"""
|
|
||||||
try:
|
|
||||||
ffprobe_path = str(self.ffmpeg_mgr.get_ffprobe_path())
|
|
||||||
cmd = [
|
|
||||||
ffprobe_path,
|
|
||||||
"-v",
|
|
||||||
"quiet",
|
|
||||||
"-print_format",
|
|
||||||
"json",
|
|
||||||
"-show_format",
|
|
||||||
file_path,
|
|
||||||
]
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
data = json.loads(result.stdout)
|
|
||||||
return float(data["format"]["duration"])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting video duration: {e}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _check_file_size(self, info):
|
|
||||||
"""Check if file size is within limits"""
|
|
||||||
if info.get("filepath") and os.path.exists(info["filepath"]):
|
|
||||||
try:
|
|
||||||
size = os.path.getsize(info["filepath"])
|
|
||||||
if size > (self.max_file_size * 1024 * 1024):
|
|
||||||
logger.info(
|
|
||||||
f"File exceeds size limit, will compress: {info['filepath']}"
|
|
||||||
)
|
|
||||||
except OSError as e:
|
|
||||||
logger.error(f"Error checking file size: {str(e)}")
|
|
||||||
|
|
||||||
def _verify_video_file(self, file_path: str) -> bool:
|
|
||||||
"""Verify video file integrity"""
|
|
||||||
try:
|
|
||||||
ffprobe_path = str(self.ffmpeg_mgr.get_ffprobe_path())
|
|
||||||
cmd = [
|
|
||||||
ffprobe_path,
|
|
||||||
"-v",
|
|
||||||
"quiet",
|
|
||||||
"-print_format",
|
|
||||||
"json",
|
|
||||||
"-show_format",
|
|
||||||
"-show_streams",
|
|
||||||
file_path,
|
|
||||||
]
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise VideoVerificationError(f"FFprobe failed: {result.stderr}")
|
|
||||||
|
|
||||||
probe = json.loads(result.stdout)
|
|
||||||
|
|
||||||
# Verify video stream
|
|
||||||
video_streams = [s for s in probe["streams"] if s["codec_type"] == "video"]
|
|
||||||
if not video_streams:
|
|
||||||
raise VideoVerificationError("No video streams found")
|
|
||||||
|
|
||||||
# Verify duration
|
|
||||||
duration = float(probe["format"].get("duration", 0))
|
|
||||||
if duration <= 0:
|
|
||||||
raise VideoVerificationError("Invalid video duration")
|
|
||||||
|
|
||||||
# Verify file is readable
|
|
||||||
with open(file_path, "rb") as f:
|
|
||||||
f.seek(0, 2)
|
|
||||||
if f.tell() == 0:
|
|
||||||
raise VideoVerificationError("Empty file")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error verifying video file {file_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _safe_download(
|
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
temp_dir: str,
|
|
||||||
progress_callback: Optional[Callable[[float], None]] = None,
|
|
||||||
) -> Tuple[bool, str, str]:
|
|
||||||
"""Safely download video with retries"""
|
|
||||||
if self._shutting_down:
|
|
||||||
return False, "", "Downloader is shutting down"
|
|
||||||
|
|
||||||
last_error = None
|
|
||||||
for attempt in range(self.MAX_RETRIES):
|
|
||||||
try:
|
|
||||||
ydl_opts = self.ydl_opts.copy()
|
|
||||||
ydl_opts["outtmpl"] = os.path.join(temp_dir, ydl_opts["outtmpl"])
|
|
||||||
|
|
||||||
# Add progress callback
|
|
||||||
if progress_callback:
|
|
||||||
original_progress_hook = ydl_opts["progress_hooks"][0]
|
|
||||||
|
|
||||||
def combined_progress_hook(d):
|
|
||||||
original_progress_hook(d)
|
|
||||||
if d["status"] == "downloading":
|
|
||||||
try:
|
|
||||||
percent = float(
|
|
||||||
d.get("_percent_str", "0").replace("%", "")
|
|
||||||
)
|
|
||||||
# Call the callback directly since it now handles task creation
|
|
||||||
progress_callback(percent)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in progress callback: {e}")
|
|
||||||
|
|
||||||
ydl_opts["progress_hooks"] = [combined_progress_hook]
|
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
||||||
info = await asyncio.get_event_loop().run_in_executor(
|
|
||||||
self.download_pool, lambda: ydl.extract_info(url, download=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
if info is None:
|
|
||||||
raise Exception("Failed to extract video information")
|
|
||||||
|
|
||||||
file_path = os.path.join(temp_dir, ydl.prepare_filename(info))
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
raise FileNotFoundError("Download completed but file not found")
|
|
||||||
|
|
||||||
if not self._verify_video_file(file_path):
|
|
||||||
raise VideoVerificationError("Downloaded file is not a valid video")
|
|
||||||
|
|
||||||
return True, file_path, ""
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
last_error = str(e)
|
|
||||||
logger.error(f"Download attempt {attempt + 1} failed: {str(e)}")
|
|
||||||
if attempt < self.MAX_RETRIES - 1:
|
|
||||||
# Exponential backoff with jitter
|
|
||||||
delay = self.RETRY_DELAY * (2**attempt) + (attempt * 2)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
else:
|
|
||||||
return False, "", f"All download attempts failed: {last_error}"
|
|
||||||
|
|
||||||
async def _safe_delete_file(self, file_path: str) -> bool:
|
|
||||||
"""Safely delete a file with retries"""
|
|
||||||
for attempt in range(self.FILE_OP_RETRIES):
|
|
||||||
try:
|
|
||||||
if await secure_delete_file(file_path):
|
|
||||||
return True
|
|
||||||
await asyncio.sleep(self.FILE_OP_RETRY_DELAY * (attempt + 1))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Delete attempt {attempt + 1} failed: {str(e)}")
|
|
||||||
if attempt == self.FILE_OP_RETRIES - 1:
|
|
||||||
return False
|
|
||||||
await asyncio.sleep(self.FILE_OP_RETRY_DELAY * (attempt + 1))
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _safe_move_file(self, src: str, dst: str) -> bool:
|
|
||||||
"""Safely move a file with retries"""
|
|
||||||
for attempt in range(self.FILE_OP_RETRIES):
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
||||||
shutil.move(src, dst)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Move attempt {attempt + 1} failed: {str(e)}")
|
|
||||||
if attempt == self.FILE_OP_RETRIES - 1:
|
|
||||||
return False
|
|
||||||
await asyncio.sleep(self.FILE_OP_RETRY_DELAY * (attempt + 1))
|
|
||||||
return False
|
|
||||||
Reference in New Issue
Block a user