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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,17 +254,46 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="config-item" id="rescan-interval-config">
|
||||
<label for="scheduled-rescan-interval" data-text="rescan-interval">Check Interval (minutes):</label>
|
||||
<input type="number" id="scheduled-rescan-interval" value="60" min="1" class="input-field">
|
||||
<small class="config-hint" data-text="rescan-interval-hint">
|
||||
How often to check for new episodes (minimum 1 minute)
|
||||
</small>
|
||||
<div class="config-item" id="rescan-time-config">
|
||||
<label for="scheduled-rescan-time" data-text="rescan-time">Run at:</label>
|
||||
<input type="time" id="scheduled-rescan-time" value="03:00" class="input-field">
|
||||
</div>
|
||||
|
||||
<div class="config-item" id="rescan-time-config">
|
||||
<label for="scheduled-rescan-time" data-text="rescan-time">Rescan Time (24h format):</label>
|
||||
<input type="time" id="scheduled-rescan-time" value="03:00" class="input-field">
|
||||
<div class="config-item" id="rescan-days-config">
|
||||
<label data-text="rescan-days">Days of week:</label>
|
||||
<div class="scheduler-days-container">
|
||||
<label class="scheduler-day-toggle-label">
|
||||
<input type="checkbox" id="scheduler-day-mon" checked class="scheduler-day-checkbox">
|
||||
<span class="scheduler-day-label" data-text="day-mon">Mon</span>
|
||||
</label>
|
||||
<label class="scheduler-day-toggle-label">
|
||||
<input type="checkbox" id="scheduler-day-tue" checked class="scheduler-day-checkbox">
|
||||
<span class="scheduler-day-label" data-text="day-tue">Tue</span>
|
||||
</label>
|
||||
<label class="scheduler-day-toggle-label">
|
||||
<input type="checkbox" id="scheduler-day-wed" checked class="scheduler-day-checkbox">
|
||||
<span class="scheduler-day-label" data-text="day-wed">Wed</span>
|
||||
</label>
|
||||
<label class="scheduler-day-toggle-label">
|
||||
<input type="checkbox" id="scheduler-day-thu" checked class="scheduler-day-checkbox">
|
||||
<span class="scheduler-day-label" data-text="day-thu">Thu</span>
|
||||
</label>
|
||||
<label class="scheduler-day-toggle-label">
|
||||
<input type="checkbox" id="scheduler-day-fri" checked class="scheduler-day-checkbox">
|
||||
<span class="scheduler-day-label" data-text="day-fri">Fri</span>
|
||||
</label>
|
||||
<label class="scheduler-day-toggle-label">
|
||||
<input type="checkbox" id="scheduler-day-sat" checked class="scheduler-day-checkbox">
|
||||
<span class="scheduler-day-label" data-text="day-sat">Sat</span>
|
||||
</label>
|
||||
<label class="scheduler-day-toggle-label">
|
||||
<input type="checkbox" id="scheduler-day-sun" checked class="scheduler-day-checkbox">
|
||||
<span class="scheduler-day-label" data-text="day-sun">Sun</span>
|
||||
</label>
|
||||
</div>
|
||||
<small class="config-hint" data-text="rescan-days-hint">
|
||||
Scheduler runs at the selected time on checked days. Uncheck all to disable scheduling.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
@@ -276,11 +305,23 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Advanced: legacy interval (hidden by default) -->
|
||||
<details class="config-advanced">
|
||||
<summary data-text="advanced-settings">Advanced</summary>
|
||||
<div class="config-item" id="rescan-interval-config">
|
||||
<label for="scheduled-rescan-interval" data-text="rescan-interval">Legacy Check Interval (minutes):</label>
|
||||
<input type="number" id="scheduled-rescan-interval" value="60" min="1" class="input-field">
|
||||
<small class="config-hint" data-text="rescan-interval-hint">
|
||||
Deprecated: only used if cron scheduling is not configured
|
||||
</small>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="config-item scheduler-status" id="scheduler-status">
|
||||
<div class="scheduler-info">
|
||||
<div class="info-row">
|
||||
<span data-text="next-rescan">Next Scheduled Rescan:</span>
|
||||
<span id="next-rescan-time" class="info-value">-</span>
|
||||
<span id="scheduler-next-run" class="info-value">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span data-text="last-rescan">Last Scheduled Rescan:</span>
|
||||
|
||||
Reference in New Issue
Block a user