feat: add sync configuration for ARR instances

- Introduced a new sync page for ARR instances, allowing users to configure quality profiles, delay profiles, and media management settings.
- Implemented backend logic to handle saving sync configurations.
- Enhanced the cache management system to support debouncing based on specific paths.
- Updated the layout to include a new "Sync" tab in the navigation.
- Added UI components for managing quality profiles, delay profiles, and media management settings with appropriate state management.
- Included informative modals to guide users on how the sync process works.
This commit is contained in:
Sam Chau
2025-12-29 04:39:52 +10:30
parent aef58ea804
commit ea5c543647
14 changed files with 1308 additions and 18 deletions

View File

@@ -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}
<svelte:component this={icon} size={20} class="text-neutral-700 dark:text-neutral-300" />
<svelte:component this={icon} size={20} class="text-neutral-700 dark:text-neutral-300 {iconClass}" />
{/if}
<slot />
</button>

View File

@@ -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';
</script>
{#if isCustomColor}
@@ -38,6 +39,23 @@
<svelte:component this={icon} size={14} class="text-white" />
{/if}
</button>
{:else if isAccent}
<button
type="button"
role="checkbox"
aria-checked={checked}
{disabled}
on:click
class="flex h-5 w-5 items-center justify-center border-2 transition-all {shapeClass} {checked
? 'bg-accent-600 border-accent-600 dark:bg-accent-500 dark:border-accent-500 hover:brightness-110'
: 'bg-neutral-50 border-neutral-300 hover:bg-neutral-100 hover:border-neutral-400 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:hover:border-neutral-500'} {disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer focus:outline-none'}"
>
{#if checked}
<svelte:component this={icon} size={14} class="text-white" />
{/if}
</button>
{:else}
<button
type="button"

View File

@@ -15,6 +15,8 @@ import { migration as migration010 } from './migrations/010_add_is_private.ts';
import { migration as migration011 } from './migrations/011_create_upgrade_configs.ts';
import { migration as migration012 } from './migrations/012_add_upgrade_last_run.ts';
import { migration as migration013 } from './migrations/013_add_upgrade_dry_run.ts';
import { migration as migration014 } from './migrations/014_create_ai_settings.ts';
import { migration as migration015 } from './migrations/015_create_arr_sync_tables.ts';
export interface Migration {
version: number;
@@ -245,7 +247,9 @@ export function loadMigrations(): Migration[] {
migration010,
migration011,
migration012,
migration013
migration013,
migration014,
migration015
];
// Sort by version number

View File

@@ -0,0 +1,42 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 014: Create ai_settings table
*
* Creates a table to store AI configuration settings.
* Uses a singleton pattern (single row with id=1).
*
* Settings:
* - enabled: Master switch for AI features
* - api_url: OpenAI-compatible API endpoint
* - api_key: API key for authentication (encrypted/obfuscated in storage)
* - model: Model name to use for generation
*/
export const migration: Migration = {
version: 14,
name: 'Create ai_settings table',
up: `
CREATE TABLE ai_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
-- AI Configuration
enabled INTEGER NOT NULL DEFAULT 0,
api_url TEXT NOT NULL DEFAULT 'https://api.openai.com/v1',
api_key TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT 'gpt-4o-mini',
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Insert default settings
INSERT INTO ai_settings (id) VALUES (1);
`,
down: `
DROP TABLE IF EXISTS ai_settings;
`
};

View File

@@ -0,0 +1,78 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 015: Create arr sync tables
*
* Creates tables for storing sync configuration per arr instance.
* - Quality profile selections and trigger config
* - Delay profile selections and trigger config
* - Media management settings and trigger config
*
* Trigger types: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule'
*/
export const migration: Migration = {
version: 15,
name: 'Create arr sync tables',
up: `
-- Quality profile selections (many-to-many)
CREATE TABLE arr_sync_quality_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
);
-- Quality profile trigger config (one per instance)
CREATE TABLE arr_sync_quality_profiles_config (
instance_id INTEGER PRIMARY KEY,
trigger TEXT NOT NULL DEFAULT 'none',
cron TEXT,
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
);
-- Delay profile selections (many-to-many)
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
);
-- Delay profile trigger config (one per instance)
CREATE TABLE arr_sync_delay_profiles_config (
instance_id INTEGER PRIMARY KEY,
trigger TEXT NOT NULL DEFAULT 'none',
cron TEXT,
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
);
-- Media management (one row per instance)
CREATE TABLE arr_sync_media_management (
instance_id INTEGER PRIMARY KEY,
naming_database_id INTEGER,
quality_definitions_database_id INTEGER,
media_settings_database_id INTEGER,
trigger TEXT NOT NULL DEFAULT 'none',
cron TEXT,
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
);
-- Indexes for faster lookups
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);
`,
down: `
DROP INDEX IF EXISTS idx_arr_sync_delay_profiles_instance;
DROP INDEX IF EXISTS idx_arr_sync_quality_profiles_instance;
DROP TABLE IF EXISTS arr_sync_media_management;
DROP TABLE IF EXISTS arr_sync_delay_profiles_config;
DROP TABLE IF EXISTS arr_sync_delay_profiles;
DROP TABLE IF EXISTS arr_sync_quality_profiles_config;
DROP TABLE IF EXISTS arr_sync_quality_profiles;
`
};

View File

@@ -0,0 +1,261 @@
import { db } from '../db.ts';
// Types
export type SyncTrigger = 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule';
export interface ProfileSelection {
databaseId: number;
profileId: number;
}
export interface SyncConfig {
trigger: SyncTrigger;
cron: string | null;
}
export interface QualityProfilesSyncData {
selections: ProfileSelection[];
config: SyncConfig;
}
export interface DelayProfilesSyncData {
selections: ProfileSelection[];
config: SyncConfig;
}
export interface MediaManagementSyncData {
namingDatabaseId: number | null;
qualityDefinitionsDatabaseId: number | null;
mediaSettingsDatabaseId: number | null;
trigger: SyncTrigger;
cron: string | null;
}
// Row types
interface ProfileSelectionRow {
instance_id: number;
database_id: number;
profile_id: number;
}
interface ConfigRow {
instance_id: number;
trigger: string;
cron: string | null;
}
interface MediaManagementRow {
instance_id: number;
naming_database_id: number | null;
quality_definitions_database_id: number | null;
media_settings_database_id: number | null;
trigger: string;
cron: string | null;
}
export const arrSyncQueries = {
// ========== Quality Profiles ==========
getQualityProfilesSync(instanceId: number): QualityProfilesSyncData {
const selectionRows = db.query<ProfileSelectionRow>(
'SELECT * FROM arr_sync_quality_profiles WHERE instance_id = ?',
instanceId
);
const configRow = db.queryFirst<ConfigRow>(
'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<ProfileSelectionRow>(
'SELECT * FROM arr_sync_delay_profiles WHERE instance_id = ?',
instanceId
);
const configRow = db.queryFirst<ConfigRow>(
'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<MediaManagementRow>(
'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
);
}
};

View File

@@ -224,9 +224,9 @@ const caches = new Map<number, PCDCache>();
const watchers = new Map<number, Deno.FsWatcher>();
/**
* Debounce timers - maps database instance ID to timer
* Debounce timers - maps "databaseInstanceId:pcdPath" to timer
*/
const debounceTimers = new Map<number, number>();
const debounceTimers = new Map<string, number>();
/**
* 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);
}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import { page } from '$app/stores';
import { Library, ArrowUpCircle, ScrollText } from 'lucide-svelte';
import { Library, RefreshCw, ArrowUpCircle, ScrollText } from 'lucide-svelte';
$: instanceId = $page.params.id;
$: currentPath = $page.url.pathname;
@@ -13,6 +13,12 @@
active: currentPath.includes('/library'),
icon: Library
},
{
label: 'Sync',
href: `/arr/${instanceId}/sync`,
active: currentPath.includes('/sync'),
icon: RefreshCw
},
{
label: 'Upgrades',
href: `/arr/${instanceId}/upgrades`,

View File

@@ -0,0 +1,169 @@
import { error, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { arrSyncQueries, type SyncTrigger, type ProfileSelection } from '$db/queries/arrSync.ts';
import { pcdManager } from '$pcd/pcd.ts';
import { logger } from '$logger/logger.ts';
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts';
export const load: ServerLoad = async ({ params }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
error(404, `Invalid instance ID: ${params.id}`);
}
const instance = arrInstancesQueries.getById(id);
if (!instance) {
error(404, `Instance not found: ${id}`);
}
// Get all databases
const databases = pcdManager.getAll();
// Fetch profiles from each database
const databasesWithProfiles = await Promise.all(
databases.map(async (db) => {
const cache = pcdManager.getCache(db.id);
if (!cache) {
return {
id: db.id,
name: db.name,
qualityProfiles: [],
delayProfiles: []
};
}
const [qualityProfiles, delayProfiles] = await Promise.all([
qualityProfileQueries.list(cache),
delayProfileQueries.list(cache)
]);
return {
id: db.id,
name: db.name,
qualityProfiles,
delayProfiles
};
})
);
// Load existing sync data
const syncData = arrSyncQueries.getFullSyncData(id);
return {
instance,
databases: databasesWithProfiles,
syncData
};
};
export const actions: Actions = {
saveQualityProfiles: async ({ params, request }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
return fail(400, { error: 'Invalid instance ID' });
}
const instance = arrInstancesQueries.getById(id);
const formData = await request.formData();
const selectionsJson = formData.get('selections') as string;
const trigger = formData.get('trigger') as SyncTrigger;
const cron = formData.get('cron') as string | null;
try {
const selections: ProfileSelection[] = JSON.parse(selectionsJson || '[]');
arrSyncQueries.saveQualityProfilesSync(id, selections, {
trigger: trigger || 'none',
cron: cron || null
});
await logger.info(`Quality profiles sync config saved for "${instance?.name}"`, {
source: 'sync',
meta: { instanceId: id, profileCount: selections.length, trigger }
});
return { success: true };
} catch (e) {
await logger.error('Failed to save quality profiles sync config', {
source: 'sync',
meta: { instanceId: id, error: e }
});
return fail(500, { error: 'Failed to save quality profiles sync config' });
}
},
saveDelayProfiles: async ({ params, request }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
return fail(400, { error: 'Invalid instance ID' });
}
const instance = arrInstancesQueries.getById(id);
const formData = await request.formData();
const selectionsJson = formData.get('selections') as string;
const trigger = formData.get('trigger') as SyncTrigger;
const cron = formData.get('cron') as string | null;
try {
const selections: ProfileSelection[] = JSON.parse(selectionsJson || '[]');
arrSyncQueries.saveDelayProfilesSync(id, selections, {
trigger: trigger || 'none',
cron: cron || null
});
await logger.info(`Delay profiles sync config saved for "${instance?.name}"`, {
source: 'sync',
meta: { instanceId: id, profileCount: selections.length, trigger }
});
return { success: true };
} catch (e) {
await logger.error('Failed to save delay profiles sync config', {
source: 'sync',
meta: { instanceId: id, error: e }
});
return fail(500, { error: 'Failed to save delay profiles sync config' });
}
},
saveMediaManagement: async ({ params, request }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
return fail(400, { error: 'Invalid instance ID' });
}
const instance = arrInstancesQueries.getById(id);
const formData = await request.formData();
const namingDatabaseId = formData.get('namingDatabaseId') as string | null;
const qualityDefinitionsDatabaseId = formData.get('qualityDefinitionsDatabaseId') as string | null;
const mediaSettingsDatabaseId = formData.get('mediaSettingsDatabaseId') as string | null;
const trigger = formData.get('trigger') as SyncTrigger;
const cron = formData.get('cron') as string | null;
try {
arrSyncQueries.saveMediaManagementSync(id, {
namingDatabaseId: namingDatabaseId ? parseInt(namingDatabaseId, 10) : null,
qualityDefinitionsDatabaseId: qualityDefinitionsDatabaseId ? parseInt(qualityDefinitionsDatabaseId, 10) : null,
mediaSettingsDatabaseId: mediaSettingsDatabaseId ? parseInt(mediaSettingsDatabaseId, 10) : null,
trigger: trigger || 'none',
cron: cron || null
});
await logger.info(`Media management sync config saved for "${instance?.name}"`, {
source: 'sync',
meta: { instanceId: id, trigger }
});
return { success: true };
} catch (e) {
await logger.error('Failed to save media management sync config', {
source: 'sync',
meta: { instanceId: id, error: e }
});
return fail(500, { error: 'Failed to save media management sync config' });
}
}
};

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import type { PageData } from './$types';
import { Info } from 'lucide-svelte';
import InfoModal from '$ui/modal/InfoModal.svelte';
import QualityProfiles from './components/QualityProfiles.svelte';
import DelayProfiles from './components/DelayProfiles.svelte';
import MediaManagement from './components/MediaManagement.svelte';
import type { SyncTrigger } from '$db/queries/arrSync.ts';
export let data: PageData;
let showInfoModal = false;
// Initialize state from loaded sync data
function buildProfileState(
selections: { databaseId: number; profileId: number }[]
): Record<number, Record<number, boolean>> {
const state: Record<number, Record<number, boolean>> = {};
for (const sel of selections) {
if (!state[sel.databaseId]) {
state[sel.databaseId] = {};
}
state[sel.databaseId][sel.profileId] = true;
}
return state;
}
let qualityProfileState = buildProfileState(data.syncData.qualityProfiles.selections);
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 mediaManagementState = {
namingDatabaseId: data.syncData.mediaManagement.namingDatabaseId,
qualityDefinitionsDatabaseId: data.syncData.mediaManagement.qualityDefinitionsDatabaseId,
mediaSettingsDatabaseId: data.syncData.mediaManagement.mediaSettingsDatabaseId
};
let mediaManagementTrigger: SyncTrigger = data.syncData.mediaManagement.trigger;
let mediaManagementCron: string = data.syncData.mediaManagement.cron || '0 * * * *';
</script>
<svelte:head>
<title>{data.instance.name} - Sync - Profilarr</title>
</svelte:head>
<div class="mt-6 space-y-6 pb-32">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-50">
Sync Configuration
</h1>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Configure which profiles and settings to sync to this instance.
</p>
</div>
<button
type="button"
on:click={() => (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"
>
<Info size={14} />
How it works
</button>
</div>
<QualityProfiles
databases={data.databases}
bind:state={qualityProfileState}
bind:syncTrigger={qualityProfileTrigger}
bind:cronExpression={qualityProfileCron}
/>
<DelayProfiles
databases={data.databases}
bind:state={delayProfileState}
bind:syncTrigger={delayProfileTrigger}
bind:cronExpression={delayProfileCron}
/>
<MediaManagement
databases={data.databases}
bind:state={mediaManagementState}
bind:syncTrigger={mediaManagementTrigger}
bind:cronExpression={mediaManagementCron}
/>
</div>
<InfoModal bind:open={showInfoModal} header="How Sync Works">
<div class="space-y-4 text-sm text-neutral-600 dark:text-neutral-400">
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Automatic Dependencies</div>
<p class="mt-1">
Quality Profiles will automatically sync the custom formats they need - you don't need to select them separately.
</p>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Namespacing</div>
<p class="mt-1">
Similarly named items from different databases will include invisible namespaces to ensure they don't override each other.
</p>
</div>
<div class="border-t border-neutral-200 pt-4 dark:border-neutral-700">
<div class="font-medium text-neutral-900 dark:text-neutral-100 mb-3">Sync Methods</div>
<div class="space-y-3">
<div>
<div class="font-medium text-neutral-800 dark:text-neutral-200">Manual</div>
<p class="mt-0.5">You manually click the sync button. Useful for media management settings that rarely get updates.</p>
</div>
<div>
<div class="font-medium text-neutral-800 dark:text-neutral-200">Schedule</div>
<p class="mt-0.5">Syncs on a defined schedule using cron expressions.</p>
</div>
<div>
<div class="font-medium text-neutral-800 dark:text-neutral-200">On Pull</div>
<p class="mt-0.5">Syncs when the upstream database gets a change (when you pull from remote).</p>
</div>
<div>
<div class="font-medium text-neutral-800 dark:text-neutral-200">On Change</div>
<p class="mt-0.5">Syncs when anything changes - whether you pull from upstream or change something yourself.</p>
</div>
</div>
</div>
<div class="border-t border-neutral-200 pt-4 dark:border-neutral-700">
<div class="font-medium text-neutral-900 dark:text-neutral-100 mb-3">Cron Expressions</div>
<p class="mb-3">Schedule uses standard cron syntax: <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="space-y-1.5 font-mono text-xs">
<div class="flex gap-3">
<code class="rounded bg-neutral-100 px-1.5 py-0.5 dark:bg-neutral-800">0 * * * *</code>
<span class="font-sans">Every hour</span>
</div>
<div class="flex gap-3">
<code class="rounded bg-neutral-100 px-1.5 py-0.5 dark:bg-neutral-800">*/15 * * * *</code>
<span class="font-sans">Every 15 minutes</span>
</div>
<div class="flex gap-3">
<code class="rounded bg-neutral-100 px-1.5 py-0.5 dark:bg-neutral-800">0 0 * * *</code>
<span class="font-sans">Daily at midnight</span>
</div>
<div class="flex gap-3">
<code class="rounded bg-neutral-100 px-1.5 py-0.5 dark:bg-neutral-800">0 6 * * 1</code>
<span class="font-sans">Every Monday at 6am</span>
</div>
</div>
</div>
</div>
</InfoModal>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import type { DelayProfileTableRow } from '$pcd/queries/delayProfiles/types.ts';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import SyncFooter from './SyncFooter.svelte';
import { Check } from 'lucide-svelte';
import { alertStore } from '$lib/client/alerts/store.ts';
interface DatabaseWithProfiles {
id: number;
name: string;
delayProfiles: DelayProfileTableRow[];
}
export let databases: DatabaseWithProfiles[];
export let state: Record<number, Record<number, boolean>> = {};
export let syncTrigger: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule' = 'none';
export let cronExpression: string = '0 * * * *';
let saving = false;
// 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;
}
}
}
}
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) });
}
}
}
return selections;
}
async function handleSave() {
saving = true;
try {
const formData = new FormData();
formData.set('selections', JSON.stringify(getSelections()));
formData.set('trigger', syncTrigger);
formData.set('cron', cronExpression);
const response = await fetch('?/saveDelayProfiles', {
method: 'POST',
body: formData
});
if (response.ok) {
alertStore.add('success', 'Delay profiles sync config saved');
} else {
alertStore.add('error', 'Failed to save delay profiles sync config');
}
} catch {
alertStore.add('error', 'Failed to save delay profiles sync config');
} finally {
saving = false;
}
}
</script>
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Delay Profiles</h2>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Select delay profiles to sync to this instance
</p>
</div>
<!-- Content -->
<div class="p-6">
{#if databases.length === 0}
<p class="text-sm text-neutral-500 dark:text-neutral-400">No databases configured</p>
{:else}
<div class="space-y-6">
{#each databases as database}
<div class="space-y-3">
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">
{database.name}
</h3>
{#if database.delayProfiles.length === 0}
<p class="text-sm text-neutral-500 dark:text-neutral-400">No delay profiles</p>
{:else}
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{#each database.delayProfiles as profile}
<div class="flex items-center gap-2">
<IconCheckbox
checked={state[database.id]?.[profile.id] ?? false}
icon={Check}
shape="rounded"
on:click={() => {
state[database.id][profile.id] = !state[database.id][profile.id];
}}
/>
<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-sm text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50">
{profile.name}
</code>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<SyncFooter bind:syncTrigger bind:cronExpression {saving} on:save={handleSave} />
</div>

View File

@@ -0,0 +1,229 @@
<script lang="ts">
import { ChevronDown, Check } from 'lucide-svelte';
import SyncFooter from './SyncFooter.svelte';
import { alertStore } from '$lib/client/alerts/store.ts';
interface Database {
id: number;
name: string;
}
export let databases: Database[];
export let state: {
namingDatabaseId: number | null;
qualityDefinitionsDatabaseId: number | null;
mediaSettingsDatabaseId: number | null;
} = {
namingDatabaseId: null,
qualityDefinitionsDatabaseId: null,
mediaSettingsDatabaseId: null
};
let showNamingDropdown = false;
let showQualityDropdown = false;
let showMediaDropdown = false;
function getSelectedName(id: number | null): string {
if (id === null) return 'None';
return databases.find((db) => db.id === id)?.name ?? 'None';
}
function selectNaming(id: number | null) {
state.namingDatabaseId = id;
showNamingDropdown = false;
}
function selectQuality(id: number | null) {
state.qualityDefinitionsDatabaseId = id;
showQualityDropdown = false;
}
function selectMedia(id: number | null) {
state.mediaSettingsDatabaseId = id;
showMediaDropdown = false;
}
export let syncTrigger: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule' = 'none';
export let cronExpression: string = '0 * * * *';
let saving = false;
async function handleSave() {
saving = true;
try {
const formData = new FormData();
formData.set('namingDatabaseId', state.namingDatabaseId?.toString() ?? '');
formData.set('qualityDefinitionsDatabaseId', state.qualityDefinitionsDatabaseId?.toString() ?? '');
formData.set('mediaSettingsDatabaseId', state.mediaSettingsDatabaseId?.toString() ?? '');
formData.set('trigger', syncTrigger);
formData.set('cron', cronExpression);
const response = await fetch('?/saveMediaManagement', {
method: 'POST',
body: formData
});
if (response.ok) {
alertStore.add('success', 'Media management sync config saved');
} else {
alertStore.add('error', 'Failed to save media management sync config');
}
} catch {
alertStore.add('error', 'Failed to save media management sync config');
} finally {
saving = false;
}
}
</script>
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Media Management</h2>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Select which database to use for each media management setting
</p>
</div>
<!-- Content -->
<div class="p-6">
<div class="grid gap-6 sm:grid-cols-3">
<!-- Naming -->
<div class="space-y-2">
<span class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
Naming
</span>
<div class="relative">
<button
type="button"
on:click={() => (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"
>
<span>{getSelectedName(state.namingDatabaseId)}</span>
<ChevronDown size={14} />
</button>
{#if showNamingDropdown}
<div class="absolute top-full z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800">
<button
type="button"
on:click={() => 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"
>
<span>None</span>
{#if state.namingDatabaseId === null}
<Check size={14} class="text-accent-600 dark:text-accent-400" />
{/if}
</button>
{#each databases as database}
<button
type="button"
on:click={() => 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"
>
<span>{database.name}</span>
{#if state.namingDatabaseId === database.id}
<Check size={14} class="text-accent-600 dark:text-accent-400" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Quality Definitions -->
<div class="space-y-2">
<span class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
Quality Definitions
</span>
<div class="relative">
<button
type="button"
on:click={() => (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"
>
<span>{getSelectedName(state.qualityDefinitionsDatabaseId)}</span>
<ChevronDown size={14} />
</button>
{#if showQualityDropdown}
<div class="absolute top-full z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800">
<button
type="button"
on:click={() => 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"
>
<span>None</span>
{#if state.qualityDefinitionsDatabaseId === null}
<Check size={14} class="text-accent-600 dark:text-accent-400" />
{/if}
</button>
{#each databases as database}
<button
type="button"
on:click={() => 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"
>
<span>{database.name}</span>
{#if state.qualityDefinitionsDatabaseId === database.id}
<Check size={14} class="text-accent-600 dark:text-accent-400" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
<!-- Media Settings -->
<div class="space-y-2">
<span class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
Media Settings
</span>
<div class="relative">
<button
type="button"
on:click={() => (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"
>
<span>{getSelectedName(state.mediaSettingsDatabaseId)}</span>
<ChevronDown size={14} />
</button>
{#if showMediaDropdown}
<div class="absolute top-full z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800">
<button
type="button"
on:click={() => 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"
>
<span>None</span>
{#if state.mediaSettingsDatabaseId === null}
<Check size={14} class="text-accent-600 dark:text-accent-400" />
{/if}
</button>
{#each databases as database}
<button
type="button"
on:click={() => 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"
>
<span>{database.name}</span>
{#if state.mediaSettingsDatabaseId === database.id}
<Check size={14} class="text-accent-600 dark:text-accent-400" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<SyncFooter bind:syncTrigger bind:cronExpression {saving} on:save={handleSave} />
</div>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import type { QualityProfileTableRow } from '$pcd/queries/qualityProfiles/types.ts';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import SyncFooter from './SyncFooter.svelte';
import { Check } from 'lucide-svelte';
import { alertStore } from '$lib/client/alerts/store.ts';
interface DatabaseWithProfiles {
id: number;
name: string;
qualityProfiles: QualityProfileTableRow[];
}
export let databases: DatabaseWithProfiles[];
export let state: Record<number, Record<number, boolean>> = {};
export let syncTrigger: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule' = 'none';
export let cronExpression: string = '0 * * * *';
let saving = false;
// Initialize state for all databases/profiles
$: {
for (const db of databases) {
if (!state[db.id]) {
state[db.id] = {};
}
for (const profile of db.qualityProfiles) {
if (state[db.id][profile.id] === undefined) {
state[db.id][profile.id] = false;
}
}
}
}
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) });
}
}
}
return selections;
}
async function handleSave() {
saving = true;
try {
const formData = new FormData();
formData.set('selections', JSON.stringify(getSelections()));
formData.set('trigger', syncTrigger);
formData.set('cron', cronExpression);
const response = await fetch('?/saveQualityProfiles', {
method: 'POST',
body: formData
});
if (response.ok) {
alertStore.add('success', 'Quality profiles sync config saved');
} else {
alertStore.add('error', 'Failed to save quality profiles sync config');
}
} catch {
alertStore.add('error', 'Failed to save quality profiles sync config');
} finally {
saving = false;
}
}
</script>
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Quality Profiles</h2>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Select quality profiles to sync to this instance
</p>
</div>
<!-- Content -->
<div class="p-6">
{#if databases.length === 0}
<p class="text-sm text-neutral-500 dark:text-neutral-400">No databases configured</p>
{:else}
<div class="space-y-6">
{#each databases as database}
<div class="space-y-3">
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">
{database.name}
</h3>
{#if database.qualityProfiles.length === 0}
<p class="text-sm text-neutral-500 dark:text-neutral-400">No quality profiles</p>
{:else}
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{#each database.qualityProfiles as profile}
<div class="flex items-center gap-2">
<IconCheckbox
checked={state[database.id]?.[profile.id] ?? false}
icon={Check}
shape="rounded"
on:click={() => {
state[database.id][profile.id] = !state[database.id][profile.id];
}}
/>
<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-sm text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50">
{profile.name}
</code>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<SyncFooter bind:syncTrigger bind:cronExpression {saving} on:save={handleSave} />
</div>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import { Check, RefreshCw, Save, Loader2 } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
export let syncTrigger: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule' = 'none';
export let cronExpression: string = '0 * * * *';
export let saving: boolean = false;
const dispatch = createEventDispatcher<{ save: void }>();
const triggerOptions = [
{ value: 'none', label: 'None' },
{ value: 'manual', label: 'Manual' },
{ value: 'on_pull', label: 'On Pull' },
{ value: 'on_change', label: 'On Change' },
{ value: 'schedule', label: 'Schedule' }
] as const;
</script>
<div class="border-t border-neutral-200 px-6 py-4 dark:border-neutral-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm text-neutral-500 dark:text-neutral-400">Trigger</span>
{#each triggerOptions as option}
<div class="flex items-center gap-2">
<IconCheckbox
checked={syncTrigger === option.value}
icon={Check}
shape="rounded"
on:click={() => (syncTrigger = option.value)}
/>
<span class="text-sm text-neutral-700 dark:text-neutral-300">{option.label}</span>
</div>
{/each}
{#if syncTrigger === 'schedule'}
<input
type="text"
bind:value={cronExpression}
placeholder="0 * * * *"
class="w-32 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-mono text-sm text-neutral-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
{/if}
</div>
<div class="flex items-center gap-2">
<button
type="button"
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"
>
<RefreshCw size={14} />
Sync Now
</button>
<button
type="button"
disabled={saving}
on:click={() => 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}
<Loader2 size={14} class="animate-spin" />
{:else}
<Save size={14} />
{/if}
Save
</button>
</div>
</div>
</div>