From f35a01f111824162a040bb6ea14f3b09901035cf Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Thu, 15 Jan 2026 15:14:54 +1030 Subject: [PATCH] feat: sync transformers and quality profile handling - Introduced new sync transformers for custom formats and quality profiles. - Implemented the `transformQualityProfile` function to convert PCD quality profile data to ARR API format. - Added functions to fetch quality profiles and custom formats from PCD cache. - Enhanced `BaseArrClient` with methods for managing custom formats and quality profiles. - Updated types to include custom format specifications and quality profile payloads. - Modified sync page server logic to calculate next run time for scheduled syncs. --- src/lib/server/sync/mappings.ts | 471 ++++++++++++++ src/lib/server/sync/processor.ts | 86 ++- src/lib/server/sync/qualityProfiles.ts | 372 ++++++++++- .../server/sync/transformers/customFormat.ts | 614 ++++++++++++++++++ src/lib/server/sync/transformers/index.ts | 28 + .../sync/transformers/qualityProfile.ts | 432 ++++++++++++ src/lib/server/utils/arr/base.ts | 118 +++- src/lib/server/utils/arr/types.ts | 77 +++ src/routes/arr/[id]/sync/+page.server.ts | 24 +- 9 files changed, 2173 insertions(+), 49 deletions(-) create mode 100644 src/lib/server/sync/mappings.ts create mode 100644 src/lib/server/sync/transformers/customFormat.ts create mode 100644 src/lib/server/sync/transformers/index.ts create mode 100644 src/lib/server/sync/transformers/qualityProfile.ts diff --git a/src/lib/server/sync/mappings.ts b/src/lib/server/sync/mappings.ts new file mode 100644 index 0000000..1f7ff06 --- /dev/null +++ b/src/lib/server/sync/mappings.ts @@ -0,0 +1,471 @@ +/** + * Arr API mappings + * Constants for transforming PCD data to arr API format + * Based on Radarr/Sonarr API specifications + */ + +// Note: This is a subset of the full ArrType from arr/types.ts +// Only includes types that support quality profiles/custom formats +export type SyncArrType = 'radarr' | 'sonarr'; + +// ============================================================================= +// Indexer Flags +// ============================================================================= + +export const INDEXER_FLAGS = { + radarr: { + freeleech: 1, + halfleech: 2, + double_upload: 4, + internal: 32, + scene: 128, + freeleech_75: 256, + freeleech_25: 512, + nuked: 2048, + ptp_golden: 8, + ptp_approved: 16 + }, + sonarr: { + freeleech: 1, + halfleech: 2, + double_upload: 4, + internal: 8, + scene: 16, + freeleech_75: 32, + freeleech_25: 64, + nuked: 128 + } +} as const; + +// ============================================================================= +// Sources +// ============================================================================= + +export const SOURCES = { + radarr: { + cam: 1, + telesync: 2, + telecine: 3, + workprint: 4, + dvd: 5, + tv: 6, + web_dl: 7, + webrip: 8, + bluray: 9 + }, + sonarr: { + television: 1, + television_raw: 2, + web_dl: 3, + webrip: 4, + dvd: 5, + bluray: 6, + bluray_raw: 7 + } +} as const; + +// ============================================================================= +// Quality Modifiers (Radarr only) +// ============================================================================= + +export const QUALITY_MODIFIERS = { + none: 0, + regional: 1, + screener: 2, + rawhd: 3, + brdisk: 4, + remux: 5 +} as const; + +// ============================================================================= +// Release Types (Sonarr only) +// ============================================================================= + +export const RELEASE_TYPES = { + none: 0, + single_episode: 1, + multi_episode: 2, + season_pack: 3 +} as const; + +// ============================================================================= +// Resolutions +// ============================================================================= + +export const RESOLUTIONS: Record = { + '360p': 360, + '480p': 480, + '540p': 540, + '576p': 576, + '720p': 720, + '1080p': 1080, + '2160p': 2160 +}; + +// ============================================================================= +// Quality Definitions +// ============================================================================= + +export interface QualityDefinition { + id: number; + name: string; + source: string; + resolution: number; +} + +export const QUALITIES: Record> = { + radarr: { + 'Unknown': { id: 0, name: 'Unknown', source: 'unknown', resolution: 0 }, + 'SDTV': { id: 1, name: 'SDTV', source: 'tv', resolution: 480 }, + 'DVD': { id: 2, name: 'DVD', source: 'dvd', resolution: 480 }, + 'WEBDL-1080p': { id: 3, name: 'WEBDL-1080p', source: 'webdl', resolution: 1080 }, + 'HDTV-720p': { id: 4, name: 'HDTV-720p', source: 'tv', resolution: 720 }, + 'WEBDL-720p': { id: 5, name: 'WEBDL-720p', source: 'webdl', resolution: 720 }, + 'Bluray-720p': { id: 6, name: 'Bluray-720p', source: 'bluray', resolution: 720 }, + 'Bluray-1080p': { id: 7, name: 'Bluray-1080p', source: 'bluray', resolution: 1080 }, + 'WEBDL-480p': { id: 8, name: 'WEBDL-480p', source: 'webdl', resolution: 480 }, + 'HDTV-1080p': { id: 9, name: 'HDTV-1080p', source: 'tv', resolution: 1080 }, + 'Raw-HD': { id: 10, name: 'Raw-HD', source: 'tv', resolution: 1080 }, + 'WEBRip-480p': { id: 12, name: 'WEBRip-480p', source: 'webrip', resolution: 480 }, + 'WEBRip-720p': { id: 14, name: 'WEBRip-720p', source: 'webrip', resolution: 720 }, + 'WEBRip-1080p': { id: 15, name: 'WEBRip-1080p', source: 'webrip', resolution: 1080 }, + 'HDTV-2160p': { id: 16, name: 'HDTV-2160p', source: 'tv', resolution: 2160 }, + 'WEBRip-2160p': { id: 17, name: 'WEBRip-2160p', source: 'webrip', resolution: 2160 }, + 'WEBDL-2160p': { id: 18, name: 'WEBDL-2160p', source: 'webdl', resolution: 2160 }, + 'Bluray-2160p': { id: 19, name: 'Bluray-2160p', source: 'bluray', resolution: 2160 }, + 'Bluray-480p': { id: 20, name: 'Bluray-480p', source: 'bluray', resolution: 480 }, + 'Bluray-576p': { id: 21, name: 'Bluray-576p', source: 'bluray', resolution: 576 }, + 'BR-DISK': { id: 22, name: 'BR-DISK', source: 'bluray', resolution: 1080 }, + 'DVD-R': { id: 23, name: 'DVD-R', source: 'dvd', resolution: 480 }, + 'WORKPRINT': { id: 24, name: 'WORKPRINT', source: 'workprint', resolution: 0 }, + 'CAM': { id: 25, name: 'CAM', source: 'cam', resolution: 0 }, + 'TELESYNC': { id: 26, name: 'TELESYNC', source: 'telesync', resolution: 0 }, + 'TELECINE': { id: 27, name: 'TELECINE', source: 'telecine', resolution: 0 }, + 'DVDSCR': { id: 28, name: 'DVDSCR', source: 'dvd', resolution: 480 }, + 'REGIONAL': { id: 29, name: 'REGIONAL', source: 'dvd', resolution: 480 }, + 'Remux-1080p': { id: 30, name: 'Remux-1080p', source: 'bluray', resolution: 1080 }, + 'Remux-2160p': { id: 31, name: 'Remux-2160p', source: 'bluray', resolution: 2160 } + }, + sonarr: { + 'Unknown': { id: 0, name: 'Unknown', source: 'unknown', resolution: 0 }, + 'SDTV': { id: 1, name: 'SDTV', source: 'television', resolution: 480 }, + 'DVD': { id: 2, name: 'DVD', source: 'dvd', resolution: 480 }, + 'WEBDL-1080p': { id: 3, name: 'WEBDL-1080p', source: 'web', resolution: 1080 }, + 'HDTV-720p': { id: 4, name: 'HDTV-720p', source: 'television', resolution: 720 }, + 'WEBDL-720p': { id: 5, name: 'WEBDL-720p', source: 'web', resolution: 720 }, + 'Bluray-720p': { id: 6, name: 'Bluray-720p', source: 'bluray', resolution: 720 }, + 'Bluray-1080p': { id: 7, name: 'Bluray-1080p', source: 'bluray', resolution: 1080 }, + 'WEBDL-480p': { id: 8, name: 'WEBDL-480p', source: 'web', resolution: 480 }, + 'HDTV-1080p': { id: 9, name: 'HDTV-1080p', source: 'television', resolution: 1080 }, + 'Raw-HD': { id: 10, name: 'Raw-HD', source: 'televisionRaw', resolution: 1080 }, + 'WEBRip-480p': { id: 12, name: 'WEBRip-480p', source: 'webRip', resolution: 480 }, + 'Bluray-480p': { id: 13, name: 'Bluray-480p', source: 'bluray', resolution: 480 }, + 'WEBRip-720p': { id: 14, name: 'WEBRip-720p', source: 'webRip', resolution: 720 }, + 'WEBRip-1080p': { id: 15, name: 'WEBRip-1080p', source: 'webRip', resolution: 1080 }, + 'HDTV-2160p': { id: 16, name: 'HDTV-2160p', source: 'television', resolution: 2160 }, + 'WEBRip-2160p': { id: 17, name: 'WEBRip-2160p', source: 'webRip', resolution: 2160 }, + 'WEBDL-2160p': { id: 18, name: 'WEBDL-2160p', source: 'web', resolution: 2160 }, + 'Bluray-2160p': { id: 19, name: 'Bluray-2160p', source: 'bluray', resolution: 2160 }, + 'Bluray-1080p Remux': { id: 20, name: 'Bluray-1080p Remux', source: 'blurayRaw', resolution: 1080 }, + 'Bluray-2160p Remux': { id: 21, name: 'Bluray-2160p Remux', source: 'blurayRaw', resolution: 2160 }, + 'Bluray-576p': { id: 22, name: 'Bluray-576p', source: 'bluray', resolution: 576 } + } +}; + +// ============================================================================= +// Languages +// ============================================================================= + +export interface LanguageDefinition { + id: number; + name: string; +} + +export const LANGUAGES: Record> = { + radarr: { + 'any': { id: -1, name: 'Any' }, + 'original': { id: -2, name: 'Original' }, + 'unknown': { id: 0, name: 'Unknown' }, + 'english': { id: 1, name: 'English' }, + 'french': { id: 2, name: 'French' }, + 'spanish': { id: 3, name: 'Spanish' }, + 'german': { id: 4, name: 'German' }, + 'italian': { id: 5, name: 'Italian' }, + 'danish': { id: 6, name: 'Danish' }, + 'dutch': { id: 7, name: 'Dutch' }, + 'japanese': { id: 8, name: 'Japanese' }, + 'icelandic': { id: 9, name: 'Icelandic' }, + 'chinese': { id: 10, name: 'Chinese' }, + 'russian': { id: 11, name: 'Russian' }, + 'polish': { id: 12, name: 'Polish' }, + 'vietnamese': { id: 13, name: 'Vietnamese' }, + 'swedish': { id: 14, name: 'Swedish' }, + 'norwegian': { id: 15, name: 'Norwegian' }, + 'finnish': { id: 16, name: 'Finnish' }, + 'turkish': { id: 17, name: 'Turkish' }, + 'portuguese': { id: 18, name: 'Portuguese' }, + 'flemish': { id: 19, name: 'Flemish' }, + 'greek': { id: 20, name: 'Greek' }, + 'korean': { id: 21, name: 'Korean' }, + 'hungarian': { id: 22, name: 'Hungarian' }, + 'hebrew': { id: 23, name: 'Hebrew' }, + 'lithuanian': { id: 24, name: 'Lithuanian' }, + 'czech': { id: 25, name: 'Czech' }, + 'hindi': { id: 26, name: 'Hindi' }, + 'romanian': { id: 27, name: 'Romanian' }, + 'thai': { id: 28, name: 'Thai' }, + 'bulgarian': { id: 29, name: 'Bulgarian' }, + 'portuguese (brazil)': { id: 30, name: 'Portuguese (Brazil)' }, + 'arabic': { id: 31, name: 'Arabic' }, + 'ukrainian': { id: 32, name: 'Ukrainian' }, + 'persian': { id: 33, name: 'Persian' }, + 'bengali': { id: 34, name: 'Bengali' }, + 'slovak': { id: 35, name: 'Slovak' }, + 'latvian': { id: 36, name: 'Latvian' }, + 'spanish (latino)': { id: 37, name: 'Spanish (Latino)' }, + 'catalan': { id: 38, name: 'Catalan' }, + 'croatian': { id: 39, name: 'Croatian' }, + 'serbian': { id: 40, name: 'Serbian' }, + 'bosnian': { id: 41, name: 'Bosnian' }, + 'estonian': { id: 42, name: 'Estonian' }, + 'tamil': { id: 43, name: 'Tamil' }, + 'indonesian': { id: 44, name: 'Indonesian' }, + 'telugu': { id: 45, name: 'Telugu' }, + 'macedonian': { id: 46, name: 'Macedonian' }, + 'slovenian': { id: 47, name: 'Slovenian' }, + 'malayalam': { id: 48, name: 'Malayalam' }, + 'kannada': { id: 49, name: 'Kannada' }, + 'albanian': { id: 50, name: 'Albanian' }, + 'afrikaans': { id: 51, name: 'Afrikaans' } + }, + sonarr: { + 'unknown': { id: 0, name: 'Unknown' }, + 'english': { id: 1, name: 'English' }, + 'french': { id: 2, name: 'French' }, + 'spanish': { id: 3, name: 'Spanish' }, + 'german': { id: 4, name: 'German' }, + 'italian': { id: 5, name: 'Italian' }, + 'danish': { id: 6, name: 'Danish' }, + 'dutch': { id: 7, name: 'Dutch' }, + 'japanese': { id: 8, name: 'Japanese' }, + 'icelandic': { id: 9, name: 'Icelandic' }, + 'chinese': { id: 10, name: 'Chinese' }, + 'russian': { id: 11, name: 'Russian' }, + 'polish': { id: 12, name: 'Polish' }, + 'vietnamese': { id: 13, name: 'Vietnamese' }, + 'swedish': { id: 14, name: 'Swedish' }, + 'norwegian': { id: 15, name: 'Norwegian' }, + 'finnish': { id: 16, name: 'Finnish' }, + 'turkish': { id: 17, name: 'Turkish' }, + 'portuguese': { id: 18, name: 'Portuguese' }, + 'flemish': { id: 19, name: 'Flemish' }, + 'greek': { id: 20, name: 'Greek' }, + 'korean': { id: 21, name: 'Korean' }, + 'hungarian': { id: 22, name: 'Hungarian' }, + 'hebrew': { id: 23, name: 'Hebrew' }, + 'lithuanian': { id: 24, name: 'Lithuanian' }, + 'czech': { id: 25, name: 'Czech' }, + 'arabic': { id: 26, name: 'Arabic' }, + 'hindi': { id: 27, name: 'Hindi' }, + 'bulgarian': { id: 28, name: 'Bulgarian' }, + 'malayalam': { id: 29, name: 'Malayalam' }, + 'ukrainian': { id: 30, name: 'Ukrainian' }, + 'slovak': { id: 31, name: 'Slovak' }, + 'thai': { id: 32, name: 'Thai' }, + 'portuguese (brazil)': { id: 33, name: 'Portuguese (Brazil)' }, + 'spanish (latino)': { id: 34, name: 'Spanish (Latino)' }, + 'romanian': { id: 35, name: 'Romanian' }, + 'latvian': { id: 36, name: 'Latvian' }, + 'persian': { id: 37, name: 'Persian' }, + 'catalan': { id: 38, name: 'Catalan' }, + 'croatian': { id: 39, name: 'Croatian' }, + 'serbian': { id: 40, name: 'Serbian' }, + 'bosnian': { id: 41, name: 'Bosnian' }, + 'estonian': { id: 42, name: 'Estonian' }, + 'tamil': { id: 43, name: 'Tamil' }, + 'indonesian': { id: 44, name: 'Indonesian' }, + 'macedonian': { id: 45, name: 'Macedonian' }, + 'slovenian': { id: 46, name: 'Slovenian' }, + 'original': { id: -2, name: 'Original' } + } +}; + +// ============================================================================= +// Name Mapping Utilities +// ============================================================================= + +/** + * Maps quality names between PCD and arr API formats + * Handles Remux naming differences and alternate spellings + */ +const REMUX_MAPPINGS: Record> = { + sonarr: { + 'Remux-1080p': 'Bluray-1080p Remux', + 'Remux-2160p': 'Bluray-2160p Remux' + }, + radarr: { + 'Remux-1080p': 'Remux-1080p', + 'Remux-2160p': 'Remux-2160p' + } +}; + +const ALTERNATE_QUALITY_NAMES: Record = { + 'BR-Disk': 'BR-DISK', + 'BRDISK': 'BR-DISK', + 'BR_DISK': 'BR-DISK', + 'BLURAY-DISK': 'BR-DISK', + 'BLURAY_DISK': 'BR-DISK', + 'BLURAYDISK': 'BR-DISK', + 'Telecine': 'TELECINE', + 'TeleCine': 'TELECINE', + 'Telesync': 'TELESYNC', + 'TeleSync': 'TELESYNC' +}; + +/** + * Map a quality name to the arr API format + */ +export function mapQualityName(name: string, arrType: SyncArrType): string { + if (!name) return name; + + // Check remux mappings first + if (REMUX_MAPPINGS[arrType][name]) { + return REMUX_MAPPINGS[arrType][name]; + } + + // Check alternate spellings + const normalized = name.toUpperCase().replace(/-/g, '').replace(/_/g, ''); + for (const [alt, standard] of Object.entries(ALTERNATE_QUALITY_NAMES)) { + if (normalized === alt.toUpperCase().replace(/-/g, '').replace(/_/g, '')) { + return standard; + } + } + + return name; +} + +/** + * Normalize language name for lookup + */ +export function normalizeLanguageName(name: string): string { + if (!name) return name; + return name.toLowerCase().replace(/-/g, ' ').replace(/_/g, ' '); +} + +// ============================================================================= +// Source Name Aliases (normalize YAML source names to API keys) +// ============================================================================= + +const SOURCE_ALIASES: Record> = { + radarr: { + // YAML uses "television", Radarr API uses "tv" + television: 'tv', + hdtv: 'tv', + // Common variations + webdl: 'web_dl', + 'web-dl': 'web_dl', + web: 'web_dl', + 'web_rip': 'webrip', + 'web-rip': 'webrip' + }, + sonarr: { + // Sonarr uses "television" directly, but add common aliases + hdtv: 'television', + tv: 'television', + webdl: 'web_dl', + 'web-dl': 'web_dl', + web: 'web_dl', + 'web_rip': 'webrip', + 'web-rip': 'webrip' + } +}; + +// ============================================================================= +// Value Resolvers +// ============================================================================= + +/** + * Get indexer flag value + */ +export function getIndexerFlag(flag: string, arrType: SyncArrType): number { + const flags = INDEXER_FLAGS[arrType]; + return flags[flag.toLowerCase() as keyof typeof flags] ?? 0; +} + +/** + * Normalize source name using aliases + */ +function normalizeSourceName(source: string, arrType: SyncArrType): string { + const normalized = source.toLowerCase().replace(/ /g, '_').replace(/-/g, '_'); + return SOURCE_ALIASES[arrType][normalized] ?? normalized; +} + +/** + * Get source value + */ +export function getSource(source: string, arrType: SyncArrType): number { + const normalizedSource = normalizeSourceName(source, arrType); + const sources = SOURCES[arrType]; + return sources[normalizedSource as keyof typeof sources] ?? 0; +} + +/** + * Get resolution value + */ +export function getResolution(resolution: string): number { + return RESOLUTIONS[resolution.toLowerCase()] ?? 0; +} + +/** + * Get quality modifier value (Radarr only) + */ +export function getQualityModifier(modifier: string): number { + return QUALITY_MODIFIERS[modifier.toLowerCase() as keyof typeof QUALITY_MODIFIERS] ?? 0; +} + +/** + * Get release type value (Sonarr only) + */ +export function getReleaseType(releaseType: string): number { + return RELEASE_TYPES[releaseType.toLowerCase() as keyof typeof RELEASE_TYPES] ?? 0; +} + +/** + * Get quality definition + */ +export function getQuality(name: string, arrType: SyncArrType): QualityDefinition | undefined { + const mappedName = mapQualityName(name, arrType); + return QUALITIES[arrType][mappedName]; +} + +/** + * Get all qualities for an arr type + */ +export function getAllQualities(arrType: SyncArrType): Record { + return QUALITIES[arrType]; +} + +/** + * Get language definition + */ +export function getLanguage(name: string, arrType: SyncArrType): LanguageDefinition { + const normalized = normalizeLanguageName(name); + const languages = LANGUAGES[arrType]; + return languages[normalized] ?? languages['unknown']; +} + +/** + * Get language for profile (Sonarr always uses Original) + */ +export function getLanguageForProfile(name: string, arrType: SyncArrType): LanguageDefinition { + // Sonarr profiles don't use language settings + if (arrType === 'sonarr') { + return { id: -2, name: 'Original' }; + } + + if (name === 'any' || !name) { + return LANGUAGES.radarr['any']; + } + + return getLanguage(name, arrType); +} diff --git a/src/lib/server/sync/processor.ts b/src/lib/server/sync/processor.ts index 025304e..1286d20 100644 --- a/src/lib/server/sync/processor.ts +++ b/src/lib/server/sync/processor.ts @@ -2,16 +2,18 @@ * Sync processor * Processes pending syncs by creating syncer instances and running them * - * TODO: Trigger markForSync() from events: + * Triggers: * - 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 + * - schedule: Cron expressions evaluated by evaluateScheduledSyncs() before processing */ import { arrSyncQueries } from '$db/queries/arrSync.ts'; import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; +import { calculateNextRun } from './cron.ts'; import { createArrClient } from '$arr/factory.ts'; import type { ArrType } from '$arr/types.ts'; +import type { SyncArrType } from './mappings.ts'; import { logger } from '$logger/logger.ts'; import { QualityProfileSyncer } from './qualityProfiles.ts'; import { DelayProfileSyncer } from './delayProfiles.ts'; @@ -29,11 +31,87 @@ export interface ProcessSyncsResult { }[]; } +/** + * Check if a scheduled config should trigger based on next_run_at + * Returns true if: + * - nextRunAt is null (first run / bootstrap) + * - current time >= nextRunAt + */ +function shouldTrigger(nextRunAt: string | null): boolean { + // Bootstrap case: no next_run_at set yet, trigger immediately + if (!nextRunAt) return true; + const now = new Date(); + const nextRun = new Date(nextRunAt); + return now >= nextRun; +} + +/** + * Evaluate scheduled sync configs and mark matching ones for sync + */ +async function evaluateScheduledSyncs(): Promise { + const scheduled = arrSyncQueries.getScheduledConfigs(); + + const totalScheduled = + scheduled.qualityProfiles.length + + scheduled.delayProfiles.length + + scheduled.mediaManagement.length; + + if (totalScheduled === 0) return; + + await logger.debug(`Evaluating ${totalScheduled} scheduled config(s)`, { + source: 'SyncProcessor', + meta: { + qualityProfiles: scheduled.qualityProfiles, + delayProfiles: scheduled.delayProfiles, + mediaManagement: scheduled.mediaManagement + } + }); + + let marked = 0; + + for (const config of scheduled.qualityProfiles) { + if (shouldTrigger(config.nextRunAt)) { + arrSyncQueries.setQualityProfilesShouldSync(config.instanceId, true); + // Calculate and store next run time + const nextRun = calculateNextRun(config.cron); + arrSyncQueries.setQualityProfilesNextRunAt(config.instanceId, nextRun); + marked++; + } + } + + for (const config of scheduled.delayProfiles) { + if (shouldTrigger(config.nextRunAt)) { + arrSyncQueries.setDelayProfilesShouldSync(config.instanceId, true); + const nextRun = calculateNextRun(config.cron); + arrSyncQueries.setDelayProfilesNextRunAt(config.instanceId, nextRun); + marked++; + } + } + + for (const config of scheduled.mediaManagement) { + if (shouldTrigger(config.nextRunAt)) { + arrSyncQueries.setMediaManagementShouldSync(config.instanceId, true); + const nextRun = calculateNextRun(config.cron); + arrSyncQueries.setMediaManagementNextRunAt(config.instanceId, nextRun); + marked++; + } + } + + if (marked > 0) { + await logger.debug(`Marked ${marked} config(s) for sync based on schedule`, { + source: 'SyncProcessor' + }); + } +} + /** * Process all pending syncs * Called by the sync job and can be called manually */ export async function processPendingSyncs(): Promise { + // Evaluate scheduled configs and mark them for sync if cron matches + await evaluateScheduledSyncs(); + const pending = arrSyncQueries.getPendingSyncs(); const results: ProcessSyncsResult['results'] = []; @@ -88,7 +166,7 @@ export async function processPendingSyncs(): Promise { // Process quality profiles if pending if (pending.qualityProfiles.includes(instanceId)) { - const syncer = new QualityProfileSyncer(client, instanceId, instance.name); + const syncer = new QualityProfileSyncer(client, instanceId, instance.name, instance.type as SyncArrType); instanceResult.qualityProfiles = await syncer.sync(); totalSynced += instanceResult.qualityProfiles.itemsSynced; @@ -163,7 +241,7 @@ export async function syncInstance(instanceId: number): Promise 0) { - const syncer = new QualityProfileSyncer(client, instanceId, instance.name); + const syncer = new QualityProfileSyncer(client, instanceId, instance.name, instance.type as SyncArrType); result.qualityProfiles = await syncer.sync(); } diff --git a/src/lib/server/sync/qualityProfiles.ts b/src/lib/server/sync/qualityProfiles.ts index 9c4a0e8..fb42e91 100644 --- a/src/lib/server/sync/qualityProfiles.ts +++ b/src/lib/server/sync/qualityProfiles.ts @@ -1,49 +1,375 @@ /** * Quality profile syncer * Syncs quality profiles from PCD to arr instances + * + * Sync order: + * 1. Fetch quality profiles and their referenced custom formats from PCD + * 2. Transform custom formats to arr API format + * 3. Sync custom formats to arr (create or update by name) + * 4. Get updated format ID map from arr + * 5. Transform quality profiles to arr API format (with correct format IDs) + * 6. Sync quality profiles to arr (create or update by name) */ -import { BaseSyncer } from './base.ts'; +import { BaseSyncer, type SyncResult } from './base.ts'; import { arrSyncQueries } from '$db/queries/arrSync.ts'; +import { getCache } from '$pcd/cache.ts'; +import { logger } from '$logger/logger.ts'; +import type { SyncArrType } from './mappings.ts'; + +// Transformers +import { + fetchCustomFormatFromPcd, + transformCustomFormat, + type PcdCustomFormat +} from './transformers/customFormat.ts'; +import { + fetchQualityProfileFromPcd, + getQualityApiMappings, + getReferencedCustomFormatIds, + transformQualityProfile, + type PcdQualityProfile +} from './transformers/qualityProfile.ts'; + +// Internal types for sync data +interface ProfileSyncData { + pcdProfile: PcdQualityProfile; + referencedFormatIds: number[]; +} + +interface SyncBatch { + profiles: ProfileSyncData[]; + customFormats: Map; // deduped by format ID +} + +interface SyncedProfileSummary { + name: string; + action: 'created' | 'updated'; + language: string; + cutoffFormatScore: number; + minFormatScore: number; + formats: { name: string; score: number }[]; +} export class QualityProfileSyncer extends BaseSyncer { + private instanceType: SyncArrType; + + constructor( + client: ConstructorParameters[0], + instanceId: number, + instanceName: string, + instanceType: SyncArrType + ) { + super(client, instanceId, instanceName); + this.instanceType = instanceType; + } + protected get syncType(): string { return 'quality profiles'; } - protected async fetchFromPcd(): Promise { + /** + * Override sync to handle the complex quality profile sync flow + */ + override async sync(): Promise { + try { + await logger.info(`Starting quality profile sync for "${this.instanceName}"`, { + source: 'Sync:QualityProfiles', + meta: { instanceId: this.instanceId, instanceType: this.instanceType } + }); + + // 1. Fetch all profiles and their custom formats from PCD + const syncBatch = await this.fetchSyncBatch(); + + if (syncBatch.profiles.length === 0) { + await logger.debug(`No quality profiles to sync for "${this.instanceName}"`, { + source: 'Sync:QualityProfiles', + meta: { instanceId: this.instanceId } + }); + return { success: true, itemsSynced: 0 }; + } + + // 2. Sync custom formats first (profiles depend on format IDs) + const formatIdMap = await this.syncCustomFormats(syncBatch.customFormats); + + // 3. Get quality API mappings for this arr type + // Use the first database's cache (all should have same mappings) + const firstSelection = arrSyncQueries.getQualityProfilesSync(this.instanceId).selections[0]; + const cache = getCache(firstSelection.databaseId); + if (!cache) { + throw new Error(`PCD cache not found for database ${firstSelection.databaseId}`); + } + const qualityMappings = await getQualityApiMappings(cache, this.instanceType); + + // 4. Sync quality profiles + const syncedProfiles = await this.syncQualityProfiles( + syncBatch.profiles, + formatIdMap, + qualityMappings + ); + + await logger.info(`Completed quality profile sync for "${this.instanceName}"`, { + source: 'Sync:QualityProfiles', + meta: { + instanceId: this.instanceId, + formatsSynced: syncBatch.customFormats.size, + profilesSynced: syncedProfiles.length, + profiles: syncedProfiles + } + }); + + return { success: true, itemsSynced: syncedProfiles.length }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + + await logger.error(`Failed quality profile sync for "${this.instanceName}"`, { + source: 'Sync:QualityProfiles', + meta: { instanceId: this.instanceId, error: errorMsg } + }); + + return { success: false, itemsSynced: 0, error: errorMsg }; + } + } + + /** + * Fetch all quality profiles and their dependent custom formats from PCD + */ + private async fetchSyncBatch(): Promise { const syncConfig = arrSyncQueries.getQualityProfilesSync(this.instanceId); if (syncConfig.selections.length === 0) { - return []; + return { profiles: [], customFormats: new Map() }; } - // 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 + const profiles: ProfileSyncData[] = []; + const customFormats = new Map(); - throw new Error('Not implemented: fetchFromPcd'); + for (const selection of syncConfig.selections) { + const cache = getCache(selection.databaseId); + if (!cache) { + await logger.warn(`PCD cache not found for database ${selection.databaseId}`, { + source: 'Sync:QualityProfiles', + meta: { instanceId: this.instanceId, databaseId: selection.databaseId } + }); + continue; + } + + // Fetch the quality profile + const pcdProfile = await fetchQualityProfileFromPcd(cache, selection.profileId, this.instanceType); + if (!pcdProfile) { + await logger.warn(`Quality profile ${selection.profileId} not found in database ${selection.databaseId}`, { + source: 'Sync:QualityProfiles', + meta: { instanceId: this.instanceId } + }); + continue; + } + + // Get referenced custom format IDs + const referencedFormatIds = await getReferencedCustomFormatIds( + cache, + selection.profileId, + this.instanceType + ); + + profiles.push({ pcdProfile, referencedFormatIds }); + + // Fetch custom formats (dedupe by ID) + for (const formatId of referencedFormatIds) { + if (!customFormats.has(formatId)) { + const pcdFormat = await fetchCustomFormatFromPcd(cache, formatId); + if (pcdFormat) { + customFormats.set(formatId, pcdFormat); + } + } + } + } + + return { profiles, customFormats }; } - 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 + /** + * Sync custom formats to arr instance + * Returns a map of format name -> arr format ID + */ + private async syncCustomFormats( + pcdFormats: Map + ): Promise> { + // Get existing formats from arr + const existingFormats = await this.client.getCustomFormats(); + const existingMap = new Map(existingFormats.map((f) => [f.name, f.id!])); - throw new Error('Not implemented: transformToArr'); + for (const pcdFormat of pcdFormats.values()) { + const arrFormat = transformCustomFormat(pcdFormat, this.instanceType); + + await logger.debug(`Compiled custom format "${arrFormat.name}"`, { + source: 'Compile:CustomFormat', + meta: { + instanceId: this.instanceId, + format: arrFormat + } + }); + + try { + if (existingMap.has(arrFormat.name)) { + // Update existing + const existingId = existingMap.get(arrFormat.name)!; + arrFormat.id = existingId; + await this.client.updateCustomFormat(existingId, arrFormat); + await logger.debug(`Updated custom format "${arrFormat.name}"`, { + source: 'Sync:CustomFormats', + meta: { instanceId: this.instanceId, formatId: existingId } + }); + } else { + // Create new + const response = await this.client.createCustomFormat(arrFormat); + existingMap.set(arrFormat.name, response.id!); + await logger.debug(`Created custom format "${arrFormat.name}"`, { + source: 'Sync:CustomFormats', + meta: { instanceId: this.instanceId, formatId: response.id } + }); + } + } catch (error) { + const errorDetails = this.extractErrorDetails(error); + await logger.error(`Failed to sync custom format "${arrFormat.name}"`, { + source: 'Sync:CustomFormats', + meta: { + instanceId: this.instanceId, + formatName: arrFormat.name, + request: arrFormat, + ...errorDetails + } + }); + } + } + + // Refresh format map from arr to get accurate IDs + const refreshedFormats = await this.client.getCustomFormats(); + const formatIdMap = new Map(); + for (const format of refreshedFormats) { + formatIdMap.set(format.name, format.id!); + } + + return formatIdMap; } - protected async pushToArr(arrData: unknown[]): Promise { - // TODO: Implement - // 1. First sync custom formats (dependencies) - // 2. Then sync quality profiles - // 3. Handle create vs update (check if profile exists by name) + /** + * Sync quality profiles to arr instance + * Returns array of synced profile summaries for logging + */ + private async syncQualityProfiles( + profiles: ProfileSyncData[], + formatIdMap: Map, + qualityMappings: Map + ): Promise { + // Get existing profiles from arr + const existingProfiles = await this.client.getQualityProfiles(); + const existingMap = new Map(existingProfiles.map((p) => [p.name, p.id])); - throw new Error('Not implemented: pushToArr'); + const syncedProfiles: SyncedProfileSummary[] = []; + + for (const { pcdProfile } of profiles) { + const arrProfile = transformQualityProfile( + pcdProfile, + this.instanceType, + qualityMappings, + formatIdMap + ); + + await logger.debug(`Compiled quality profile "${arrProfile.name}"`, { + source: 'Compile:QualityProfile', + meta: { + instanceId: this.instanceId, + profile: arrProfile + } + }); + + try { + const isUpdate = existingMap.has(arrProfile.name); + if (isUpdate) { + // Update existing + const existingId = existingMap.get(arrProfile.name)!; + arrProfile.id = existingId; + await this.client.updateQualityProfile(existingId, arrProfile); + await logger.debug(`Updated quality profile "${arrProfile.name}"`, { + source: 'Sync:QualityProfiles', + meta: { instanceId: this.instanceId, profileId: existingId } + }); + } else { + // Create new + const response = await this.client.createQualityProfile(arrProfile); + await logger.debug(`Created quality profile "${arrProfile.name}"`, { + source: 'Sync:QualityProfiles', + meta: { instanceId: this.instanceId, profileId: response.id } + }); + } + + // Build summary for completion log + const scoredFormats = arrProfile.formatItems + .filter((f) => f.score !== 0) + .map((f) => ({ name: f.name, score: f.score })); + + syncedProfiles.push({ + name: arrProfile.name, + action: isUpdate ? 'updated' : 'created', + language: arrProfile.language.name, + cutoffFormatScore: arrProfile.cutoffFormatScore, + minFormatScore: arrProfile.minFormatScore, + formats: scoredFormats + }); + } catch (error) { + const errorDetails = this.extractErrorDetails(error); + await logger.error(`Failed to sync quality profile "${arrProfile.name}"`, { + source: 'Sync:QualityProfiles', + meta: { + instanceId: this.instanceId, + profileName: arrProfile.name, + request: arrProfile, + ...errorDetails + } + }); + } + } + + return syncedProfiles; + } + + /** + * Extract error details from HTTP errors for logging + * Attempts to get response body, status, etc. + */ + private extractErrorDetails(error: unknown): Record { + const details: Record = { + error: error instanceof Error ? error.message : 'Unknown error' + }; + + // Check if it's an HTTP error with response details + if (error && typeof error === 'object') { + const err = error as Record; + + // Common HTTP client error properties + if ('status' in err) details.status = err.status; + if ('statusText' in err) details.statusText = err.statusText; + if ('response' in err) details.response = err.response; + if ('body' in err) details.responseBody = err.body; + if ('data' in err) details.responseData = err.data; + + // If error has a cause, include it + if (err.cause) details.cause = err.cause; + } + + return details; + } + + // Base class abstract methods - implemented but not used since we override sync() + protected async fetchFromPcd(): Promise { + return []; + } + + protected transformToArr(_pcdData: unknown[]): unknown[] { + return []; + } + + protected async pushToArr(_arrData: unknown[]): Promise { + // Not used - logic is in sync() } } diff --git a/src/lib/server/sync/transformers/customFormat.ts b/src/lib/server/sync/transformers/customFormat.ts new file mode 100644 index 0000000..ce39f82 --- /dev/null +++ b/src/lib/server/sync/transformers/customFormat.ts @@ -0,0 +1,614 @@ +/** + * Custom Format Transformer + * Transforms PCD custom format data to arr API format + */ + +import type { PCDCache } from '$pcd/cache.ts'; +import { + type SyncArrType, + getSource, + getResolution, + getIndexerFlag, + getQualityModifier, + getReleaseType, + getLanguage +} from '../mappings.ts'; + +// ============================================================================= +// Arr API Types +// ============================================================================= + +export interface ArrCustomFormatSpecification { + name: string; + implementation: string; + negate: boolean; + required: boolean; + fields: { name: string; value: unknown }[]; +} + +export interface ArrCustomFormat { + id?: number; + name: string; + includeCustomFormatWhenRenaming?: boolean; + specifications: ArrCustomFormatSpecification[]; +} + +// ============================================================================= +// PCD Data Types +// ============================================================================= + +export interface PcdCustomFormat { + id: number; + name: string; + includeInRename: boolean; + conditions: PcdCondition[]; +} + +export interface PcdCondition { + id: number; + name: string; + type: string; + arrType: string; // 'radarr', 'sonarr', 'all' + negate: boolean; + required: boolean; + // Type-specific data + patterns?: { id: number; pattern: string }[]; + languages?: { id: number; name: string; except: boolean }[]; + sources?: string[]; + resolutions?: string[]; + qualityModifiers?: string[]; + releaseTypes?: string[]; + indexerFlags?: string[]; + size?: { minBytes: number | null; maxBytes: number | null }; + years?: { minYear: number | null; maxYear: number | null }; +} + +// ============================================================================= +// Condition Type to Implementation Mapping +// ============================================================================= + +const CONDITION_IMPLEMENTATIONS: Record = { + release_title: 'ReleaseTitleSpecification', + release_group: 'ReleaseGroupSpecification', + edition: 'EditionSpecification', + source: 'SourceSpecification', + resolution: 'ResolutionSpecification', + indexer_flag: 'IndexerFlagSpecification', + quality_modifier: 'QualityModifierSpecification', + size: 'SizeSpecification', + language: 'LanguageSpecification', + release_type: 'ReleaseTypeSpecification', + year: 'YearSpecification' +}; + +// ============================================================================= +// Transformer Functions +// ============================================================================= + +/** + * Transform a single condition to arr API specification format + * Returns null if the condition should be skipped for this arr type + */ +function transformCondition( + condition: PcdCondition, + arrType: SyncArrType +): ArrCustomFormatSpecification | null { + // Skip conditions not applicable to this arr type + if (condition.arrType !== 'all' && condition.arrType !== arrType) { + return null; + } + + // Quality modifier is Radarr-only + if (condition.type === 'quality_modifier' && arrType === 'sonarr') { + return null; + } + + // Release type is Sonarr-only + if (condition.type === 'release_type' && arrType === 'radarr') { + return null; + } + + const implementation = CONDITION_IMPLEMENTATIONS[condition.type]; + if (!implementation) { + return null; + } + + const spec: ArrCustomFormatSpecification = { + name: condition.name, + implementation, + negate: condition.negate, + required: condition.required, + fields: [] + }; + + // Build fields based on condition type + switch (condition.type) { + case 'release_title': + case 'release_group': + case 'edition': { + // Pattern-based conditions use the regex pattern + const pattern = condition.patterns?.[0]?.pattern; + if (!pattern) return null; + spec.fields = [{ name: 'value', value: pattern }]; + break; + } + + case 'source': { + const source = condition.sources?.[0]; + if (!source) return null; + spec.fields = [{ name: 'value', value: getSource(source, arrType) }]; + break; + } + + case 'resolution': { + const resolution = condition.resolutions?.[0]; + if (!resolution) return null; + spec.fields = [{ name: 'value', value: getResolution(resolution) }]; + break; + } + + case 'indexer_flag': { + const flag = condition.indexerFlags?.[0]; + if (!flag) return null; + spec.fields = [{ name: 'value', value: getIndexerFlag(flag, arrType) }]; + break; + } + + case 'quality_modifier': { + const modifier = condition.qualityModifiers?.[0]; + if (!modifier) return null; + spec.fields = [{ name: 'value', value: getQualityModifier(modifier) }]; + break; + } + + case 'release_type': { + const releaseType = condition.releaseTypes?.[0]; + if (!releaseType) return null; + spec.fields = [{ name: 'value', value: getReleaseType(releaseType) }]; + break; + } + + case 'size': { + const size = condition.size; + if (!size) return null; + spec.fields = [ + { name: 'min', value: size.minBytes ?? 0 }, + { name: 'max', value: size.maxBytes ?? 0 } + ]; + break; + } + + case 'year': { + const years = condition.years; + if (!years) return null; + spec.fields = [ + { name: 'min', value: years.minYear ?? 0 }, + { name: 'max', value: years.maxYear ?? 0 } + ]; + break; + } + + case 'language': { + const lang = condition.languages?.[0]; + if (!lang) return null; + const langData = getLanguage(lang.name, arrType); + spec.fields = [{ name: 'value', value: langData.id }]; + // Add exceptLanguage field if present + if (lang.except) { + spec.fields.push({ name: 'exceptLanguage', value: true }); + } + break; + } + + default: + return null; + } + + return spec; +} + +/** + * Transform a PCD custom format to arr API format + */ +export function transformCustomFormat(format: PcdCustomFormat, arrType: SyncArrType): ArrCustomFormat { + const specifications: ArrCustomFormatSpecification[] = []; + + for (const condition of format.conditions) { + const spec = transformCondition(condition, arrType); + if (spec) { + specifications.push(spec); + } + } + + const result: ArrCustomFormat = { + name: format.name, + specifications + }; + + if (format.includeInRename) { + result.includeCustomFormatWhenRenaming = true; + } + + return result; +} + +// ============================================================================= +// PCD Query Functions +// ============================================================================= + +/** + * Fetch a custom format from PCD cache with all conditions + */ +export async function fetchCustomFormatFromPcd( + cache: PCDCache, + formatId: number +): Promise { + const db = cache.kb; + + // Get custom format + const format = await db + .selectFrom('custom_formats') + .select(['id', 'name', 'include_in_rename']) + .where('id', '=', formatId) + .executeTakeFirst(); + + if (!format) return null; + + // Get conditions + const conditions = await db + .selectFrom('custom_format_conditions') + .select(['id', 'name', 'type', 'arr_type', 'negate', 'required']) + .where('custom_format_id', '=', formatId) + .execute(); + + if (conditions.length === 0) { + return { + id: format.id, + name: format.name, + includeInRename: format.include_in_rename === 1, + conditions: [] + }; + } + + const conditionIds = conditions.map((c) => c.id); + + // Fetch all condition data in parallel + const [patterns, languages, sources, resolutions, qualityModifiers, releaseTypes, indexerFlags, sizes, years] = + await Promise.all([ + db + .selectFrom('condition_patterns as cp') + .innerJoin('regular_expressions as re', 're.id', 'cp.regular_expression_id') + .select(['cp.custom_format_condition_id', 're.id', 're.pattern']) + .where('cp.custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_languages as cl') + .innerJoin('languages as l', 'l.id', 'cl.language_id') + .select(['cl.custom_format_condition_id', 'l.id', 'l.name', 'cl.except_language']) + .where('cl.custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_sources') + .select(['custom_format_condition_id', 'source']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_resolutions') + .select(['custom_format_condition_id', 'resolution']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_quality_modifiers') + .select(['custom_format_condition_id', 'quality_modifier']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_release_types') + .select(['custom_format_condition_id', 'release_type']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_indexer_flags') + .select(['custom_format_condition_id', 'flag']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_sizes') + .select(['custom_format_condition_id', 'min_bytes', 'max_bytes']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_years') + .select(['custom_format_condition_id', 'min_year', 'max_year']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute() + ]); + + // Build lookup maps + const patternsMap = new Map(); + for (const p of patterns) { + if (!patternsMap.has(p.custom_format_condition_id)) { + patternsMap.set(p.custom_format_condition_id, []); + } + patternsMap.get(p.custom_format_condition_id)!.push({ id: p.id, pattern: p.pattern }); + } + + const languagesMap = new Map(); + for (const l of languages) { + if (!languagesMap.has(l.custom_format_condition_id)) { + languagesMap.set(l.custom_format_condition_id, []); + } + languagesMap.get(l.custom_format_condition_id)!.push({ + id: l.id, + name: l.name, + except: l.except_language === 1 + }); + } + + const sourcesMap = new Map(); + for (const s of sources) { + if (!sourcesMap.has(s.custom_format_condition_id)) { + sourcesMap.set(s.custom_format_condition_id, []); + } + sourcesMap.get(s.custom_format_condition_id)!.push(s.source); + } + + const resolutionsMap = new Map(); + for (const r of resolutions) { + if (!resolutionsMap.has(r.custom_format_condition_id)) { + resolutionsMap.set(r.custom_format_condition_id, []); + } + resolutionsMap.get(r.custom_format_condition_id)!.push(r.resolution); + } + + const qualityModifiersMap = new Map(); + for (const q of qualityModifiers) { + if (!qualityModifiersMap.has(q.custom_format_condition_id)) { + qualityModifiersMap.set(q.custom_format_condition_id, []); + } + qualityModifiersMap.get(q.custom_format_condition_id)!.push(q.quality_modifier); + } + + const releaseTypesMap = new Map(); + for (const r of releaseTypes) { + if (!releaseTypesMap.has(r.custom_format_condition_id)) { + releaseTypesMap.set(r.custom_format_condition_id, []); + } + releaseTypesMap.get(r.custom_format_condition_id)!.push(r.release_type); + } + + const indexerFlagsMap = new Map(); + for (const f of indexerFlags) { + if (!indexerFlagsMap.has(f.custom_format_condition_id)) { + indexerFlagsMap.set(f.custom_format_condition_id, []); + } + indexerFlagsMap.get(f.custom_format_condition_id)!.push(f.flag); + } + + const sizesMap = new Map(); + for (const s of sizes) { + sizesMap.set(s.custom_format_condition_id, { + minBytes: s.min_bytes, + maxBytes: s.max_bytes + }); + } + + const yearsMap = new Map(); + for (const y of years) { + yearsMap.set(y.custom_format_condition_id, { + minYear: y.min_year, + maxYear: y.max_year + }); + } + + // Build conditions + const pcdConditions: PcdCondition[] = conditions.map((c) => ({ + id: c.id, + name: c.name, + type: c.type, + arrType: c.arr_type, + negate: c.negate === 1, + required: c.required === 1, + patterns: patternsMap.get(c.id), + languages: languagesMap.get(c.id), + sources: sourcesMap.get(c.id), + resolutions: resolutionsMap.get(c.id), + qualityModifiers: qualityModifiersMap.get(c.id), + releaseTypes: releaseTypesMap.get(c.id), + indexerFlags: indexerFlagsMap.get(c.id), + size: sizesMap.get(c.id), + years: yearsMap.get(c.id) + })); + + return { + id: format.id, + name: format.name, + includeInRename: format.include_in_rename === 1, + conditions: pcdConditions + }; +} + +/** + * Fetch all custom formats from PCD cache + * Used when syncing all formats referenced by quality profiles + */ +export async function fetchAllCustomFormatsFromPcd(cache: PCDCache): Promise { + const db = cache.kb; + + // Get all custom formats + const formats = await db.selectFrom('custom_formats').select(['id', 'name', 'include_in_rename']).execute(); + + if (formats.length === 0) return []; + + // Get all conditions + const conditions = await db + .selectFrom('custom_format_conditions') + .select(['id', 'custom_format_id', 'name', 'type', 'arr_type', 'negate', 'required']) + .execute(); + + const conditionIds = conditions.map((c) => c.id); + + // Fetch all condition data in parallel (same as above) + const [patterns, languages, sources, resolutions, qualityModifiers, releaseTypes, indexerFlags, sizes, years] = + conditionIds.length > 0 + ? await Promise.all([ + db + .selectFrom('condition_patterns as cp') + .innerJoin('regular_expressions as re', 're.id', 'cp.regular_expression_id') + .select(['cp.custom_format_condition_id', 're.id', 're.pattern']) + .where('cp.custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_languages as cl') + .innerJoin('languages as l', 'l.id', 'cl.language_id') + .select(['cl.custom_format_condition_id', 'l.id', 'l.name', 'cl.except_language']) + .where('cl.custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_sources') + .select(['custom_format_condition_id', 'source']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_resolutions') + .select(['custom_format_condition_id', 'resolution']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_quality_modifiers') + .select(['custom_format_condition_id', 'quality_modifier']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_release_types') + .select(['custom_format_condition_id', 'release_type']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_indexer_flags') + .select(['custom_format_condition_id', 'flag']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_sizes') + .select(['custom_format_condition_id', 'min_bytes', 'max_bytes']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute(), + db + .selectFrom('condition_years') + .select(['custom_format_condition_id', 'min_year', 'max_year']) + .where('custom_format_condition_id', 'in', conditionIds) + .execute() + ]) + : [[], [], [], [], [], [], [], [], []]; + + // Build lookup maps (same pattern as above) + const patternsMap = new Map(); + for (const p of patterns) { + if (!patternsMap.has(p.custom_format_condition_id)) { + patternsMap.set(p.custom_format_condition_id, []); + } + patternsMap.get(p.custom_format_condition_id)!.push({ id: p.id, pattern: p.pattern }); + } + + const languagesMap = new Map(); + for (const l of languages) { + if (!languagesMap.has(l.custom_format_condition_id)) { + languagesMap.set(l.custom_format_condition_id, []); + } + languagesMap.get(l.custom_format_condition_id)!.push({ + id: l.id, + name: l.name, + except: l.except_language === 1 + }); + } + + const sourcesMap = new Map(); + for (const s of sources) { + if (!sourcesMap.has(s.custom_format_condition_id)) { + sourcesMap.set(s.custom_format_condition_id, []); + } + sourcesMap.get(s.custom_format_condition_id)!.push(s.source); + } + + const resolutionsMap = new Map(); + for (const r of resolutions) { + if (!resolutionsMap.has(r.custom_format_condition_id)) { + resolutionsMap.set(r.custom_format_condition_id, []); + } + resolutionsMap.get(r.custom_format_condition_id)!.push(r.resolution); + } + + const qualityModifiersMap = new Map(); + for (const q of qualityModifiers) { + if (!qualityModifiersMap.has(q.custom_format_condition_id)) { + qualityModifiersMap.set(q.custom_format_condition_id, []); + } + qualityModifiersMap.get(q.custom_format_condition_id)!.push(q.quality_modifier); + } + + const releaseTypesMap = new Map(); + for (const r of releaseTypes) { + if (!releaseTypesMap.has(r.custom_format_condition_id)) { + releaseTypesMap.set(r.custom_format_condition_id, []); + } + releaseTypesMap.get(r.custom_format_condition_id)!.push(r.release_type); + } + + const indexerFlagsMap = new Map(); + for (const f of indexerFlags) { + if (!indexerFlagsMap.has(f.custom_format_condition_id)) { + indexerFlagsMap.set(f.custom_format_condition_id, []); + } + indexerFlagsMap.get(f.custom_format_condition_id)!.push(f.flag); + } + + const sizesMap = new Map(); + for (const s of sizes) { + sizesMap.set(s.custom_format_condition_id, { + minBytes: s.min_bytes, + maxBytes: s.max_bytes + }); + } + + const yearsMap = new Map(); + for (const y of years) { + yearsMap.set(y.custom_format_condition_id, { + minYear: y.min_year, + maxYear: y.max_year + }); + } + + // Group conditions by format + const conditionsByFormat = new Map(); + for (const c of conditions) { + if (!conditionsByFormat.has(c.custom_format_id)) { + conditionsByFormat.set(c.custom_format_id, []); + } + conditionsByFormat.get(c.custom_format_id)!.push({ + id: c.id, + name: c.name, + type: c.type, + arrType: c.arr_type, + negate: c.negate === 1, + required: c.required === 1, + patterns: patternsMap.get(c.id), + languages: languagesMap.get(c.id), + sources: sourcesMap.get(c.id), + resolutions: resolutionsMap.get(c.id), + qualityModifiers: qualityModifiersMap.get(c.id), + releaseTypes: releaseTypesMap.get(c.id), + indexerFlags: indexerFlagsMap.get(c.id), + size: sizesMap.get(c.id), + years: yearsMap.get(c.id) + }); + } + + // Build result + return formats.map((f) => ({ + id: f.id, + name: f.name, + includeInRename: f.include_in_rename === 1, + conditions: conditionsByFormat.get(f.id) || [] + })); +} diff --git a/src/lib/server/sync/transformers/index.ts b/src/lib/server/sync/transformers/index.ts new file mode 100644 index 0000000..3cba26f --- /dev/null +++ b/src/lib/server/sync/transformers/index.ts @@ -0,0 +1,28 @@ +/** + * Sync Transformers + * Transform PCD data to arr API format + */ + +export { + transformCustomFormat, + fetchCustomFormatFromPcd, + fetchAllCustomFormatsFromPcd, + type ArrCustomFormat, + type ArrCustomFormatSpecification, + type PcdCustomFormat, + type PcdCondition +} from './customFormat.ts'; + +export { + transformQualityProfile, + fetchQualityProfileFromPcd, + getQualityApiMappings, + getReferencedCustomFormatIds, + type ArrQualityProfile, + type ArrQualityItem, + type ArrFormatItem, + type PcdQualityProfile, + type PcdQualityItem, + type PcdLanguageConfig, + type PcdCustomFormatScore +} from './qualityProfile.ts'; diff --git a/src/lib/server/sync/transformers/qualityProfile.ts b/src/lib/server/sync/transformers/qualityProfile.ts new file mode 100644 index 0000000..512049c --- /dev/null +++ b/src/lib/server/sync/transformers/qualityProfile.ts @@ -0,0 +1,432 @@ +/** + * Quality Profile Transformer + * Transforms PCD quality profile data to arr API format + */ + +import type { PCDCache } from '$pcd/cache.ts'; +import { + type SyncArrType, + type QualityDefinition, + getAllQualities, + getLanguageForProfile, + mapQualityName +} from '../mappings.ts'; + +// ============================================================================= +// Arr API Types +// ============================================================================= + +export interface ArrQualityItem { + quality?: QualityDefinition; + items: ArrQualityItem[]; + allowed: boolean; + id?: number; + name?: string; +} + +export interface ArrFormatItem { + format: number; + name: string; + score: number; +} + +export interface ArrQualityProfile { + id?: number; + name: string; + items: ArrQualityItem[]; + language: { id: number; name: string }; + upgradeAllowed: boolean; + cutoff: number; + minFormatScore: number; + cutoffFormatScore: number; + minUpgradeFormatScore: number; + formatItems: ArrFormatItem[]; +} + +// ============================================================================= +// PCD Data Types +// ============================================================================= + +export interface PcdQualityProfile { + id: number; + name: string; + upgradesAllowed: boolean; + minimumCustomFormatScore: number; + upgradeUntilScore: number; + upgradeScoreIncrement: number; + qualities: PcdQualityItem[]; + language: PcdLanguageConfig | null; + customFormats: PcdCustomFormatScore[]; +} + +export interface PcdQualityItem { + type: 'quality' | 'group'; + referenceId: number; + name: string; + position: number; + enabled: boolean; + upgradeUntil: boolean; + members?: { id: number; name: string }[]; +} + +export interface PcdLanguageConfig { + id: number; + name: string; + type: 'must' | 'only' | 'not' | 'simple'; +} + +export interface PcdCustomFormatScore { + formatId: number; + formatName: string; + score: number; +} + +// ============================================================================= +// Transformer Functions +// ============================================================================= + +/** + * Convert PCD group ID to arr group ID + * PCD uses sequential IDs, arr expects 1000+offset for groups + */ +function convertGroupId(_groupId: number, index: number): number { + return 1000 + index + 1; +} + +/** + * Transform a PCD quality profile to arr API format + */ +export function transformQualityProfile( + profile: PcdQualityProfile, + arrType: SyncArrType, + qualityApiMappings: Map, + formatIdMap: Map +): ArrQualityProfile { + const allQualities = getAllQualities(arrType); + + // Build quality items + const items: ArrQualityItem[] = []; + const usedQualityNames = new Set(); + const qualityIdsInGroups = new Set(); + let cutoffId: number | undefined; + let groupIndex = 0; + + // First pass: identify qualities in groups + for (const item of profile.qualities) { + if (item.type === 'group' && item.members) { + for (const member of item.members) { + const apiName = qualityApiMappings.get(member.name.toLowerCase()) ?? mapQualityName(member.name, arrType); + const quality = allQualities[apiName]; + if (quality) { + qualityIdsInGroups.add(quality.id); + } + } + } + } + + // Second pass: build items + for (const item of profile.qualities) { + if (item.type === 'group') { + // Group item + const groupId = convertGroupId(item.referenceId, groupIndex++); + + const groupItem: ArrQualityItem = { + id: groupId, + name: item.name, + items: [], + allowed: item.enabled + }; + + // Add members + if (item.members) { + for (const member of item.members) { + const apiName = + qualityApiMappings.get(member.name.toLowerCase()) ?? mapQualityName(member.name, arrType); + const quality = allQualities[apiName]; + + if (quality) { + groupItem.items.push({ + quality: { ...quality }, + items: [], + allowed: true + }); + usedQualityNames.add(apiName.toUpperCase()); + } + } + } + + if (groupItem.items.length > 0) { + items.push(groupItem); + } + + // Check if this is the cutoff + if (item.upgradeUntil) { + cutoffId = groupId; + } + } else { + // Single quality + const apiName = qualityApiMappings.get(item.name.toLowerCase()) ?? mapQualityName(item.name, arrType); + const quality = allQualities[apiName]; + + if (quality) { + items.push({ + quality: { ...quality }, + items: [], + allowed: item.enabled + }); + usedQualityNames.add(apiName.toUpperCase()); + + // Check if this is the cutoff + if (item.upgradeUntil) { + cutoffId = quality.id; + } + } + } + } + + // Add unused qualities as disabled + for (const [qualityName, quality] of Object.entries(allQualities)) { + if (!usedQualityNames.has(qualityName.toUpperCase()) && !qualityIdsInGroups.has(quality.id)) { + items.push({ + quality: { ...quality }, + items: [], + allowed: false + }); + } + } + + // Reverse items to match arr expected order + items.reverse(); + + // Build language config + const languageName = profile.language?.name ?? 'any'; + const language = getLanguageForProfile(languageName, arrType); + + // Build format items + const formatItems: ArrFormatItem[] = []; + const processedFormats = new Set(); + + // Add explicit scores from profile + for (const cf of profile.customFormats) { + const formatId = formatIdMap.get(cf.formatName); + if (formatId !== undefined) { + formatItems.push({ + format: formatId, + name: cf.formatName, + score: cf.score + }); + processedFormats.add(cf.formatName); + } + } + + // Add all other formats with score 0 (arr requirement) + for (const [formatName, formatId] of formatIdMap) { + if (!processedFormats.has(formatName)) { + formatItems.push({ + format: formatId, + name: formatName, + score: 0 + }); + } + } + + return { + name: profile.name, + items, + language, + upgradeAllowed: profile.upgradesAllowed, + cutoff: cutoffId ?? items[items.length - 1]?.quality?.id ?? 0, + minFormatScore: profile.minimumCustomFormatScore, + cutoffFormatScore: profile.upgradeUntilScore, + minUpgradeFormatScore: Math.max(1, profile.upgradeScoreIncrement), + formatItems + }; +} + +// ============================================================================= +// PCD Query Functions +// ============================================================================= + +/** + * Fetch a quality profile from PCD cache with all related data + */ +export async function fetchQualityProfileFromPcd( + cache: PCDCache, + profileId: number, + arrType: SyncArrType +): Promise { + const db = cache.kb; + + // Get profile base info + const profile = await db + .selectFrom('quality_profiles') + .select([ + 'id', + 'name', + 'upgrades_allowed', + 'minimum_custom_format_score', + 'upgrade_until_score', + 'upgrade_score_increment' + ]) + .where('id', '=', profileId) + .executeTakeFirst(); + + if (!profile) return null; + + // Get all qualities in one query (for reference) + const allQualities = await db.selectFrom('qualities').select(['id', 'name']).execute(); + const qualityNameMap = new Map(allQualities.map((q) => [q.id, q.name])); + + // Get quality groups for this profile + const groups = await db + .selectFrom('quality_groups') + .select(['id', 'name']) + .where('quality_profile_id', '=', profileId) + .execute(); + + // Get group members + const groupMembers = + groups.length > 0 + ? await db + .selectFrom('quality_group_members') + .innerJoin('qualities', 'qualities.id', 'quality_group_members.quality_id') + .select([ + 'quality_group_members.quality_group_id', + 'qualities.id as quality_id', + 'qualities.name as quality_name' + ]) + .where( + 'quality_group_members.quality_group_id', + 'in', + groups.map((g) => g.id) + ) + .execute() + : []; + + // Build groups map + const groupsMap = new Map(); + for (const group of groups) { + groupsMap.set(group.id, { id: group.id, name: group.name, members: [] }); + } + for (const member of groupMembers) { + const group = groupsMap.get(member.quality_group_id); + if (group) { + group.members.push({ id: member.quality_id, name: member.quality_name }); + } + } + + // Get ordered quality items + const orderedItems = await db + .selectFrom('quality_profile_qualities') + .select(['id', 'quality_id', 'quality_group_id', 'position', 'enabled', 'upgrade_until']) + .where('quality_profile_id', '=', profileId) + .orderBy('position') + .execute(); + + // Build quality items + const qualities: PcdQualityItem[] = orderedItems.map((item) => { + const isGroup = item.quality_group_id !== null; + const referenceId = isGroup ? item.quality_group_id! : item.quality_id!; + const name = isGroup ? groupsMap.get(referenceId)?.name ?? 'Group' : qualityNameMap.get(referenceId) ?? 'Unknown'; + + const result: PcdQualityItem = { + type: isGroup ? 'group' : 'quality', + referenceId, + name, + position: item.position, + enabled: item.enabled === 1, + upgradeUntil: item.upgrade_until === 1 + }; + + if (isGroup) { + result.members = groupsMap.get(referenceId)?.members || []; + } + + return result; + }); + + // Get language config (first one if exists) + const languageRow = await db + .selectFrom('quality_profile_languages as qpl') + .innerJoin('languages as l', 'l.id', 'qpl.language_id') + .select(['l.id as language_id', 'l.name as language_name', 'qpl.type']) + .where('qpl.quality_profile_id', '=', profileId) + .executeTakeFirst(); + + const language: PcdLanguageConfig | null = languageRow + ? { + id: languageRow.language_id, + name: languageRow.language_name, + type: languageRow.type as 'must' | 'only' | 'not' | 'simple' + } + : null; + + // Get custom format scores for this arr type + const cfScores = await db + .selectFrom('quality_profile_custom_formats as qpcf') + .innerJoin('custom_formats as cf', 'cf.id', 'qpcf.custom_format_id') + .select(['cf.id as format_id', 'cf.name as format_name', 'qpcf.score']) + .where('qpcf.quality_profile_id', '=', profileId) + .where((eb) => eb.or([eb('qpcf.arr_type', '=', arrType), eb('qpcf.arr_type', '=', 'all')])) + .execute(); + + // For "all" type entries, if there's also a specific arr_type entry, prefer the specific one + const cfScoresMap = new Map(); + for (const row of cfScores) { + // Later entries (specific arr_type) will override earlier ones (all) + cfScoresMap.set(row.format_id, { + formatId: row.format_id, + formatName: row.format_name, + score: row.score + }); + } + + return { + id: profile.id, + name: profile.name, + upgradesAllowed: profile.upgrades_allowed === 1, + minimumCustomFormatScore: profile.minimum_custom_format_score, + upgradeUntilScore: profile.upgrade_until_score, + upgradeScoreIncrement: profile.upgrade_score_increment, + qualities, + language, + customFormats: Array.from(cfScoresMap.values()) + }; +} + +/** + * Get quality API mappings from PCD cache + * Returns a map of PCD quality name (lowercase) -> arr API name + */ +export async function getQualityApiMappings(cache: PCDCache, arrType: SyncArrType): Promise> { + const rows = await cache.kb + .selectFrom('quality_api_mappings as qam') + .innerJoin('qualities as q', 'q.id', 'qam.quality_id') + .where('qam.arr_type', '=', arrType) + .select(['q.name as quality_name', 'qam.api_name']) + .execute(); + + const map = new Map(); + for (const row of rows) { + map.set(row.quality_name.toLowerCase(), row.api_name); + } + return map; +} + +/** + * Get all custom format IDs referenced by a quality profile + */ +export async function getReferencedCustomFormatIds( + cache: PCDCache, + profileId: number, + arrType: SyncArrType +): Promise { + const rows = await cache.kb + .selectFrom('quality_profile_custom_formats') + .select(['custom_format_id']) + .where('quality_profile_id', '=', profileId) + .where((eb) => eb.or([eb('arr_type', '=', arrType), eb('arr_type', '=', 'all')])) + .execute(); + + return [...new Set(rows.map((r) => r.custom_format_id))]; +} diff --git a/src/lib/server/utils/arr/base.ts b/src/lib/server/utils/arr/base.ts index f662f97..4d9fda9 100644 --- a/src/lib/server/utils/arr/base.ts +++ b/src/lib/server/utils/arr/base.ts @@ -1,5 +1,15 @@ import { BaseHttpClient } from '../http/client.ts'; -import type { ArrSystemStatus, ArrDelayProfile, ArrTag, ArrMediaManagementConfig, ArrNamingConfig, ArrQualityDefinition } from './types.ts'; +import type { + ArrSystemStatus, + ArrDelayProfile, + ArrTag, + ArrMediaManagementConfig, + ArrNamingConfig, + ArrQualityDefinition, + ArrCustomFormat, + ArrQualityProfilePayload, + RadarrQualityProfile +} from './types.ts'; import { logger } from '$logger/logger.ts'; /** @@ -57,36 +67,36 @@ export class BaseArrClient extends BaseHttpClient { /** * Get all delay profiles */ - async getDelayProfiles(): Promise { + getDelayProfiles(): Promise { return this.get(`/api/${this.apiVersion}/delayprofile`); } /** * Get a delay profile by ID */ - async getDelayProfile(id: number): Promise { + getDelayProfile(id: number): Promise { return this.get(`/api/${this.apiVersion}/delayprofile/${id}`); } /** * Create a new delay profile */ - async createDelayProfile(profile: Omit): Promise { + createDelayProfile(profile: Omit): Promise { return this.post(`/api/${this.apiVersion}/delayprofile`, profile); } /** * Update an existing delay profile */ - async updateDelayProfile(id: number, profile: ArrDelayProfile): Promise { + updateDelayProfile(id: number, profile: ArrDelayProfile): Promise { return this.put(`/api/${this.apiVersion}/delayprofile/${id}`, profile); } /** * Delete a delay profile */ - async deleteDelayProfile(id: number): Promise { - await this.delete(`/api/${this.apiVersion}/delayprofile/${id}`); + deleteDelayProfile(id: number): Promise { + return this.delete(`/api/${this.apiVersion}/delayprofile/${id}`); } // ========================================================================= @@ -96,14 +106,14 @@ export class BaseArrClient extends BaseHttpClient { /** * Get all tags */ - async getTags(): Promise { + getTags(): Promise { return this.get(`/api/${this.apiVersion}/tag`); } /** * Create a new tag */ - async createTag(label: string): Promise { + createTag(label: string): Promise { return this.post(`/api/${this.apiVersion}/tag`, { label }); } @@ -114,7 +124,7 @@ export class BaseArrClient extends BaseHttpClient { /** * Get media management config */ - async getMediaManagementConfig(): Promise { + getMediaManagementConfig(): Promise { return this.get(`/api/${this.apiVersion}/config/mediamanagement`); } @@ -122,7 +132,7 @@ export class BaseArrClient extends BaseHttpClient { * Update media management config * Note: Must PUT to /{id} endpoint */ - async updateMediaManagementConfig(config: ArrMediaManagementConfig): Promise { + updateMediaManagementConfig(config: ArrMediaManagementConfig): Promise { return this.put( `/api/${this.apiVersion}/config/mediamanagement/${config.id}`, config @@ -136,7 +146,7 @@ export class BaseArrClient extends BaseHttpClient { /** * Get naming config */ - async getNamingConfig(): Promise { + getNamingConfig(): Promise { return this.get(`/api/${this.apiVersion}/config/naming`); } @@ -144,7 +154,7 @@ export class BaseArrClient extends BaseHttpClient { * Update naming config * Note: Must PUT to /{id} endpoint */ - async updateNamingConfig(config: ArrNamingConfig): Promise { + updateNamingConfig(config: ArrNamingConfig): Promise { return this.put( `/api/${this.apiVersion}/config/naming/${config.id}`, config @@ -158,7 +168,7 @@ export class BaseArrClient extends BaseHttpClient { /** * Get all quality definitions */ - async getQualityDefinitions(): Promise { + getQualityDefinitions(): Promise { return this.get(`/api/${this.apiVersion}/qualitydefinition`); } @@ -166,10 +176,88 @@ export class BaseArrClient extends BaseHttpClient { * Update all quality definitions * Note: PUT to /update endpoint with full array */ - async updateQualityDefinitions(definitions: ArrQualityDefinition[]): Promise { + updateQualityDefinitions(definitions: ArrQualityDefinition[]): Promise { return this.put( `/api/${this.apiVersion}/qualitydefinition/update`, definitions ); } + + // ========================================================================= + // Custom Formats + // ========================================================================= + + /** + * Get all custom formats + */ + getCustomFormats(): Promise { + return this.get(`/api/${this.apiVersion}/customformat`); + } + + /** + * Get a custom format by ID + */ + getCustomFormat(id: number): Promise { + return this.get(`/api/${this.apiVersion}/customformat/${id}`); + } + + /** + * Create a new custom format + */ + createCustomFormat(format: Omit): Promise { + return this.post(`/api/${this.apiVersion}/customformat`, format); + } + + /** + * Update an existing custom format + */ + updateCustomFormat(id: number, format: ArrCustomFormat): Promise { + return this.put(`/api/${this.apiVersion}/customformat/${id}`, format); + } + + /** + * Delete a custom format + */ + deleteCustomFormat(id: number): Promise { + return this.delete(`/api/${this.apiVersion}/customformat/${id}`); + } + + // ========================================================================= + // Quality Profiles + // ========================================================================= + + /** + * Get all quality profiles + */ + getQualityProfiles(): Promise { + return this.get(`/api/${this.apiVersion}/qualityprofile`); + } + + /** + * Get a quality profile by ID + */ + getQualityProfile(id: number): Promise { + return this.get(`/api/${this.apiVersion}/qualityprofile/${id}`); + } + + /** + * Create a new quality profile + */ + createQualityProfile(profile: ArrQualityProfilePayload): Promise { + return this.post(`/api/${this.apiVersion}/qualityprofile`, profile); + } + + /** + * Update an existing quality profile + */ + updateQualityProfile(id: number, profile: ArrQualityProfilePayload): Promise { + return this.put(`/api/${this.apiVersion}/qualityprofile/${id}`, profile); + } + + /** + * Delete a quality profile + */ + deleteQualityProfile(id: number): Promise { + return this.delete(`/api/${this.apiVersion}/qualityprofile/${id}`); + } } diff --git a/src/lib/server/utils/arr/types.ts b/src/lib/server/utils/arr/types.ts index de62a7d..1aa20f3 100644 --- a/src/lib/server/utils/arr/types.ts +++ b/src/lib/server/utils/arr/types.ts @@ -326,6 +326,83 @@ export interface ArrQualityDefinition { preferredSize: number | null; } +// ============================================================================= +// Custom Format Types +// ============================================================================= + +/** + * Custom format specification field + */ +export interface ArrSpecificationField { + name: string; + value: unknown; +} + +/** + * Custom format specification (condition) + */ +export interface ArrCustomFormatSpecification { + name: string; + implementation: string; + negate: boolean; + required: boolean; + fields: ArrSpecificationField[]; +} + +/** + * Custom format from /api/v3/customformat + */ +export interface ArrCustomFormat { + id?: number; + name: string; + includeCustomFormatWhenRenaming?: boolean; + specifications: ArrCustomFormatSpecification[]; +} + +// ============================================================================= +// Quality Profile Types (for create/update) +// ============================================================================= + +/** + * Quality item within a quality profile + */ +export interface ArrQualityProfileItem { + quality?: { + id: number; + name: string; + source?: string; + resolution?: number; + }; + items: ArrQualityProfileItem[]; + allowed: boolean; + id?: number; + name?: string; +} + +/** + * Language setting for quality profile + */ +export interface ArrLanguage { + id: number; + name: string; +} + +/** + * Quality profile for create/update operations + */ +export interface ArrQualityProfilePayload { + id?: number; + name: string; + items: ArrQualityProfileItem[]; + language: ArrLanguage; + upgradeAllowed: boolean; + cutoff: number; + minFormatScore: number; + cutoffFormatScore: number; + minUpgradeFormatScore: number; + formatItems: QualityProfileFormatItem[]; +} + // ============================================================================= // System Types // ============================================================================= diff --git a/src/routes/arr/[id]/sync/+page.server.ts b/src/routes/arr/[id]/sync/+page.server.ts index f5e85e3..525e2cc 100644 --- a/src/routes/arr/[id]/sync/+page.server.ts +++ b/src/routes/arr/[id]/sync/+page.server.ts @@ -6,6 +6,7 @@ import { pcdManager } from '$pcd/pcd.ts'; import { logger } from '$logger/logger.ts'; import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts'; import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts'; +import { calculateNextRun } from '$lib/server/sync/cron.ts'; export const load: ServerLoad = async ({ params }) => { const id = parseInt(params.id || '', 10); @@ -75,9 +76,12 @@ export const actions: Actions = { try { const selections: ProfileSelection[] = JSON.parse(selectionsJson || '[]'); + const effectiveTrigger = trigger || 'none'; + const effectiveCron = cron || null; arrSyncQueries.saveQualityProfilesSync(id, selections, { - trigger: trigger || 'none', - cron: cron || null + trigger: effectiveTrigger, + cron: effectiveCron, + nextRunAt: effectiveTrigger === 'schedule' ? calculateNextRun(effectiveCron) : null }); await logger.info(`Quality profiles sync config saved for "${instance?.name}"`, { @@ -109,9 +113,12 @@ export const actions: Actions = { try { const selections: ProfileSelection[] = JSON.parse(selectionsJson || '[]'); + const effectiveTrigger = trigger || 'none'; + const effectiveCron = cron || null; arrSyncQueries.saveDelayProfilesSync(id, selections, { - trigger: trigger || 'none', - cron: cron || null + trigger: effectiveTrigger, + cron: effectiveCron, + nextRunAt: effectiveTrigger === 'schedule' ? calculateNextRun(effectiveCron) : null }); await logger.info(`Delay profiles sync config saved for "${instance?.name}"`, { @@ -144,12 +151,15 @@ export const actions: Actions = { const cron = formData.get('cron') as string | null; try { + const effectiveTrigger = trigger || 'none'; + const effectiveCron = cron || null; arrSyncQueries.saveMediaManagementSync(id, { namingDatabaseId: namingDatabaseId ? parseInt(namingDatabaseId, 10) : null, qualityDefinitionsDatabaseId: qualityDefinitionsDatabaseId ? parseInt(qualityDefinitionsDatabaseId, 10) : null, mediaSettingsDatabaseId: mediaSettingsDatabaseId ? parseInt(mediaSettingsDatabaseId, 10) : null, - trigger: trigger || 'none', - cron: cron || null + trigger: effectiveTrigger, + cron: effectiveCron, + nextRunAt: effectiveTrigger === 'schedule' ? calculateNextRun(effectiveCron) : null }); await logger.info(`Media management sync config saved for "${instance?.name}"`, { @@ -216,7 +226,7 @@ export const actions: Actions = { 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 syncer = new QualityProfileSyncer(client, id, instance.name, instance.type as 'radarr' | 'sonarr'); const result = await syncer.sync(); await logger.info(`Manual quality profiles sync completed for "${instance.name}"`, {