Files
FabledCurator/backend/app/services/scheduler_service.py
T
2026-05-21 15:59:35 -04:00

81 lines
2.7 KiB
Python

"""FC-3d: pure logic for deciding which sources are due for a check.
The Celery tick task wraps `select_due_sources` and fires
`download_source.delay()` per result; this module knows nothing about
Celery, gallery-dl, or any side effect.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..models import Artist, ImportSettings, Source
MIN_INTERVAL_SECONDS = 60
MAX_INTERVAL_SECONDS = 86400
MAX_BACKOFF_EXPONENT = 6
def compute_effective_interval(
source: Source, artist: Artist, settings: ImportSettings,
) -> int:
"""Return the seconds between scheduled checks for one source.
Precedence: source.check_interval_override > artist.check_interval_seconds
> settings.download_schedule_default_seconds. Multiplied by
2 ** min(consecutive_failures, MAX_BACKOFF_EXPONENT) and clamped to
[MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS].
"""
base = (
source.check_interval_override
or artist.check_interval_seconds
or settings.download_schedule_default_seconds
)
exponent = min(max(0, source.consecutive_failures or 0), MAX_BACKOFF_EXPONENT)
factor = 2 ** exponent
raw = base * factor
return max(MIN_INTERVAL_SECONDS, min(MAX_INTERVAL_SECONDS, raw))
async def select_due_sources(session: AsyncSession) -> list[Source]:
"""Sources where (enabled, artist.auto_check) and now >= last_checked_at + effective_interval.
Never-checked sources (last_checked_at IS NULL) are always due.
"""
rows = (await session.execute(
select(Source)
.options(selectinload(Source.artist))
.join(Artist, Source.artist_id == Artist.id)
.where(Source.enabled.is_(True))
.where(Artist.auto_check.is_(True))
)).scalars().all()
settings = (await session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
now = datetime.now(UTC)
due: list[Source] = []
for s in rows:
interval = compute_effective_interval(s, s.artist, settings)
if s.last_checked_at is None:
due.append(s)
continue
elapsed = (now - s.last_checked_at).total_seconds()
if elapsed >= interval:
due.append(s)
return due
def compute_next_check_at(
source: Source, artist: Artist, settings: ImportSettings,
) -> datetime | None:
"""Return the projected datetime of the next check, or None if never checked."""
if source.last_checked_at is None:
return None
interval = compute_effective_interval(source, artist, settings)
return source.last_checked_at + timedelta(seconds=interval)