"""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)