mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
@@ -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
|
||||
|
||||
27
src/lib/server/db/migrations/016_add_should_sync_flags.ts
Normal file
27
src/lib/server/db/migrations/016_add_should_sync_flags.ts
Normal 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;
|
||||
`
|
||||
};
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
61
src/lib/server/jobs/definitions/syncArr.ts
Normal file
61
src/lib/server/jobs/definitions/syncArr.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
90
src/lib/server/jobs/logic/syncArr.ts
Normal file
90
src/lib/server/jobs/logic/syncArr.ts
Normal 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
|
||||
};
|
||||
}
|
||||
94
src/lib/server/sync/base.ts
Normal file
94
src/lib/server/sync/base.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
267
src/lib/server/sync/delayProfiles.ts
Normal file
267
src/lib/server/sync/delayProfiles.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
18
src/lib/server/sync/index.ts
Normal file
18
src/lib/server/sync/index.ts
Normal 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';
|
||||
54
src/lib/server/sync/mediaManagement.ts
Normal file
54
src/lib/server/sync/mediaManagement.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
186
src/lib/server/sync/processor.ts
Normal file
186
src/lib/server/sync/processor.ts
Normal 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;
|
||||
}
|
||||
49
src/lib/server/sync/qualityProfiles.ts
Normal file
49
src/lib/server/sync/qualityProfiles.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user