diff --git a/src/lib/client/ui/actions/ActionButton.svelte b/src/lib/client/ui/actions/ActionButton.svelte index 266a5a5..559c627 100644 --- a/src/lib/client/ui/actions/ActionButton.svelte +++ b/src/lib/client/ui/actions/ActionButton.svelte @@ -3,6 +3,7 @@ import type { ComponentType } from 'svelte'; export let icon: ComponentType | undefined = undefined; + export let iconClass: string = ''; export let square: boolean = true; // Fixed size square button export let hasDropdown: boolean = false; export let dropdownPosition: 'left' | 'right' | 'middle' = 'left'; @@ -39,7 +40,7 @@ on:click > {#if icon} - + {/if} diff --git a/src/lib/client/ui/form/IconCheckbox.svelte b/src/lib/client/ui/form/IconCheckbox.svelte index 066b7f2..59370be 100644 --- a/src/lib/client/ui/form/IconCheckbox.svelte +++ b/src/lib/client/ui/form/IconCheckbox.svelte @@ -3,7 +3,7 @@ export let checked: boolean = false; export let icon: ComponentType; - export let color: string = 'blue'; // blue, green, red, or hex color like #FFC230 + export let color: string = 'accent'; // accent, blue, green, red, or hex color like #FFC230 export let shape: 'square' | 'circle' | 'rounded' = 'rounded'; export let disabled: boolean = false; @@ -16,6 +16,7 @@ $: shapeClass = shapeClasses[shape] || shapeClasses.rounded; $: isCustomColor = color.startsWith('#'); + $: isAccent = color === 'accent'; {#if isCustomColor} @@ -38,6 +39,23 @@ {/if} +{:else if isAccent} + + {#if checked} + + {/if} + {:else} ( + 'SELECT * FROM arr_sync_quality_profiles WHERE instance_id = ?', + instanceId + ); + + const configRow = db.queryFirst( + 'SELECT * FROM arr_sync_quality_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) ?? 'none', + cron: configRow?.cron ?? null + } + }; + }, + + saveQualityProfilesSync( + instanceId: number, + selections: ProfileSelection[], + config: SyncConfig + ): void { + // Clear existing selections + db.execute('DELETE FROM arr_sync_quality_profiles WHERE instance_id = ?', instanceId); + + // Insert new selections + for (const sel of selections) { + db.execute( + 'INSERT INTO arr_sync_quality_profiles (instance_id, database_id, profile_id) VALUES (?, ?, ?)', + instanceId, + sel.databaseId, + sel.profileId + ); + } + + // 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 = ?`, + instanceId, + config.trigger, + config.cron, + config.trigger, + config.cron + ); + }, + + // ========== Delay Profiles ========== + + getDelayProfilesSync(instanceId: number): DelayProfilesSyncData { + const selectionRows = db.query( + 'SELECT * FROM arr_sync_delay_profiles WHERE instance_id = ?', + instanceId + ); + + const configRow = 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) ?? 'none', + cron: configRow?.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 + db.execute( + `INSERT INTO arr_sync_delay_profiles_config (instance_id, trigger, cron) + VALUES (?, ?, ?) + ON CONFLICT(instance_id) DO UPDATE SET trigger = ?, cron = ?`, + instanceId, + config.trigger, + config.cron, + config.trigger, + config.cron + ); + }, + + // ========== Media Management ========== + + getMediaManagementSync(instanceId: number): MediaManagementSyncData { + const row = db.queryFirst( + 'SELECT * FROM arr_sync_media_management WHERE instance_id = ?', + instanceId + ); + + return { + namingDatabaseId: row?.naming_database_id ?? null, + qualityDefinitionsDatabaseId: row?.quality_definitions_database_id ?? null, + mediaSettingsDatabaseId: row?.media_settings_database_id ?? null, + trigger: (row?.trigger as SyncTrigger) ?? 'none', + cron: row?.cron ?? null + }; + }, + + 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 (?, ?, ?, ?, ?, ?) + ON CONFLICT(instance_id) DO UPDATE SET + naming_database_id = ?, + quality_definitions_database_id = ?, + media_settings_database_id = ?, + trigger = ?, + cron = ?`, + instanceId, + data.namingDatabaseId, + data.qualityDefinitionsDatabaseId, + data.mediaSettingsDatabaseId, + data.trigger, + data.cron, + data.namingDatabaseId, + data.qualityDefinitionsDatabaseId, + data.mediaSettingsDatabaseId, + data.trigger, + data.cron + ); + }, + + // ========== Full Sync Data ========== + + getFullSyncData(instanceId: number) { + return { + qualityProfiles: this.getQualityProfilesSync(instanceId), + delayProfiles: this.getDelayProfilesSync(instanceId), + mediaManagement: this.getMediaManagementSync(instanceId) + }; + }, + + // ========== Cleanup ========== + + /** + * Remove orphaned profile references when a profile is deleted + */ + removeQualityProfileReference(databaseId: number, profileId: number): number { + return db.execute( + 'DELETE FROM arr_sync_quality_profiles WHERE database_id = ? AND profile_id = ?', + databaseId, + profileId + ); + }, + + removeDelayProfileReference(databaseId: number, profileId: number): number { + return db.execute( + 'DELETE FROM arr_sync_delay_profiles WHERE database_id = ? AND profile_id = ?', + databaseId, + profileId + ); + }, + + /** + * Remove all references to a database (when database is deleted) + */ + 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_media_management SET naming_database_id = NULL WHERE naming_database_id = ?', + databaseId + ); + db.execute( + 'UPDATE arr_sync_media_management SET quality_definitions_database_id = NULL WHERE quality_definitions_database_id = ?', + databaseId + ); + db.execute( + 'UPDATE arr_sync_media_management SET media_settings_database_id = NULL WHERE media_settings_database_id = ?', + databaseId + ); + } +}; diff --git a/src/lib/server/pcd/cache.ts b/src/lib/server/pcd/cache.ts index 6d3c8d9..816305f 100644 --- a/src/lib/server/pcd/cache.ts +++ b/src/lib/server/pcd/cache.ts @@ -224,9 +224,9 @@ const caches = new Map(); const watchers = new Map(); /** - * Debounce timers - maps database instance ID to timer + * Debounce timers - maps "databaseInstanceId:pcdPath" to timer */ -const debounceTimers = new Map(); +const debounceTimers = new Map(); /** * Debounce delay in milliseconds @@ -397,18 +397,29 @@ export async function startWatch(pcdPath: string, databaseInstanceId: number): P /** * Stop watching a PCD for changes */ -function stopWatch(databaseInstanceId: number): void { +function stopWatch(databaseInstanceId: number, pcdPath?: string): void { const watcher = watchers.get(databaseInstanceId); if (watcher) { watcher.close(); watchers.delete(databaseInstanceId); } - // Clear any pending debounce timer - const timer = debounceTimers.get(databaseInstanceId); - if (timer) { - clearTimeout(timer); - debounceTimers.delete(databaseInstanceId); + // Clear any pending debounce timer for this specific path + if (pcdPath) { + const timerKey = `${databaseInstanceId}:${pcdPath}`; + const timer = debounceTimers.get(timerKey); + if (timer) { + clearTimeout(timer); + debounceTimers.delete(timerKey); + } + } else { + // Clear all timers for this databaseInstanceId (fallback) + for (const [key, timer] of debounceTimers.entries()) { + if (key.startsWith(`${databaseInstanceId}:`)) { + clearTimeout(timer); + debounceTimers.delete(key); + } + } } } @@ -416,8 +427,10 @@ function stopWatch(databaseInstanceId: number): void { * Schedule a rebuild with debouncing */ function scheduleRebuild(pcdPath: string, databaseInstanceId: number): void { - // Clear existing timer - const existingTimer = debounceTimers.get(databaseInstanceId); + const timerKey = `${databaseInstanceId}:${pcdPath}`; + + // Clear existing timer for this specific path + const existingTimer = debounceTimers.get(timerKey); if (existingTimer) { clearTimeout(existingTimer); } @@ -426,7 +439,7 @@ function scheduleRebuild(pcdPath: string, databaseInstanceId: number): void { const timer = setTimeout(async () => { await logger.info('Rebuilding cache due to file changes', { source: 'PCDCache', - meta: { databaseInstanceId } + meta: { databaseInstanceId, pcdPath } }); try { @@ -436,12 +449,12 @@ function scheduleRebuild(pcdPath: string, databaseInstanceId: number): void { } catch (error) { await logger.error('Failed to rebuild cache', { source: 'PCDCache', - meta: { error: String(error), databaseInstanceId } + meta: { error: String(error), databaseInstanceId, pcdPath } }); } - debounceTimers.delete(databaseInstanceId); + debounceTimers.delete(timerKey); }, DEBOUNCE_DELAY); - debounceTimers.set(databaseInstanceId, timer); + debounceTimers.set(timerKey, timer); } diff --git a/src/routes/arr/[id]/+layout.svelte b/src/routes/arr/[id]/+layout.svelte index 2bfb836..5a7e573 100644 --- a/src/routes/arr/[id]/+layout.svelte +++ b/src/routes/arr/[id]/+layout.svelte @@ -1,7 +1,7 @@ + + + {data.instance.name} - Sync - Profilarr + + + + + + + + Sync Configuration + + + Configure which profiles and settings to sync to this instance. + + + (showInfoModal = true)} + class="flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700" + > + + How it works + + + + + + + + + + + + Automatic Dependencies + + Quality Profiles will automatically sync the custom formats they need - you don't need to select them separately. + + + + + Namespacing + + Similarly named items from different databases will include invisible namespaces to ensure they don't override each other. + + + + + Sync Methods + + + + Manual + You manually click the sync button. Useful for media management settings that rarely get updates. + + + + Schedule + Syncs on a defined schedule using cron expressions. + + + + On Pull + Syncs when the upstream database gets a change (when you pull from remote). + + + + On Change + Syncs when anything changes - whether you pull from upstream or change something yourself. + + + + + + Cron Expressions + Schedule uses standard cron syntax: minute hour day month weekday + + + 0 * * * * + Every hour + + + */15 * * * * + Every 15 minutes + + + 0 0 * * * + Daily at midnight + + + 0 6 * * 1 + Every Monday at 6am + + + + + diff --git a/src/routes/arr/[id]/sync/components/DelayProfiles.svelte b/src/routes/arr/[id]/sync/components/DelayProfiles.svelte new file mode 100644 index 0000000..9057315 --- /dev/null +++ b/src/routes/arr/[id]/sync/components/DelayProfiles.svelte @@ -0,0 +1,122 @@ + + + + + + Delay Profiles + + Select delay profiles to sync to this instance + + + + + + {#if databases.length === 0} + No databases configured + {:else} + + {#each databases as database} + + + {database.name} + + + {#if database.delayProfiles.length === 0} + No delay profiles + {:else} + + {#each database.delayProfiles as profile} + + { + state[database.id][profile.id] = !state[database.id][profile.id]; + }} + /> + + {profile.name} + + + {/each} + + {/if} + + {/each} + + {/if} + + + + diff --git a/src/routes/arr/[id]/sync/components/MediaManagement.svelte b/src/routes/arr/[id]/sync/components/MediaManagement.svelte new file mode 100644 index 0000000..6a6cdb6 --- /dev/null +++ b/src/routes/arr/[id]/sync/components/MediaManagement.svelte @@ -0,0 +1,229 @@ + + + + + + Media Management + + Select which database to use for each media management setting + + + + + + + + + + Naming + + + (showNamingDropdown = !showNamingDropdown)} + on:blur={() => setTimeout(() => (showNamingDropdown = false), 200)} + class="flex w-full items-center justify-between rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700" + > + {getSelectedName(state.namingDatabaseId)} + + + + {#if showNamingDropdown} + + selectNaming(null)} + class="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700" + > + None + {#if state.namingDatabaseId === null} + + {/if} + + {#each databases as database} + selectNaming(database.id)} + class="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700" + > + {database.name} + {#if state.namingDatabaseId === database.id} + + {/if} + + {/each} + + {/if} + + + + + + + Quality Definitions + + + (showQualityDropdown = !showQualityDropdown)} + on:blur={() => setTimeout(() => (showQualityDropdown = false), 200)} + class="flex w-full items-center justify-between rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700" + > + {getSelectedName(state.qualityDefinitionsDatabaseId)} + + + + {#if showQualityDropdown} + + selectQuality(null)} + class="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700" + > + None + {#if state.qualityDefinitionsDatabaseId === null} + + {/if} + + {#each databases as database} + selectQuality(database.id)} + class="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700" + > + {database.name} + {#if state.qualityDefinitionsDatabaseId === database.id} + + {/if} + + {/each} + + {/if} + + + + + + + Media Settings + + + (showMediaDropdown = !showMediaDropdown)} + on:blur={() => setTimeout(() => (showMediaDropdown = false), 200)} + class="flex w-full items-center justify-between rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700" + > + {getSelectedName(state.mediaSettingsDatabaseId)} + + + + {#if showMediaDropdown} + + selectMedia(null)} + class="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700" + > + None + {#if state.mediaSettingsDatabaseId === null} + + {/if} + + {#each databases as database} + selectMedia(database.id)} + class="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700" + > + {database.name} + {#if state.mediaSettingsDatabaseId === database.id} + + {/if} + + {/each} + + {/if} + + + + + + + diff --git a/src/routes/arr/[id]/sync/components/QualityProfiles.svelte b/src/routes/arr/[id]/sync/components/QualityProfiles.svelte new file mode 100644 index 0000000..deff29e --- /dev/null +++ b/src/routes/arr/[id]/sync/components/QualityProfiles.svelte @@ -0,0 +1,122 @@ + + + + + + Quality Profiles + + Select quality profiles to sync to this instance + + + + + + {#if databases.length === 0} + No databases configured + {:else} + + {#each databases as database} + + + {database.name} + + + {#if database.qualityProfiles.length === 0} + No quality profiles + {:else} + + {#each database.qualityProfiles as profile} + + { + state[database.id][profile.id] = !state[database.id][profile.id]; + }} + /> + + {profile.name} + + + {/each} + + {/if} + + {/each} + + {/if} + + + + diff --git a/src/routes/arr/[id]/sync/components/SyncFooter.svelte b/src/routes/arr/[id]/sync/components/SyncFooter.svelte new file mode 100644 index 0000000..8e76804 --- /dev/null +++ b/src/routes/arr/[id]/sync/components/SyncFooter.svelte @@ -0,0 +1,70 @@ + + + + + + Trigger + {#each triggerOptions as option} + + (syncTrigger = option.value)} + /> + {option.label} + + {/each} + + {#if syncTrigger === 'schedule'} + + {/if} + + + + + + Sync Now + + dispatch('save')} + class="flex items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed" + > + {#if saving} + + {:else} + + {/if} + Save + + + +
+ Configure which profiles and settings to sync to this instance. +
+ Quality Profiles will automatically sync the custom formats they need - you don't need to select them separately. +
+ Similarly named items from different databases will include invisible namespaces to ensure they don't override each other. +
You manually click the sync button. Useful for media management settings that rarely get updates.
Syncs on a defined schedule using cron expressions.
Syncs when the upstream database gets a change (when you pull from remote).
Syncs when anything changes - whether you pull from upstream or change something yourself.
Schedule uses standard cron syntax: minute hour day month weekday
minute hour day month weekday
0 * * * *
*/15 * * * *
0 0 * * *
0 6 * * 1
+ Select delay profiles to sync to this instance +
No databases configured
No delay profiles
+ {profile.name} +
+ Select which database to use for each media management setting +
+ Select quality profiles to sync to this instance +
No quality profiles