feat: Implement comprehensive progress callback system
- Created callback interfaces (ProgressCallback, ErrorCallback, CompletionCallback) - Defined rich context objects (ProgressContext, ErrorContext, CompletionContext) - Implemented CallbackManager for managing multiple callbacks - Integrated callbacks into SerieScanner for scan progress reporting - Enhanced SeriesApp with download progress tracking via callbacks - Added error and completion notifications throughout core operations - Maintained backward compatibility with legacy callback system - Created 22 comprehensive unit tests with 100% pass rate - Updated infrastructure.md with callback system documentation - Removed completed tasks from instructions.md The callback system provides: - Real-time progress updates with percentage and phase tracking - Comprehensive error reporting with recovery information - Operation completion notifications with statistics - Thread-safe callback execution with exception handling - Support for multiple simultaneous callbacks per type
This commit is contained in:
@@ -1,59 +1,257 @@
|
||||
"""
|
||||
SerieScanner - Scans directories for anime series and missing episodes.
|
||||
|
||||
This module provides functionality to scan anime directories, identify
|
||||
missing episodes, and report progress through callback interfaces.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
from .entities.series import Serie
|
||||
import traceback
|
||||
from ..infrastructure.logging.GlobalLogger import error_logger, noKeyFound_logger
|
||||
from .exceptions.Exceptions import NoKeyFoundException, MatchNotFoundError
|
||||
from .providers.base_provider import Loader
|
||||
import uuid
|
||||
from typing import Callable, Optional
|
||||
|
||||
from src.core.entities.series import Serie
|
||||
from src.core.exceptions.Exceptions import MatchNotFoundError, NoKeyFoundException
|
||||
from src.core.interfaces.callbacks import (
|
||||
CallbackManager,
|
||||
CompletionContext,
|
||||
ErrorContext,
|
||||
OperationType,
|
||||
ProgressContext,
|
||||
ProgressPhase,
|
||||
)
|
||||
from src.core.providers.base_provider import Loader
|
||||
from src.infrastructure.logging.GlobalLogger import error_logger, noKeyFound_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SerieScanner:
|
||||
def __init__(self, basePath: str, loader: Loader):
|
||||
"""
|
||||
Scans directories for anime series and identifies missing episodes.
|
||||
|
||||
Supports progress callbacks for real-time scanning updates.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
basePath: str,
|
||||
loader: Loader,
|
||||
callback_manager: Optional[CallbackManager] = None
|
||||
):
|
||||
"""
|
||||
Initialize the SerieScanner.
|
||||
|
||||
Args:
|
||||
basePath: Base directory containing anime series
|
||||
loader: Loader instance for fetching series information
|
||||
callback_manager: Optional callback manager for progress updates
|
||||
"""
|
||||
self.directory = basePath
|
||||
self.folderDict: dict[str, Serie] = {} # Proper initialization
|
||||
self.folderDict: dict[str, Serie] = {}
|
||||
self.loader = loader
|
||||
logging.info(f"Initialized Loader with base path: {self.directory}")
|
||||
self._callback_manager = callback_manager or CallbackManager()
|
||||
self._current_operation_id: Optional[str] = None
|
||||
|
||||
logger.info("Initialized SerieScanner with base path: %s", basePath)
|
||||
|
||||
@property
|
||||
def callback_manager(self) -> CallbackManager:
|
||||
"""Get the callback manager instance."""
|
||||
return self._callback_manager
|
||||
|
||||
def Reinit(self):
|
||||
self.folderDict: dict[str, Serie] = {} # Proper initialization
|
||||
|
||||
"""Reinitialize the folder dictionary."""
|
||||
self.folderDict: dict[str, Serie] = {}
|
||||
|
||||
def is_null_or_whitespace(self, s):
|
||||
"""Check if a string is None or whitespace."""
|
||||
return s is None or s.strip() == ""
|
||||
|
||||
def GetTotalToScan(self):
|
||||
"""Get the total number of folders to scan."""
|
||||
result = self.__find_mp4_files()
|
||||
return sum(1 for _ in result)
|
||||
|
||||
def Scan(self, callback):
|
||||
logging.info("Starting process to load missing episodes")
|
||||
result = self.__find_mp4_files()
|
||||
counter = 0
|
||||
for folder, mp4_files in result:
|
||||
try:
|
||||
counter += 1
|
||||
callback(folder, counter)
|
||||
serie = self.__ReadDataFromFile(folder)
|
||||
if (serie != None and not self.is_null_or_whitespace(serie.key)):
|
||||
missings, site = self.__GetMissingEpisodesAndSeason(serie.key, mp4_files)
|
||||
serie.episodeDict = missings
|
||||
serie.folder = folder
|
||||
serie.save_to_file(os.path.join(os.path.join(self.directory, folder), 'data'))
|
||||
if (serie.key in self.folderDict):
|
||||
logging.ERROR(f"dublication found: {serie.key}");
|
||||
pass
|
||||
self.folderDict[serie.key] = serie
|
||||
noKeyFound_logger.info(f"Saved Serie: '{str(serie)}'")
|
||||
except NoKeyFoundException as nkfe:
|
||||
NoKeyFoundException.error(f"Error processing folder '{folder}': {nkfe}")
|
||||
except Exception as e:
|
||||
error_logger.error(f"Folder: '{folder}' - Unexpected error processing folder '{folder}': {e} \n {traceback.format_exc()}")
|
||||
continue
|
||||
def Scan(self, callback: Optional[Callable[[str, int], None]] = None):
|
||||
"""
|
||||
Scan directories for anime series and missing episodes.
|
||||
|
||||
Args:
|
||||
callback: Optional legacy callback function (folder, count)
|
||||
|
||||
Raises:
|
||||
Exception: If scan fails critically
|
||||
"""
|
||||
# Generate unique operation ID
|
||||
self._current_operation_id = str(uuid.uuid4())
|
||||
|
||||
logger.info("Starting scan for missing episodes")
|
||||
|
||||
# Notify scan starting
|
||||
self._callback_manager.notify_progress(
|
||||
ProgressContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
phase=ProgressPhase.STARTING,
|
||||
current=0,
|
||||
total=0,
|
||||
percentage=0.0,
|
||||
message="Initializing scan"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Get total items to process
|
||||
total_to_scan = self.GetTotalToScan()
|
||||
logger.info("Total folders to scan: %d", total_to_scan)
|
||||
|
||||
result = self.__find_mp4_files()
|
||||
counter = 0
|
||||
|
||||
for folder, mp4_files in result:
|
||||
try:
|
||||
counter += 1
|
||||
|
||||
# Calculate progress
|
||||
percentage = (
|
||||
(counter / total_to_scan * 100)
|
||||
if total_to_scan > 0 else 0
|
||||
)
|
||||
|
||||
# Notify progress
|
||||
self._callback_manager.notify_progress(
|
||||
ProgressContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
phase=ProgressPhase.IN_PROGRESS,
|
||||
current=counter,
|
||||
total=total_to_scan,
|
||||
percentage=percentage,
|
||||
message=f"Scanning: {folder}",
|
||||
details=f"Found {len(mp4_files)} episodes"
|
||||
)
|
||||
)
|
||||
|
||||
# Call legacy callback if provided
|
||||
if callback:
|
||||
callback(folder, counter)
|
||||
|
||||
serie = self.__ReadDataFromFile(folder)
|
||||
if (
|
||||
serie is not None
|
||||
and not self.is_null_or_whitespace(serie.key)
|
||||
):
|
||||
missings, site = self.__GetMissingEpisodesAndSeason(
|
||||
serie.key, mp4_files
|
||||
)
|
||||
serie.episodeDict = missings
|
||||
serie.folder = folder
|
||||
data_path = os.path.join(
|
||||
self.directory, folder, 'data'
|
||||
)
|
||||
serie.save_to_file(data_path)
|
||||
|
||||
if serie.key in self.folderDict:
|
||||
logger.error(
|
||||
"Duplication found: %s", serie.key
|
||||
)
|
||||
else:
|
||||
self.folderDict[serie.key] = serie
|
||||
noKeyFound_logger.info(
|
||||
"Saved Serie: '%s'", str(serie)
|
||||
)
|
||||
|
||||
except NoKeyFoundException as nkfe:
|
||||
# Log error and notify via callback
|
||||
error_msg = f"Error processing folder '{folder}': {nkfe}"
|
||||
NoKeyFoundException.error(error_msg)
|
||||
|
||||
self._callback_manager.notify_error(
|
||||
ErrorContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
error=nkfe,
|
||||
message=error_msg,
|
||||
recoverable=True,
|
||||
metadata={"folder": folder}
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error and notify via callback
|
||||
error_msg = (
|
||||
f"Folder: '{folder}' - "
|
||||
f"Unexpected error: {e}"
|
||||
)
|
||||
error_logger.error(
|
||||
"%s\n%s",
|
||||
error_msg,
|
||||
traceback.format_exc()
|
||||
)
|
||||
|
||||
self._callback_manager.notify_error(
|
||||
ErrorContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
error=e,
|
||||
message=error_msg,
|
||||
recoverable=True,
|
||||
metadata={"folder": folder}
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Notify scan completion
|
||||
self._callback_manager.notify_completion(
|
||||
CompletionContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
success=True,
|
||||
message=f"Scan completed. Processed {counter} folders.",
|
||||
statistics={
|
||||
"total_folders": counter,
|
||||
"series_found": len(self.folderDict)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Scan completed. Processed %d folders, found %d series",
|
||||
counter,
|
||||
len(self.folderDict)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Critical error - notify and re-raise
|
||||
error_msg = f"Critical scan error: {e}"
|
||||
logger.error("%s\n%s", error_msg, traceback.format_exc())
|
||||
|
||||
self._callback_manager.notify_error(
|
||||
ErrorContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
error=e,
|
||||
message=error_msg,
|
||||
recoverable=False
|
||||
)
|
||||
)
|
||||
|
||||
self._callback_manager.notify_completion(
|
||||
CompletionContext(
|
||||
operation_type=OperationType.SCAN,
|
||||
operation_id=self._current_operation_id,
|
||||
success=False,
|
||||
message=error_msg
|
||||
)
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
def __find_mp4_files(self):
|
||||
logging.info("Scanning for .mp4 files")
|
||||
"""Find all .mp4 files in the directory structure."""
|
||||
logger.info("Scanning for .mp4 files")
|
||||
for anime_name in os.listdir(self.directory):
|
||||
anime_path = os.path.join(self.directory, anime_name)
|
||||
if os.path.isdir(anime_path):
|
||||
@@ -67,43 +265,68 @@ class SerieScanner:
|
||||
yield anime_name, mp4_files if has_files else []
|
||||
|
||||
def __remove_year(self, input_string: str):
|
||||
"""Remove year information from input string."""
|
||||
cleaned_string = re.sub(r'\(\d{4}\)', '', input_string).strip()
|
||||
logging.debug(f"Removed year from '{input_string}' -> '{cleaned_string}'")
|
||||
logger.debug(
|
||||
"Removed year from '%s' -> '%s'",
|
||||
input_string,
|
||||
cleaned_string
|
||||
)
|
||||
return cleaned_string
|
||||
|
||||
def __ReadDataFromFile(self, folder_name: str):
|
||||
"""Read serie data from file or key file."""
|
||||
folder_path = os.path.join(self.directory, folder_name)
|
||||
key = None
|
||||
key_file = os.path.join(folder_path, 'key')
|
||||
serie_file = os.path.join(folder_path, 'data')
|
||||
|
||||
if os.path.exists(key_file):
|
||||
with open(key_file, 'r') as file:
|
||||
with open(key_file, 'r', encoding='utf-8') as file:
|
||||
key = file.read().strip()
|
||||
logging.info(f"Key found for folder '{folder_name}': {key}")
|
||||
logger.info(
|
||||
"Key found for folder '%s': %s",
|
||||
folder_name,
|
||||
key
|
||||
)
|
||||
return Serie(key, "", "aniworld.to", folder_name, dict())
|
||||
|
||||
if os.path.exists(serie_file):
|
||||
with open(serie_file, "rb") as file:
|
||||
logging.info(f"load serie_file from '{folder_name}': {serie_file}")
|
||||
logger.info(
|
||||
"load serie_file from '%s': %s",
|
||||
folder_name,
|
||||
serie_file
|
||||
)
|
||||
return Serie.load_from_file(serie_file)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def __GetEpisodeAndSeason(self, filename: str):
|
||||
"""Extract season and episode numbers from filename."""
|
||||
pattern = r'S(\d+)E(\d+)'
|
||||
match = re.search(pattern, filename)
|
||||
if match:
|
||||
season = match.group(1)
|
||||
episode = match.group(2)
|
||||
logging.debug(f"Extracted season {season}, episode {episode} from '{filename}'")
|
||||
logger.debug(
|
||||
"Extracted season %s, episode %s from '%s'",
|
||||
season,
|
||||
episode,
|
||||
filename
|
||||
)
|
||||
return int(season), int(episode)
|
||||
else:
|
||||
logging.error(f"Failed to find season/episode pattern in '{filename}'")
|
||||
raise MatchNotFoundError("Season and episode pattern not found in the filename.")
|
||||
logger.error(
|
||||
"Failed to find season/episode pattern in '%s'",
|
||||
filename
|
||||
)
|
||||
raise MatchNotFoundError(
|
||||
"Season and episode pattern not found in the filename."
|
||||
)
|
||||
|
||||
def __GetEpisodesAndSeasons(self, mp4_files: []):
|
||||
def __GetEpisodesAndSeasons(self, mp4_files: list):
|
||||
"""Get episodes grouped by season from mp4 files."""
|
||||
episodes_dict = {}
|
||||
|
||||
for file in mp4_files:
|
||||
@@ -115,13 +338,19 @@ class SerieScanner:
|
||||
episodes_dict[season] = [episode]
|
||||
return episodes_dict
|
||||
|
||||
def __GetMissingEpisodesAndSeason(self, key: str, mp4_files: []):
|
||||
expected_dict = self.loader.get_season_episode_count(key) # key season , value count of episodes
|
||||
def __GetMissingEpisodesAndSeason(self, key: str, mp4_files: list):
|
||||
"""Get missing episodes for a serie."""
|
||||
# key season , value count of episodes
|
||||
expected_dict = self.loader.get_season_episode_count(key)
|
||||
filedict = self.__GetEpisodesAndSeasons(mp4_files)
|
||||
episodes_dict = {}
|
||||
for season, expected_count in expected_dict.items():
|
||||
existing_episodes = filedict.get(season, [])
|
||||
missing_episodes = [ep for ep in range(1, expected_count + 1) if ep not in existing_episodes and self.loader.IsLanguage(season, ep, key)]
|
||||
missing_episodes = [
|
||||
ep for ep in range(1, expected_count + 1)
|
||||
if ep not in existing_episodes
|
||||
and self.loader.IsLanguage(season, ep, key)
|
||||
]
|
||||
|
||||
if missing_episodes:
|
||||
episodes_dict[season] = missing_episodes
|
||||
|
||||
Reference in New Issue
Block a user