fix(library): run scans on the stored auto-scan schedule

The auto-scan cron expression was validated, persisted and shown in the UI, but nothing ever
consumed it, so a library configured with a schedule never scanned and no scan job was ever
recorded with the schedule trigger.

Register a cron job per configured library on boot and keep it in step as libraries are created,
updated and deleted. An occurrence that lands while a scan is already running is skipped rather
than retried, and a failing occurrence leaves the job registered so later ones still fire.

Schedules run in the server timezone, matching the cleanup, audit and statistics jobs, so a
deployment that sets TZ gets the wall-clock time its users expect.

Move cron validation into a single shared grammar used by both the API and the schedule editor.
The two sides previously disagreed in both directions: the UI accepted step-zero and reversed
ranges that the API rejected, and rejected day names the API accepted. The grammar is a strict
subset of what the scheduler and the human-readable preview can each parse, so an expression the
editor marks valid can always be scheduled.

Closes #985
This commit is contained in:
neon
2026-08-14 04:46:29 +00:00
parent 34e09fa655
commit 53be54752d
22 changed files with 545 additions and 23 deletions
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Eye } from '@lucide/vue'
import cronstrue from 'cronstrue'
import { isFiveFieldCronExpression } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
const { t } = useI18n()
@@ -27,8 +28,6 @@ const presets = computed(() => [
{ label: t('library.creator.schedule.presets.custom'), value: '__custom__' },
])
const CRON_REGEX = /^((\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)? ){4}(\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?$/
const isCustom = computed(() => {
if (props.autoScanCronExpression === null) return false
return !presets.value.some((p) => p.value === props.autoScanCronExpression)
@@ -36,7 +35,7 @@ const isCustom = computed(() => {
const isCronValid = computed(() => {
if (!isCustom.value || !props.autoScanCronExpression) return true
return CRON_REGEX.test(props.autoScanCronExpression)
return isFiveFieldCronExpression(props.autoScanCronExpression)
})
const selectedPreset = computed(() => {
@@ -74,7 +73,7 @@ function humanReadableCron(cron: string | null): string {
try {
return cronstrue.toString(cron)
} catch {
return 'Enter a valid schedule to see a preview'
return t('library.creator.schedule.human.invalid')
}
}
</script>
@@ -98,6 +97,7 @@ function humanReadableCron(cron: string | null): string {
<!-- Auto-scan schedule -->
<div>
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground mb-3">{{ t('library.creator.schedule.autoScanSchedule') }}</p>
<p class="mb-3 text-xs text-muted-foreground">{{ t('library.creator.schedule.timezoneHint') }}</p>
<div class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
v-for="preset in presets"
@@ -1,6 +1,6 @@
import { computed, reactive, ref } from 'vue'
import { api } from '@/lib/api'
import { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS } from '@bookorbit/types'
import { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS, isFiveFieldCronExpression } from '@bookorbit/types'
import type { CoverAspectRatio, Library, OrganizationMode, PrescanResult } from '@bookorbit/types'
export { DEFAULT_FORMAT_PRIORITY, FORMAT_LABELS }
@@ -14,7 +14,6 @@ export const METADATA_LABELS: Record<string, string> = {
export type LibraryCreatorSectionId = 'details' | 'folders' | 'scanner' | 'metadata' | 'reading' | 'schedule' | 'fileWrite' | 'access'
const CRON_REGEX = /^((\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)? ){4}(\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?$/
const FILE_SIZE_MIN_MB = 1
const FILE_SIZE_MAX_MB = 10_000
@@ -66,7 +65,7 @@ export function useLibraryCreator() {
if (!form.name.trim()) errors.details = 'Enter a library name.'
else if (!form.icon?.trim()) errors.details = 'Choose an icon.'
if (form.folders.length === 0) errors.folders = 'Add at least one folder.'
if (form.autoScanCronExpression && !CRON_REGEX.test(form.autoScanCronExpression)) {
if (form.autoScanCronExpression && !isFiveFieldCronExpression(form.autoScanCronExpression)) {
errors.schedule = 'Enter a valid 5-field cron expression.'
}
if (form.readingThreshold < 0.05 || form.readingThreshold > 5) {
@@ -1,7 +1,25 @@
import { describe, expect, it } from 'vitest'
import { isFiveFieldCronExpression } from '@bookorbit/types'
import { parseCronToHuman } from '../cron'
describe('isFiveFieldCronExpression', () => {
it.each(['0 4 * * *', '*/30 * * * *', '0 0 * * 1', '0 4 * * MON', '0 4 * * *', '1-5/2 * * * *', '0 4 * jan-mar *'])(
'accepts %s and previews it',
(expression) => {
expect(isFiveFieldCronExpression(expression)).toBe(true)
expect(parseCronToHuman(expression)).not.toBe(expression)
},
)
it.each(['not a cron', '0 99 * * *', '* * * * * *', '*/0 * * * *', '5-1 * * * *', '0 4 */0 * *', '@daily', '0 4 * * FRI-MON'])(
'rejects %s',
(expression) => {
expect(isFiveFieldCronExpression(expression)).toBe(false)
},
)
})
describe('parseCronToHuman', () => {
it('returns null for null input', () => {
expect(parseCronToHuman(null)).toBeNull()
+2
View File
@@ -5672,6 +5672,7 @@
"schedule": {
"fileWatching": "File watching",
"autoScanSchedule": "Auto-scan schedule",
"timezoneHint": "Schedules run in the server's timezone.",
"cronExpression": "Cron expression",
"cronInvalid": "Invalid cron expression.",
"cronFormat": "Format: minute hour day month weekday",
@@ -5690,6 +5691,7 @@
},
"human": {
"disabled": "Auto-scan disabled",
"invalid": "Enter a valid schedule to see a preview",
"hourly": "Every hour",
"every6Hours": "Every 6 hours",
"every12Hours": "Every 12 hours",