refactor(scheduler): separate scheduler logic from scan/rescan logic

- Extract rescan logic into new RescanService (src/server/services/rescan_service.py)
- SchedulerService now only handles APScheduler cron scheduling
- Move scheduler sub-services (folder_rename, folder_scan, key_resolution) to scheduler/ folder
- Keep RescanOrchestrator as backward-compatible alias
- Update all imports across api/, server/, and test files
This commit is contained in:
2026-06-03 20:58:30 +02:00
parent 9d64241230
commit 9c3f03d610
25 changed files with 1080 additions and 578 deletions

View File

@@ -17,7 +17,7 @@ class TestFolderRenameScanCalledInFolderScan:
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
"src.server.services.scheduler.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
@@ -31,7 +31,7 @@ class TestFolderRenameScanCalledInFolderScan:
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
"src.server.services.scheduler.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
@@ -52,7 +52,7 @@ class TestFolderRenameIntegration:
@pytest.mark.asyncio
async def test_folder_rename_runs_during_scan(self, tmp_path):
"""When folder_scan_enabled is true, the scan renames mismatched folders."""
from src.server.services.folder_scan_service import FolderScanService
from src.server.services.scheduler.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
@@ -69,15 +69,15 @@ class TestFolderRenameIntegration:
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_rename_service.settings", mock_settings
"src.server.services.scheduler.folder_rename_service.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service._is_series_being_downloaded",
"src.server.services.scheduler.folder_rename_service._is_series_being_downloaded",
return_value=False,
), patch(
"src.server.services.folder_rename_service._update_database_paths",
"src.server.services.scheduler.folder_rename_service._update_database_paths",
new_callable=AsyncMock,
):
service = FolderScanService()
@@ -89,7 +89,7 @@ class TestFolderRenameIntegration:
@pytest.mark.asyncio
async def test_folder_rename_skipped_when_prerequisites_not_met(self, tmp_path):
"""If anime directory is missing, rename logic is skipped gracefully."""
from src.server.services.folder_scan_service import FolderScanService
from src.server.services.scheduler.folder_scan_service import FolderScanService
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
@@ -98,10 +98,10 @@ class TestFolderRenameIntegration:
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders"
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders"
) as mock_rename:
service = FolderScanService()
await service.run_folder_scan()

View File

@@ -34,7 +34,7 @@ class TestNfoRepairScanCalledInFolderScan:
"""folder_scan_service.py imports perform_nfo_repair_scan."""
import importlib
source = importlib.util.find_spec("src.server.services.folder_scan_service").origin
source = importlib.util.find_spec("src.server.services.scheduler.folder_scan_service").origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
@@ -46,7 +46,7 @@ class TestNfoRepairScanCalledInFolderScan:
"""perform_nfo_repair_scan must be called inside run_folder_scan."""
import importlib
source = importlib.util.find_spec("src.server.services.folder_scan_service").origin
source = importlib.util.find_spec("src.server.services.scheduler.folder_scan_service").origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
@@ -67,7 +67,9 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
@pytest.mark.asyncio
async def test_incomplete_nfo_series_scheduled_for_repair(self, tmp_path):
"""Series whose tvshow.nfo is missing required tags are scheduled via asyncio.create_task."""
from src.server.services.folder_scan_service import perform_nfo_repair_scan
from src.server.services.scheduler.folder_scan_service import (
perform_nfo_repair_scan,
)
series_dir = tmp_path / "IncompleteAnime"
series_dir.mkdir()
@@ -83,7 +85,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_repair_service.repair_series = AsyncMock(return_value=True)
with patch(
"src.server.services.folder_scan_service._settings", mock_settings
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=True,
@@ -103,7 +105,9 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
@pytest.mark.asyncio
async def test_complete_nfo_series_not_scheduled(self, tmp_path):
"""Series whose tvshow.nfo has all required tags are not scheduled for repair."""
from src.server.services.folder_scan_service import perform_nfo_repair_scan
from src.server.services.scheduler.folder_scan_service import (
perform_nfo_repair_scan,
)
series_dir = tmp_path / "CompleteAnime"
series_dir.mkdir()
@@ -116,7 +120,7 @@ class TestNfoRepairScanIntegrationWithBackgroundLoader:
mock_settings.anime_directory = str(tmp_path)
with patch(
"src.server.services.folder_scan_service._settings", mock_settings
"src.server.services.scheduler.folder_scan_service._settings", mock_settings
), patch(
"src.core.services.nfo_repair_service.nfo_needs_repair",
return_value=False,

View File

@@ -19,7 +19,7 @@ class TestPosterCheckScanCalledInFolderScan:
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
"src.server.services.scheduler.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
@@ -33,7 +33,7 @@ class TestPosterCheckScanCalledInFolderScan:
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
"src.server.services.scheduler.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
@@ -54,7 +54,7 @@ class TestPosterCheckIntegration:
@pytest.mark.asyncio
async def test_poster_check_downloads_missing_poster(self, tmp_path):
"""When poster.jpg is missing, the scan downloads it from the NFO thumb URL."""
from src.server.services.folder_scan_service import FolderScanService
from src.server.services.scheduler.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
@@ -91,14 +91,14 @@ class TestPosterCheckIntegration:
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch(
"src.server.services.folder_scan_service.ImageDownloader",
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
new=MockDownloader,
):
service = FolderScanService()
@@ -112,7 +112,7 @@ class TestPosterCheckIntegration:
@pytest.mark.asyncio
async def test_poster_check_skips_valid_poster(self, tmp_path):
"""When poster.jpg exists and is large enough, the scan skips it."""
from src.server.services.folder_scan_service import FolderScanService
from src.server.services.scheduler.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
@@ -136,14 +136,14 @@ class TestPosterCheckIntegration:
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch(
"src.server.services.folder_scan_service.ImageDownloader"
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
@@ -153,7 +153,7 @@ class TestPosterCheckIntegration:
@pytest.mark.asyncio
async def test_poster_check_skips_when_no_thumb_url(self, tmp_path):
"""When NFO has no thumb URL, the scan skips the folder."""
from src.server.services.folder_scan_service import FolderScanService
from src.server.services.scheduler.folder_scan_service import FolderScanService
anime_dir = tmp_path / "anime"
anime_dir.mkdir()
@@ -173,14 +173,14 @@ class TestPosterCheckIntegration:
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch(
"src.server.services.folder_scan_service.ImageDownloader"
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
@@ -190,7 +190,7 @@ class TestPosterCheckIntegration:
@pytest.mark.asyncio
async def test_poster_check_skipped_when_prerequisites_not_met(self, tmp_path):
"""If anime directory is missing, poster check logic is skipped gracefully."""
from src.server.services.folder_scan_service import FolderScanService
from src.server.services.scheduler.folder_scan_service import FolderScanService
mock_settings = MagicMock()
mock_settings.tmdb_api_key = "test-key"
@@ -199,12 +199,12 @@ class TestPosterCheckIntegration:
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders"
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders"
) as mock_rename, patch(
"src.server.services.folder_scan_service.ImageDownloader"
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
await service.run_folder_scan()
@@ -220,7 +220,7 @@ class TestPosterCheckSemaphore:
import importlib
source = importlib.util.find_spec(
"src.server.services.folder_scan_service"
"src.server.services.scheduler.folder_scan_service"
).origin
with open(source, "r", encoding="utf-8") as fh:
content = fh.read()
@@ -232,7 +232,7 @@ class TestPosterCheckSemaphore:
@pytest.mark.asyncio
async def test_poster_download_uses_semaphore(self, tmp_path):
"""Poster downloads are gated by the semaphore."""
from src.server.services.folder_scan_service import (
from src.server.services.scheduler.folder_scan_service import (
_POSTER_DOWNLOAD_SEMAPHORE,
FolderScanService,
)
@@ -270,14 +270,14 @@ class TestPosterCheckSemaphore:
with patch(
"src.config.settings.settings", mock_settings
), patch(
"src.server.services.folder_scan_service.perform_nfo_repair_scan",
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
new_callable=AsyncMock,
), patch(
"src.server.services.folder_rename_service.validate_and_rename_series_folders",
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
new_callable=AsyncMock,
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
), patch(
"src.server.services.folder_scan_service.ImageDownloader"
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
mock_downloader = AsyncMock()
mock_downloader.download_poster = AsyncMock(side_effect=tracked_download)

View File

@@ -11,15 +11,14 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from src.server.models.config import AppConfig, SchedulerConfig
from src.server.services.scheduler_service import (
from src.server.services.scheduler.scheduler_service import (
_JOB_ID,
SchedulerService,
SchedulerServiceError,
_JOB_ID,
get_scheduler_service,
reset_scheduler_service,
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@@ -27,7 +26,7 @@ from src.server.services.scheduler_service import (
@pytest.fixture
def mock_config_service():
"""Patch get_config_service used by SchedulerService.start()."""
with patch("src.server.services.scheduler_service.get_config_service") as mock:
with patch("src.server.services.scheduler.scheduler_service.get_config_service") as mock:
config_service = Mock()
app_config = AppConfig(
scheduler=SchedulerConfig(