From 1e8fc7a42d8117bfd0a2db802fbc141af23c3a25 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Mon, 29 Dec 2025 05:37:55 +1030 Subject: [PATCH] feat(sync): implement sync functionality for delay profiles - Added syncArrJob to handle syncing of PCD profiles and settings to arr instances. - Created syncArr logic to process pending syncs and log results. - Introduced BaseSyncer class for common sync operations and specific syncers for delay profiles - Implemented fetch, transform, and push methods for delay profiles - Added manual sync actions in the UI for delay profiles - Enhanced logging for sync operations and error handling. --- src/lib/server/db/migrations.ts | 4 +- .../migrations/016_add_should_sync_flags.ts | 27 ++ src/lib/server/db/queries/arrSync.ts | 82 ++++++ src/lib/server/db/schema.sql | 104 ++++++- src/lib/server/jobs/definitions/syncArr.ts | 61 ++++ src/lib/server/jobs/init.ts | 2 + src/lib/server/jobs/logic/syncArr.ts | 90 ++++++ src/lib/server/sync/base.ts | 94 ++++++ src/lib/server/sync/delayProfiles.ts | 267 ++++++++++++++++++ src/lib/server/sync/index.ts | 18 ++ src/lib/server/sync/mediaManagement.ts | 54 ++++ src/lib/server/sync/processor.ts | 186 ++++++++++++ src/lib/server/sync/qualityProfiles.ts | 49 ++++ src/lib/server/utils/arr/base.ts | 59 +++- src/lib/server/utils/arr/types.ts | 34 +++ src/routes/arr/[id]/sync/+page.server.ts | 102 +++++++ .../[id]/sync/components/DelayProfiles.svelte | 23 +- .../sync/components/MediaManagement.svelte | 23 +- .../sync/components/QualityProfiles.svelte | 23 +- .../[id]/sync/components/SyncFooter.svelte | 13 +- 20 files changed, 1305 insertions(+), 10 deletions(-) create mode 100644 src/lib/server/db/migrations/016_add_should_sync_flags.ts create mode 100644 src/lib/server/jobs/definitions/syncArr.ts create mode 100644 src/lib/server/jobs/logic/syncArr.ts create mode 100644 src/lib/server/sync/base.ts create mode 100644 src/lib/server/sync/delayProfiles.ts create mode 100644 src/lib/server/sync/index.ts create mode 100644 src/lib/server/sync/mediaManagement.ts create mode 100644 src/lib/server/sync/processor.ts create mode 100644 src/lib/server/sync/qualityProfiles.ts diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index d561079..57591a2 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -17,6 +17,7 @@ import { migration as migration012 } from './migrations/012_add_upgrade_last_run 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'; +import { migration as migration016 } from './migrations/016_add_should_sync_flags.ts'; export interface Migration { version: number; @@ -249,7 +250,8 @@ export function loadMigrations(): Migration[] { migration012, migration013, migration014, - migration015 + migration015, + migration016 ]; // Sort by version number diff --git a/src/lib/server/db/migrations/016_add_should_sync_flags.ts b/src/lib/server/db/migrations/016_add_should_sync_flags.ts new file mode 100644 index 0000000..c01147c --- /dev/null +++ b/src/lib/server/db/migrations/016_add_should_sync_flags.ts @@ -0,0 +1,27 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 016: Add should_sync flags to sync config tables + * + * Adds a should_sync boolean to each sync config table. + * This flag is set to true when a sync should be triggered + * (based on trigger type: on_pull, on_change, schedule). + * The sync job checks this flag and syncs when true, then resets it. + */ + +export const migration: Migration = { + version: 16, + name: 'Add should_sync flags', + + up: ` + ALTER TABLE arr_sync_quality_profiles_config ADD COLUMN should_sync INTEGER NOT NULL DEFAULT 0; + ALTER TABLE arr_sync_delay_profiles_config ADD COLUMN should_sync INTEGER NOT NULL DEFAULT 0; + ALTER TABLE arr_sync_media_management ADD COLUMN should_sync INTEGER NOT NULL DEFAULT 0; + `, + + down: ` + ALTER TABLE arr_sync_quality_profiles_config DROP COLUMN should_sync; + ALTER TABLE arr_sync_delay_profiles_config DROP COLUMN should_sync; + ALTER TABLE arr_sync_media_management DROP COLUMN should_sync; + ` +}; diff --git a/src/lib/server/db/queries/arrSync.ts b/src/lib/server/db/queries/arrSync.ts index 178574b..9c8dcc5 100644 --- a/src/lib/server/db/queries/arrSync.ts +++ b/src/lib/server/db/queries/arrSync.ts @@ -257,5 +257,87 @@ export const arrSyncQueries = { 'UPDATE arr_sync_media_management SET media_settings_database_id = NULL WHERE media_settings_database_id = ?', databaseId ); + }, + + // ========== Should Sync Flags ========== + + /** + * Set should_sync flag for quality profiles + */ + setQualityProfilesShouldSync(instanceId: number, shouldSync: boolean): void { + db.execute( + 'UPDATE arr_sync_quality_profiles_config SET should_sync = ? WHERE instance_id = ?', + shouldSync ? 1 : 0, + instanceId + ); + }, + + /** + * Set should_sync flag for delay profiles + */ + setDelayProfilesShouldSync(instanceId: number, shouldSync: boolean): void { + db.execute( + 'UPDATE arr_sync_delay_profiles_config SET should_sync = ? WHERE instance_id = ?', + shouldSync ? 1 : 0, + instanceId + ); + }, + + /** + * Set should_sync flag for media management + */ + setMediaManagementShouldSync(instanceId: number, shouldSync: boolean): void { + db.execute( + 'UPDATE arr_sync_media_management SET should_sync = ? WHERE instance_id = ?', + shouldSync ? 1 : 0, + instanceId + ); + }, + + /** + * Mark all configs with a specific trigger as should_sync + * Used when events occur (pull, change) + */ + markForSync(trigger: 'on_pull' | 'on_change'): void { + const triggers = trigger === 'on_change' ? ['on_pull', 'on_change'] : ['on_pull']; + const placeholders = triggers.map(() => '?').join(', '); + + db.execute( + `UPDATE arr_sync_quality_profiles_config SET should_sync = 1 WHERE trigger IN (${placeholders})`, + ...triggers + ); + db.execute( + `UPDATE arr_sync_delay_profiles_config SET should_sync = 1 WHERE trigger IN (${placeholders})`, + ...triggers + ); + db.execute( + `UPDATE arr_sync_media_management SET should_sync = 1 WHERE trigger IN (${placeholders})`, + ...triggers + ); + }, + + /** + * Get all configs that need syncing (should_sync = true) + */ + getPendingSyncs(): { + qualityProfiles: number[]; + delayProfiles: number[]; + mediaManagement: number[]; + } { + const qp = db.query<{ instance_id: number }>( + 'SELECT instance_id FROM arr_sync_quality_profiles_config WHERE should_sync = 1' + ); + const dp = db.query<{ instance_id: number }>( + 'SELECT instance_id FROM arr_sync_delay_profiles_config WHERE should_sync = 1' + ); + const mm = db.query<{ instance_id: number }>( + 'SELECT instance_id FROM arr_sync_media_management WHERE should_sync = 1' + ); + + return { + qualityProfiles: qp.map((r) => r.instance_id), + delayProfiles: dp.map((r) => r.instance_id), + mediaManagement: mm.map((r) => r.instance_id) + }; } }; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 4d71ca7..4fd54bd 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: 2025-12-27 +-- Last updated: 2025-12-29 -- ============================================================================== -- TABLE: migrations @@ -228,7 +228,7 @@ CREATE TABLE database_instances ( -- ============================================================================== -- TABLE: upgrade_configs -- Purpose: Store upgrade configuration per arr instance for automated quality upgrades --- Migration: 011_create_upgrade_configs.ts +-- Migration: 011_create_upgrade_configs.ts, 012_add_upgrade_last_run.ts, 013_add_upgrade_dry_run.ts -- ============================================================================== CREATE TABLE upgrade_configs ( @@ -239,6 +239,7 @@ CREATE TABLE upgrade_configs ( -- Core settings enabled INTEGER NOT NULL DEFAULT 0, -- Master on/off switch + dry_run INTEGER NOT NULL DEFAULT 0, -- 1=dry run mode, 0=normal (Migration 013) schedule INTEGER NOT NULL DEFAULT 360, -- Run interval in minutes (default 6 hours) filter_mode TEXT NOT NULL DEFAULT 'round_robin', -- 'round_robin' or 'random' @@ -256,6 +257,101 @@ CREATE TABLE upgrade_configs ( FOREIGN KEY (arr_instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE ); +-- ============================================================================== +-- TABLE: ai_settings +-- Purpose: Store AI/LLM configuration for commit message generation +-- Migration: 014_create_ai_settings.ts +-- ============================================================================== + +CREATE TABLE ai_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + + -- Provider settings + provider TEXT NOT NULL DEFAULT 'openai', -- 'openai', 'anthropic', etc. + api_key TEXT, -- Encrypted API key + model TEXT NOT NULL DEFAULT 'gpt-4o-mini', -- Model identifier + + -- Feature flags + enabled INTEGER NOT NULL DEFAULT 0, -- Master on/off switch + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================== +-- TABLE: arr_sync_quality_profiles +-- Purpose: Store quality profile sync selections (many-to-many) +-- Migration: 015_create_arr_sync_tables.ts +-- ============================================================================== + +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 +); + +-- ============================================================================== +-- TABLE: arr_sync_quality_profiles_config +-- Purpose: Store quality profile sync trigger configuration (one per instance) +-- Migration: 015_create_arr_sync_tables.ts, 016_add_should_sync_flags.ts +-- ============================================================================== + +CREATE TABLE arr_sync_quality_profiles_config ( + instance_id INTEGER PRIMARY KEY, + trigger TEXT NOT NULL DEFAULT 'none', -- 'none', 'manual', 'on_pull', 'on_change', 'schedule' + cron TEXT, -- Cron expression for schedule trigger + should_sync INTEGER NOT NULL DEFAULT 0, -- Flag for pending sync (Migration 016) + 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 +-- ============================================================================== + +CREATE TABLE arr_sync_delay_profiles_config ( + instance_id INTEGER PRIMARY KEY, + trigger TEXT NOT NULL DEFAULT 'none', -- 'none', 'manual', 'on_pull', 'on_change', 'schedule' + cron TEXT, -- Cron expression for schedule trigger + should_sync INTEGER NOT NULL DEFAULT 0, -- Flag for pending sync (Migration 016) + FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE +); + +-- ============================================================================== +-- TABLE: arr_sync_media_management +-- Purpose: Store media management sync configuration (one per instance) +-- Migration: 015_create_arr_sync_tables.ts, 016_add_should_sync_flags.ts +-- ============================================================================== + +CREATE TABLE arr_sync_media_management ( + instance_id INTEGER PRIMARY KEY, + naming_database_id INTEGER, -- Database to use for naming settings + quality_definitions_database_id INTEGER, -- Database to use for quality definitions + media_settings_database_id INTEGER, -- Database to use for media settings + trigger TEXT NOT NULL DEFAULT 'none', -- 'none', 'manual', 'on_pull', 'on_change', 'schedule' + cron TEXT, -- Cron expression for schedule trigger + should_sync INTEGER NOT NULL DEFAULT 0, -- Flag for pending sync (Migration 016) + FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE +); + -- ============================================================================== -- INDEXES -- Purpose: Improve query performance @@ -283,3 +379,7 @@ CREATE INDEX idx_database_instances_uuid ON database_instances(uuid); -- Upgrade configs indexes (Migration: 011_create_upgrade_configs.ts) 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); diff --git a/src/lib/server/jobs/definitions/syncArr.ts b/src/lib/server/jobs/definitions/syncArr.ts new file mode 100644 index 0000000..fb63858 --- /dev/null +++ b/src/lib/server/jobs/definitions/syncArr.ts @@ -0,0 +1,61 @@ +import { logger } from '$logger/logger.ts'; +import { syncArr } from '../logic/syncArr.ts'; +import type { JobDefinition, JobResult } from '../types.ts'; + +/** + * Sync arr instances job + * Processes pending syncs and pushes profiles/settings to arr instances + */ +export const syncArrJob: JobDefinition = { + name: 'sync_arr', + description: 'Sync PCD profiles and settings to arr instances', + schedule: '* * * * *', // Every minute + + handler: async (): Promise => { + try { + const result = await syncArr(); + + // Nothing to process + if (result.totalProcessed === 0) { + return { + success: true, + output: 'No pending syncs' + }; + } + + // Log individual results + for (const sync of result.syncs) { + if (sync.success) { + await logger.info(`Synced ${sync.section} to ${sync.instanceName}`, { + source: 'SyncArrJob', + meta: { instanceId: sync.instanceId, section: sync.section } + }); + } else { + await logger.error(`Failed to sync ${sync.section} to ${sync.instanceName}`, { + source: 'SyncArrJob', + meta: { instanceId: sync.instanceId, section: sync.section, error: sync.error } + }); + } + } + + const message = `Sync completed: ${result.successCount} successful, ${result.failureCount} failed (${result.totalProcessed} total)`; + + if (result.failureCount > 0 && result.successCount === 0) { + return { + success: false, + error: message + }; + } + + return { + success: true, + output: message + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + } +}; diff --git a/src/lib/server/jobs/init.ts b/src/lib/server/jobs/init.ts index b3a59cf..95c312c 100644 --- a/src/lib/server/jobs/init.ts +++ b/src/lib/server/jobs/init.ts @@ -8,6 +8,7 @@ import { createBackupJob } from './definitions/createBackup.ts'; import { cleanupBackupsJob } from './definitions/cleanupBackups.ts'; import { syncDatabasesJob } from './definitions/syncDatabases.ts'; import { upgradeManagerJob } from './definitions/upgradeManager.ts'; +import { syncArrJob } from './definitions/syncArr.ts'; /** * Register all job definitions @@ -19,6 +20,7 @@ function registerAllJobs(): void { jobRegistry.register(cleanupBackupsJob); jobRegistry.register(syncDatabasesJob); jobRegistry.register(upgradeManagerJob); + jobRegistry.register(syncArrJob); } /** diff --git a/src/lib/server/jobs/logic/syncArr.ts b/src/lib/server/jobs/logic/syncArr.ts new file mode 100644 index 0000000..ea2e0a2 --- /dev/null +++ b/src/lib/server/jobs/logic/syncArr.ts @@ -0,0 +1,90 @@ +/** + * Core sync logic for syncing PCD profiles to arr instances + * Delegates to the sync processor module + */ + +import { processPendingSyncs, type ProcessSyncsResult } from '$lib/server/sync/index.ts'; + +export interface SyncStatus { + instanceId: number; + instanceName: string; + section: 'qualityProfiles' | 'delayProfiles' | 'mediaManagement'; + success: boolean; + error?: string; +} + +export interface SyncArrResult { + totalProcessed: number; + successCount: number; + failureCount: number; + syncs: SyncStatus[]; +} + +/** + * Process all pending syncs + * Delegates to processPendingSyncs and transforms result to job-expected format + */ +export async function syncArr(): Promise { + const result = await processPendingSyncs(); + return transformResult(result); +} + +/** + * Transform ProcessSyncsResult to SyncArrResult for job compatibility + */ +function transformResult(result: ProcessSyncsResult): SyncArrResult { + const syncs: SyncStatus[] = []; + let successCount = 0; + let failureCount = 0; + + for (const instanceResult of result.results) { + // Quality profiles + if (instanceResult.qualityProfiles) { + const qp = instanceResult.qualityProfiles; + syncs.push({ + instanceId: instanceResult.instanceId, + instanceName: instanceResult.instanceName, + section: 'qualityProfiles', + success: qp.success, + error: qp.error + }); + if (qp.success) successCount++; + else failureCount++; + } + + // Delay profiles + if (instanceResult.delayProfiles) { + const dp = instanceResult.delayProfiles; + syncs.push({ + instanceId: instanceResult.instanceId, + instanceName: instanceResult.instanceName, + section: 'delayProfiles', + success: dp.success, + error: dp.error + }); + if (dp.success) successCount++; + else failureCount++; + } + + // Media management + if (instanceResult.mediaManagement) { + const mm = instanceResult.mediaManagement; + syncs.push({ + instanceId: instanceResult.instanceId, + instanceName: instanceResult.instanceName, + section: 'mediaManagement', + success: mm.success, + error: mm.error + }); + if (mm.success) successCount++; + else failureCount++; + } + } + + return { + totalProcessed: syncs.length, + successCount, + failureCount, + syncs + }; +} diff --git a/src/lib/server/sync/base.ts b/src/lib/server/sync/base.ts new file mode 100644 index 0000000..0cac67d --- /dev/null +++ b/src/lib/server/sync/base.ts @@ -0,0 +1,94 @@ +/** + * Base syncer class + * Provides common structure for syncing PCD data to arr instances + */ + +import type { BaseArrClient } from '$arr/base.ts'; +import { logger } from '$logger/logger.ts'; + +export interface SyncResult { + success: boolean; + itemsSynced: number; + error?: string; +} + +/** + * Abstract base class for syncers + * Each syncer type (quality profiles, delay profiles, media management) extends this + */ +export abstract class BaseSyncer { + protected client: BaseArrClient; + protected instanceId: number; + protected instanceName: string; + + constructor(client: BaseArrClient, instanceId: number, instanceName: string) { + this.client = client; + this.instanceId = instanceId; + this.instanceName = instanceName; + } + + /** + * Get the sync type name for logging + */ + protected abstract get syncType(): string; + + /** + * Fetch data from PCD based on sync config + */ + protected abstract fetchFromPcd(): Promise; + + /** + * Transform PCD data to arr API format + */ + protected abstract transformToArr(pcdData: unknown[]): unknown[]; + + /** + * Push transformed data to arr instance + */ + protected abstract pushToArr(arrData: unknown[]): Promise; + + /** + * Main sync method - orchestrates fetch, transform, push + */ + async sync(): Promise { + try { + await logger.info(`Starting ${this.syncType} sync for "${this.instanceName}"`, { + source: 'Syncer', + meta: { instanceId: this.instanceId, syncType: this.syncType } + }); + + // Fetch from PCD + const pcdData = await this.fetchFromPcd(); + + if (pcdData.length === 0) { + await logger.debug(`No ${this.syncType} to sync for "${this.instanceName}"`, { + source: 'Syncer', + meta: { instanceId: this.instanceId } + }); + return { success: true, itemsSynced: 0 }; + } + + // Transform to arr format + const arrData = this.transformToArr(pcdData); + + // Push to arr + await this.pushToArr(arrData); + + await logger.info(`Completed ${this.syncType} sync for "${this.instanceName}"`, { + source: 'Syncer', + meta: { instanceId: this.instanceId, itemsSynced: arrData.length } + }); + + return { success: true, itemsSynced: arrData.length }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + + await logger.error(`Failed ${this.syncType} sync for "${this.instanceName}"`, { + source: 'Syncer', + meta: { instanceId: this.instanceId, error: errorMsg } + }); + + return { success: false, itemsSynced: 0, error: errorMsg }; + } + } +} diff --git a/src/lib/server/sync/delayProfiles.ts b/src/lib/server/sync/delayProfiles.ts new file mode 100644 index 0000000..efa57bf --- /dev/null +++ b/src/lib/server/sync/delayProfiles.ts @@ -0,0 +1,267 @@ +/** + * Delay profile syncer + * Syncs delay profiles from PCD to arr instances + * + * TODO: Handle ordering for multiple profiles + * - Delay profiles have an `order` field (lower = higher priority) + * - Currently we just create in selection order + * - May need to use PUT /api/v3/delayprofile/reorder/{id} endpoint + * - Consider: should PCD store order, or derive from selection order? + */ + +import { BaseSyncer } 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 { 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'; + } + + protected async fetchFromPcd(): 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', + meta: { instanceId: this.instanceId } + }); + } + + // Build tag name -> ID map + const tagMap = new Map(); + for (const tag of existingTags) { + tagMap.set(tag.label.toLowerCase(), tag.id); + } + + // Create new profiles + for (const profile of arrData) { + // 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, + 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 } + }); + } + + await logger.debug(`Push complete - ${arrData.length} profiles created`, { + source: 'Sync:DelayProfiles:push', + meta: { instanceId: this.instanceId } + }); + } +} diff --git a/src/lib/server/sync/index.ts b/src/lib/server/sync/index.ts new file mode 100644 index 0000000..37742a3 --- /dev/null +++ b/src/lib/server/sync/index.ts @@ -0,0 +1,18 @@ +/** + * Sync module - handles syncing PCD profiles to arr instances + * + * Used by: + * - Sync job (automatic, triggered by should_sync flag) + * - Manual sync (Sync Now button) + */ + +// Base class +export { BaseSyncer, type SyncResult } from './base.ts'; + +// Syncer implementations +export { QualityProfileSyncer } from './qualityProfiles.ts'; +export { DelayProfileSyncer } from './delayProfiles.ts'; +export { MediaManagementSyncer } from './mediaManagement.ts'; + +// Processor functions +export { processPendingSyncs, syncInstance, type ProcessSyncsResult } from './processor.ts'; diff --git a/src/lib/server/sync/mediaManagement.ts b/src/lib/server/sync/mediaManagement.ts new file mode 100644 index 0000000..7bf25fc --- /dev/null +++ b/src/lib/server/sync/mediaManagement.ts @@ -0,0 +1,54 @@ +/** + * Media management syncer + * Syncs media management settings from PCD to arr instances + */ + +import { BaseSyncer } from './base.ts'; +import { arrSyncQueries } from '$db/queries/arrSync.ts'; + +export class MediaManagementSyncer extends BaseSyncer { + protected get syncType(): string { + return 'media management'; + } + + protected async fetchFromPcd(): Promise { + const syncConfig = arrSyncQueries.getMediaManagementSync(this.instanceId); + + // Check if any settings are configured + if ( + !syncConfig.namingDatabaseId && + !syncConfig.qualityDefinitionsDatabaseId && + !syncConfig.mediaSettingsDatabaseId + ) { + return []; + } + + // TODO: Implement + // Fetch each configured setting from PCD: + // 1. Naming settings (if namingDatabaseId is set) + // 2. Quality definitions (if qualityDefinitionsDatabaseId is set) + // 3. Media settings (if mediaSettingsDatabaseId is set) + + throw new Error('Not implemented: fetchFromPcd'); + } + + protected transformToArr(pcdData: unknown[]): unknown[] { + // TODO: Implement + // Transform PCD settings to arr API format + // Each setting type has different structure: + // - Naming: renaming rules, folder format, etc. + // - Quality definitions: min/max sizes per quality + // - Media settings: file management options + + throw new Error('Not implemented: transformToArr'); + } + + protected async pushToArr(arrData: unknown[]): Promise { + // TODO: Implement + // Push settings to arr instance + // These are typically PUT operations to update existing config + // rather than creating new items + + throw new Error('Not implemented: pushToArr'); + } +} diff --git a/src/lib/server/sync/processor.ts b/src/lib/server/sync/processor.ts new file mode 100644 index 0000000..f7d8aa2 --- /dev/null +++ b/src/lib/server/sync/processor.ts @@ -0,0 +1,186 @@ +/** + * Sync processor + * Processes pending syncs by creating syncer instances and running them + * + * TODO: Trigger markForSync() from events: + * - on_pull: Call arrSyncQueries.markForSync('on_pull') after database git pull completes + * - on_change: Call arrSyncQueries.markForSync('on_change') after PCD files change + * - schedule: Evaluate cron expressions and set should_sync when schedule matches + */ + +import { arrSyncQueries } from '$db/queries/arrSync.ts'; +import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; +import { createArrClient } from '$arr/factory.ts'; +import type { ArrType } from '$arr/types.ts'; +import { logger } from '$logger/logger.ts'; +import { QualityProfileSyncer } from './qualityProfiles.ts'; +import { DelayProfileSyncer } from './delayProfiles.ts'; +import { MediaManagementSyncer } from './mediaManagement.ts'; +import type { SyncResult } from './base.ts'; + +export interface ProcessSyncsResult { + totalSynced: number; + results: { + instanceId: number; + instanceName: string; + qualityProfiles?: SyncResult; + delayProfiles?: SyncResult; + mediaManagement?: SyncResult; + }[]; +} + +/** + * Process all pending syncs + * Called by the sync job and can be called manually + */ +export async function processPendingSyncs(): Promise { + const pending = arrSyncQueries.getPendingSyncs(); + const results: ProcessSyncsResult['results'] = []; + + // Collect all unique instance IDs + const instanceIds = new Set([ + ...pending.qualityProfiles, + ...pending.delayProfiles, + ...pending.mediaManagement + ]); + + if (instanceIds.size === 0) { + await logger.debug('No pending syncs', { source: 'SyncProcessor' }); + return { totalSynced: 0, results: [] }; + } + + await logger.info(`Processing syncs for ${instanceIds.size} instance(s)`, { + source: 'SyncProcessor', + meta: { + qualityProfiles: pending.qualityProfiles.length, + delayProfiles: pending.delayProfiles.length, + mediaManagement: pending.mediaManagement.length + } + }); + + let totalSynced = 0; + + for (const instanceId of instanceIds) { + const instance = arrInstancesQueries.getById(instanceId); + + if (!instance) { + await logger.warn(`Instance ${instanceId} not found, skipping sync`, { + source: 'SyncProcessor' + }); + continue; + } + + if (!instance.enabled) { + await logger.debug(`Instance "${instance.name}" is disabled, skipping sync`, { + source: 'SyncProcessor' + }); + continue; + } + + const instanceResult: ProcessSyncsResult['results'][0] = { + instanceId, + instanceName: instance.name + }; + + try { + // Create arr client for this instance + const client = createArrClient(instance.type as ArrType, instance.url, instance.api_key); + + // Process quality profiles if pending + if (pending.qualityProfiles.includes(instanceId)) { + const syncer = new QualityProfileSyncer(client, instanceId, instance.name); + instanceResult.qualityProfiles = await syncer.sync(); + totalSynced += instanceResult.qualityProfiles.itemsSynced; + + // Clear the should_sync flag + arrSyncQueries.setQualityProfilesShouldSync(instanceId, false); + } + + // Process delay profiles if pending + if (pending.delayProfiles.includes(instanceId)) { + const syncer = new DelayProfileSyncer(client, instanceId, instance.name); + instanceResult.delayProfiles = await syncer.sync(); + totalSynced += instanceResult.delayProfiles.itemsSynced; + + // Clear the should_sync flag + arrSyncQueries.setDelayProfilesShouldSync(instanceId, false); + } + + // Process media management if pending + if (pending.mediaManagement.includes(instanceId)) { + const syncer = new MediaManagementSyncer(client, instanceId, instance.name); + instanceResult.mediaManagement = await syncer.sync(); + totalSynced += instanceResult.mediaManagement.itemsSynced; + + // Clear the should_sync flag + arrSyncQueries.setMediaManagementShouldSync(instanceId, false); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + await logger.error(`Failed to sync instance "${instance.name}"`, { + source: 'SyncProcessor', + meta: { instanceId, error: errorMsg } + }); + } + + results.push(instanceResult); + } + + await logger.info(`Sync processing complete`, { + source: 'SyncProcessor', + meta: { totalSynced, instanceCount: results.length } + }); + + return { totalSynced, results }; +} + +/** + * Sync a specific instance manually + * Syncs all configured sections regardless of should_sync flag + */ +export async function syncInstance(instanceId: number): Promise { + const instance = arrInstancesQueries.getById(instanceId); + + if (!instance) { + throw new Error(`Instance ${instanceId} not found`); + } + + await logger.info(`Manual sync triggered for "${instance.name}"`, { + source: 'SyncProcessor', + meta: { instanceId } + }); + + const client = createArrClient(instance.type as ArrType, instance.url, instance.api_key); + const result: ProcessSyncsResult['results'][0] = { + instanceId, + instanceName: instance.name + }; + + // Get sync configs to check what's enabled + const qpConfig = arrSyncQueries.getQualityProfilesSync(instanceId); + const dpConfig = arrSyncQueries.getDelayProfilesSync(instanceId); + const mmConfig = arrSyncQueries.getMediaManagementSync(instanceId); + + // Sync quality profiles if configured + if (qpConfig.config.trigger !== 'none' && qpConfig.selections.length > 0) { + const syncer = new QualityProfileSyncer(client, instanceId, instance.name); + result.qualityProfiles = await syncer.sync(); + } + + // Sync delay profiles if configured + if (dpConfig.config.trigger !== 'none' && dpConfig.selections.length > 0) { + const syncer = new DelayProfileSyncer(client, instanceId, instance.name); + result.delayProfiles = await syncer.sync(); + } + + // Sync media management if configured + if ( + mmConfig.trigger !== 'none' && + (mmConfig.namingDatabaseId || mmConfig.qualityDefinitionsDatabaseId || mmConfig.mediaSettingsDatabaseId) + ) { + const syncer = new MediaManagementSyncer(client, instanceId, instance.name); + result.mediaManagement = await syncer.sync(); + } + + return result; +} diff --git a/src/lib/server/sync/qualityProfiles.ts b/src/lib/server/sync/qualityProfiles.ts new file mode 100644 index 0000000..9c4a0e8 --- /dev/null +++ b/src/lib/server/sync/qualityProfiles.ts @@ -0,0 +1,49 @@ +/** + * Quality profile syncer + * Syncs quality profiles from PCD to arr instances + */ + +import { BaseSyncer } from './base.ts'; +import { arrSyncQueries } from '$db/queries/arrSync.ts'; + +export class QualityProfileSyncer extends BaseSyncer { + protected get syncType(): string { + return 'quality profiles'; + } + + protected async fetchFromPcd(): Promise { + const syncConfig = arrSyncQueries.getQualityProfilesSync(this.instanceId); + + if (syncConfig.selections.length === 0) { + return []; + } + + // TODO: Implement + // For each selection (databaseId, profileId): + // 1. Get the PCD cache for the database + // 2. Fetch the quality profile by ID + // 3. Also fetch dependent custom formats + + throw new Error('Not implemented: fetchFromPcd'); + } + + protected transformToArr(pcdData: unknown[]): unknown[] { + // TODO: Implement + // Transform PCD quality profile format to arr API format + // This includes: + // - Quality profile structure + // - Custom format mappings + // - Quality tier configurations + + throw new Error('Not implemented: transformToArr'); + } + + protected async pushToArr(arrData: unknown[]): Promise { + // TODO: Implement + // 1. First sync custom formats (dependencies) + // 2. Then sync quality profiles + // 3. Handle create vs update (check if profile exists by name) + + throw new Error('Not implemented: pushToArr'); + } +} diff --git a/src/lib/server/utils/arr/base.ts b/src/lib/server/utils/arr/base.ts index 10aaa31..291fd6a 100644 --- a/src/lib/server/utils/arr/base.ts +++ b/src/lib/server/utils/arr/base.ts @@ -1,5 +1,5 @@ import { BaseHttpClient } from '../http/client.ts'; -import type { ArrSystemStatus } from './types.ts'; +import type { ArrSystemStatus, ArrDelayProfile, ArrTag } from './types.ts'; import { logger } from '$logger/logger.ts'; /** @@ -49,4 +49,61 @@ export class BaseArrClient extends BaseHttpClient { return false; } } + + // ========================================================================= + // Delay Profiles + // ========================================================================= + + /** + * Get all delay profiles + */ + async getDelayProfiles(): Promise { + return this.get(`/api/${this.apiVersion}/delayprofile`); + } + + /** + * Get a delay profile by ID + */ + async getDelayProfile(id: number): Promise { + return this.get(`/api/${this.apiVersion}/delayprofile/${id}`); + } + + /** + * Create a new delay profile + */ + async createDelayProfile(profile: Omit): Promise { + return this.post(`/api/${this.apiVersion}/delayprofile`, profile); + } + + /** + * Update an existing delay profile + */ + async updateDelayProfile(id: number, profile: ArrDelayProfile): Promise { + return this.put(`/api/${this.apiVersion}/delayprofile/${id}`, profile); + } + + /** + * Delete a delay profile + */ + async deleteDelayProfile(id: number): Promise { + await this.delete(`/api/${this.apiVersion}/delayprofile/${id}`); + } + + // ========================================================================= + // Tags + // ========================================================================= + + /** + * Get all tags + */ + async getTags(): Promise { + return this.get(`/api/${this.apiVersion}/tag`); + } + + /** + * Create a new tag + */ + async createTag(label: string): Promise { + return this.post(`/api/${this.apiVersion}/tag`, { label }); + } } diff --git a/src/lib/server/utils/arr/types.ts b/src/lib/server/utils/arr/types.ts index 79d634d..e99da39 100644 --- a/src/lib/server/utils/arr/types.ts +++ b/src/lib/server/utils/arr/types.ts @@ -198,6 +198,40 @@ export interface RadarrLibraryItem { isProfilarrProfile: boolean; // true if profile name matches a Profilarr database profile } +// ============================================================================= +// Delay Profile Types (shared across arr apps) +// ============================================================================= + +/** + * Delay profile from /api/v3/delayprofile + * Schema is identical for Radarr and Sonarr + */ +export interface ArrDelayProfile { + id: number; + enableUsenet: boolean; + enableTorrent: boolean; + preferredProtocol: string; // 'usenet' | 'torrent' | 'unknown' + usenetDelay: number; + torrentDelay: number; + bypassIfHighestQuality: boolean; + bypassIfAboveCustomFormatScore: boolean; + minimumCustomFormatScore: number; + order: number; + tags: number[]; +} + +/** + * Tag from /api/v3/tag (shared across arr apps) + */ +export interface ArrTag { + id: number; + label: string; +} + +// ============================================================================= +// System Types +// ============================================================================= + /** * System status response from /api/v3/system/status * Based on actual Radarr API response diff --git a/src/routes/arr/[id]/sync/+page.server.ts b/src/routes/arr/[id]/sync/+page.server.ts index 6a6e144..93b32ae 100644 --- a/src/routes/arr/[id]/sync/+page.server.ts +++ b/src/routes/arr/[id]/sync/+page.server.ts @@ -165,5 +165,107 @@ export const actions: Actions = { }); return fail(500, { error: 'Failed to save media management sync config' }); } + }, + + syncDelayProfiles: async ({ params }) => { + const id = parseInt(params.id || '', 10); + if (isNaN(id)) { + return fail(400, { error: 'Invalid instance ID' }); + } + + const instance = arrInstancesQueries.getById(id); + if (!instance) { + return fail(404, { error: 'Instance not found' }); + } + + try { + const { createArrClient } = await import('$arr/factory.ts'); + const { DelayProfileSyncer } = await import('$lib/server/sync/delayProfiles.ts'); + const client = createArrClient(instance.type as 'radarr' | 'sonarr' | 'lidarr' | 'chaptarr', instance.url, instance.api_key); + const syncer = new DelayProfileSyncer(client, id, instance.name); + const result = await syncer.sync(); + + await logger.info(`Manual delay profiles sync completed for "${instance.name}"`, { + source: 'sync', + meta: { instanceId: id, result } + }); + + return { success: true, result }; + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error'; + await logger.error(`Manual delay profiles sync failed for "${instance.name}"`, { + source: 'sync', + meta: { instanceId: id, error: errorMsg } + }); + return fail(500, { error: `Sync failed: ${errorMsg}` }); + } + }, + + syncQualityProfiles: async ({ params }) => { + const id = parseInt(params.id || '', 10); + if (isNaN(id)) { + return fail(400, { error: 'Invalid instance ID' }); + } + + const instance = arrInstancesQueries.getById(id); + if (!instance) { + return fail(404, { error: 'Instance not found' }); + } + + try { + const { createArrClient } = await import('$arr/factory.ts'); + const { QualityProfileSyncer } = await import('$lib/server/sync/qualityProfiles.ts'); + const client = createArrClient(instance.type as 'radarr' | 'sonarr' | 'lidarr' | 'chaptarr', instance.url, instance.api_key); + const syncer = new QualityProfileSyncer(client, id, instance.name); + const result = await syncer.sync(); + + await logger.info(`Manual quality profiles sync completed for "${instance.name}"`, { + source: 'sync', + meta: { instanceId: id, result } + }); + + return { success: true, result }; + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error'; + await logger.error(`Manual quality profiles sync failed for "${instance.name}"`, { + source: 'sync', + meta: { instanceId: id, error: errorMsg } + }); + return fail(500, { error: `Sync failed: ${errorMsg}` }); + } + }, + + syncMediaManagement: async ({ params }) => { + const id = parseInt(params.id || '', 10); + if (isNaN(id)) { + return fail(400, { error: 'Invalid instance ID' }); + } + + const instance = arrInstancesQueries.getById(id); + if (!instance) { + return fail(404, { error: 'Instance not found' }); + } + + try { + const { createArrClient } = await import('$arr/factory.ts'); + const { MediaManagementSyncer } = await import('$lib/server/sync/mediaManagement.ts'); + const client = createArrClient(instance.type as 'radarr' | 'sonarr' | 'lidarr' | 'chaptarr', instance.url, instance.api_key); + const syncer = new MediaManagementSyncer(client, id, instance.name); + const result = await syncer.sync(); + + await logger.info(`Manual media management sync completed for "${instance.name}"`, { + source: 'sync', + meta: { instanceId: id, result } + }); + + return { success: true, result }; + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error'; + await logger.error(`Manual media management sync failed for "${instance.name}"`, { + source: 'sync', + meta: { instanceId: id, error: errorMsg } + }); + return fail(500, { error: `Sync failed: ${errorMsg}` }); + } } }; diff --git a/src/routes/arr/[id]/sync/components/DelayProfiles.svelte b/src/routes/arr/[id]/sync/components/DelayProfiles.svelte index 9057315..701cdf3 100644 --- a/src/routes/arr/[id]/sync/components/DelayProfiles.svelte +++ b/src/routes/arr/[id]/sync/components/DelayProfiles.svelte @@ -17,6 +17,7 @@ export let cronExpression: string = '0 * * * *'; let saving = false; + let syncing = false; // Initialize state for all databases/profiles $: { @@ -68,6 +69,26 @@ saving = false; } } + + async function handleSync() { + syncing = true; + try { + const response = await fetch('?/syncDelayProfiles', { + method: 'POST', + body: new FormData() + }); + + if (response.ok) { + alertStore.add('success', 'Sync completed successfully'); + } else { + alertStore.add('error', 'Sync failed'); + } + } catch { + alertStore.add('error', 'Sync failed'); + } finally { + syncing = false; + } + }
@@ -118,5 +139,5 @@ {/if}
- + diff --git a/src/routes/arr/[id]/sync/components/MediaManagement.svelte b/src/routes/arr/[id]/sync/components/MediaManagement.svelte index 6a6cdb6..c6426aa 100644 --- a/src/routes/arr/[id]/sync/components/MediaManagement.svelte +++ b/src/routes/arr/[id]/sync/components/MediaManagement.svelte @@ -47,6 +47,7 @@ export let cronExpression: string = '0 * * * *'; let saving = false; + let syncing = false; async function handleSave() { saving = true; @@ -74,6 +75,26 @@ saving = false; } } + + async function handleSync() { + syncing = true; + try { + const response = await fetch('?/syncMediaManagement', { + method: 'POST', + body: new FormData() + }); + + if (response.ok) { + alertStore.add('success', 'Sync completed successfully'); + } else { + alertStore.add('error', 'Sync failed'); + } + } catch { + alertStore.add('error', 'Sync failed'); + } finally { + syncing = false; + } + }
@@ -225,5 +246,5 @@
- + diff --git a/src/routes/arr/[id]/sync/components/QualityProfiles.svelte b/src/routes/arr/[id]/sync/components/QualityProfiles.svelte index deff29e..c9520e3 100644 --- a/src/routes/arr/[id]/sync/components/QualityProfiles.svelte +++ b/src/routes/arr/[id]/sync/components/QualityProfiles.svelte @@ -17,6 +17,7 @@ export let cronExpression: string = '0 * * * *'; let saving = false; + let syncing = false; // Initialize state for all databases/profiles $: { @@ -68,6 +69,26 @@ saving = false; } } + + async function handleSync() { + syncing = true; + try { + const response = await fetch('?/syncQualityProfiles', { + method: 'POST', + body: new FormData() + }); + + if (response.ok) { + alertStore.add('success', 'Sync completed successfully'); + } else { + alertStore.add('error', 'Sync failed'); + } + } catch { + alertStore.add('error', 'Sync failed'); + } finally { + syncing = false; + } + }
@@ -118,5 +139,5 @@ {/if}
- + diff --git a/src/routes/arr/[id]/sync/components/SyncFooter.svelte b/src/routes/arr/[id]/sync/components/SyncFooter.svelte index 8e76804..4eee87d 100644 --- a/src/routes/arr/[id]/sync/components/SyncFooter.svelte +++ b/src/routes/arr/[id]/sync/components/SyncFooter.svelte @@ -6,8 +6,9 @@ export let syncTrigger: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule' = 'none'; export let cronExpression: string = '0 * * * *'; export let saving: boolean = false; + export let syncing: boolean = false; - const dispatch = createEventDispatcher<{ save: void }>(); + const dispatch = createEventDispatcher<{ save: void; sync: void }>(); const triggerOptions = [ { value: 'none', label: 'None' }, @@ -47,9 +48,15 @@