Compare commits

...

2 Commits

Author SHA1 Message Date
6c9605e896 chore: bump version 2026-05-26 08:58:37 +02:00
3947f6d266 refactor(scheduler): replace structlog with std logging and add extensive diagnostics
- Switch scheduler_service from structlog to standard logging for consistency
- Add detailed lifecycle logging in SchedulerService (start, stop, rescan)
- Add debug logging in fastapi_app scheduler initialization
- Fix test_add_series_episodes to mock EpisodeService.get_by_series
2026-05-26 07:51:22 +02:00
5 changed files with 91 additions and 50 deletions

View File

@@ -1 +1 @@
v1.1.14 v1.1.15

View File

@@ -1,6 +1,6 @@
{ {
"name": "aniworld-web", "name": "aniworld-web",
"version": "1.1.14", "version": "1.1.15",
"description": "Aniworld Anime Download Manager - Web Frontend", "description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -121,7 +121,7 @@ async def _run_startup_health_checks(logger) -> dict:
import asyncio import asyncio
import shutil import shutil
import socket import socket
from typing import Dict, Any from typing import Any, Dict
checks: Dict[str, Any] = { checks: Dict[str, Any] = {
"ffmpeg": {"status": "unknown", "message": None}, "ffmpeg": {"status": "unknown", "message": None},
@@ -400,13 +400,15 @@ async def lifespan(_application: FastAPI):
# Initialize and start scheduler service # Initialize and start scheduler service
try: try:
logger.info("Initializing scheduler service...")
from src.server.services.scheduler_service import ( from src.server.services.scheduler_service import (
get_scheduler_service, get_scheduler_service,
) )
scheduler_service = get_scheduler_service() scheduler_service = get_scheduler_service()
logger.info("Scheduler service instance obtained, starting...")
await scheduler_service.start() await scheduler_service.start()
initialized['scheduler'] = True initialized['scheduler'] = True
logger.info("Scheduler service started") logger.info("Scheduler service started successfully")
except Exception as e: except Exception as e:
logger.warning("Failed to start scheduler service: %s", e) logger.warning("Failed to start scheduler service: %s", e)
# Continue - scheduler is optional # Continue - scheduler is optional

View File

@@ -10,10 +10,10 @@ cron time), the job is triggered immediately within a grace period.
""" """
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
import structlog
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
@@ -21,7 +21,7 @@ from apscheduler.triggers.cron import CronTrigger
from src.server.models.config import SchedulerConfig from src.server.models.config import SchedulerConfig
from src.server.services.config_service import ConfigServiceError, get_config_service from src.server.services.config_service import ConfigServiceError, get_config_service
logger = structlog.get_logger(__name__) logger = logging.getLogger(__name__)
_JOB_ID = "scheduled_rescan" _JOB_ID = "scheduled_rescan"
@@ -69,15 +69,18 @@ class SchedulerService:
SchedulerServiceError: If the scheduler is already running or SchedulerServiceError: If the scheduler is already running or
config cannot be loaded. config cannot be loaded.
""" """
logger.info("SchedulerService.start() called")
if self._is_running: if self._is_running:
logger.warning("Scheduler start called but already running")
raise SchedulerServiceError("Scheduler is already running") raise SchedulerServiceError("Scheduler is already running")
try: try:
config_service = get_config_service() config_service = get_config_service()
config = config_service.load_config() config = config_service.load_config()
self._config = config.scheduler self._config = config.scheduler
logger.info("Scheduler config loaded successfully")
except ConfigServiceError as exc: except ConfigServiceError as exc:
logger.error("Failed to load scheduler configuration", error=str(exc)) logger.error("Failed to load scheduler configuration: %s", exc)
raise SchedulerServiceError(f"Failed to load config: {exc}") from exc raise SchedulerServiceError(f"Failed to load config: {exc}") from exc
jobstores = { jobstores = {
@@ -90,6 +93,15 @@ class SchedulerService:
self._is_running = True self._is_running = True
return return
logger.info(
"Scheduler config loaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
self._config.enabled,
self._config.schedule_time,
self._config.schedule_days,
self._config.auto_download_after_rescan,
self._config.folder_scan_enabled,
)
trigger = self._build_cron_trigger() trigger = self._build_cron_trigger()
if trigger is None: if trigger is None:
logger.warning( logger.warning(
@@ -105,9 +117,9 @@ class SchedulerService:
coalesce=True, coalesce=True,
) )
logger.info( logger.info(
"Scheduler started with cron trigger", "Scheduler started with cron trigger: time=%s days=%s",
schedule_time=self._config.schedule_time, self._config.schedule_time,
schedule_days=self._config.schedule_days, self._config.schedule_days,
) )
self._scheduler.start() self._scheduler.start()
@@ -121,12 +133,13 @@ class SchedulerService:
if job: if job:
next_run = job.next_run_time next_run = job.next_run_time
logger.info( logger.info(
"Scheduler next run", "Scheduler next run: %s",
next_run=next_run.isoformat() if next_run else None, next_run.isoformat() if next_run else None,
) )
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the APScheduler gracefully.""" """Stop the APScheduler gracefully."""
logger.info("SchedulerService.stop() called")
if not self._is_running: if not self._is_running:
logger.debug("Scheduler stop called but not running") logger.debug("Scheduler stop called but not running")
return return
@@ -134,8 +147,11 @@ class SchedulerService:
if self._scheduler and self._scheduler.running: if self._scheduler and self._scheduler.running:
self._scheduler.shutdown(wait=False) self._scheduler.shutdown(wait=False)
logger.info("Scheduler stopped") logger.info("Scheduler stopped")
else:
logger.info("Scheduler stop: scheduler was not running")
self._is_running = False self._is_running = False
logger.info("SchedulerService stopped successfully")
async def trigger_rescan(self) -> bool: async def trigger_rescan(self) -> bool:
"""Manually trigger a library rescan. """Manually trigger a library rescan.
@@ -168,12 +184,12 @@ class SchedulerService:
""" """
self._config = config self._config = config
logger.info( logger.info(
"Scheduler config reloaded", "Scheduler config reloaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
enabled=config.enabled, config.enabled,
schedule_time=config.schedule_time, config.schedule_time,
schedule_days=config.schedule_days, config.schedule_days,
auto_download=config.auto_download_after_rescan, config.auto_download_after_rescan,
folder_scan=config.folder_scan_enabled, config.folder_scan_enabled,
) )
if not self._scheduler or not self._scheduler.running: if not self._scheduler or not self._scheduler.running:
@@ -194,9 +210,9 @@ class SchedulerService:
if self._scheduler.get_job(_JOB_ID): if self._scheduler.get_job(_JOB_ID):
self._scheduler.reschedule_job(_JOB_ID, trigger=trigger) self._scheduler.reschedule_job(_JOB_ID, trigger=trigger)
logger.info( logger.info(
"Scheduler rescheduled with cron trigger", "Scheduler rescheduled with cron trigger: time=%s days=%s",
schedule_time=config.schedule_time, config.schedule_time,
schedule_days=config.schedule_days, config.schedule_days,
) )
else: else:
self._scheduler.add_job( self._scheduler.add_job(
@@ -208,9 +224,9 @@ class SchedulerService:
coalesce=True, coalesce=True,
) )
logger.info( logger.info(
"Scheduler job added with cron trigger", "Scheduler job added with cron trigger: time=%s days=%s",
schedule_time=config.schedule_time, config.schedule_time,
schedule_days=config.schedule_days, config.schedule_days,
) )
def get_status(self) -> dict: def get_status(self) -> dict:
@@ -264,10 +280,10 @@ class SchedulerService:
day_of_week=day_of_week, day_of_week=day_of_week,
) )
logger.debug( logger.debug(
"CronTrigger built", "CronTrigger built: hour=%s minute=%s day_of_week=%s",
hour=hour_str, hour_str,
minute=minute_str, minute_str,
day_of_week=day_of_week, day_of_week,
) )
return trigger return trigger
@@ -281,7 +297,7 @@ class SchedulerService:
ws_service = get_websocket_service() ws_service = get_websocket_service()
await ws_service.manager.broadcast({"type": event_type, "data": data}) await ws_service.manager.broadcast({"type": event_type, "data": data})
except Exception as exc: # pylint: disable=broad-exception-caught except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("WebSocket broadcast failed", event=event_type, error=str(exc)) logger.warning("WebSocket broadcast failed: event=%s error=%s", event_type, exc)
async def _auto_download_missing(self) -> None: async def _auto_download_missing(self) -> None:
"""Queue and start downloads for all series with missing episodes.""" """Queue and start downloads for all series with missing episodes."""
@@ -299,9 +315,9 @@ class SchedulerService:
elapsed = now - self._last_auto_download_time elapsed = now - self._last_auto_download_time
if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds): if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds):
logger.debug( logger.debug(
"Auto-download skipped: cooldown active", "Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)",
elapsed_seconds=elapsed.total_seconds(), elapsed.total_seconds(),
cooldown_seconds=self._auto_download_cooldown_seconds, self._auto_download_cooldown_seconds,
) )
return return
@@ -334,30 +350,31 @@ class SchedulerService:
) )
queued_count += len(episodes) queued_count += len(episodes)
logger.info( logger.info(
"Auto-download queued episodes", "Auto-download queued episodes for series=%s count=%d",
series=series.get("key"), series.get("key"),
count=len(episodes), len(episodes),
) )
if queued_count: if queued_count:
await download_service.start_queue_processing() await download_service.start_queue_processing()
logger.info("Auto-download queue processing started", queued=queued_count) logger.info("Auto-download queue processing started: queued=%d", queued_count)
await self._broadcast("auto_download_started", {"queued_count": queued_count}) await self._broadcast("auto_download_started", {"queued_count": queued_count})
logger.info("Auto-download completed", queued_count=queued_count) logger.info("Auto-download completed: queued_count=%d", queued_count)
# Update cooldown timestamp after successful auto-download # Update cooldown timestamp after successful auto-download
self._last_auto_download_time = datetime.now(timezone.utc) self._last_auto_download_time = datetime.now(timezone.utc)
async def _perform_rescan(self) -> None: async def _perform_rescan(self) -> None:
"""Execute a library rescan and optionally trigger auto-download.""" """Execute a library rescan and optionally trigger auto-download."""
logger.info("Scheduler _perform_rescan entered", scan_in_progress=self._scan_in_progress) logger.info("Scheduler _perform_rescan entered: scan_in_progress=%s", self._scan_in_progress)
if self._scan_in_progress: if self._scan_in_progress:
logger.warning("Skipping rescan: previous scan still in progress") logger.warning("Skipping rescan: previous scan still in progress")
return return
self._scan_in_progress = True self._scan_in_progress = True
scan_start = datetime.now(timezone.utc) scan_start = datetime.now(timezone.utc)
logger.info("Scheduled rescan started at %s", scan_start.isoformat())
try: try:
logger.info("Starting scheduled library rescan") logger.info("Starting scheduled library rescan")
@@ -365,18 +382,20 @@ class SchedulerService:
from src.server.utils.dependencies import get_anime_service # noqa: PLC0415 from src.server.utils.dependencies import get_anime_service # noqa: PLC0415
anime_service = get_anime_service() anime_service = get_anime_service()
logger.info("Anime service obtained for rescan")
await self._broadcast( await self._broadcast(
"scheduled_rescan_started", "scheduled_rescan_started",
{"timestamp": scan_start.isoformat()}, {"timestamp": scan_start.isoformat()},
) )
logger.info("Calling anime_service.rescan()...")
await anime_service.rescan() await anime_service.rescan()
self._last_scan_time = datetime.now(timezone.utc) self._last_scan_time = datetime.now(timezone.utc)
duration = (self._last_scan_time - scan_start).total_seconds() duration = (self._last_scan_time - scan_start).total_seconds()
logger.info("Scheduled library rescan completed", duration_seconds=duration) logger.info("Scheduled library rescan completed: duration=%.2fs", duration)
await self._broadcast( await self._broadcast(
"scheduled_rescan_completed", "scheduled_rescan_completed",
@@ -393,8 +412,8 @@ class SchedulerService:
await self._auto_download_missing() await self._auto_download_missing()
except Exception as dl_exc: # pylint: disable=broad-exception-caught except Exception as dl_exc: # pylint: disable=broad-exception-caught
logger.error( logger.error(
"Auto-download after rescan failed", "Auto-download after rescan failed: %s",
error=str(dl_exc), dl_exc,
exc_info=True, exc_info=True,
) )
await self._broadcast( await self._broadcast(
@@ -413,10 +432,11 @@ class SchedulerService:
folder_scan_service = FolderScanService() folder_scan_service = FolderScanService()
await folder_scan_service.run_folder_scan() await folder_scan_service.run_folder_scan()
logger.info("Folder scan completed successfully")
except Exception as fs_exc: # pylint: disable=broad-exception-caught except Exception as fs_exc: # pylint: disable=broad-exception-caught
logger.error( logger.error(
"Folder scan failed", "Folder scan failed: %s",
error=str(fs_exc), fs_exc,
exc_info=True, exc_info=True,
) )
await self._broadcast( await self._broadcast(
@@ -426,7 +446,7 @@ class SchedulerService:
logger.debug("Folder scan is disabled — skipping") logger.debug("Folder scan is disabled — skipping")
except Exception as exc: # pylint: disable=broad-exception-caught except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Scheduled rescan failed", error=str(exc), exc_info=True) logger.error("Scheduled rescan failed: %s", exc, exc_info=True)
await self._broadcast( await self._broadcast(
"scheduled_rescan_error", "scheduled_rescan_error",
{"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()}, {"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()},
@@ -434,6 +454,7 @@ class SchedulerService:
finally: finally:
self._scan_in_progress = False self._scan_in_progress = False
logger.info("Scheduled rescan finished: scan_in_progress reset to False")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -446,9 +467,14 @@ class SchedulerService:
async def _run_rescan_job() -> None: async def _run_rescan_job() -> None:
"""Module-level job entry point — delegates to the current service.""" """Module-level job entry point — delegates to the current service."""
logger.info("=" * 60)
logger.info("APScheduler triggered _run_rescan_job") logger.info("APScheduler triggered _run_rescan_job")
logger.info("Getting scheduler service singleton...")
svc = get_scheduler_service() svc = get_scheduler_service()
logger.info("Scheduler service obtained, calling _perform_rescan()")
await svc._perform_rescan() await svc._perform_rescan()
logger.info("_run_rescan_job completed")
logger.info("=" * 60)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -462,7 +488,10 @@ def get_scheduler_service() -> SchedulerService:
"""Return the singleton SchedulerService instance.""" """Return the singleton SchedulerService instance."""
global _scheduler_service global _scheduler_service
if _scheduler_service is None: if _scheduler_service is None:
logger.info("Creating new SchedulerService singleton")
_scheduler_service = SchedulerService() _scheduler_service = SchedulerService()
else:
logger.debug("Returning existing SchedulerService singleton")
return _scheduler_service return _scheduler_service

View File

@@ -392,6 +392,7 @@ class TestAddSeriesWithEpisodes:
nfo_created_at=datetime(2024, 1, 1, 12, 0, 0), nfo_created_at=datetime(2024, 1, 1, 12, 0, 0),
nfo_updated_at=datetime(2024, 1, 2, 12, 0, 0) nfo_updated_at=datetime(2024, 1, 2, 12, 0, 0)
) )
mock_db_series.id = 1
# Create service with mocked WebSocket # Create service with mocked WebSocket
anime_service = AnimeService(mock_series_app) anime_service = AnimeService(mock_series_app)
@@ -403,12 +404,21 @@ class TestAddSeriesWithEpisodes:
mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session) mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
mock_db_session.__aexit__ = AsyncMock() mock_db_session.__aexit__ = AsyncMock()
# Mock episodes that match the in-memory episodeDict
mock_episodes = [
MagicMock(season=1, episode_number=1),
MagicMock(season=1, episode_number=2),
MagicMock(season=1, episode_number=3),
]
with patch('src.server.database.connection.get_db_session', return_value=mock_db_session): with patch('src.server.database.connection.get_db_session', return_value=mock_db_session):
with patch('src.server.database.service.AnimeSeriesService') as MockAnimeSeriesService: with patch('src.server.database.service.AnimeSeriesService') as MockAnimeSeriesService:
MockAnimeSeriesService.get_by_key = AsyncMock(return_value=mock_db_series) MockAnimeSeriesService.get_by_key = AsyncMock(return_value=mock_db_series)
with patch('src.server.database.service.EpisodeService') as MockEpisodeService:
MockEpisodeService.get_by_series = AsyncMock(return_value=mock_episodes)
# Act # Act
await anime_service._broadcast_series_updated(key) await anime_service._broadcast_series_updated(key)
# Assert # Assert
mock_websocket.broadcast.assert_called_once() mock_websocket.broadcast.assert_called_once()