downloads: surface daily-limit stall to users

Add daily_limit_reached to the DownloadManager summary (/api/status):
True when due (new, non-recurring) downloads exist and every one of
them is blocked by the global or per-domain daily download limit.
Show a "Max Daily Downloads Reached" banner on the Downloads page when
set (and the manager is neither disabled nor stopped).

Previously a queue stalled by the daily limit looked broken: the UI
reported 0 pending with no explanation, and the skip reason was only
visible at DEBUG log level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-29 14:40:58 -06:00
parent 2baa9cbd2e
commit a4eeba14ee
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
4 changed files with 84 additions and 0 deletions

View file

@ -611,6 +611,17 @@ export function DownloadWindowMessage() {
return null;
}
export function DailyLimitMessage() {
const {status} = React.useContext(StatusContext);
const downloads = status ? status.downloads : null;
if (downloads && downloads.daily_limit_reached && !downloads.disabled && !downloads.stopped) {
return <Message icon='pause circle outline' header='Max Daily Downloads Reached'
content='Downloads are paused because the daily download limit has been reached. Downloads will resume tomorrow.'/>
}
return null;
}
export function CookiesUnlockModal({open, onClose, onSuccess}) {
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);

View file

@ -15,6 +15,7 @@ import {
APIButton,
CookiesLockedMessage,
DisableDownloadsToggle,
DailyLimitMessage,
DownloadWindowMessage,
ErrorMessage,
formatFrequency,
@ -684,6 +685,7 @@ export function DownloadsPage() {
return <>
<WROLModeMessage content='Downloads are disabled because WROL Mode is enabled.'/>
<DownloadWindowMessage/>
<DailyLimitMessage/>
<DisableDownloadsToggle/>
<CookiesLockedMessage/>

View file

@ -1543,15 +1543,41 @@ class DownloadManager:
func.count(Download.id).filter(Download.status == DownloadStatus.pending),
func.count(Download.id).filter(Download.frequency != None), # noqa
).one()
daily_limit_reached = self.daily_limit_reached(session)
summary = dict(
pending=pending_downloads,
recurring=recurring_downloads,
disabled=self.is_disabled,
stopped=self.is_stopped,
outside_download_window=self.outside_download_window,
daily_limit_reached=daily_limit_reached,
)
return summary
def daily_limit_reached(self, session: Session) -> bool:
"""True when due (`new`, non-recurring) downloads exist and every one of them is blocked by the
daily download limits. This is what the UI uses to explain a stalled-looking queue; partial
blockage (some domains capped, others still downloading) does not count."""
limit_per_domain = get_wrolpi_config().download_daily_limit_per_domain or 0
limit_global = get_wrolpi_config().download_daily_limit_global or 0
if not limit_per_domain and not limit_global:
return False
due_downloads = session.query(Download).filter(
Download.status == DownloadStatus.new,
Download.frequency.is_(None),
).all()
if not due_downloads:
return False
global_count, domain_counts = self.daily_download_counts(session)
if limit_global and global_count >= limit_global:
return True
if limit_per_domain:
return all(domain_counts.get(self._normalize_download_domain(download), 0) >= limit_per_domain
for download in due_downloads)
return False
@staticmethod
def calculate_next_download(session: Session, download: Download) -> Optional[datetime]:
"""

View file

@ -1859,6 +1859,51 @@ async def test_daily_limit_counts_failed_attempts(test_session, test_download_ma
assert 'https://example.com/new' not in _dispatched_urls(dispatch)
@pytest.mark.asyncio
async def test_summary_daily_limit_reached(test_session, test_download_manager, test_downloader):
"""get_summary reports daily_limit_reached only when every due download is blocked by a daily limit."""
config = get_wrolpi_config()
name = test_downloader.name
# No limits configured.
assert test_download_manager.get_summary()['daily_limit_reached'] is False
config.download_daily_limit_per_domain = 2
# No due downloads => not reached, even though limits are configured.
_make_processed_download(test_session, name, 'https://example.com/1')
_make_processed_download(test_session, name, 'https://example.com/2')
test_session.commit()
assert test_download_manager.get_summary()['daily_limit_reached'] is False
# A due download for the capped domain => reached.
blocked = Download(url='https://example.com/new', downloader=name, status='new')
test_session.add(blocked)
test_session.commit()
assert test_download_manager.get_summary()['daily_limit_reached'] is True
# A due download for another domain can still dispatch => not reached.
allowed = Download(url='https://rumble.com/new', downloader=name, status='new')
test_session.add(allowed)
test_session.commit()
assert test_download_manager.get_summary()['daily_limit_reached'] is False
# Recurring downloads are never gated by daily limits and must not count as "due".
test_session.delete(allowed)
recurring = Download(url='https://example.com/feed', downloader=name, status='new',
frequency=DownloadFrequency.daily)
test_session.add(recurring)
test_session.commit()
assert test_download_manager.get_summary()['daily_limit_reached'] is True
# Global limit blocks everything, even a fresh domain.
config.download_daily_limit_per_domain = None
config.download_daily_limit_global = 2
fresh = Download(url='https://wikipedia.org/new', downloader=name, status='new')
test_session.add(fresh)
test_session.commit()
assert test_download_manager.get_summary()['daily_limit_reached'] is True
@pytest.mark.asyncio
async def test_daily_limit_global_blocks(test_session, test_download_manager, test_downloader):
"""The global daily limit caps downloads across all domains."""