feat: add next_run_at column to sync config tables and update job schedules to cron expressions

This commit is contained in:
Sam Chau
2026-01-15 15:14:21 +10:30
parent f73d08c5b3
commit 27835c3426
13 changed files with 190 additions and 59 deletions

View File

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

15
deno.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -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<JobResult> => {
try {

View File

@@ -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<JobResult> => {
try {

View File

@@ -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<JobResult> => {
try {

View File

@@ -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<JobResult> => {
try {

View File

@@ -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<JobResult> => {
try {

View File

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

View File

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

View File

@@ -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"
/>
<div class="mt-2 space-y-1">
<p class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Examples:</p>
<ul
class="list-inside list-disc space-y-1 text-sm text-neutral-600 dark:text-neutral-400"
>
<li>
<code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">daily</code> - Runs
once per day
</li>
<li>
<code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">hourly</code> - Runs
every hour
</li>
<li>
<code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">*/5 minutes</code
> - Runs every 5 minutes
</li>
<li>
<code class="rounded bg-neutral-100 px-1 py-0.5 dark:bg-neutral-800">weekly</code> - Runs
once per week
</li>
</ul>
<div class="mt-2 space-y-2">
<p class="text-sm text-neutral-600 dark:text-neutral-400">
Cron expression: <code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">minute hour day month weekday</code>
</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-neutral-600 dark:text-neutral-400">
<div class="flex items-center gap-2">
<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">* * * * *</code>
<span>Every minute</span>
</div>
<div class="flex items-center gap-2">
<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">*/5 * * * *</code>
<span>Every 5 minutes</span>
</div>
<div class="flex items-center gap-2">
<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">0 * * * *</code>
<span>Every hour</span>
</div>
<div class="flex items-center gap-2">
<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">0 0 * * *</code>
<span>Daily at midnight</span>
</div>
<div class="flex items-center gap-2">
<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">0 6 * * 1</code>
<span>Mondays at 6am</span>
</div>
<div class="flex items-center gap-2">
<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">0 0 1 * *</code>
<span>First of month</span>
</div>
</div>
</div>
</div>