From 27835c34260d678a27df2bc7dfeab870c6c697f9 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Thu, 15 Jan 2026 15:14:21 +1030 Subject: [PATCH] feat: add next_run_at column to sync config tables and update job schedules to cron expressions --- deno.json | 3 +- deno.lock | 15 ++- src/lib/server/db/migrations.ts | 4 +- .../db/migrations/022_add_next_run_at.ts | 25 +++++ src/lib/server/db/queries/arrSync.ts | 91 ++++++++++++++++--- .../server/jobs/definitions/cleanupBackups.ts | 2 +- .../server/jobs/definitions/cleanupLogs.ts | 2 +- .../server/jobs/definitions/createBackup.ts | 2 +- .../server/jobs/definitions/syncDatabases.ts | 2 +- .../server/jobs/definitions/upgradeManager.ts | 2 +- src/lib/server/jobs/runner.ts | 26 +++--- src/lib/server/sync/cron.ts | 19 ++++ .../settings/jobs/[id]/edit/+page.svelte | 56 +++++++----- 13 files changed, 190 insertions(+), 59 deletions(-) create mode 100644 src/lib/server/db/migrations/022_add_next_run_at.ts create mode 100644 src/lib/server/sync/cron.ts diff --git a/deno.json b/deno.json index 930cbfe..62a6881 100644 --- a/deno.json +++ b/deno.json @@ -21,7 +21,8 @@ "@std/assert": "jsr:@std/assert@^1.0.0", "marked": "npm:marked@^15.0.6", "simple-icons": "npm:simple-icons@^15.17.0", - "highlight.js": "npm:highlight.js@^11.11.1" + "highlight.js": "npm:highlight.js@^11.11.1", + "croner": "npm:croner@^8.1.2" }, "tasks": { "dev": "DENO_ENV=development PORT=6969 HOST=0.0.0.0 APP_BASE_PATH=./dist/dev PARSER_HOST=localhost PARSER_PORT=5000 deno run -A npm:vite dev", diff --git a/deno.lock b/deno.lock index c4c8abf..cbf3a0b 100644 --- a/deno.lock +++ b/deno.lock @@ -26,10 +26,13 @@ "npm:@tailwindcss/forms@~0.5.10": "0.5.10_tailwindcss@4.1.16", "npm:@tailwindcss/vite@^4.1.13": "4.1.16_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0", "npm:@types/node@22": "22.19.0", + "npm:croner@^8.1.2": "8.1.2", "npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.1", "npm:eslint-plugin-svelte@^3.12.4": "3.13.0_eslint@9.39.1_svelte@5.43.3__acorn@8.15.0_postcss@8.5.6", "npm:eslint@^9.36.0": "9.39.1", "npm:globals@^16.4.0": "16.5.0", + "npm:highlight.js@^11.11.1": "11.11.1", + "npm:kysely@0.27.6": "0.27.6", "npm:kysely@~0.27.2": "0.27.6", "npm:lucide-svelte@0.546": "0.546.0_svelte@5.43.3__acorn@8.15.0", "npm:marked@^15.0.6": "15.0.12", @@ -65,7 +68,7 @@ "@soapbox/kysely-deno-sqlite@2.2.0": { "integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a", "dependencies": [ - "npm:kysely" + "npm:kysely@~0.27.2" ] }, "@std/assert@0.217.0": { @@ -1032,6 +1035,9 @@ "cookie@0.6.0": { "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, + "croner@8.1.2": { + "integrity": "sha512-ypfPFcAXHuAZRCzo3vJL6ltENzniTjwe/qsLleH1V2/7SRDjgvRQyrLmumFTLmjFax4IuSxfGXEn79fozXcJog==" + }, "cross-spawn@7.0.6": { "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": [ @@ -1347,6 +1353,9 @@ "has-flag@4.0.0": { "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, + "highlight.js@11.11.1": { + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==" + }, "ignore@5.3.2": { "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" }, @@ -1419,9 +1428,6 @@ "kysely@0.27.6": { "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" }, - "kysely@0.28.8": { - "integrity": "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==" - }, "levn@0.4.1": { "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dependencies": [ @@ -1947,6 +1953,7 @@ "dependencies": [ "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "jsr:@std/assert@1", + "npm:croner@^8.1.2", "npm:highlight.js@^11.11.1", "npm:marked@^15.0.6", "npm:simple-icons@^15.17.0" diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 4c9a2be..28dbf90 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -23,6 +23,7 @@ import { migration as migration018 } from './migrations/018_create_app_info.ts'; import { migration as migration019 } from './migrations/019_default_log_level_debug.ts'; import { migration as migration020 } from './migrations/020_create_tmdb_settings.ts'; import { migration as migration021 } from './migrations/021_create_parsed_release_cache.ts'; +import { migration as migration022 } from './migrations/022_add_next_run_at.ts'; export interface Migration { version: number; @@ -258,7 +259,8 @@ export function loadMigrations(): Migration[] { migration018, migration019, migration020, - migration021 + migration021, + migration022 ]; // Sort by version number diff --git a/src/lib/server/db/migrations/022_add_next_run_at.ts b/src/lib/server/db/migrations/022_add_next_run_at.ts new file mode 100644 index 0000000..bd894ac --- /dev/null +++ b/src/lib/server/db/migrations/022_add_next_run_at.ts @@ -0,0 +1,25 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 022: Add next_run_at to sync config tables + * + * Stores when each scheduled config should next trigger. + * Enables simple timestamp comparison instead of cron parsing on every evaluation. + */ + +export const migration: Migration = { + version: 22, + name: 'Add next_run_at to sync configs', + + up: ` + ALTER TABLE arr_sync_quality_profiles_config ADD COLUMN next_run_at TEXT; + ALTER TABLE arr_sync_delay_profiles_config ADD COLUMN next_run_at TEXT; + ALTER TABLE arr_sync_media_management ADD COLUMN next_run_at TEXT; + `, + + down: ` + ALTER TABLE arr_sync_quality_profiles_config DROP COLUMN next_run_at; + ALTER TABLE arr_sync_delay_profiles_config DROP COLUMN next_run_at; + ALTER TABLE arr_sync_media_management DROP COLUMN next_run_at; + ` +}; diff --git a/src/lib/server/db/queries/arrSync.ts b/src/lib/server/db/queries/arrSync.ts index 9c8dcc5..2e57706 100644 --- a/src/lib/server/db/queries/arrSync.ts +++ b/src/lib/server/db/queries/arrSync.ts @@ -11,6 +11,7 @@ export interface ProfileSelection { export interface SyncConfig { trigger: SyncTrigger; cron: string | null; + nextRunAt?: string | null; } export interface QualityProfilesSyncData { @@ -29,6 +30,7 @@ export interface MediaManagementSyncData { mediaSettingsDatabaseId: number | null; trigger: SyncTrigger; cron: string | null; + nextRunAt?: string | null; } // Row types @@ -99,14 +101,16 @@ export const arrSyncQueries = { // Upsert config db.execute( - `INSERT INTO arr_sync_quality_profiles_config (instance_id, trigger, cron) - VALUES (?, ?, ?) - ON CONFLICT(instance_id) DO UPDATE SET trigger = ?, cron = ?`, + `INSERT INTO arr_sync_quality_profiles_config (instance_id, trigger, cron, next_run_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(instance_id) DO UPDATE SET trigger = ?, cron = ?, next_run_at = ?`, instanceId, config.trigger, config.cron, + config.nextRunAt ?? null, config.trigger, - config.cron + config.cron, + config.nextRunAt ?? null ); }, @@ -155,14 +159,16 @@ export const arrSyncQueries = { // Upsert config db.execute( - `INSERT INTO arr_sync_delay_profiles_config (instance_id, trigger, cron) - VALUES (?, ?, ?) - ON CONFLICT(instance_id) DO UPDATE SET trigger = ?, cron = ?`, + `INSERT INTO arr_sync_delay_profiles_config (instance_id, trigger, cron, next_run_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(instance_id) DO UPDATE SET trigger = ?, cron = ?, next_run_at = ?`, instanceId, config.trigger, config.cron, + config.nextRunAt ?? null, config.trigger, - config.cron + config.cron, + config.nextRunAt ?? null ); }, @@ -186,25 +192,28 @@ export const arrSyncQueries = { saveMediaManagementSync(instanceId: number, data: MediaManagementSyncData): void { db.execute( `INSERT INTO arr_sync_media_management - (instance_id, naming_database_id, quality_definitions_database_id, media_settings_database_id, trigger, cron) - VALUES (?, ?, ?, ?, ?, ?) + (instance_id, naming_database_id, quality_definitions_database_id, media_settings_database_id, trigger, cron, next_run_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(instance_id) DO UPDATE SET naming_database_id = ?, quality_definitions_database_id = ?, media_settings_database_id = ?, trigger = ?, - cron = ?`, + cron = ?, + next_run_at = ?`, instanceId, data.namingDatabaseId, data.qualityDefinitionsDatabaseId, data.mediaSettingsDatabaseId, data.trigger, data.cron, + data.nextRunAt ?? null, data.namingDatabaseId, data.qualityDefinitionsDatabaseId, data.mediaSettingsDatabaseId, data.trigger, - data.cron + data.cron, + data.nextRunAt ?? null ); }, @@ -339,5 +348,63 @@ export const arrSyncQueries = { delayProfiles: dp.map((r) => r.instance_id), mediaManagement: mm.map((r) => r.instance_id) }; + }, + + /** + * Get all scheduled configs that haven't been marked for sync yet + */ + getScheduledConfigs(): { + qualityProfiles: { instanceId: number; cron: string | null; nextRunAt: string | null }[]; + delayProfiles: { instanceId: number; cron: string | null; nextRunAt: string | null }[]; + mediaManagement: { instanceId: number; cron: string | null; nextRunAt: string | null }[]; + } { + const qp = db.query<{ instance_id: number; cron: string | null; next_run_at: string | null }>( + "SELECT instance_id, cron, next_run_at FROM arr_sync_quality_profiles_config WHERE trigger = 'schedule' AND should_sync = 0" + ); + const dp = db.query<{ instance_id: number; cron: string | null; next_run_at: string | null }>( + "SELECT instance_id, cron, next_run_at FROM arr_sync_delay_profiles_config WHERE trigger = 'schedule' AND should_sync = 0" + ); + const mm = db.query<{ instance_id: number; cron: string | null; next_run_at: string | null }>( + "SELECT instance_id, cron, next_run_at FROM arr_sync_media_management WHERE trigger = 'schedule' AND should_sync = 0" + ); + + return { + qualityProfiles: qp.map((r) => ({ instanceId: r.instance_id, cron: r.cron, nextRunAt: r.next_run_at })), + delayProfiles: dp.map((r) => ({ instanceId: r.instance_id, cron: r.cron, nextRunAt: r.next_run_at })), + mediaManagement: mm.map((r) => ({ instanceId: r.instance_id, cron: r.cron, nextRunAt: r.next_run_at })) + }; + }, + + /** + * Update next_run_at for a quality profiles config + */ + setQualityProfilesNextRunAt(instanceId: number, nextRunAt: string | null): void { + db.execute( + 'UPDATE arr_sync_quality_profiles_config SET next_run_at = ? WHERE instance_id = ?', + nextRunAt, + instanceId + ); + }, + + /** + * Update next_run_at for a delay profiles config + */ + setDelayProfilesNextRunAt(instanceId: number, nextRunAt: string | null): void { + db.execute( + 'UPDATE arr_sync_delay_profiles_config SET next_run_at = ? WHERE instance_id = ?', + nextRunAt, + instanceId + ); + }, + + /** + * Update next_run_at for a media management config + */ + setMediaManagementNextRunAt(instanceId: number, nextRunAt: string | null): void { + db.execute( + 'UPDATE arr_sync_media_management SET next_run_at = ? WHERE instance_id = ?', + nextRunAt, + instanceId + ); } }; diff --git a/src/lib/server/jobs/definitions/cleanupBackups.ts b/src/lib/server/jobs/definitions/cleanupBackups.ts index 65e80d4..848fced 100644 --- a/src/lib/server/jobs/definitions/cleanupBackups.ts +++ b/src/lib/server/jobs/definitions/cleanupBackups.ts @@ -10,7 +10,7 @@ import type { JobDefinition, JobResult } from '../types.ts'; export const cleanupBackupsJob: JobDefinition = { name: 'cleanup_backups', description: 'Delete backups according to retention policy', - schedule: 'daily', + schedule: '0 0 * * *', // Daily at midnight handler: async (): Promise => { try { diff --git a/src/lib/server/jobs/definitions/cleanupLogs.ts b/src/lib/server/jobs/definitions/cleanupLogs.ts index 05421ce..e866e23 100644 --- a/src/lib/server/jobs/definitions/cleanupLogs.ts +++ b/src/lib/server/jobs/definitions/cleanupLogs.ts @@ -11,7 +11,7 @@ import type { JobDefinition, JobResult } from '../types.ts'; export const cleanupLogsJob: JobDefinition = { name: 'cleanup_logs', description: 'Delete log files according to retention policy', - schedule: 'daily', + schedule: '0 0 * * *', // Daily at midnight handler: async (): Promise => { try { diff --git a/src/lib/server/jobs/definitions/createBackup.ts b/src/lib/server/jobs/definitions/createBackup.ts index da3384e..d259517 100644 --- a/src/lib/server/jobs/definitions/createBackup.ts +++ b/src/lib/server/jobs/definitions/createBackup.ts @@ -11,7 +11,7 @@ import type { JobDefinition, JobResult } from '../types.ts'; export const createBackupJob: JobDefinition = { name: 'create_backup', description: 'Create compressed backup of data directory', - schedule: 'daily', + schedule: '0 0 * * *', // Daily at midnight handler: async (): Promise => { try { diff --git a/src/lib/server/jobs/definitions/syncDatabases.ts b/src/lib/server/jobs/definitions/syncDatabases.ts index 36c62d0..d86f749 100644 --- a/src/lib/server/jobs/definitions/syncDatabases.ts +++ b/src/lib/server/jobs/definitions/syncDatabases.ts @@ -9,7 +9,7 @@ import type { JobDefinition, JobResult } from '../types.ts'; export const syncDatabasesJob: JobDefinition = { name: 'sync_databases', description: 'Auto-sync PCD databases with remote repositories', - schedule: '*/5 minutes', + schedule: '*/5 * * * *', // Every 5 minutes handler: async (): Promise => { try { diff --git a/src/lib/server/jobs/definitions/upgradeManager.ts b/src/lib/server/jobs/definitions/upgradeManager.ts index a235b72..46eff19 100644 --- a/src/lib/server/jobs/definitions/upgradeManager.ts +++ b/src/lib/server/jobs/definitions/upgradeManager.ts @@ -12,7 +12,7 @@ import type { JobDefinition, JobResult } from '../types.ts'; export const upgradeManagerJob: JobDefinition = { name: 'upgrade_manager', description: 'Process library upgrades for arr instances', - schedule: '*/30 minutes', + schedule: '*/30 * * * *', // Every 30 minutes handler: async (): Promise => { try { diff --git a/src/lib/server/jobs/runner.ts b/src/lib/server/jobs/runner.ts index c95f722..7512513 100644 --- a/src/lib/server/jobs/runner.ts +++ b/src/lib/server/jobs/runner.ts @@ -4,36 +4,38 @@ import { logger } from '$logger/logger.ts'; import { notify } from '$notifications/builder.ts'; import { NotificationTypes } from '$notifications/types.ts'; import type { Job, JobResult } from './types.ts'; +import { Cron } from 'croner'; /** * Parse schedule string and calculate next run time - * @param schedule Schedule string (e.g., "daily", "hourly", "every 5 minutes") + * Supports cron expressions and legacy formats + * @param schedule Schedule string (cron expression or legacy format) * @returns ISO timestamp for next run */ function calculateNextRun(schedule: string): string { const now = new Date(); - // Parse schedule + // Handle legacy formats for backwards compatibility if (schedule === 'daily') { - // Run at midnight next day const next = new Date(now); next.setDate(next.getDate() + 1); next.setHours(0, 0, 0, 0); return next.toISOString(); } else if (schedule === 'hourly') { - // Run at start of next hour const next = new Date(now); next.setHours(next.getHours() + 1, 0, 0, 0); return next.toISOString(); - } else if (schedule.startsWith('*/')) { - // Parse "*/N minutes" format - const match = schedule.match(/^\*\/(\d+)\s+minutes?$/); - if (match) { - const minutes = parseInt(match[1], 10); - const next = new Date(now); - next.setMinutes(next.getMinutes() + minutes, 0, 0); - return next.toISOString(); + } + + // Try to parse as cron expression + try { + const cron = new Cron(schedule); + const nextRun = cron.nextRun(); + if (nextRun) { + return nextRun.toISOString(); } + } catch { + // Invalid cron expression, fall through to default } // Default: run in 1 hour if we can't parse diff --git a/src/lib/server/sync/cron.ts b/src/lib/server/sync/cron.ts new file mode 100644 index 0000000..86eccf8 --- /dev/null +++ b/src/lib/server/sync/cron.ts @@ -0,0 +1,19 @@ +/** + * Cron utility functions for sync scheduling + */ + +import { Cron } from 'croner'; + +/** + * Calculate the next run time from a cron expression + */ +export function calculateNextRun(cronExpr: string | null): string | null { + if (!cronExpr) return null; + try { + const cron = new Cron(cronExpr); + const nextRun = cron.nextRun(); + return nextRun?.toISOString() ?? null; + } catch { + return null; + } +} diff --git a/src/routes/settings/jobs/[id]/edit/+page.svelte b/src/routes/settings/jobs/[id]/edit/+page.svelte index cc3935f..32e9324 100644 --- a/src/routes/settings/jobs/[id]/edit/+page.svelte +++ b/src/routes/settings/jobs/[id]/edit/+page.svelte @@ -89,31 +89,39 @@ name="schedule" bind:value={schedule} required - placeholder="e.g., daily, hourly, */5 minutes" - class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-500 dark:focus:ring-neutral-500" + placeholder="* * * * *" + class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-neutral-900 placeholder-neutral-400 focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-500 dark:focus:ring-neutral-500" /> -
-

Examples:

-
    -
  • - daily - Runs - once per day -
  • -
  • - hourly - Runs - every hour -
  • -
  • - */5 minutes - Runs every 5 minutes -
  • -
  • - weekly - Runs - once per week -
  • -
+
+

+ Cron expression: minute hour day month weekday +

+
+
+ * * * * * + Every minute +
+
+ */5 * * * * + Every 5 minutes +
+
+ 0 * * * * + Every hour +
+
+ 0 0 * * * + Daily at midnight +
+
+ 0 6 * * 1 + Mondays at 6am +
+
+ 0 0 1 * * + First of month +
+