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.
This commit is contained in:
Sam Chau
2025-12-29 05:37:55 +10:30
parent ea5c543647
commit 1e8fc7a42d
20 changed files with 1305 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<unknown[]>;
/**
* 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<void>;
/**
* Main sync method - orchestrates fetch, transform, push
*/
async sync(): Promise<SyncResult> {
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 };
}
}
}

View File

@@ -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<DelayProfileTableRow[]> {
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<void> {
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<string, number>();
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 }
});
}
}

View File

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

View File

@@ -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<unknown[]> {
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<void> {
// 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');
}
}

View File

@@ -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<ProcessSyncsResult> {
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<ProcessSyncsResult['results'][0]> {
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;
}

View File

@@ -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<unknown[]> {
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<void> {
// 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');
}
}

View File

@@ -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<ArrDelayProfile[]> {
return this.get<ArrDelayProfile[]>(`/api/${this.apiVersion}/delayprofile`);
}
/**
* Get a delay profile by ID
*/
async getDelayProfile(id: number): Promise<ArrDelayProfile> {
return this.get<ArrDelayProfile>(`/api/${this.apiVersion}/delayprofile/${id}`);
}
/**
* Create a new delay profile
*/
async createDelayProfile(profile: Omit<ArrDelayProfile, 'id' | 'order'>): Promise<ArrDelayProfile> {
return this.post<ArrDelayProfile>(`/api/${this.apiVersion}/delayprofile`, profile);
}
/**
* Update an existing delay profile
*/
async updateDelayProfile(id: number, profile: ArrDelayProfile): Promise<ArrDelayProfile> {
return this.put<ArrDelayProfile>(`/api/${this.apiVersion}/delayprofile/${id}`, profile);
}
/**
* Delete a delay profile
*/
async deleteDelayProfile(id: number): Promise<void> {
await this.delete(`/api/${this.apiVersion}/delayprofile/${id}`);
}
// =========================================================================
// Tags
// =========================================================================
/**
* Get all tags
*/
async getTags(): Promise<ArrTag[]> {
return this.get<ArrTag[]>(`/api/${this.apiVersion}/tag`);
}
/**
* Create a new tag
*/
async createTag(label: string): Promise<ArrTag> {
return this.post<ArrTag>(`/api/${this.apiVersion}/tag`, { label });
}
}

View File

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

View File

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

View File

@@ -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;
}
}
</script>
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
@@ -118,5 +139,5 @@
{/if}
</div>
<SyncFooter bind:syncTrigger bind:cronExpression {saving} on:save={handleSave} />
<SyncFooter bind:syncTrigger bind:cronExpression {saving} {syncing} on:save={handleSave} on:sync={handleSync} />
</div>

View File

@@ -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;
}
}
</script>
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
@@ -225,5 +246,5 @@
</div>
</div>
<SyncFooter bind:syncTrigger bind:cronExpression {saving} on:save={handleSave} />
<SyncFooter bind:syncTrigger bind:cronExpression {saving} {syncing} on:save={handleSave} on:sync={handleSync} />
</div>

View File

@@ -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;
}
}
</script>
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
@@ -118,5 +139,5 @@
{/if}
</div>
<SyncFooter bind:syncTrigger bind:cronExpression {saving} on:save={handleSave} />
<SyncFooter bind:syncTrigger bind:cronExpression {saving} {syncing} on:save={handleSave} on:sync={handleSync} />
</div>

View File

@@ -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 @@
<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"
disabled={syncing}
on:click={() => dispatch('sync')}
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 disabled:opacity-50 disabled:cursor-not-allowed dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<RefreshCw size={14} />
{#if syncing}
<Loader2 size={14} class="animate-spin" />
{:else}
<RefreshCw size={14} />
{/if}
Sync Now
</button>
<button