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:
2026-02-21 08:56:17 +01:00
parent ac7e15e1eb
commit 0265ae2a70
15 changed files with 1923 additions and 1628 deletions

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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>