feat: cron-based scheduler with auto-download after rescan

- Replace asyncio sleep loop with APScheduler AsyncIOScheduler + CronTrigger
- Add schedule_time (HH:MM), schedule_days (days of week), auto_download_after_rescan fields to SchedulerConfig
- Add _auto_download_missing() to queue missing episodes after rescan
- Reload config live via reload_config(SchedulerConfig) without restart
- Update GET/POST /api/scheduler/config to return {success, config, status} envelope
- Add day-of-week pill toggles to Settings -> Scheduler section in UI
- Update JS loadSchedulerConfig / saveSchedulerConfig for new API shape
- Add 29 unit tests for SchedulerConfig model, 18 unit tests for SchedulerService
- Rewrite 23 endpoint tests and 36 integration tests for APScheduler behaviour
- Coverage: 96% api/scheduler, 95% scheduler_service, 90% total (>= 80% threshold)
- Update docs: API.md, CONFIGURATION.md, features.md, CHANGELOG.md
This commit is contained in:
2026-02-21 08:56:17 +01:00
parent ac7e15e1eb
commit 0265ae2a70
15 changed files with 1923 additions and 1628 deletions

View File

@@ -0,0 +1,129 @@
"""Unit tests for SchedulerConfig model fields and validators (Task 3)."""
import pytest
from pydantic import ValidationError
from src.server.models.config import SchedulerConfig
ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
class TestSchedulerConfigDefaults:
"""3.1 Default values."""
def test_default_schedule_time(self) -> None:
config = SchedulerConfig()
assert config.schedule_time == "03:00"
def test_default_schedule_days(self) -> None:
config = SchedulerConfig()
assert config.schedule_days == ALL_DAYS
def test_default_auto_download(self) -> None:
config = SchedulerConfig()
assert config.auto_download_after_rescan is False
def test_default_enabled(self) -> None:
config = SchedulerConfig()
assert config.enabled is True
def test_default_interval_minutes(self) -> None:
config = SchedulerConfig()
assert config.interval_minutes == 60
class TestSchedulerConfigValidScheduleTime:
"""3.2 Valid schedule_time values."""
@pytest.mark.parametrize("time_val", ["00:00", "03:00", "12:30", "23:59"])
def test_valid_times(self, time_val: str) -> None:
config = SchedulerConfig(schedule_time=time_val)
assert config.schedule_time == time_val
class TestSchedulerConfigInvalidScheduleTime:
"""3.3 Invalid schedule_time values must raise ValidationError."""
@pytest.mark.parametrize(
"time_val",
["25:00", "3pm", "", "3:00pm", "24:00", "-1:00", "9:00", "1:60"],
)
def test_invalid_times(self, time_val: str) -> None:
with pytest.raises(ValidationError):
SchedulerConfig(schedule_time=time_val)
class TestSchedulerConfigValidScheduleDays:
"""3.4 Valid schedule_days values."""
def test_single_day(self) -> None:
config = SchedulerConfig(schedule_days=["mon"])
assert config.schedule_days == ["mon"]
def test_multiple_days(self) -> None:
config = SchedulerConfig(schedule_days=["mon", "fri"])
assert config.schedule_days == ["mon", "fri"]
def test_all_days(self) -> None:
config = SchedulerConfig(schedule_days=ALL_DAYS)
assert config.schedule_days == ALL_DAYS
def test_empty_list(self) -> None:
config = SchedulerConfig(schedule_days=[])
assert config.schedule_days == []
class TestSchedulerConfigInvalidScheduleDays:
"""3.5 Invalid schedule_days values must raise ValidationError."""
@pytest.mark.parametrize(
"days",
[
["monday"],
["xyz"],
["Mon"], # Case-sensitive — must be lowercase
[""],
],
)
def test_invalid_days(self, days: list) -> None:
with pytest.raises(ValidationError):
SchedulerConfig(schedule_days=days)
class TestSchedulerConfigAutoDownload:
"""3.6 auto_download_after_rescan field."""
def test_set_true(self) -> None:
config = SchedulerConfig(auto_download_after_rescan=True)
assert config.auto_download_after_rescan is True
def test_set_false(self) -> None:
config = SchedulerConfig(auto_download_after_rescan=False)
assert config.auto_download_after_rescan is False
class TestSchedulerConfigBackwardCompat:
"""3.7 Backward compatibility: old fields still work."""
def test_legacy_fields_use_defaults(self) -> None:
config = SchedulerConfig(enabled=True, interval_minutes=30)
assert config.schedule_time == "03:00"
assert config.schedule_days == ALL_DAYS
assert config.auto_download_after_rescan is False
assert config.enabled is True
assert config.interval_minutes == 30
class TestSchedulerConfigSerialisation:
"""3.8 Serialisation roundtrip."""
def test_roundtrip(self) -> None:
original = SchedulerConfig(
enabled=True,
interval_minutes=120,
schedule_time="04:30",
schedule_days=["mon", "wed", "fri"],
auto_download_after_rescan=True,
)
dumped = original.model_dump()
restored = SchedulerConfig(**dumped)
assert restored == original

File diff suppressed because it is too large Load Diff