diff --git a/backend/app/api/sources.py b/backend/app/api/sources.py index 9d7c9b1..a1312ba 100644 --- a/backend/app/api/sources.py +++ b/backend/app/api/sources.py @@ -5,7 +5,7 @@ from sqlalchemy import select from ..extensions import get_session from ..models import DownloadEvent, Source -from ..services.scheduler_service import scheduler_status +from ..services.scheduler_service import active_platform_cooldowns, scheduler_status from ..services.source_service import ( KNOWN_PLATFORMS, ArtistNotFoundError, @@ -125,7 +125,16 @@ async def check_source(source_id: int): """FC-3c: enqueue a download for this source. Returns 202 with the new DownloadEvent id. If a pending/running - event already exists for this source, returns 409 with that id.""" + event already exists for this source, returns 409 with that id. If + the source's platform is currently in a rate-limit cooldown, returns + **202 with `{status: "deferred", cooldown_until, platform}`** and + does NOT create an event or dispatch — the bulk retry path uses this + to avoid bowling N sources right back into the rate limit the + cooldown is preventing. Single-click "retry this one source" passes + `?force=true` to override the cooldown (operator-explicit, useful + for rapid auth-fix testing). The in-flight guard always applies. + """ + force = (request.args.get("force") or "").lower() in ("1", "true", "yes") async with get_session() as session: source = (await session.execute( select(Source).where(Source.id == source_id) @@ -135,6 +144,19 @@ async def check_source(source_id: int): if not source.enabled: return _bad("source_disabled", detail="enable the source first") + # Cooldown gate (unless explicitly overridden). Checked before the + # in-flight guard because a deferred retry doesn't need to create + # or check for an event at all. + if not force: + cooldowns = await active_platform_cooldowns(session) + expires_at = cooldowns.get(source.platform) + if expires_at is not None: + return jsonify({ + "status": "deferred", + "platform": source.platform, + "cooldown_until": expires_at.isoformat(), + }), 202 + in_flight = (await session.execute( select(DownloadEvent.id).where( DownloadEvent.source_id == source_id, diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py index dd038a4..adda050 100644 --- a/backend/app/services/scheduler_service.py +++ b/backend/app/services/scheduler_service.py @@ -87,10 +87,15 @@ async def set_platform_cooldown( await session.execute(stmt) -async def _platforms_in_cooldown(session: AsyncSession) -> dict[str, datetime]: +async def active_platform_cooldowns(session: AsyncSession) -> dict[str, datetime]: """Return {platform: expires_at} for platforms whose cooldown is still in the future. Expired rows are ignored (a future maintenance sweep can - delete them; they don't affect routing decisions on their own).""" + delete them; they don't affect routing decisions on their own). + + Exposed beyond scheduler_service so the manual check endpoint + (`/api/sources//check`) can defer bulk retries that would bowl + into the same rate limit the cooldown is preventing. + """ rows = (await session.execute( select(AppSetting.key, AppSetting.value) .where(AppSetting.key.startswith(PLATFORM_COOLDOWN_KEY_PREFIX)) @@ -133,7 +138,7 @@ async def select_due_sources(session: AsyncSession) -> list[Source]: .order_by(Source.last_checked_at.asc().nulls_first(), Source.id) )).scalars().all() - cooldowns = await _platforms_in_cooldown(session) + cooldowns = await active_platform_cooldowns(session) settings = await ImportSettings.load(session) now = datetime.now(UTC) @@ -212,7 +217,7 @@ async def scheduler_status(session: AsyncSession) -> dict: elif next_due_at is None or nca < next_due_at: next_due_at = nca - cooldowns = await _platforms_in_cooldown(session) + cooldowns = await active_platform_cooldowns(session) return { "last_tick_at": last_tick_at, diff --git a/frontend/src/components/discovery/MasonryGrid.vue b/frontend/src/components/discovery/MasonryGrid.vue index f90a5ce..436b8e9 100644 --- a/frontend/src/components/discovery/MasonryGrid.vue +++ b/frontend/src/components/discovery/MasonryGrid.vue @@ -79,9 +79,18 @@ function aspectStyle(item) { return { aspectRatio: `${w} / ${h}` } } +// Larger rootMargin than the composable default (600px) because the +// sentinel sits at the BOTTOM of the masonry container, whose height is +// the MAX of the column heights. A single tall image (long manga page, +// panorama) in one column pushes the sentinel way past the visible +// bottom of the SHORTER columns — the user reads the short-column +// bottoms long before the sentinel comes into view, and load-more +// fires too late. 2400px ≈ 2-3 screen-heights of pre-emptive trigger, +// comfortably covering typical tall-image heights. Operator-flagged +// 2026-05-30. useInfiniteScroll(sentinelEl, () => { if (props.hasMore && !props.loading) emit('load-more') -}) +}, { rootMargin: '2400px' })