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.
This commit is contained in:
Sam Chau
2026-01-15 15:14:54 +10:30
parent 27835c3426
commit f35a01f111
9 changed files with 2173 additions and 49 deletions

View File

@@ -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<string, number> = {
'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<SyncArrType, Record<string, QualityDefinition>> = {
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<SyncArrType, Record<string, LanguageDefinition>> = {
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<SyncArrType, Record<string, string>> = {
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<string, string> = {
'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<SyncArrType, Record<string, string>> = {
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<string, QualityDefinition> {
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);
}

View File

@@ -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<void> {
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<ProcessSyncsResult> {
// 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<ProcessSyncsResult> {
// 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<ProcessSyncsResu
// Sync quality profiles if configured
if (qpConfig.config.trigger !== 'none' && qpConfig.selections.length > 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();
}

View File

@@ -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<number, PcdCustomFormat>; // 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<typeof BaseSyncer>[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<unknown[]> {
/**
* Override sync to handle the complex quality profile sync flow
*/
override async sync(): Promise<SyncResult> {
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<SyncBatch> {
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<number, PcdCustomFormat>();
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<number, PcdCustomFormat>
): Promise<Map<string, number>> {
// 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<string, number>();
for (const format of refreshedFormats) {
formatIdMap.set(format.name, format.id!);
}
return formatIdMap;
}
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)
/**
* Sync quality profiles to arr instance
* Returns array of synced profile summaries for logging
*/
private async syncQualityProfiles(
profiles: ProfileSyncData[],
formatIdMap: Map<string, number>,
qualityMappings: Map<string, string>
): Promise<SyncedProfileSummary[]> {
// 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<string, unknown> {
const details: Record<string, unknown> = {
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<string, unknown>;
// 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<unknown[]> {
return [];
}
protected transformToArr(_pcdData: unknown[]): unknown[] {
return [];
}
protected async pushToArr(_arrData: unknown[]): Promise<void> {
// Not used - logic is in sync()
}
}

View File

@@ -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<string, string> = {
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<PcdCustomFormat | null> {
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<number, { id: number; pattern: string }[]>();
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<number, { id: number; name: string; except: boolean }[]>();
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<number, string[]>();
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<number, string[]>();
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<number, string[]>();
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<number, string[]>();
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<number, string[]>();
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<number, { minBytes: number | null; maxBytes: number | null }>();
for (const s of sizes) {
sizesMap.set(s.custom_format_condition_id, {
minBytes: s.min_bytes,
maxBytes: s.max_bytes
});
}
const yearsMap = new Map<number, { minYear: number | null; maxYear: number | null }>();
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<PcdCustomFormat[]> {
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<number, { id: number; pattern: string }[]>();
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<number, { id: number; name: string; except: boolean }[]>();
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<number, string[]>();
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<number, string[]>();
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<number, string[]>();
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<number, string[]>();
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<number, string[]>();
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<number, { minBytes: number | null; maxBytes: number | null }>();
for (const s of sizes) {
sizesMap.set(s.custom_format_condition_id, {
minBytes: s.min_bytes,
maxBytes: s.max_bytes
});
}
const yearsMap = new Map<number, { minYear: number | null; maxYear: number | null }>();
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<number, PcdCondition[]>();
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) || []
}));
}

View File

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

View File

@@ -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<string, string>,
formatIdMap: Map<string, number>
): ArrQualityProfile {
const allQualities = getAllQualities(arrType);
// Build quality items
const items: ArrQualityItem[] = [];
const usedQualityNames = new Set<string>();
const qualityIdsInGroups = new Set<number>();
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<string>();
// 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<PcdQualityProfile | null> {
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<number, { id: number; name: string; members: { id: number; name: string }[] }>();
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<number, PcdCustomFormatScore>();
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<Map<string, string>> {
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<string, string>();
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<number[]> {
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))];
}

View File

@@ -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<ArrDelayProfile[]> {
getDelayProfiles(): Promise<ArrDelayProfile[]> {
return this.get<ArrDelayProfile[]>(`/api/${this.apiVersion}/delayprofile`);
}
/**
* Get a delay profile by ID
*/
async getDelayProfile(id: number): Promise<ArrDelayProfile> {
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> {
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> {
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}`);
deleteDelayProfile(id: number): Promise<void> {
return this.delete(`/api/${this.apiVersion}/delayprofile/${id}`);
}
// =========================================================================
@@ -96,14 +106,14 @@ export class BaseArrClient extends BaseHttpClient {
/**
* Get all tags
*/
async getTags(): Promise<ArrTag[]> {
getTags(): Promise<ArrTag[]> {
return this.get<ArrTag[]>(`/api/${this.apiVersion}/tag`);
}
/**
* Create a new tag
*/
async createTag(label: string): Promise<ArrTag> {
createTag(label: string): Promise<ArrTag> {
return this.post<ArrTag>(`/api/${this.apiVersion}/tag`, { label });
}
@@ -114,7 +124,7 @@ export class BaseArrClient extends BaseHttpClient {
/**
* Get media management config
*/
async getMediaManagementConfig(): Promise<ArrMediaManagementConfig> {
getMediaManagementConfig(): Promise<ArrMediaManagementConfig> {
return this.get<ArrMediaManagementConfig>(`/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<ArrMediaManagementConfig> {
updateMediaManagementConfig(config: ArrMediaManagementConfig): Promise<ArrMediaManagementConfig> {
return this.put<ArrMediaManagementConfig>(
`/api/${this.apiVersion}/config/mediamanagement/${config.id}`,
config
@@ -136,7 +146,7 @@ export class BaseArrClient extends BaseHttpClient {
/**
* Get naming config
*/
async getNamingConfig(): Promise<ArrNamingConfig> {
getNamingConfig(): Promise<ArrNamingConfig> {
return this.get<ArrNamingConfig>(`/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<ArrNamingConfig> {
updateNamingConfig(config: ArrNamingConfig): Promise<ArrNamingConfig> {
return this.put<ArrNamingConfig>(
`/api/${this.apiVersion}/config/naming/${config.id}`,
config
@@ -158,7 +168,7 @@ export class BaseArrClient extends BaseHttpClient {
/**
* Get all quality definitions
*/
async getQualityDefinitions(): Promise<ArrQualityDefinition[]> {
getQualityDefinitions(): Promise<ArrQualityDefinition[]> {
return this.get<ArrQualityDefinition[]>(`/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<ArrQualityDefinition[]> {
updateQualityDefinitions(definitions: ArrQualityDefinition[]): Promise<ArrQualityDefinition[]> {
return this.put<ArrQualityDefinition[]>(
`/api/${this.apiVersion}/qualitydefinition/update`,
definitions
);
}
// =========================================================================
// Custom Formats
// =========================================================================
/**
* Get all custom formats
*/
getCustomFormats(): Promise<ArrCustomFormat[]> {
return this.get<ArrCustomFormat[]>(`/api/${this.apiVersion}/customformat`);
}
/**
* Get a custom format by ID
*/
getCustomFormat(id: number): Promise<ArrCustomFormat> {
return this.get<ArrCustomFormat>(`/api/${this.apiVersion}/customformat/${id}`);
}
/**
* Create a new custom format
*/
createCustomFormat(format: Omit<ArrCustomFormat, 'id'>): Promise<ArrCustomFormat> {
return this.post<ArrCustomFormat>(`/api/${this.apiVersion}/customformat`, format);
}
/**
* Update an existing custom format
*/
updateCustomFormat(id: number, format: ArrCustomFormat): Promise<ArrCustomFormat> {
return this.put<ArrCustomFormat>(`/api/${this.apiVersion}/customformat/${id}`, format);
}
/**
* Delete a custom format
*/
deleteCustomFormat(id: number): Promise<void> {
return this.delete(`/api/${this.apiVersion}/customformat/${id}`);
}
// =========================================================================
// Quality Profiles
// =========================================================================
/**
* Get all quality profiles
*/
getQualityProfiles(): Promise<RadarrQualityProfile[]> {
return this.get<RadarrQualityProfile[]>(`/api/${this.apiVersion}/qualityprofile`);
}
/**
* Get a quality profile by ID
*/
getQualityProfile(id: number): Promise<RadarrQualityProfile> {
return this.get<RadarrQualityProfile>(`/api/${this.apiVersion}/qualityprofile/${id}`);
}
/**
* Create a new quality profile
*/
createQualityProfile(profile: ArrQualityProfilePayload): Promise<RadarrQualityProfile> {
return this.post<RadarrQualityProfile>(`/api/${this.apiVersion}/qualityprofile`, profile);
}
/**
* Update an existing quality profile
*/
updateQualityProfile(id: number, profile: ArrQualityProfilePayload): Promise<RadarrQualityProfile> {
return this.put<RadarrQualityProfile>(`/api/${this.apiVersion}/qualityprofile/${id}`, profile);
}
/**
* Delete a quality profile
*/
deleteQualityProfile(id: number): Promise<void> {
return this.delete(`/api/${this.apiVersion}/qualityprofile/${id}`);
}
}

View File

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

View File

@@ -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}"`, {