mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
471
src/lib/server/sync/mappings.ts
Normal file
471
src/lib/server/sync/mappings.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
614
src/lib/server/sync/transformers/customFormat.ts
Normal file
614
src/lib/server/sync/transformers/customFormat.ts
Normal 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) || []
|
||||
}));
|
||||
}
|
||||
28
src/lib/server/sync/transformers/index.ts
Normal file
28
src/lib/server/sync/transformers/index.ts
Normal 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';
|
||||
432
src/lib/server/sync/transformers/qualityProfile.ts
Normal file
432
src/lib/server/sync/transformers/qualityProfile.ts
Normal 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))];
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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}"`, {
|
||||
|
||||
Reference in New Issue
Block a user