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:
@@ -228,3 +228,122 @@
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Scheduler day-of-week toggle pills
|
||||
============================================================ */
|
||||
|
||||
.scheduler-days-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.scheduler-day-toggle-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Hide the raw checkbox visually */
|
||||
.scheduler-day-toggle-label .scheduler-day-checkbox {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Pill styling */
|
||||
.scheduler-day-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.6rem;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-xl);
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
background-color: var(--color-bg-secondary);
|
||||
transition: background-color var(--transition-duration) var(--transition-easing),
|
||||
color var(--transition-duration) var(--transition-easing),
|
||||
border-color var(--transition-duration) var(--transition-easing);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Checked state – filled accent */
|
||||
.scheduler-day-checkbox:checked + .scheduler-day-label {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Hover for unchecked */
|
||||
.scheduler-day-toggle-label:hover .scheduler-day-label {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Hover for checked */
|
||||
.scheduler-day-toggle-label:hover .scheduler-day-checkbox:checked + .scheduler-day-label {
|
||||
background-color: var(--color-accent-hover);
|
||||
border-color: var(--color-accent-hover);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark theme overrides */
|
||||
[data-theme="dark"] .scheduler-day-label {
|
||||
border-color: var(--color-border-dark);
|
||||
color: var(--color-text-secondary-dark);
|
||||
background-color: var(--color-bg-secondary-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .scheduler-day-checkbox:checked + .scheduler-day-label {
|
||||
background-color: var(--color-accent-dark);
|
||||
border-color: var(--color-accent-dark);
|
||||
color: var(--color-bg-primary-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .scheduler-day-toggle-label:hover .scheduler-day-label {
|
||||
border-color: var(--color-accent-dark);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
/* Next run display */
|
||||
#scheduler-next-run {
|
||||
font-style: italic;
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] #scheduler-next-run {
|
||||
color: var(--color-text-tertiary-dark);
|
||||
}
|
||||
|
||||
/* Advanced/collapsible section */
|
||||
.config-advanced {
|
||||
font-size: var(--font-size-caption);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.config-advanced summary {
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs) 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive: wrap day pills to 2 rows on mobile */
|
||||
@media (max-width: 480px) {
|
||||
.scheduler-days-container {
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.scheduler-day-label {
|
||||
min-width: 2.2rem;
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1554,24 +1554,42 @@ class AniWorldApp {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const config = data.config;
|
||||
const config = data.config || {};
|
||||
const schedulerStatus = data.status || {};
|
||||
|
||||
// Update UI elements
|
||||
document.getElementById('scheduled-rescan-enabled').checked = config.enabled;
|
||||
document.getElementById('scheduled-rescan-time').value = config.time || '03:00';
|
||||
document.getElementById('auto-download-after-rescan').checked = config.auto_download_after_rescan;
|
||||
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
|
||||
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
|
||||
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
|
||||
|
||||
// Update day-of-week checkboxes
|
||||
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
|
||||
['mon','tue','wed','thu','fri','sat','sun'].forEach(day => {
|
||||
const cb = document.getElementById(`scheduler-day-${day}`);
|
||||
if (cb) cb.checked = days.includes(day);
|
||||
});
|
||||
|
||||
// Update status display
|
||||
document.getElementById('next-rescan-time').textContent =
|
||||
config.next_run ? new Date(config.next_run).toLocaleString() : 'Not scheduled';
|
||||
document.getElementById('last-rescan-time').textContent =
|
||||
config.last_run ? new Date(config.last_run).toLocaleString() : 'Never';
|
||||
const nextRunEl = document.getElementById('scheduler-next-run');
|
||||
if (nextRunEl) {
|
||||
nextRunEl.textContent = schedulerStatus.next_run
|
||||
? new Date(schedulerStatus.next_run).toLocaleString()
|
||||
: 'Not scheduled';
|
||||
}
|
||||
const lastRunEl = document.getElementById('last-rescan-time');
|
||||
if (lastRunEl) {
|
||||
lastRunEl.textContent = schedulerStatus.last_run
|
||||
? new Date(schedulerStatus.last_run).toLocaleString()
|
||||
: 'Never';
|
||||
}
|
||||
|
||||
const statusBadge = document.getElementById('scheduler-running-status');
|
||||
statusBadge.textContent = config.is_running ? 'Running' : 'Stopped';
|
||||
statusBadge.className = `info-value status-badge ${config.is_running ? 'running' : 'stopped'}`;
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = schedulerStatus.is_running ? 'Running' : 'Stopped';
|
||||
statusBadge.className = `info-value status-badge ${schedulerStatus.is_running ? 'running' : 'stopped'}`;
|
||||
}
|
||||
|
||||
// Enable/disable time input based on checkbox
|
||||
// Enable/disable time/day inputs based on checkbox
|
||||
this.toggleSchedulerTimeInput();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1583,17 +1601,23 @@ class AniWorldApp {
|
||||
async saveSchedulerConfig() {
|
||||
try {
|
||||
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
|
||||
const time = document.getElementById('scheduled-rescan-time').value;
|
||||
const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
|
||||
const autoDownload = document.getElementById('auto-download-after-rescan').checked;
|
||||
|
||||
// Collect checked day-of-week values
|
||||
const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
|
||||
.filter(day => {
|
||||
const cb = document.getElementById(`scheduler-day-${day}`);
|
||||
return cb ? cb.checked : true;
|
||||
});
|
||||
|
||||
const response = await this.makeAuthenticatedRequest('/api/scheduler/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: enabled,
|
||||
time: time,
|
||||
schedule_time: scheduleTime,
|
||||
schedule_days: scheduleDays,
|
||||
auto_download_after_rescan: autoDownload
|
||||
})
|
||||
});
|
||||
@@ -1603,7 +1627,12 @@ class AniWorldApp {
|
||||
|
||||
if (data.success) {
|
||||
this.showToast('Scheduler configuration saved successfully', 'success');
|
||||
// Reload config to update display
|
||||
// Update next-run display from response
|
||||
const nextRunEl = document.getElementById('scheduler-next-run');
|
||||
if (nextRunEl && data.status && data.status.next_run) {
|
||||
nextRunEl.textContent = new Date(data.status.next_run).toLocaleString();
|
||||
}
|
||||
// Reload config to sync the full UI
|
||||
await this.loadSchedulerConfig();
|
||||
} else {
|
||||
this.showToast(`Failed to save config: ${data.error}`, 'error');
|
||||
@@ -1637,11 +1666,19 @@ class AniWorldApp {
|
||||
toggleSchedulerTimeInput() {
|
||||
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
|
||||
const timeConfig = document.getElementById('rescan-time-config');
|
||||
const daysConfig = document.getElementById('rescan-days-config');
|
||||
const nextRunEl = document.getElementById('scheduler-next-run');
|
||||
|
||||
if (enabled) {
|
||||
timeConfig.classList.add('enabled');
|
||||
} else {
|
||||
timeConfig.classList.remove('enabled');
|
||||
if (timeConfig) {
|
||||
timeConfig.classList.toggle('enabled', enabled);
|
||||
}
|
||||
if (daysConfig) {
|
||||
daysConfig.classList.toggle('enabled', enabled);
|
||||
}
|
||||
if (nextRunEl) {
|
||||
nextRunEl.parentElement && nextRunEl.parentElement.parentElement
|
||||
? nextRunEl.parentElement.parentElement.classList.toggle('hidden', !enabled)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user