From 353fe3832f5fe8294a1adee874bd09a55af019c9 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Wed, 21 Jan 2026 09:30:48 +1030 Subject: [PATCH] refactor: delay profile handling. remove tags, only allow 1 delay profile to be synced at once. simplified dp sync config --- bruno/radarr/create-delay-profile.bru | 30 ++ bruno/radarr/get-delay-profiles.bru | 15 + bruno/radarr/get-tags.bru | 15 + .../radarr/update-delay-profile-with-tags.bru | 32 ++ bruno/radarr/update-delay-profile.bru | 32 ++ bruno/sonarr/get-delay-profiles.bru | 15 + bruno/sonarr/get-tags.bru | 15 + src/lib/server/db/migrations.ts | 4 +- .../028_simplify_delay_profile_sync.ts | 36 ++ src/lib/server/db/queries/arrSync.ts | 89 +++-- src/lib/server/db/schema.sql | 23 +- .../pcd/queries/delayProfiles/create.ts | 30 +- .../pcd/queries/delayProfiles/delete.ts | 26 +- .../server/pcd/queries/delayProfiles/get.ts | 17 - .../server/pcd/queries/delayProfiles/list.ts | 30 -- .../server/pcd/queries/delayProfiles/types.ts | 3 - .../pcd/queries/delayProfiles/update.ts | 62 +--- src/lib/server/pcd/schema.ts | 8 +- src/lib/server/sync/delayProfiles.ts | 337 +++++------------- src/lib/server/sync/processor.ts | 4 +- src/routes/arr/[id]/sync/+page.server.ts | 16 +- src/routes/arr/[id]/sync/+page.svelte | 52 ++- .../[id]/sync/components/DelayProfiles.svelte | 59 ++- .../sync/components/QualityProfiles.svelte | 24 +- .../[databaseId]/[id]/+page.server.ts | 13 - .../[databaseId]/[id]/+page.svelte | 1 - .../components/DelayProfileForm.svelte | 23 +- .../[databaseId]/new/+page.server.ts | 13 - .../[databaseId]/new/+page.svelte | 1 - .../[databaseId]/views/CardView.svelte | 13 +- .../[databaseId]/views/TableView.svelte | 23 +- 31 files changed, 442 insertions(+), 619 deletions(-) create mode 100644 bruno/radarr/create-delay-profile.bru create mode 100644 bruno/radarr/get-delay-profiles.bru create mode 100644 bruno/radarr/get-tags.bru create mode 100644 bruno/radarr/update-delay-profile-with-tags.bru create mode 100644 bruno/radarr/update-delay-profile.bru create mode 100644 bruno/sonarr/get-delay-profiles.bru create mode 100644 bruno/sonarr/get-tags.bru create mode 100644 src/lib/server/db/migrations/028_simplify_delay_profile_sync.ts diff --git a/bruno/radarr/create-delay-profile.bru b/bruno/radarr/create-delay-profile.bru new file mode 100644 index 0000000..0b542b3 --- /dev/null +++ b/bruno/radarr/create-delay-profile.bru @@ -0,0 +1,30 @@ +meta { + name: Create Delay Profile + type: http + seq: 6 +} + +post { + url: {{radarrUrl}}/api/v3/delayprofile + body: json + auth: none +} + +headers { + X-Api-Key: {{radarrApiKey}} + Content-Type: application/json +} + +body:json { + { + "enableUsenet": true, + "enableTorrent": true, + "preferredProtocol": "usenet", + "usenetDelay": 30, + "torrentDelay": 60, + "bypassIfHighestQuality": true, + "bypassIfAboveCustomFormatScore": false, + "minimumCustomFormatScore": 0, + "tags": [] + } +} diff --git a/bruno/radarr/get-delay-profiles.bru b/bruno/radarr/get-delay-profiles.bru new file mode 100644 index 0000000..929505d --- /dev/null +++ b/bruno/radarr/get-delay-profiles.bru @@ -0,0 +1,15 @@ +meta { + name: Get Delay Profiles + type: http + seq: 4 +} + +get { + url: {{radarrUrl}}/api/v3/delayprofile + body: none + auth: none +} + +headers { + X-Api-Key: {{radarrApiKey}} +} diff --git a/bruno/radarr/get-tags.bru b/bruno/radarr/get-tags.bru new file mode 100644 index 0000000..a2dfefa --- /dev/null +++ b/bruno/radarr/get-tags.bru @@ -0,0 +1,15 @@ +meta { + name: Get Tags + type: http + seq: 5 +} + +get { + url: {{radarrUrl}}/api/v3/tag + body: none + auth: none +} + +headers { + X-Api-Key: {{radarrApiKey}} +} diff --git a/bruno/radarr/update-delay-profile-with-tags.bru b/bruno/radarr/update-delay-profile-with-tags.bru new file mode 100644 index 0000000..f52f5c0 --- /dev/null +++ b/bruno/radarr/update-delay-profile-with-tags.bru @@ -0,0 +1,32 @@ +meta { + name: Update Delay Profile (Default) with Tags + type: http + seq: 8 +} + +put { + url: {{radarrUrl}}/api/v3/delayprofile/1 + body: json + auth: none +} + +headers { + X-Api-Key: {{radarrApiKey}} + Content-Type: application/json +} + +body:json { + { + "id": 1, + "enableUsenet": true, + "enableTorrent": true, + "preferredProtocol": "usenet", + "usenetDelay": 45, + "torrentDelay": 90, + "bypassIfHighestQuality": true, + "bypassIfAboveCustomFormatScore": false, + "minimumCustomFormatScore": 0, + "order": 2147483647, + "tags": [8] + } +} diff --git a/bruno/radarr/update-delay-profile.bru b/bruno/radarr/update-delay-profile.bru new file mode 100644 index 0000000..a1b4df4 --- /dev/null +++ b/bruno/radarr/update-delay-profile.bru @@ -0,0 +1,32 @@ +meta { + name: Update Delay Profile (Default) + type: http + seq: 7 +} + +put { + url: {{radarrUrl}}/api/v3/delayprofile/1 + body: json + auth: none +} + +headers { + X-Api-Key: {{radarrApiKey}} + Content-Type: application/json +} + +body:json { + { + "id": 1, + "enableUsenet": true, + "enableTorrent": true, + "preferredProtocol": "usenet", + "usenetDelay": 30, + "torrentDelay": 60, + "bypassIfHighestQuality": true, + "bypassIfAboveCustomFormatScore": false, + "minimumCustomFormatScore": 0, + "order": 2147483647, + "tags": [] + } +} diff --git a/bruno/sonarr/get-delay-profiles.bru b/bruno/sonarr/get-delay-profiles.bru new file mode 100644 index 0000000..444fc59 --- /dev/null +++ b/bruno/sonarr/get-delay-profiles.bru @@ -0,0 +1,15 @@ +meta { + name: Get Delay Profiles + type: http + seq: 1 +} + +get { + url: {{sonarrUrl}}/api/v3/delayprofile + body: none + auth: none +} + +headers { + X-Api-Key: {{sonarrApiKey}} +} diff --git a/bruno/sonarr/get-tags.bru b/bruno/sonarr/get-tags.bru new file mode 100644 index 0000000..533141b --- /dev/null +++ b/bruno/sonarr/get-tags.bru @@ -0,0 +1,15 @@ +meta { + name: Get Tags + type: http + seq: 2 +} + +get { + url: {{sonarrUrl}}/api/v3/tag + body: none + auth: none +} + +headers { + X-Api-Key: {{sonarrApiKey}} +} diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 2cba436..a892218 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -29,6 +29,7 @@ import { migration as migration024 } from './migrations/024_create_arr_rename_se import { migration as migration025 } from './migrations/025_add_rename_notification_mode.ts'; import { migration as migration026 } from './migrations/026_create_upgrade_runs.ts'; import { migration as migration027 } from './migrations/027_create_rename_runs.ts'; +import { migration as migration028 } from './migrations/028_simplify_delay_profile_sync.ts'; export interface Migration { version: number; @@ -270,7 +271,8 @@ export function loadMigrations(): Migration[] { migration024, migration025, migration026, - migration027 + migration027, + migration028 ]; // Sort by version number diff --git a/src/lib/server/db/migrations/028_simplify_delay_profile_sync.ts b/src/lib/server/db/migrations/028_simplify_delay_profile_sync.ts new file mode 100644 index 0000000..af880e0 --- /dev/null +++ b/src/lib/server/db/migrations/028_simplify_delay_profile_sync.ts @@ -0,0 +1,36 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 028: Simplify delay profile sync to single profile + * + * Only one delay profile can be synced per arr instance (updates id=1). + */ + +export const migration: Migration = { + version: 28, + name: 'Simplify delay profile sync to single profile', + + up: ` + -- Drop the multi-select table + DROP INDEX IF EXISTS idx_arr_sync_delay_profiles_instance; + DROP TABLE IF EXISTS arr_sync_delay_profiles; + + -- Add single profile reference to config table + ALTER TABLE arr_sync_delay_profiles_config ADD COLUMN database_id INTEGER; + ALTER TABLE arr_sync_delay_profiles_config ADD COLUMN profile_id INTEGER; + `, + + down: ` + -- Recreate multi-select table + CREATE TABLE arr_sync_delay_profiles ( + instance_id INTEGER NOT NULL, + database_id INTEGER NOT NULL, + profile_id INTEGER NOT NULL, + PRIMARY KEY (instance_id, database_id, profile_id), + FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE + ); + CREATE INDEX idx_arr_sync_delay_profiles_instance ON arr_sync_delay_profiles(instance_id); + + -- Note: Cannot easily remove columns in SQLite, leaving them + ` +}; diff --git a/src/lib/server/db/queries/arrSync.ts b/src/lib/server/db/queries/arrSync.ts index 2f2c0c8..c4ae091 100644 --- a/src/lib/server/db/queries/arrSync.ts +++ b/src/lib/server/db/queries/arrSync.ts @@ -20,8 +20,11 @@ export interface QualityProfilesSyncData { } export interface DelayProfilesSyncData { - selections: ProfileSelection[]; - config: SyncConfig; + databaseId: number | null; + profileId: number | null; + trigger: SyncTrigger; + cron: string | null; + nextRunAt?: string | null; } export interface MediaManagementSyncData { @@ -46,6 +49,14 @@ interface ConfigRow { cron: string | null; } +interface DelayProfileConfigRow { + instance_id: number; + database_id: number | null; + profile_id: number | null; + trigger: string; + cron: string | null; +} + interface MediaManagementRow { instance_id: number; naming_database_id: number | null; @@ -117,58 +128,41 @@ export const arrSyncQueries = { // ========== Delay Profiles ========== getDelayProfilesSync(instanceId: number): DelayProfilesSyncData { - const selectionRows = db.query( - 'SELECT * FROM arr_sync_delay_profiles WHERE instance_id = ?', - instanceId - ); - - const configRow = db.queryFirst( + const row = db.queryFirst( 'SELECT * FROM arr_sync_delay_profiles_config WHERE instance_id = ?', instanceId ); return { - selections: selectionRows.map((row) => ({ - databaseId: row.database_id, - profileId: row.profile_id - })), - config: { - trigger: (configRow?.trigger as SyncTrigger) ?? 'manual', - cron: configRow?.cron ?? null - } + databaseId: row?.database_id ?? null, + profileId: row?.profile_id ?? null, + trigger: (row?.trigger as SyncTrigger) ?? 'manual', + cron: row?.cron ?? null }; }, - saveDelayProfilesSync( - instanceId: number, - selections: ProfileSelection[], - config: SyncConfig - ): void { - // Clear existing selections - db.execute('DELETE FROM arr_sync_delay_profiles WHERE instance_id = ?', instanceId); - - // Insert new selections - for (const sel of selections) { - db.execute( - 'INSERT INTO arr_sync_delay_profiles (instance_id, database_id, profile_id) VALUES (?, ?, ?)', - instanceId, - sel.databaseId, - sel.profileId - ); - } - - // Upsert config + saveDelayProfilesSync(instanceId: number, data: DelayProfilesSyncData): void { db.execute( - `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 = ?`, + `INSERT INTO arr_sync_delay_profiles_config + (instance_id, database_id, profile_id, trigger, cron, next_run_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(instance_id) DO UPDATE SET + database_id = ?, + profile_id = ?, + trigger = ?, + cron = ?, + next_run_at = ?`, instanceId, - config.trigger, - config.cron, - config.nextRunAt ?? null, - config.trigger, - config.cron, - config.nextRunAt ?? null + data.databaseId, + data.profileId, + data.trigger, + data.cron, + data.nextRunAt ?? null, + data.databaseId, + data.profileId, + data.trigger, + data.cron, + data.nextRunAt ?? null ); }, @@ -242,7 +236,7 @@ export const arrSyncQueries = { removeDelayProfileReference(databaseId: number, profileId: number): number { return db.execute( - 'DELETE FROM arr_sync_delay_profiles WHERE database_id = ? AND profile_id = ?', + 'UPDATE arr_sync_delay_profiles_config SET database_id = NULL, profile_id = NULL WHERE database_id = ? AND profile_id = ?', databaseId, profileId ); @@ -253,7 +247,10 @@ export const arrSyncQueries = { */ removeDatabaseReferences(databaseId: number): void { db.execute('DELETE FROM arr_sync_quality_profiles WHERE database_id = ?', databaseId); - db.execute('DELETE FROM arr_sync_delay_profiles WHERE database_id = ?', databaseId); + db.execute( + 'UPDATE arr_sync_delay_profiles_config SET database_id = NULL, profile_id = NULL WHERE database_id = ?', + databaseId + ); db.execute( 'UPDATE arr_sync_media_management SET naming_database_id = NULL WHERE naming_database_id = ?', databaseId diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index ca0142e..7cc1f2f 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -1,7 +1,7 @@ -- Profilarr Database Schema -- This file documents the current database schema after all migrations -- DO NOT execute this file directly - use migrations instead --- Last updated: 2026-01-16 +-- Last updated: 2026-01-21 -- ============================================================================== -- TABLE: migrations @@ -308,24 +308,10 @@ CREATE TABLE arr_sync_quality_profiles_config ( FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE ); --- ============================================================================== --- TABLE: arr_sync_delay_profiles --- Purpose: Store delay profile sync selections (many-to-many) --- Migration: 015_create_arr_sync_tables.ts --- ============================================================================== - -CREATE TABLE arr_sync_delay_profiles ( - instance_id INTEGER NOT NULL, - database_id INTEGER NOT NULL, - profile_id INTEGER NOT NULL, - PRIMARY KEY (instance_id, database_id, profile_id), - FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE -); - -- ============================================================================== -- TABLE: arr_sync_delay_profiles_config --- Purpose: Store delay profile sync trigger configuration (one per instance) --- Migration: 015_create_arr_sync_tables.ts, 016_add_should_sync_flags.ts +-- Purpose: Store delay profile sync configuration (one per instance, single profile) +-- Migration: 015_create_arr_sync_tables.ts, 016_add_should_sync_flags.ts, 028_simplify_delay_profile_sync.ts -- ============================================================================== CREATE TABLE arr_sync_delay_profiles_config ( @@ -334,6 +320,8 @@ CREATE TABLE arr_sync_delay_profiles_config ( cron TEXT, -- Cron expression for schedule trigger should_sync INTEGER NOT NULL DEFAULT 0, -- Flag for pending sync (Migration 016) next_run_at TEXT, -- Next scheduled run timestamp (Migration 022) + database_id INTEGER, -- Single database reference (Migration 028) + profile_id INTEGER, -- Single profile reference (Migration 028) FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE ); @@ -385,7 +373,6 @@ CREATE INDEX idx_upgrade_configs_arr_instance ON upgrade_configs(arr_instance_id -- Arr sync indexes (Migration: 015_create_arr_sync_tables.ts) CREATE INDEX idx_arr_sync_quality_profiles_instance ON arr_sync_quality_profiles(instance_id); -CREATE INDEX idx_arr_sync_delay_profiles_instance ON arr_sync_delay_profiles(instance_id); -- ============================================================================== -- TABLE: regex101_cache diff --git a/src/lib/server/pcd/queries/delayProfiles/create.ts b/src/lib/server/pcd/queries/delayProfiles/create.ts index 775f153..ca0e420 100644 --- a/src/lib/server/pcd/queries/delayProfiles/create.ts +++ b/src/lib/server/pcd/queries/delayProfiles/create.ts @@ -8,7 +8,6 @@ import type { PreferredProtocol } from './types.ts'; export interface CreateDelayProfileInput { name: string; - tags: string[]; preferredProtocol: PreferredProtocol; usenetDelay: number; torrentDelay: number; @@ -31,8 +30,6 @@ export async function create(options: CreateDelayProfileOptions) { const { databaseId, cache, layer, input } = options; const db = cache.kb; - const queries = []; - // Determine delay values based on protocol (schema has CHECK constraints) // only_torrent -> usenet_delay must be NULL // only_usenet -> torrent_delay must be NULL @@ -42,7 +39,6 @@ export async function create(options: CreateDelayProfileOptions) { // minimum_custom_format_score must be NULL if bypass_if_above_custom_format_score is false const minimumCfScore = input.bypassIfAboveCfScore ? input.minimumCfScore : null; - // 1. Insert the delay profile const insertProfile = db .insertInto('delay_profiles') .values({ @@ -56,35 +52,11 @@ export async function create(options: CreateDelayProfileOptions) { }) .compile(); - queries.push(insertProfile); - - // 2. Insert tags (create if not exist, then link) - for (const tagName of input.tags) { - // Insert tag if not exists - const insertTag = db - .insertInto('tags') - .values({ name: tagName }) - .onConflict((oc) => oc.column('name').doNothing()) - .compile(); - - queries.push(insertTag); - - // Link tag to delay profile using direct name values - const linkTag = { - sql: `INSERT INTO delay_profile_tags (delay_profile_name, tag_name) VALUES ('${input.name.replace(/'/g, "''")}', '${tagName.replace(/'/g, "''")}')`, - parameters: [], - query: {} as never - }; - - queries.push(linkTag); - } - - // Write the operation const result = await writeOperation({ databaseId, layer, description: `create-delay-profile-${input.name}`, - queries, + queries: [insertProfile], metadata: { operation: 'create', entity: 'delay_profile', diff --git a/src/lib/server/pcd/queries/delayProfiles/delete.ts b/src/lib/server/pcd/queries/delayProfiles/delete.ts index de0e0a3..37abbfa 100644 --- a/src/lib/server/pcd/queries/delayProfiles/delete.ts +++ b/src/lib/server/pcd/queries/delayProfiles/delete.ts @@ -14,13 +14,6 @@ export interface DeleteDelayProfileOptions { current: DelayProfileTableRow; } -/** - * Escape a string for SQL - */ -function esc(value: string): string { - return value.replace(/'/g, "''"); -} - /** * Delete a delay profile by writing an operation to the specified layer * Uses value guards to detect conflicts with upstream changes @@ -29,19 +22,7 @@ export async function remove(options: DeleteDelayProfileOptions) { const { databaseId, cache, layer, current } = options; const db = cache.kb; - const queries = []; - - // 1. Delete tag links first (foreign key constraint) - for (const tag of current.tags) { - const removeTagLink = { - sql: `DELETE FROM delay_profile_tags WHERE delay_profile_name = '${esc(current.name)}' AND tag_name = '${esc(tag.name)}'`, - parameters: [], - query: {} as never - }; - queries.push(removeTagLink); - } - - // 2. Delete the delay profile with value guards + // Delete the delay profile with value guards const deleteProfile = db .deleteFrom('delay_profiles') .where('id', '=', current.id) @@ -50,14 +31,11 @@ export async function remove(options: DeleteDelayProfileOptions) { .where('preferred_protocol', '=', current.preferred_protocol) .compile(); - queries.push(deleteProfile); - - // Write the operation const result = await writeOperation({ databaseId, layer, description: `delete-delay-profile-${current.name}`, - queries, + queries: [deleteProfile], metadata: { operation: 'delete', entity: 'delay_profile', diff --git a/src/lib/server/pcd/queries/delayProfiles/get.ts b/src/lib/server/pcd/queries/delayProfiles/get.ts index d7aebb5..1a2fbb5 100644 --- a/src/lib/server/pcd/queries/delayProfiles/get.ts +++ b/src/lib/server/pcd/queries/delayProfiles/get.ts @@ -3,7 +3,6 @@ */ import type { PCDCache } from '../../cache.ts'; -import type { Tag } from '../../types.ts'; import type { DelayProfileTableRow, PreferredProtocol } from './types.ts'; /** @@ -12,7 +11,6 @@ import type { DelayProfileTableRow, PreferredProtocol } from './types.ts'; export async function get(cache: PCDCache, id: number): Promise { const db = cache.kb; - // Get the delay profile const profile = await db .selectFrom('delay_profiles') .select([ @@ -32,20 +30,6 @@ export async function get(cache: PCDCache, id: number): Promise ({ - name: t.tag_name, - created_at: t.tag_created_at - })); - return { id: profile.id, name: profile.name, @@ -55,7 +39,6 @@ export async function get(cache: PCDCache, id: number): Promise { const db = cache.kb; - // 1. Get all delay profiles const profiles = await db .selectFrom('delay_profiles') .select([ @@ -30,33 +28,6 @@ export async function list(cache: PCDCache): Promise { .orderBy('name') .execute(); - if (profiles.length === 0) return []; - - const profileNames = profiles.map((p) => p.name); - - // 2. Get all tags for all profiles - const allTags = await db - .selectFrom('delay_profile_tags as dpt') - .innerJoin('tags as t', 't.name', 'dpt.tag_name') - .select(['dpt.delay_profile_name', 't.name as tag_name', 't.created_at as tag_created_at']) - .where('dpt.delay_profile_name', 'in', profileNames) - .orderBy('dpt.delay_profile_name') - .orderBy('t.name') - .execute(); - - // Build tags map - const tagsMap = new Map(); - for (const tag of allTags) { - if (!tagsMap.has(tag.delay_profile_name)) { - tagsMap.set(tag.delay_profile_name, []); - } - tagsMap.get(tag.delay_profile_name)!.push({ - name: tag.tag_name, - created_at: tag.tag_created_at - }); - } - - // Build the final result return profiles.map((profile) => ({ id: profile.id, name: profile.name, @@ -66,7 +37,6 @@ export async function list(cache: PCDCache): Promise { bypass_if_highest_quality: profile.bypass_if_highest_quality === 1, bypass_if_above_custom_format_score: profile.bypass_if_above_custom_format_score === 1, minimum_custom_format_score: profile.minimum_custom_format_score, - tags: tagsMap.get(profile.name) || [], created_at: profile.created_at, updated_at: profile.updated_at })); diff --git a/src/lib/server/pcd/queries/delayProfiles/types.ts b/src/lib/server/pcd/queries/delayProfiles/types.ts index 9e4ada1..26d5dd0 100644 --- a/src/lib/server/pcd/queries/delayProfiles/types.ts +++ b/src/lib/server/pcd/queries/delayProfiles/types.ts @@ -2,8 +2,6 @@ * Delay Profile query-specific types */ -import type { Tag } from '../../types.ts'; - /** Preferred protocol options */ export type PreferredProtocol = 'prefer_usenet' | 'prefer_torrent' | 'only_usenet' | 'only_torrent'; @@ -17,7 +15,6 @@ export interface DelayProfileTableRow { bypass_if_highest_quality: boolean; bypass_if_above_custom_format_score: boolean; minimum_custom_format_score: number | null; - tags: Tag[]; created_at: string; updated_at: string; } diff --git a/src/lib/server/pcd/queries/delayProfiles/update.ts b/src/lib/server/pcd/queries/delayProfiles/update.ts index 18d050b..684352e 100644 --- a/src/lib/server/pcd/queries/delayProfiles/update.ts +++ b/src/lib/server/pcd/queries/delayProfiles/update.ts @@ -9,7 +9,6 @@ import { logger } from '$logger/logger.ts'; export interface UpdateDelayProfileInput { name: string; - tags: string[]; preferredProtocol: PreferredProtocol; usenetDelay: number; torrentDelay: number; @@ -28,13 +27,6 @@ export interface UpdateDelayProfileOptions { input: UpdateDelayProfileInput; } -/** - * Escape a string for SQL - */ -function esc(value: string): string { - return value.replace(/'/g, "''"); -} - /** * Update a delay profile by writing an operation to the specified layer * Uses value guards to detect conflicts with upstream changes @@ -43,8 +35,6 @@ export async function update(options: UpdateDelayProfileOptions) { const { databaseId, cache, layer, current, input } = options; const db = cache.kb; - const queries = []; - // Determine delay values based on protocol (schema has CHECK constraints) // only_torrent -> usenet_delay must be NULL // only_usenet -> torrent_delay must be NULL @@ -54,8 +44,7 @@ export async function update(options: UpdateDelayProfileOptions) { // minimum_custom_format_score must be NULL if bypass_if_above_custom_format_score is false const minimumCfScore = input.bypassIfAboveCfScore ? input.minimumCfScore : null; - // 1. Update the delay profile with value guards - // We build the WHERE clause to include current values as guards + // Update the delay profile with value guards const updateProfile = db .updateTable('delay_profiles') .set({ @@ -73,48 +62,7 @@ export async function update(options: UpdateDelayProfileOptions) { .where('preferred_protocol', '=', current.preferred_protocol) .compile(); - queries.push(updateProfile); - - // 2. Handle tag changes - const currentTagNames = current.tags.map((t) => t.name); - const newTagNames = input.tags; - - // Tags to remove - const tagsToRemove = currentTagNames.filter((t) => !newTagNames.includes(t)); - for (const tagName of tagsToRemove) { - const removeTag = { - sql: `DELETE FROM delay_profile_tags WHERE delay_profile_name = '${esc(current.name)}' AND tag_name = '${esc(tagName)}'`, - parameters: [], - query: {} as never - }; - queries.push(removeTag); - } - - // Tags to add - const tagsToAdd = newTagNames.filter((t) => !currentTagNames.includes(t)); - for (const tagName of tagsToAdd) { - // Insert tag if not exists - const insertTag = db - .insertInto('tags') - .values({ name: tagName }) - .onConflict((oc) => oc.column('name').doNothing()) - .compile(); - - queries.push(insertTag); - - // Link tag to delay profile - // Use input.name since the profile might have been renamed - const profileName = input.name !== current.name ? input.name : current.name; - const linkTag = { - sql: `INSERT INTO delay_profile_tags (delay_profile_name, tag_name) VALUES ('${esc(profileName)}', '${esc(tagName)}')`, - parameters: [], - query: {} as never - }; - - queries.push(linkTag); - } - - // Log what's being changed (before the write) + // Log what's being changed const changes: Record = {}; if (current.name !== input.name) { @@ -144,9 +92,6 @@ export async function update(options: UpdateDelayProfileOptions) { if (current.minimum_custom_format_score !== minimumCfScore) { changes.minimumCfScore = { from: current.minimum_custom_format_score, to: minimumCfScore }; } - if (tagsToAdd.length > 0 || tagsToRemove.length > 0) { - changes.tags = { from: currentTagNames, to: input.tags }; - } await logger.info(`Save delay profile "${input.name}"`, { source: 'DelayProfile', @@ -157,14 +102,13 @@ export async function update(options: UpdateDelayProfileOptions) { }); // Write the operation with metadata - // Include previousName if this is a rename const isRename = input.name !== current.name; const result = await writeOperation({ databaseId, layer, description: `update-delay-profile-${input.name}`, - queries, + queries: [updateProfile], metadata: { operation: 'update', entity: 'delay_profile', diff --git a/src/lib/server/pcd/schema.ts b/src/lib/server/pcd/schema.ts index 9f0126e..0fef741 100644 --- a/src/lib/server/pcd/schema.ts +++ b/src/lib/server/pcd/schema.ts @@ -40,7 +40,7 @@ export interface QualitiesTable { } export interface QualityApiMappingsTable { - quality_id: number; + quality_name: string; arr_type: string; api_name: string; created_at: Generated; @@ -255,11 +255,6 @@ export interface DelayProfilesTable { updated_at: Generated; } -export interface DelayProfileTagsTable { - delay_profile_name: string; - tag_name: string; -} - // ============================================================================ // MEDIA MANAGEMENT TABLES // ============================================================================ @@ -358,7 +353,6 @@ export interface PCDDatabase { test_entities: TestEntitiesTable; test_releases: TestReleasesTable; delay_profiles: DelayProfilesTable; - delay_profile_tags: DelayProfileTagsTable; quality_api_mappings: QualityApiMappingsTable; radarr_quality_definitions: RadarrQualityDefinitionsTable; sonarr_quality_definitions: SonarrQualityDefinitionsTable; diff --git a/src/lib/server/sync/delayProfiles.ts b/src/lib/server/sync/delayProfiles.ts index ce7545d..a238fda 100644 --- a/src/lib/server/sync/delayProfiles.ts +++ b/src/lib/server/sync/delayProfiles.ts @@ -1,271 +1,122 @@ /** * Delay profile syncer - * Syncs delay profiles from PCD to arr instances * - * Profile ordering is derived from selection order (first selected = highest priority) + * Syncs a single delay profile from PCD to the arr instance's default profile (id=1). + * Only one delay profile can be synced per arr - it overwrites the default. */ -import { BaseSyncer } from './base.ts'; +import { BaseSyncer, type SyncResult } from './base.ts'; import { arrSyncQueries } from '$db/queries/arrSync.ts'; import { getCache } from '$lib/server/pcd/cache.ts'; import { get as getDelayProfile } from '$lib/server/pcd/queries/delayProfiles/get.ts'; import type { DelayProfileTableRow } from '$lib/server/pcd/queries/delayProfiles/types.ts'; +import type { ArrDelayProfile } from '$arr/types.ts'; import { logger } from '$logger/logger.ts'; -/** - * Intermediate type for transformed profiles (before tag ID resolution) - */ -interface TransformedDelayProfile { - name: string; - enableUsenet: boolean; - enableTorrent: boolean; - preferredProtocol: string; - usenetDelay: number; - torrentDelay: number; - bypassIfHighestQuality: boolean; - bypassIfAboveCustomFormatScore: boolean; - minimumCustomFormatScore: number; - tagNames: string[]; -} - export class DelayProfileSyncer extends BaseSyncer { protected get syncType(): string { - return 'delay profiles'; + return 'delay profile'; } - protected async fetchFromPcd(): Promise { + /** + * Override sync to handle single profile update to id=1 + */ + override async sync(): Promise { const syncConfig = arrSyncQueries.getDelayProfilesSync(this.instanceId); - await logger.debug(`Starting fetch - ${syncConfig.selections.length} selections configured`, { - source: 'Sync:DelayProfiles:fetch', - meta: { - instanceId: this.instanceId, - selections: syncConfig.selections - } - }); - - if (syncConfig.selections.length === 0) { - return []; - } - - const profiles: DelayProfileTableRow[] = []; - - for (const selection of syncConfig.selections) { - await logger.debug( - `Fetching profile ${selection.profileId} from database ${selection.databaseId}`, - { - source: 'Sync:DelayProfiles:fetch', - meta: { instanceId: this.instanceId, ...selection } - } - ); - - const cache = getCache(selection.databaseId); - - if (!cache) { - await logger.warn(`PCD cache not found for database ${selection.databaseId}`, { - source: 'Sync:DelayProfiles:fetch', - meta: { instanceId: this.instanceId, databaseId: selection.databaseId } - }); - continue; - } - - const profile = await getDelayProfile(cache, selection.profileId); - - if (!profile) { - await logger.warn(`Profile ${selection.profileId} not found`, { - source: 'Sync:DelayProfiles:fetch', - meta: { instanceId: this.instanceId, ...selection } - }); - continue; - } - - await logger.debug(`Found profile: "${profile.name}" (${profile.preferred_protocol})`, { - source: 'Sync:DelayProfiles:fetch', - meta: { - instanceId: this.instanceId, - profileId: profile.id, - name: profile.name, - protocol: profile.preferred_protocol, - usenetDelay: profile.usenet_delay, - torrentDelay: profile.torrent_delay, - tags: profile.tags.map((t) => t.name) - } - }); - - profiles.push(profile); - } - - await logger.debug(`Fetch complete - ${profiles.length} profiles retrieved`, { - source: 'Sync:DelayProfiles:fetch', - meta: { - instanceId: this.instanceId, - profiles: profiles.map((p) => p.name) - } - }); - - return profiles; - } - - protected transformToArr(pcdData: DelayProfileTableRow[]): TransformedDelayProfile[] { - // Note: transformToArr is sync but logger is async - we'll log in pushToArr instead - return pcdData.map((profile) => { - let enableUsenet = true; - let enableTorrent = true; - let preferredProtocol = 'usenet'; - - switch (profile.preferred_protocol) { - case 'prefer_usenet': - enableUsenet = true; - enableTorrent = true; - preferredProtocol = 'usenet'; - break; - case 'prefer_torrent': - enableUsenet = true; - enableTorrent = true; - preferredProtocol = 'torrent'; - break; - case 'only_usenet': - enableUsenet = true; - enableTorrent = false; - preferredProtocol = 'usenet'; - break; - case 'only_torrent': - enableUsenet = false; - enableTorrent = true; - preferredProtocol = 'torrent'; - break; - } - - return { - name: profile.name, - enableUsenet, - enableTorrent, - preferredProtocol, - usenetDelay: profile.usenet_delay ?? 0, - torrentDelay: profile.torrent_delay ?? 0, - bypassIfHighestQuality: profile.bypass_if_highest_quality, - bypassIfAboveCustomFormatScore: profile.bypass_if_above_custom_format_score, - minimumCustomFormatScore: profile.minimum_custom_format_score ?? 0, - tagNames: profile.tags.map((t) => t.name) - }; - }); - } - - protected async pushToArr(arrData: TransformedDelayProfile[]): Promise { - await logger.debug(`Starting push - ${arrData.length} profiles to sync`, { - source: 'Sync:DelayProfiles:push', - meta: { - instanceId: this.instanceId, - profiles: arrData.map((p) => ({ - name: p.name, - enableUsenet: p.enableUsenet, - enableTorrent: p.enableTorrent, - preferredProtocol: p.preferredProtocol, - usenetDelay: p.usenetDelay, - torrentDelay: p.torrentDelay, - tags: p.tagNames - })) - } - }); - - // Get existing delay profiles and tags from arr - const existingProfiles = await this.client.getDelayProfiles(); - const existingTags = await this.client.getTags(); - - await logger.debug( - `Found ${existingProfiles.length} existing profiles, ${existingTags.length} tags in arr`, - { - source: 'Sync:DelayProfiles:push', - meta: { - instanceId: this.instanceId, - existingProfiles: existingProfiles.map((p) => ({ id: p.id, tags: p.tags })), - existingTags: existingTags.map((t) => ({ id: t.id, label: t.label })) - } - } - ); - - // Delete all non-default delay profiles (id !== 1) - const profilesToDelete = existingProfiles.filter((p) => p.id !== 1); - - if (profilesToDelete.length > 0) { - await logger.debug(`Deleting ${profilesToDelete.length} existing profiles`, { - source: 'Sync:DelayProfiles:push', - meta: { instanceId: this.instanceId, deletingIds: profilesToDelete.map((p) => p.id) } - }); - - for (const profile of profilesToDelete) { - await this.client.deleteDelayProfile(profile.id); - } - - await logger.debug(`Deleted ${profilesToDelete.length} profiles`, { - source: 'Sync:DelayProfiles:push', + if (!syncConfig.databaseId || !syncConfig.profileId) { + await logger.debug('No delay profile configured for sync', { + source: 'Sync:DelayProfile', meta: { instanceId: this.instanceId } }); + return { success: true, itemsSynced: 0 }; } - // Build tag name -> ID map - const tagMap = new Map(); - for (const tag of existingTags) { - tagMap.set(tag.label.toLowerCase(), tag.id); - } - - // Create new profiles with explicit ordering (lower order = higher priority) - for (let i = 0; i < arrData.length; i++) { - const profile = arrData[i]; - // Resolve tag names to IDs, creating missing tags - const tagIds: number[] = []; - - for (const tagName of profile.tagNames) { - let tagId = tagMap.get(tagName.toLowerCase()); - - if (tagId === undefined) { - await logger.debug(`Creating missing tag "${tagName}"`, { - source: 'Sync:DelayProfiles:push', - meta: { instanceId: this.instanceId, tagName } - }); - - const newTag = await this.client.createTag(tagName); - tagId = newTag.id; - tagMap.set(tagName.toLowerCase(), tagId); - - await logger.debug(`Created tag "${tagName}" with id=${tagId}`, { - source: 'Sync:DelayProfiles:push', - meta: { instanceId: this.instanceId, tagName, tagId } - }); - } - - tagIds.push(tagId); - } - - const profileData = { - enableUsenet: profile.enableUsenet, - enableTorrent: profile.enableTorrent, - preferredProtocol: profile.preferredProtocol, - usenetDelay: profile.usenetDelay, - torrentDelay: profile.torrentDelay, - bypassIfHighestQuality: profile.bypassIfHighestQuality, - bypassIfAboveCustomFormatScore: profile.bypassIfAboveCustomFormatScore, - minimumCustomFormatScore: profile.minimumCustomFormatScore, - order: i + 1, // Selection order determines priority (1 = highest) - tags: tagIds - }; - - await logger.debug(`Creating profile "${profile.name}"`, { - source: 'Sync:DelayProfiles:push', - meta: { instanceId: this.instanceId, profileData } - }); - - const created = await this.client.createDelayProfile(profileData); - - await logger.debug(`Created profile "${profile.name}" with id=${created.id}`, { - source: 'Sync:DelayProfiles:push', - meta: { instanceId: this.instanceId, createdId: created.id } + const cache = getCache(syncConfig.databaseId); + if (!cache) { + await logger.warn(`PCD cache not found for database ${syncConfig.databaseId}`, { + source: 'Sync:DelayProfile', + meta: { instanceId: this.instanceId } }); + return { success: false, itemsSynced: 0, error: 'PCD cache not found' }; } - await logger.debug(`Push complete - ${arrData.length} profiles created`, { - source: 'Sync:DelayProfiles:push', + const profile = await getDelayProfile(cache, syncConfig.profileId); + if (!profile) { + await logger.warn(`Profile ${syncConfig.profileId} not found`, { + source: 'Sync:DelayProfile', + meta: { instanceId: this.instanceId, profileId: syncConfig.profileId } + }); + return { success: false, itemsSynced: 0, error: 'Profile not found in PCD' }; + } + + await logger.debug(`Syncing "${profile.name}" to default profile (id=1)`, { + source: 'Sync:DelayProfile', + meta: { instanceId: this.instanceId, profileName: profile.name } + }); + + const transformed = this.transform(profile); + await this.client.updateDelayProfile(1, transformed); + + await logger.info(`Synced delay profile "${profile.name}" to "${this.instanceName}"`, { + source: 'Sync:DelayProfile', meta: { instanceId: this.instanceId } }); + + return { success: true, itemsSynced: 1 }; } + + private transform(profile: DelayProfileTableRow): ArrDelayProfile { + let enableUsenet = true; + let enableTorrent = true; + let preferredProtocol = 'usenet'; + + switch (profile.preferred_protocol) { + case 'prefer_usenet': + enableUsenet = true; + enableTorrent = true; + preferredProtocol = 'usenet'; + break; + case 'prefer_torrent': + enableUsenet = true; + enableTorrent = true; + preferredProtocol = 'torrent'; + break; + case 'only_usenet': + enableUsenet = true; + enableTorrent = false; + preferredProtocol = 'usenet'; + break; + case 'only_torrent': + enableUsenet = false; + enableTorrent = true; + preferredProtocol = 'torrent'; + break; + } + + return { + id: 1, + enableUsenet, + enableTorrent, + preferredProtocol, + usenetDelay: profile.usenet_delay ?? 0, + torrentDelay: profile.torrent_delay ?? 0, + bypassIfHighestQuality: profile.bypass_if_highest_quality, + bypassIfAboveCustomFormatScore: profile.bypass_if_above_custom_format_score, + minimumCustomFormatScore: profile.minimum_custom_format_score ?? 0, + order: 2147483647, // Default profile order + tags: [] // Default profile must have empty tags + }; + } + + // Base class abstract methods - not used since we override sync() + protected async fetchFromPcd(): Promise { + return []; + } + + protected transformToArr(_pcdData: unknown[]): unknown[] { + return []; + } + + protected async pushToArr(_arrData: unknown[]): Promise {} } diff --git a/src/lib/server/sync/processor.ts b/src/lib/server/sync/processor.ts index ed02171..a898574 100644 --- a/src/lib/server/sync/processor.ts +++ b/src/lib/server/sync/processor.ts @@ -260,8 +260,8 @@ export async function syncInstance(instanceId: number): Promise 0) { + // Sync delay profile if configured + if (dpConfig.databaseId && dpConfig.profileId) { const syncer = new DelayProfileSyncer(client, instanceId, instance.name); result.delayProfiles = await syncer.sync(); } diff --git a/src/routes/arr/[id]/sync/+page.server.ts b/src/routes/arr/[id]/sync/+page.server.ts index 6739fef..f9396b5 100644 --- a/src/routes/arr/[id]/sync/+page.server.ts +++ b/src/routes/arr/[id]/sync/+page.server.ts @@ -107,32 +107,34 @@ export const actions: Actions = { const instance = arrInstancesQueries.getById(id); const formData = await request.formData(); - const selectionsJson = formData.get('selections') as string; + const databaseId = formData.get('databaseId') as string | null; + const profileId = formData.get('profileId') as string | null; const trigger = formData.get('trigger') as SyncTrigger; const cron = formData.get('cron') as string | null; try { - const selections: ProfileSelection[] = JSON.parse(selectionsJson || '[]'); const effectiveTrigger = trigger || 'manual'; const effectiveCron = cron || null; - arrSyncQueries.saveDelayProfilesSync(id, selections, { + arrSyncQueries.saveDelayProfilesSync(id, { + databaseId: databaseId ? parseInt(databaseId, 10) : null, + profileId: profileId ? parseInt(profileId, 10) : null, trigger: effectiveTrigger, cron: effectiveCron, nextRunAt: effectiveTrigger === 'schedule' ? calculateNextRun(effectiveCron) : null }); - await logger.info(`Delay profiles sync config saved for "${instance?.name}"`, { + await logger.info(`Delay profile sync config saved for "${instance?.name}"`, { source: 'sync', - meta: { instanceId: id, profileCount: selections.length, trigger } + meta: { instanceId: id, databaseId, profileId, trigger } }); return { success: true }; } catch (e) { - await logger.error('Failed to save delay profiles sync config', { + await logger.error('Failed to save delay profile sync config', { source: 'sync', meta: { instanceId: id, error: e } }); - return fail(500, { error: 'Failed to save delay profiles sync config' }); + return fail(500, { error: 'Failed to save delay profile sync config' }); } }, diff --git a/src/routes/arr/[id]/sync/+page.svelte b/src/routes/arr/[id]/sync/+page.svelte index cddf102..2a4fee0 100644 --- a/src/routes/arr/[id]/sync/+page.svelte +++ b/src/routes/arr/[id]/sync/+page.svelte @@ -32,9 +32,12 @@ let qualityProfileTrigger: SyncTrigger = data.syncData.qualityProfiles.config.trigger; let qualityProfileCron: string = data.syncData.qualityProfiles.config.cron || '0 * * * *'; - let delayProfileState = buildProfileState(data.syncData.delayProfiles.selections); - let delayProfileTrigger: SyncTrigger = data.syncData.delayProfiles.config.trigger; - let delayProfileCron: string = data.syncData.delayProfiles.config.cron || '0 * * * *'; + let delayProfileState = { + databaseId: data.syncData.delayProfiles.databaseId, + profileId: data.syncData.delayProfiles.profileId + }; + let delayProfileTrigger: SyncTrigger = data.syncData.delayProfiles.trigger; + let delayProfileCron: string = data.syncData.delayProfiles.cron || '0 * * * *'; let mediaManagementState = { namingDatabaseId: data.syncData.mediaManagement.namingDatabaseId, @@ -59,26 +62,45 @@ $: anyDirty = qualityProfilesDirty || delayProfilesDirty || mediaManagementDirty; $: update('anyDirty', anyDirty); - // Validation: Quality profiles require media management settings (saved, not dirty) + // Validation: Quality profiles require both media management AND delay profiles (saved, not dirty) $: hasQualityProfilesSelected = Object.values(qualityProfileState).some((db) => Object.values(db).some((selected) => selected) ); $: hasMediaManagement = - mediaManagementState.namingDatabaseId !== null && - mediaManagementState.qualityDefinitionsDatabaseId !== null && - mediaManagementState.mediaSettingsDatabaseId !== null; + typeof mediaManagementState.namingDatabaseId === 'number' && + typeof mediaManagementState.qualityDefinitionsDatabaseId === 'number' && + typeof mediaManagementState.mediaSettingsDatabaseId === 'number'; + + $: hasDelayProfile = + typeof delayProfileState.databaseId === 'number' && + typeof delayProfileState.profileId === 'number'; $: qualityProfilesCanSave = - !hasQualityProfilesSelected || (hasMediaManagement && !mediaManagementDirty); + !hasQualityProfilesSelected || + (hasMediaManagement && !mediaManagementDirty && hasDelayProfile && !delayProfilesDirty); - $: qualityProfilesWarning = !hasQualityProfilesSelected - ? null - : !hasMediaManagement - ? 'Quality profiles require media management settings. Configure media management settings above.' - : mediaManagementDirty - ? 'Save your media management settings before saving quality profiles.' - : null; + // Build warning message showing all missing requirements + $: qualityProfilesWarning = (() => { + if (!hasQualityProfilesSelected) return null; + + const issues: string[] = []; + + if (!hasMediaManagement) { + issues.push('media management settings (configure above)'); + } else if (mediaManagementDirty) { + issues.push('media management settings to be saved'); + } + + if (!hasDelayProfile) { + issues.push('a delay profile (configure below)'); + } else if (delayProfilesDirty) { + issues.push('delay profile settings to be saved'); + } + + if (issues.length === 0) return null; + return `Quality profiles require ${issues.join(' and ')}.`; + })(); diff --git a/src/routes/arr/[id]/sync/components/DelayProfiles.svelte b/src/routes/arr/[id]/sync/components/DelayProfiles.svelte index 9ddc4b0..4dce252 100644 --- a/src/routes/arr/[id]/sync/components/DelayProfiles.svelte +++ b/src/routes/arr/[id]/sync/components/DelayProfiles.svelte @@ -12,7 +12,13 @@ } export let databases: DatabaseWithProfiles[]; - export let state: Record> = {}; + export let state: { + databaseId: number | null; + profileId: number | null; + } = { + databaseId: null, + profileId: null + }; export let syncTrigger: 'manual' | 'on_pull' | 'on_change' | 'schedule' = 'manual'; export let cronExpression: string = '0 * * * *'; @@ -25,37 +31,32 @@ export let isDirty = false; $: isDirty = currentState !== savedState; - // Initialize state for all databases/profiles - $: { - for (const db of databases) { - if (!state[db.id]) { - state[db.id] = {}; - } - for (const profile of db.delayProfiles) { - if (state[db.id][profile.id] === undefined) { - state[db.id][profile.id] = false; - } - } - } + // Reactive selected key for checkbox state + $: selectedKey = + state.databaseId !== null && state.profileId !== null + ? `${state.databaseId}-${state.profileId}` + : null; + + function isSelected(databaseId: number, profileId: number): boolean { + return selectedKey === `${databaseId}-${profileId}`; } - function getSelections(): { databaseId: number; profileId: number }[] { - const selections: { databaseId: number; profileId: number }[] = []; - for (const [dbId, profiles] of Object.entries(state)) { - for (const [profileId, selected] of Object.entries(profiles)) { - if (selected) { - selections.push({ databaseId: parseInt(dbId), profileId: parseInt(profileId) }); - } - } + function toggleProfile(databaseId: number, profileId: number) { + if (isSelected(databaseId, profileId)) { + // Deselect + state = { databaseId: null, profileId: null }; + } else { + // Select this one (deselects any previous) + state = { databaseId, profileId }; } - return selections; } async function handleSave() { saving = true; try { const formData = new FormData(); - formData.set('selections', JSON.stringify(getSelections())); + formData.set('databaseId', state.databaseId?.toString() ?? ''); + formData.set('profileId', state.profileId?.toString() ?? ''); formData.set('trigger', syncTrigger); formData.set('cron', cronExpression); @@ -65,14 +66,14 @@ }); if (response.ok) { - alertStore.add('success', 'Delay profiles sync config saved'); + alertStore.add('success', 'Delay profile sync config saved'); // Update saved state to current savedState = JSON.stringify({ state, syncTrigger, cronExpression }); } else { - alertStore.add('error', 'Failed to save delay profiles sync config'); + alertStore.add('error', 'Failed to save delay profile sync config'); } } catch { - alertStore.add('error', 'Failed to save delay profiles sync config'); + alertStore.add('error', 'Failed to save delay profile sync config'); } finally { saving = false; } @@ -130,15 +131,13 @@