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:
2025-10-17 20:05:57 +02:00
parent 59edf6bd50
commit a0f32b1a00
6 changed files with 1300 additions and 63 deletions

View File

@@ -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