- |
+ |
diff --git a/src/lib/server/pcd/queries/customFormats/conditions.ts b/src/lib/server/pcd/queries/customFormats/conditions.ts
new file mode 100644
index 0000000..0a1e3fe
--- /dev/null
+++ b/src/lib/server/pcd/queries/customFormats/conditions.ts
@@ -0,0 +1,209 @@
+/**
+ * Custom format condition queries for test evaluation
+ */
+
+import type { PCDCache } from '../../cache.ts';
+
+/** Full condition data for evaluation */
+export interface ConditionData {
+ id: number;
+ name: string;
+ type: string;
+ 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 };
+}
+
+/**
+ * Get all conditions for a custom format with full data for evaluation
+ */
+export async function getConditionsForEvaluation(
+ cache: PCDCache,
+ formatId: number
+): Promise {
+ const db = cache.kb;
+
+ // Get base conditions
+ const conditions = await db
+ .selectFrom('custom_format_conditions')
+ .select(['id', 'name', 'type', 'negate', 'required'])
+ .where('custom_format_id', '=', formatId)
+ .execute();
+
+ if (conditions.length === 0) return [];
+
+ const conditionIds = conditions.map((c) => c.id);
+
+ // Get all related data in parallel
+ const [patterns, languages, sources, resolutions, qualityModifiers, releaseTypes, indexerFlags, sizes, years] =
+ await Promise.all([
+ // Patterns with regex
+ 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(),
+
+ // Languages
+ 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(),
+
+ // Sources
+ db
+ .selectFrom('condition_sources')
+ .select(['custom_format_condition_id', 'source'])
+ .where('custom_format_condition_id', 'in', conditionIds)
+ .execute(),
+
+ // Resolutions
+ db
+ .selectFrom('condition_resolutions')
+ .select(['custom_format_condition_id', 'resolution'])
+ .where('custom_format_condition_id', 'in', conditionIds)
+ .execute(),
+
+ // Quality modifiers
+ db
+ .selectFrom('condition_quality_modifiers')
+ .select(['custom_format_condition_id', 'quality_modifier'])
+ .where('custom_format_condition_id', 'in', conditionIds)
+ .execute(),
+
+ // Release types
+ db
+ .selectFrom('condition_release_types')
+ .select(['custom_format_condition_id', 'release_type'])
+ .where('custom_format_condition_id', 'in', conditionIds)
+ .execute(),
+
+ // Indexer flags
+ db
+ .selectFrom('condition_indexer_flags')
+ .select(['custom_format_condition_id', 'flag'])
+ .where('custom_format_condition_id', 'in', conditionIds)
+ .execute(),
+
+ // Sizes
+ db
+ .selectFrom('condition_sizes')
+ .select(['custom_format_condition_id', 'min_bytes', 'max_bytes'])
+ .where('custom_format_condition_id', 'in', conditionIds)
+ .execute(),
+
+ // Years
+ db
+ .selectFrom('condition_years')
+ .select(['custom_format_condition_id', 'min_year', 'max_year'])
+ .where('custom_format_condition_id', 'in', conditionIds)
+ .execute()
+ ]);
+
+ // Build lookup maps
+ const patternsMap = new Map();
+ for (const p of patterns) {
+ if (!patternsMap.has(p.custom_format_condition_id)) {
+ patternsMap.set(p.custom_format_condition_id, []);
+ }
+ patternsMap.get(p.custom_format_condition_id)!.push({ id: p.id, pattern: p.pattern });
+ }
+
+ const languagesMap = new Map();
+ for (const l of languages) {
+ if (!languagesMap.has(l.custom_format_condition_id)) {
+ languagesMap.set(l.custom_format_condition_id, []);
+ }
+ languagesMap.get(l.custom_format_condition_id)!.push({
+ id: l.id,
+ name: l.name,
+ except: l.except_language === 1
+ });
+ }
+
+ const sourcesMap = new Map();
+ for (const s of sources) {
+ if (!sourcesMap.has(s.custom_format_condition_id)) {
+ sourcesMap.set(s.custom_format_condition_id, []);
+ }
+ sourcesMap.get(s.custom_format_condition_id)!.push(s.source);
+ }
+
+ const resolutionsMap = new Map();
+ for (const r of resolutions) {
+ if (!resolutionsMap.has(r.custom_format_condition_id)) {
+ resolutionsMap.set(r.custom_format_condition_id, []);
+ }
+ resolutionsMap.get(r.custom_format_condition_id)!.push(r.resolution);
+ }
+
+ const qualityModifiersMap = new Map();
+ for (const q of qualityModifiers) {
+ if (!qualityModifiersMap.has(q.custom_format_condition_id)) {
+ qualityModifiersMap.set(q.custom_format_condition_id, []);
+ }
+ qualityModifiersMap.get(q.custom_format_condition_id)!.push(q.quality_modifier);
+ }
+
+ const releaseTypesMap = new Map();
+ for (const r of releaseTypes) {
+ if (!releaseTypesMap.has(r.custom_format_condition_id)) {
+ releaseTypesMap.set(r.custom_format_condition_id, []);
+ }
+ releaseTypesMap.get(r.custom_format_condition_id)!.push(r.release_type);
+ }
+
+ const indexerFlagsMap = new Map();
+ for (const f of indexerFlags) {
+ if (!indexerFlagsMap.has(f.custom_format_condition_id)) {
+ indexerFlagsMap.set(f.custom_format_condition_id, []);
+ }
+ indexerFlagsMap.get(f.custom_format_condition_id)!.push(f.flag);
+ }
+
+ const sizesMap = new Map();
+ for (const s of sizes) {
+ sizesMap.set(s.custom_format_condition_id, {
+ minBytes: s.min_bytes,
+ maxBytes: s.max_bytes
+ });
+ }
+
+ const yearsMap = new Map();
+ for (const y of years) {
+ yearsMap.set(y.custom_format_condition_id, {
+ minYear: y.min_year,
+ maxYear: y.max_year
+ });
+ }
+
+ // Build final result
+ return conditions.map((c) => ({
+ id: c.id,
+ name: c.name,
+ type: c.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)
+ }));
+}
diff --git a/src/lib/server/pcd/queries/customFormats/evaluator.ts b/src/lib/server/pcd/queries/customFormats/evaluator.ts
new file mode 100644
index 0000000..169fc6c
--- /dev/null
+++ b/src/lib/server/pcd/queries/customFormats/evaluator.ts
@@ -0,0 +1,473 @@
+/**
+ * Custom format condition evaluator
+ * Evaluates conditions against parsed release titles
+ */
+
+import { logger } from '$logger/logger.ts';
+import type { ParseResult } from '$lib/server/utils/arr/parser/types.ts';
+import { QualitySource, QualityModifier, Resolution, ReleaseType, Language } from '$lib/server/utils/arr/parser/types.ts';
+import type { ConditionData } from './conditions.ts';
+
+export interface ConditionResult {
+ conditionId: number;
+ conditionName: string;
+ conditionType: string;
+ matched: boolean;
+ required: boolean;
+ negate: boolean;
+ /** Final result after applying negate */
+ passes: boolean;
+ /** What the condition expected */
+ expected: string;
+ /** What was actually found in the parsed title */
+ actual: string;
+}
+
+export interface EvaluationResult {
+ /** Whether the custom format matches overall */
+ matches: boolean;
+ /** Individual condition results */
+ conditions: ConditionResult[];
+}
+
+/** Serializable parsed info for frontend display */
+export interface ParsedInfo {
+ source: string;
+ resolution: string;
+ modifier: string;
+ languages: string[];
+ releaseGroup: string | null;
+ year: number;
+ edition: string | null;
+ releaseType: string | null;
+}
+
+// Name mappings
+const sourceNames: Record = {
+ [QualitySource.Unknown]: 'Unknown',
+ [QualitySource.Cam]: 'Cam',
+ [QualitySource.Telesync]: 'Telesync',
+ [QualitySource.Telecine]: 'Telecine',
+ [QualitySource.Workprint]: 'Workprint',
+ [QualitySource.DVD]: 'DVD',
+ [QualitySource.TV]: 'TV',
+ [QualitySource.WebDL]: 'WebDL',
+ [QualitySource.WebRip]: 'WebRip',
+ [QualitySource.Bluray]: 'Bluray'
+};
+
+const resolutionNames: Record = {
+ [Resolution.Unknown]: 'Unknown',
+ [Resolution.R360p]: '360p',
+ [Resolution.R480p]: '480p',
+ [Resolution.R540p]: '540p',
+ [Resolution.R576p]: '576p',
+ [Resolution.R720p]: '720p',
+ [Resolution.R1080p]: '1080p',
+ [Resolution.R2160p]: '2160p'
+};
+
+const modifierNames: Record = {
+ [QualityModifier.None]: 'None',
+ [QualityModifier.Regional]: 'Regional',
+ [QualityModifier.Screener]: 'Screener',
+ [QualityModifier.RawHD]: 'RawHD',
+ [QualityModifier.BRDisk]: 'BRDisk',
+ [QualityModifier.Remux]: 'Remux'
+};
+
+const releaseTypeNames: Record = {
+ [ReleaseType.Unknown]: 'Unknown',
+ [ReleaseType.SingleEpisode]: 'SingleEpisode',
+ [ReleaseType.MultiEpisode]: 'MultiEpisode',
+ [ReleaseType.SeasonPack]: 'SeasonPack'
+};
+
+const languageNames: Record = {
+ [Language.Unknown]: 'Unknown',
+ [Language.English]: 'English',
+ [Language.French]: 'French',
+ [Language.Spanish]: 'Spanish',
+ [Language.German]: 'German',
+ [Language.Italian]: 'Italian',
+ [Language.Danish]: 'Danish',
+ [Language.Dutch]: 'Dutch',
+ [Language.Japanese]: 'Japanese',
+ [Language.Icelandic]: 'Icelandic',
+ [Language.Chinese]: 'Chinese',
+ [Language.Russian]: 'Russian',
+ [Language.Polish]: 'Polish',
+ [Language.Vietnamese]: 'Vietnamese',
+ [Language.Swedish]: 'Swedish',
+ [Language.Norwegian]: 'Norwegian',
+ [Language.Finnish]: 'Finnish',
+ [Language.Turkish]: 'Turkish',
+ [Language.Portuguese]: 'Portuguese',
+ [Language.Flemish]: 'Flemish',
+ [Language.Greek]: 'Greek',
+ [Language.Korean]: 'Korean',
+ [Language.Hungarian]: 'Hungarian',
+ [Language.Hebrew]: 'Hebrew',
+ [Language.Lithuanian]: 'Lithuanian',
+ [Language.Czech]: 'Czech',
+ [Language.Hindi]: 'Hindi',
+ [Language.Romanian]: 'Romanian',
+ [Language.Thai]: 'Thai',
+ [Language.Bulgarian]: 'Bulgarian',
+ [Language.PortugueseBR]: 'Portuguese (BR)',
+ [Language.Arabic]: 'Arabic',
+ [Language.Ukrainian]: 'Ukrainian',
+ [Language.Persian]: 'Persian',
+ [Language.Bengali]: 'Bengali',
+ [Language.Slovak]: 'Slovak',
+ [Language.Latvian]: 'Latvian',
+ [Language.SpanishLatino]: 'Spanish (Latino)',
+ [Language.Catalan]: 'Catalan',
+ [Language.Croatian]: 'Croatian',
+ [Language.Serbian]: 'Serbian',
+ [Language.Bosnian]: 'Bosnian',
+ [Language.Estonian]: 'Estonian',
+ [Language.Tamil]: 'Tamil',
+ [Language.Indonesian]: 'Indonesian',
+ [Language.Telugu]: 'Telugu',
+ [Language.Macedonian]: 'Macedonian',
+ [Language.Slovenian]: 'Slovenian',
+ [Language.Malayalam]: 'Malayalam',
+ [Language.Kannada]: 'Kannada',
+ [Language.Albanian]: 'Albanian',
+ [Language.Afrikaans]: 'Afrikaans',
+ [Language.Marathi]: 'Marathi',
+ [Language.Tagalog]: 'Tagalog',
+ [Language.Urdu]: 'Urdu',
+ [Language.Romansh]: 'Romansh',
+ [Language.Mongolian]: 'Mongolian',
+ [Language.Georgian]: 'Georgian',
+ [Language.Original]: 'Original'
+};
+
+/**
+ * Get serializable parsed info for frontend display
+ */
+export function getParsedInfo(parsed: ParseResult): ParsedInfo {
+ return {
+ source: sourceNames[parsed.source] || 'Unknown',
+ resolution: resolutionNames[parsed.resolution] || 'Unknown',
+ modifier: modifierNames[parsed.modifier] || 'None',
+ languages: parsed.languages.map((l) => languageNames[l] || 'Unknown'),
+ releaseGroup: parsed.releaseGroup,
+ year: parsed.year,
+ edition: parsed.edition,
+ releaseType: parsed.episode ? releaseTypeNames[parsed.episode.releaseType] || 'Unknown' : null
+ };
+}
+
+interface ConditionEvalResult {
+ matched: boolean;
+ expected: string;
+ actual: string;
+}
+
+/**
+ * Evaluate a single condition against parsed result
+ */
+function evaluateCondition(
+ condition: ConditionData,
+ parsed: ParseResult,
+ title: string
+): ConditionEvalResult {
+ switch (condition.type) {
+ case 'release_title':
+ return evaluatePattern(condition, title);
+
+ case 'language':
+ return evaluateLanguage(condition, parsed);
+
+ case 'source':
+ return evaluateSource(condition, parsed);
+
+ case 'resolution':
+ return evaluateResolution(condition, parsed);
+
+ case 'quality_modifier':
+ return evaluateQualityModifier(condition, parsed);
+
+ case 'release_type':
+ return evaluateReleaseType(condition, parsed);
+
+ case 'year':
+ return evaluateYear(condition, parsed);
+
+ case 'edition':
+ return evaluateEdition(condition, parsed, title);
+
+ case 'release_group':
+ return evaluateReleaseGroup(condition, parsed, title);
+
+ // These require additional data we don't have
+ case 'indexer_flag':
+ return { matched: false, expected: 'Indexer flags', actual: 'N/A (no indexer data)' };
+ case 'size':
+ return { matched: false, expected: 'File size range', actual: 'N/A (no file data)' };
+
+ default:
+ logger.warn(`Unknown condition type: ${condition.type}`);
+ return { matched: false, expected: 'Unknown', actual: 'Unknown' };
+ }
+}
+
+/**
+ * Evaluate regex pattern against title
+ */
+function evaluatePattern(condition: ConditionData, title: string): ConditionEvalResult {
+ if (!condition.patterns || condition.patterns.length === 0) {
+ return { matched: false, expected: 'No patterns defined', actual: title };
+ }
+
+ const patternStrs = condition.patterns.map((p) => p.pattern);
+ const expected = patternStrs.join(' OR ');
+
+ for (const pattern of condition.patterns) {
+ try {
+ const regex = new RegExp(pattern.pattern, 'i');
+ if (regex.test(title)) {
+ return { matched: true, expected, actual: `Matched: ${pattern.pattern}` };
+ }
+ } catch (e) {
+ logger.warn(`Invalid regex pattern: ${pattern.pattern}`, e);
+ }
+ }
+ return { matched: false, expected, actual: 'No match' };
+}
+
+/**
+ * Evaluate language condition
+ */
+function evaluateLanguage(condition: ConditionData, parsed: ParseResult): ConditionEvalResult {
+ if (!condition.languages || condition.languages.length === 0) {
+ return { matched: false, expected: 'No languages defined', actual: 'N/A' };
+ }
+
+ const parsedLangNames = parsed.languages.map((l) => languageNames[l] || 'Unknown');
+ const actual = parsedLangNames.length > 0 ? parsedLangNames.join(', ') : 'None detected';
+
+ const expectedParts: string[] = [];
+ for (const lang of condition.languages) {
+ if (lang.except) {
+ expectedParts.push(`NOT ${lang.name}`);
+ } else {
+ expectedParts.push(lang.name);
+ }
+ }
+ const expected = expectedParts.join(' OR ');
+
+ for (const lang of condition.languages) {
+ const langEnum = Language[lang.name as keyof typeof Language];
+ if (langEnum === undefined) continue;
+
+ const hasLanguage = parsed.languages.includes(langEnum);
+
+ if (lang.except) {
+ if (hasLanguage) return { matched: false, expected, actual };
+ } else {
+ if (hasLanguage) return { matched: true, expected, actual };
+ }
+ }
+
+ const onlyExcepts = condition.languages.every((l) => l.except);
+ if (onlyExcepts) return { matched: true, expected, actual };
+
+ return { matched: false, expected, actual };
+}
+
+/**
+ * Evaluate source condition (Bluray, WebDL, etc.)
+ */
+function evaluateSource(condition: ConditionData, parsed: ParseResult): ConditionEvalResult {
+ if (!condition.sources || condition.sources.length === 0) {
+ return { matched: false, expected: 'No sources defined', actual: 'N/A' };
+ }
+
+ const actual = sourceNames[parsed.source] || 'Unknown';
+ const expected = condition.sources.join(' OR ');
+ const matched = condition.sources.some((s) => s.toLowerCase() === actual.toLowerCase());
+
+ return { matched, expected, actual };
+}
+
+/**
+ * Evaluate resolution condition
+ */
+function evaluateResolution(condition: ConditionData, parsed: ParseResult): ConditionEvalResult {
+ if (!condition.resolutions || condition.resolutions.length === 0) {
+ return { matched: false, expected: 'No resolutions defined', actual: 'N/A' };
+ }
+
+ const actual = resolutionNames[parsed.resolution] || 'Unknown';
+ const expected = condition.resolutions.join(' OR ');
+ const matched = condition.resolutions.some((r) => r.toLowerCase() === actual.toLowerCase());
+
+ return { matched, expected, actual };
+}
+
+/**
+ * Evaluate quality modifier condition (Remux, etc.)
+ */
+function evaluateQualityModifier(condition: ConditionData, parsed: ParseResult): ConditionEvalResult {
+ if (!condition.qualityModifiers || condition.qualityModifiers.length === 0) {
+ return { matched: false, expected: 'No modifiers defined', actual: 'N/A' };
+ }
+
+ const actual = modifierNames[parsed.modifier] || 'None';
+ const expected = condition.qualityModifiers.join(' OR ');
+ const matched = condition.qualityModifiers.some((m) => m.toLowerCase() === actual.toLowerCase());
+
+ return { matched, expected, actual };
+}
+
+/**
+ * Evaluate release type condition (SingleEpisode, SeasonPack, etc.)
+ */
+function evaluateReleaseType(condition: ConditionData, parsed: ParseResult): ConditionEvalResult {
+ if (!condition.releaseTypes || condition.releaseTypes.length === 0) {
+ return { matched: false, expected: 'No release types defined', actual: 'N/A' };
+ }
+
+ const expected = condition.releaseTypes.join(' OR ');
+
+ if (!parsed.episode) {
+ return { matched: false, expected, actual: 'N/A (not a series)' };
+ }
+
+ const actual = releaseTypeNames[parsed.episode.releaseType] || 'Unknown';
+ const matched = condition.releaseTypes.some((t) => t.toLowerCase() === actual.toLowerCase());
+
+ return { matched, expected, actual };
+}
+
+/**
+ * Evaluate year condition
+ */
+function evaluateYear(condition: ConditionData, parsed: ParseResult): ConditionEvalResult {
+ if (!condition.years) {
+ return { matched: false, expected: 'No year range defined', actual: 'N/A' };
+ }
+
+ const { minYear, maxYear } = condition.years;
+ const expectedParts: string[] = [];
+ if (minYear !== null) expectedParts.push(`>= ${minYear}`);
+ if (maxYear !== null) expectedParts.push(`<= ${maxYear}`);
+ const expected = expectedParts.join(' AND ') || 'Any year';
+
+ const year = parsed.year;
+ if (!year || year === 0) {
+ return { matched: false, expected, actual: 'No year detected' };
+ }
+
+ const actual = String(year);
+
+ if (minYear !== null && year < minYear) return { matched: false, expected, actual };
+ if (maxYear !== null && year > maxYear) return { matched: false, expected, actual };
+
+ return { matched: true, expected, actual };
+}
+
+/**
+ * Evaluate edition condition (regex on edition or title)
+ */
+function evaluateEdition(condition: ConditionData, parsed: ParseResult, title: string): ConditionEvalResult {
+ if (!condition.patterns || condition.patterns.length === 0) {
+ return { matched: false, expected: 'No patterns defined', actual: 'N/A' };
+ }
+
+ const textToCheck = parsed.edition || title;
+ const actual = parsed.edition || 'None detected';
+ const patternStrs = condition.patterns.map((p) => p.pattern);
+ const expected = patternStrs.join(' OR ');
+
+ for (const pattern of condition.patterns) {
+ try {
+ const regex = new RegExp(pattern.pattern, 'i');
+ if (regex.test(textToCheck)) {
+ return { matched: true, expected, actual };
+ }
+ } catch (e) {
+ logger.warn(`Invalid regex pattern: ${pattern.pattern}`, e);
+ }
+ }
+ return { matched: false, expected, actual };
+}
+
+/**
+ * Evaluate release group condition
+ */
+function evaluateReleaseGroup(condition: ConditionData, parsed: ParseResult, title: string): ConditionEvalResult {
+ if (!condition.patterns || condition.patterns.length === 0) {
+ return { matched: false, expected: 'No patterns defined', actual: 'N/A' };
+ }
+
+ const textToCheck = parsed.releaseGroup || title;
+ const actual = parsed.releaseGroup || 'None detected';
+ const patternStrs = condition.patterns.map((p) => p.pattern);
+ const expected = patternStrs.join(' OR ');
+
+ for (const pattern of condition.patterns) {
+ try {
+ const regex = new RegExp(pattern.pattern, 'i');
+ if (regex.test(textToCheck)) {
+ return { matched: true, expected, actual };
+ }
+ } catch (e) {
+ logger.warn(`Invalid regex pattern: ${pattern.pattern}`, e);
+ }
+ }
+ return { matched: false, expected, actual };
+}
+
+/**
+ * Evaluate all conditions for a custom format against a parsed release
+ *
+ * Custom format matching logic:
+ * - ALL required conditions must pass
+ * - At least ONE non-required condition must pass (if any exist)
+ */
+export function evaluateCustomFormat(
+ conditions: ConditionData[],
+ parsed: ParseResult,
+ title: string
+): EvaluationResult {
+ const results: ConditionResult[] = [];
+
+ for (const condition of conditions) {
+ const evalResult = evaluateCondition(condition, parsed, title);
+ const passes = condition.negate ? !evalResult.matched : evalResult.matched;
+
+ results.push({
+ conditionId: condition.id,
+ conditionName: condition.name,
+ conditionType: condition.type,
+ matched: evalResult.matched,
+ required: condition.required,
+ negate: condition.negate,
+ passes,
+ expected: evalResult.expected,
+ actual: evalResult.actual
+ });
+ }
+
+ // Check if format matches
+ const requiredConditions = results.filter((r) => r.required);
+ const optionalConditions = results.filter((r) => !r.required);
+
+ // All required must pass
+ const allRequiredPass = requiredConditions.every((r) => r.passes);
+
+ // If there are optional conditions, at least one must pass
+ // If there are no optional conditions, only required matter
+ const optionalPass =
+ optionalConditions.length === 0 || optionalConditions.some((r) => r.passes);
+
+ return {
+ matches: allRequiredPass && optionalPass,
+ conditions: results
+ };
+}
diff --git a/src/lib/server/pcd/queries/customFormats/index.ts b/src/lib/server/pcd/queries/customFormats/index.ts
index 6f86c0e..0f329eb 100644
--- a/src/lib/server/pcd/queries/customFormats/index.ts
+++ b/src/lib/server/pcd/queries/customFormats/index.ts
@@ -3,7 +3,20 @@
*/
// Export all types
-export type { CustomFormatTableRow, ConditionRef } from './types.ts';
+export type { CustomFormatTableRow, ConditionRef, CustomFormatBasic, CustomFormatTest } from './types.ts';
+export type { CreateTestInput, CreateTestOptions } from './testCreate.ts';
+export type { UpdateTestInput, UpdateTestOptions } from './testUpdate.ts';
+export type { DeleteTestOptions } from './testDelete.ts';
+export type { ConditionData } from './conditions.ts';
+export type { ConditionResult, EvaluationResult, ParsedInfo } from './evaluator.ts';
-// Export query functions
+// Export query functions (reads)
export { list } from './list.ts';
+export { getById, listTests, getTestById } from './tests.ts';
+export { getConditionsForEvaluation } from './conditions.ts';
+export { evaluateCustomFormat, getParsedInfo } from './evaluator.ts';
+
+// Export mutation functions (writes via PCD operations)
+export { createTest } from './testCreate.ts';
+export { updateTest } from './testUpdate.ts';
+export { deleteTest } from './testDelete.ts';
diff --git a/src/lib/server/pcd/queries/customFormats/testCreate.ts b/src/lib/server/pcd/queries/customFormats/testCreate.ts
new file mode 100644
index 0000000..62bbe0e
--- /dev/null
+++ b/src/lib/server/pcd/queries/customFormats/testCreate.ts
@@ -0,0 +1,57 @@
+/**
+ * Create a custom format test operation
+ */
+
+import { writeOperation, type OperationLayer } from '../../writer.ts';
+
+export interface CreateTestInput {
+ title: string;
+ type: 'movie' | 'series';
+ should_match: boolean;
+ description: string | null;
+}
+
+export interface CreateTestOptions {
+ databaseId: number;
+ layer: OperationLayer;
+ formatName: string;
+ input: CreateTestInput;
+}
+
+/**
+ * Escape a string for SQL
+ */
+function esc(value: string): string {
+ return value.replace(/'/g, "''");
+}
+
+/**
+ * Create a custom format test by writing an operation to the specified layer
+ */
+export async function createTest(options: CreateTestOptions) {
+ const { databaseId, layer, formatName, input } = options;
+
+ // Build raw SQL using cf() helper to resolve custom format by name
+ const descriptionValue = input.description ? `'${esc(input.description)}'` : 'NULL';
+
+ const insertTest = {
+ sql: `INSERT INTO custom_format_tests (custom_format_id, title, type, should_match, description) VALUES (cf('${esc(formatName)}'), '${esc(input.title)}', '${esc(input.type)}', ${input.should_match ? 1 : 0}, ${descriptionValue})`,
+ parameters: [],
+ query: {} as never
+ };
+
+ // Write the operation
+ const result = await writeOperation({
+ databaseId,
+ layer,
+ description: `create-test-${formatName}`,
+ queries: [insertTest],
+ metadata: {
+ operation: 'create',
+ entity: 'custom_format_test',
+ name: `${formatName}: ${input.title.substring(0, 30)}`
+ }
+ });
+
+ return result;
+}
diff --git a/src/lib/server/pcd/queries/customFormats/testDelete.ts b/src/lib/server/pcd/queries/customFormats/testDelete.ts
new file mode 100644
index 0000000..8137635
--- /dev/null
+++ b/src/lib/server/pcd/queries/customFormats/testDelete.ts
@@ -0,0 +1,51 @@
+/**
+ * Delete a custom format test operation
+ */
+
+import { writeOperation, type OperationLayer } from '../../writer.ts';
+import type { CustomFormatTest } from './types.ts';
+
+export interface DeleteTestOptions {
+ databaseId: number;
+ layer: OperationLayer;
+ formatName: string;
+ /** The current test data (for value guards) */
+ current: CustomFormatTest;
+}
+
+/**
+ * Escape a string for SQL
+ */
+function esc(value: string): string {
+ return value.replace(/'/g, "''");
+}
+
+/**
+ * Delete a custom format test by writing an operation to the specified layer
+ * Uses value guards to detect conflicts with upstream changes
+ */
+export async function deleteTest(options: DeleteTestOptions) {
+ const { databaseId, layer, formatName, current } = options;
+
+ // Delete with value guards to ensure we're deleting the expected record
+ const deleteTestQuery = {
+ sql: `DELETE FROM custom_format_tests WHERE custom_format_id = cf('${esc(formatName)}') AND title = '${esc(current.title)}' AND type = '${esc(current.type)}'`,
+ parameters: [],
+ query: {} as never
+ };
+
+ // Write the operation
+ const result = await writeOperation({
+ databaseId,
+ layer,
+ description: `delete-test-${formatName}`,
+ queries: [deleteTestQuery],
+ metadata: {
+ operation: 'delete',
+ entity: 'custom_format_test',
+ name: `${formatName}: ${current.title.substring(0, 30)}`
+ }
+ });
+
+ return result;
+}
diff --git a/src/lib/server/pcd/queries/customFormats/testUpdate.ts b/src/lib/server/pcd/queries/customFormats/testUpdate.ts
new file mode 100644
index 0000000..db1040d
--- /dev/null
+++ b/src/lib/server/pcd/queries/customFormats/testUpdate.ts
@@ -0,0 +1,67 @@
+/**
+ * Update a custom format test operation
+ */
+
+import { writeOperation, type OperationLayer } from '../../writer.ts';
+import type { CustomFormatTest } from './types.ts';
+
+export interface UpdateTestInput {
+ title: string;
+ type: 'movie' | 'series';
+ should_match: boolean;
+ description: string | null;
+}
+
+export interface UpdateTestOptions {
+ databaseId: number;
+ layer: OperationLayer;
+ formatName: string;
+ /** The current test data (for value guards) */
+ current: CustomFormatTest;
+ /** The new values */
+ input: UpdateTestInput;
+}
+
+/**
+ * Escape a string for SQL
+ */
+function esc(value: string): string {
+ return value.replace(/'/g, "''");
+}
+
+/**
+ * Update a custom format test by writing an operation to the specified layer
+ * Uses value guards to detect conflicts with upstream changes
+ */
+export async function updateTest(options: UpdateTestOptions) {
+ const { databaseId, layer, formatName, current, input } = options;
+
+ const descriptionValue = input.description ? `'${esc(input.description)}'` : 'NULL';
+
+ // Update with value guards on the current values
+ // We match on id AND verify the current values haven't changed
+ const updateTest = {
+ sql: `UPDATE custom_format_tests SET title = '${esc(input.title)}', type = '${esc(input.type)}', should_match = ${input.should_match ? 1 : 0}, description = ${descriptionValue} WHERE custom_format_id = cf('${esc(formatName)}') AND title = '${esc(current.title)}' AND type = '${esc(current.type)}'`,
+ parameters: [],
+ query: {} as never
+ };
+
+ // Track if title changed for metadata
+ const isTitleChange = input.title !== current.title;
+
+ // Write the operation
+ const result = await writeOperation({
+ databaseId,
+ layer,
+ description: `update-test-${formatName}`,
+ queries: [updateTest],
+ metadata: {
+ operation: 'update',
+ entity: 'custom_format_test',
+ name: `${formatName}: ${input.title.substring(0, 30)}`,
+ ...(isTitleChange && { previousName: `${formatName}: ${current.title.substring(0, 30)}` })
+ }
+ });
+
+ return result;
+}
diff --git a/src/lib/server/pcd/queries/customFormats/tests.ts b/src/lib/server/pcd/queries/customFormats/tests.ts
new file mode 100644
index 0000000..ec18944
--- /dev/null
+++ b/src/lib/server/pcd/queries/customFormats/tests.ts
@@ -0,0 +1,60 @@
+/**
+ * Custom format test read queries
+ */
+
+import type { PCDCache } from '../../cache.ts';
+import type { CustomFormatBasic, CustomFormatTest } from './types.ts';
+
+/**
+ * Get custom format basic info by ID
+ */
+export async function getById(cache: PCDCache, formatId: number): Promise {
+ const db = cache.kb;
+
+ const format = await db
+ .selectFrom('custom_formats')
+ .select(['id', 'name', 'description'])
+ .where('id', '=', formatId)
+ .executeTakeFirst();
+
+ return format ?? null;
+}
+
+/**
+ * Get all tests for a custom format
+ */
+export async function listTests(cache: PCDCache, formatId: number): Promise {
+ const db = cache.kb;
+
+ const tests = await db
+ .selectFrom('custom_format_tests')
+ .select(['id', 'title', 'type', 'should_match', 'description'])
+ .where('custom_format_id', '=', formatId)
+ .orderBy('id')
+ .execute();
+
+ return tests.map((test) => ({
+ ...test,
+ should_match: test.should_match === 1
+ }));
+}
+
+/**
+ * Get a single test by ID
+ */
+export async function getTestById(cache: PCDCache, testId: number): Promise {
+ const db = cache.kb;
+
+ const test = await db
+ .selectFrom('custom_format_tests')
+ .select(['id', 'title', 'type', 'should_match', 'description'])
+ .where('id', '=', testId)
+ .executeTakeFirst();
+
+ if (!test) return null;
+
+ return {
+ ...test,
+ should_match: test.should_match === 1
+ };
+}
diff --git a/src/lib/server/pcd/queries/customFormats/types.ts b/src/lib/server/pcd/queries/customFormats/types.ts
index f260fa3..a0a582d 100644
--- a/src/lib/server/pcd/queries/customFormats/types.ts
+++ b/src/lib/server/pcd/queries/customFormats/types.ts
@@ -20,3 +20,19 @@ export interface CustomFormatTableRow {
tags: Tag[];
conditions: ConditionRef[];
}
+
+/** Custom format basic info */
+export interface CustomFormatBasic {
+ id: number;
+ name: string;
+ description: string | null;
+}
+
+/** Custom format test case */
+export interface CustomFormatTest {
+ id: number;
+ title: string;
+ type: string;
+ should_match: boolean;
+ description: string | null;
+}
diff --git a/src/lib/server/pcd/schema.ts b/src/lib/server/pcd/schema.ts
index 3695bcd..9bf70f0 100644
--- a/src/lib/server/pcd/schema.ts
+++ b/src/lib/server/pcd/schema.ts
@@ -189,6 +189,20 @@ export interface ConditionYearsTable {
max_year: number | null;
}
+// ============================================================================
+// CUSTOM FORMAT TESTING
+// ============================================================================
+
+export interface CustomFormatTestsTable {
+ id: Generated;
+ custom_format_id: number;
+ title: string;
+ type: string;
+ should_match: number;
+ description: string | null;
+ created_at: Generated;
+}
+
// ============================================================================
// DELAY PROFILES
// ============================================================================
@@ -305,6 +319,7 @@ export interface PCDDatabase {
condition_sizes: ConditionSizesTable;
condition_release_types: ConditionReleaseTypesTable;
condition_years: ConditionYearsTable;
+ custom_format_tests: CustomFormatTestsTable;
delay_profiles: DelayProfilesTable;
delay_profile_tags: DelayProfileTagsTable;
quality_api_mappings: QualityApiMappingsTable;
diff --git a/src/routes/api/regex101/[id]/+server.ts b/src/routes/api/regex101/[id]/+server.ts
index 26c779e..c05ddf2 100644
--- a/src/routes/api/regex101/[id]/+server.ts
+++ b/src/routes/api/regex101/[id]/+server.ts
@@ -100,6 +100,11 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
return json(JSON.parse(cached.response));
}
+ await logger.debug('regex101 cache miss', {
+ source: 'Regex101API',
+ meta: { id }
+ });
+
// Handle ID with optional version (e.g., "ABC123" or "ABC123/1")
const [regexId, version] = id.split('/');
diff --git a/src/routes/custom-formats/[databaseId]/[id]/+layout.svelte b/src/routes/custom-formats/[databaseId]/[id]/+layout.svelte
new file mode 100644
index 0000000..2c9a4a3
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/+layout.svelte
@@ -0,0 +1,34 @@
+
+
+
+
+
+
diff --git a/src/routes/custom-formats/[databaseId]/[id]/+page.server.ts b/src/routes/custom-formats/[databaseId]/[id]/+page.server.ts
new file mode 100644
index 0000000..98950cd
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/+page.server.ts
@@ -0,0 +1,7 @@
+import { redirect } from '@sveltejs/kit';
+import type { ServerLoad } from '@sveltejs/kit';
+
+export const load: ServerLoad = async ({ params }) => {
+ // Redirect to the general tab by default
+ throw redirect(303, `/custom-formats/${params.databaseId}/${params.id}/general`);
+};
diff --git a/src/routes/custom-formats/[databaseId]/[id]/general/+page.server.ts b/src/routes/custom-formats/[databaseId]/[id]/general/+page.server.ts
new file mode 100644
index 0000000..140d222
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/general/+page.server.ts
@@ -0,0 +1,41 @@
+import { error } from '@sveltejs/kit';
+import type { ServerLoad } from '@sveltejs/kit';
+import { pcdManager } from '$pcd/pcd.ts';
+import * as customFormatQueries from '$pcd/queries/customFormats/index.ts';
+
+export const load: ServerLoad = async ({ params }) => {
+ const { databaseId, id } = params;
+
+ // Validate params exist
+ if (!databaseId || !id) {
+ throw error(400, 'Missing required parameters');
+ }
+
+ // Parse and validate the database ID
+ const currentDatabaseId = parseInt(databaseId, 10);
+ if (isNaN(currentDatabaseId)) {
+ throw error(400, 'Invalid database ID');
+ }
+
+ // Parse and validate the format ID
+ const formatId = parseInt(id, 10);
+ if (isNaN(formatId)) {
+ throw error(400, 'Invalid format ID');
+ }
+
+ // Get the cache for the database
+ const cache = pcdManager.getCache(currentDatabaseId);
+ if (!cache) {
+ throw error(500, 'Database cache not available');
+ }
+
+ // Get custom format basic info
+ const format = await customFormatQueries.getById(cache, formatId);
+ if (!format) {
+ throw error(404, 'Custom format not found');
+ }
+
+ return {
+ format
+ };
+};
diff --git a/src/routes/custom-formats/[databaseId]/[id]/general/+page.svelte b/src/routes/custom-formats/[databaseId]/[id]/general/+page.svelte
new file mode 100644
index 0000000..e1893f4
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/general/+page.svelte
@@ -0,0 +1,15 @@
+
+
+
+ {data.format.name} - General - Profilarr
+
+
+
+
+ General tab placeholder - conditions and settings will go here.
+
+
diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/+page.server.ts b/src/routes/custom-formats/[databaseId]/[id]/testing/+page.server.ts
new file mode 100644
index 0000000..7ac905c
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/testing/+page.server.ts
@@ -0,0 +1,192 @@
+import { error, fail } from '@sveltejs/kit';
+import type { ServerLoad, Actions } from '@sveltejs/kit';
+import { pcdManager } from '$pcd/pcd.ts';
+import { canWriteToBase, type OperationLayer } from '$pcd/writer.ts';
+import * as customFormatQueries from '$pcd/queries/customFormats/index.ts';
+import type { ConditionResult, ParsedInfo } from '$pcd/queries/customFormats/index.ts';
+import { parse, isParserHealthy } from '$lib/server/utils/arr/parser/client.ts';
+import type { MediaType } from '$lib/server/utils/arr/parser/types.ts';
+
+export type TestResult = 'pass' | 'fail' | 'unknown';
+
+export interface TestWithResult {
+ id: number;
+ title: string;
+ type: string;
+ should_match: boolean;
+ description: string | null;
+ /** Whether the format actually matched */
+ actual_match: boolean | null;
+ /** Test result: pass if actual matches expected, fail if not, unknown if parser unavailable */
+ result: TestResult;
+ /** Parsed info from the title */
+ parsed: ParsedInfo | null;
+ /** Condition evaluation results */
+ conditions: ConditionResult[];
+}
+
+export const load: ServerLoad = async ({ params }) => {
+ const { databaseId, id } = params;
+
+ // Validate params exist
+ if (!databaseId || !id) {
+ throw error(400, 'Missing required parameters');
+ }
+
+ // Parse and validate the database ID
+ const currentDatabaseId = parseInt(databaseId, 10);
+ if (isNaN(currentDatabaseId)) {
+ throw error(400, 'Invalid database ID');
+ }
+
+ // Parse and validate the format ID
+ const formatId = parseInt(id, 10);
+ if (isNaN(formatId)) {
+ throw error(400, 'Invalid format ID');
+ }
+
+ // Get the cache for the database
+ const cache = pcdManager.getCache(currentDatabaseId);
+ if (!cache) {
+ throw error(500, 'Database cache not available');
+ }
+
+ // Get custom format basic info
+ const format = await customFormatQueries.getById(cache, formatId);
+ if (!format) {
+ throw error(404, 'Custom format not found');
+ }
+
+ // Get tests for this custom format
+ const tests = await customFormatQueries.listTests(cache, formatId);
+
+ // Check if parser is available
+ const parserAvailable = await isParserHealthy();
+
+ // If no parser or no tests, return early
+ if (!parserAvailable || tests.length === 0) {
+ const testsWithResults: TestWithResult[] = tests.map((test) => ({
+ ...test,
+ actual_match: null,
+ result: 'unknown' as TestResult,
+ parsed: null,
+ conditions: []
+ }));
+
+ return {
+ format,
+ tests: testsWithResults,
+ parserAvailable,
+ canWriteToBase: canWriteToBase(currentDatabaseId)
+ };
+ }
+
+ // Get conditions for evaluation
+ const conditions = await customFormatQueries.getConditionsForEvaluation(cache, formatId);
+
+ // Evaluate each test
+ const testsWithResults: TestWithResult[] = await Promise.all(
+ tests.map(async (test) => {
+ try {
+ // Parse the release title
+ const parsedResult = await parse(test.title, test.type as MediaType);
+
+ // Get serializable parsed info
+ const parsed = customFormatQueries.getParsedInfo(parsedResult);
+
+ // Evaluate the custom format conditions
+ const evaluation = customFormatQueries.evaluateCustomFormat(
+ conditions,
+ parsedResult,
+ test.title
+ );
+
+ // Determine if test passes (actual matches expected)
+ const actual_match = evaluation.matches;
+ const result: TestResult =
+ actual_match === test.should_match ? 'pass' : 'fail';
+
+ return {
+ ...test,
+ actual_match,
+ result,
+ parsed,
+ conditions: evaluation.conditions
+ };
+ } catch (e) {
+ console.error(`Failed to evaluate test ${test.id}:`, e);
+ return {
+ ...test,
+ actual_match: null,
+ result: 'unknown' as TestResult,
+ parsed: null,
+ conditions: []
+ };
+ }
+ })
+ );
+
+ return {
+ format,
+ tests: testsWithResults,
+ parserAvailable,
+ canWriteToBase: canWriteToBase(currentDatabaseId)
+ };
+};
+
+export const actions: Actions = {
+ delete: async ({ request, params }) => {
+ const { databaseId, id } = params;
+
+ if (!databaseId || !id) {
+ return fail(400, { error: 'Missing required parameters' });
+ }
+
+ const currentDatabaseId = parseInt(databaseId, 10);
+ if (isNaN(currentDatabaseId)) {
+ return fail(400, { error: 'Invalid database ID' });
+ }
+
+ const cache = pcdManager.getCache(currentDatabaseId);
+ if (!cache) {
+ return fail(500, { error: 'Database cache not available' });
+ }
+
+ const formData = await request.formData();
+ const testId = parseInt(formData.get('testId') as string, 10);
+ const formatName = formData.get('formatName') as string;
+ const layer = (formData.get('layer') as OperationLayer) || 'user';
+
+ if (isNaN(testId)) {
+ return fail(400, { error: 'Invalid test ID' });
+ }
+
+ if (!formatName) {
+ return fail(400, { error: 'Format name is required' });
+ }
+
+ // Get current test for value guards
+ const current = await customFormatQueries.getTestById(cache, testId);
+ if (!current) {
+ return fail(404, { error: 'Test not found' });
+ }
+
+ // Check layer permission
+ if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
+ return fail(403, { error: 'Cannot write to base layer without personal access token' });
+ }
+
+ const result = await customFormatQueries.deleteTest({
+ databaseId: currentDatabaseId,
+ layer,
+ formatName,
+ current
+ });
+
+ if (!result.success) {
+ return fail(500, { error: result.error || 'Failed to delete test' });
+ }
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/+page.svelte b/src/routes/custom-formats/[databaseId]/[id]/testing/+page.svelte
new file mode 100644
index 0000000..b0e36bc
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/testing/+page.svelte
@@ -0,0 +1,429 @@
+
+
+
+ {data.format.name} - Testing - Profilarr
+
+
+
+
+
+
+
+ Test Cases
+
+
+ Test release titles against this custom format
+
+
+
+
+
+
+ {#if !data.parserAvailable}
+
+
+
+
+ Parser service unavailable
+
+
+ Test results cannot be evaluated. Start the parser microservice to see pass/fail status.
+
+
+
+ {/if}
+
+
+ {#if data.tests.length === 0}
+
+
+ No test cases yet. Add a test to verify this custom format works correctly.
+
+
+ {:else}
+
+
+ {#if column.key === 'title'}
+ {row.title}
+ {#if row.description}
+ {row.description}
+ {/if}
+ {:else if column.key === 'should_match'}
+ {#if row.should_match}
+ Should Match
+ {:else}
+ Shouldn't Match
+ {/if}
+ {:else if column.key === 'type'}
+ {row.type}
+ {:else if column.key === 'result'}
+ {#if row.result === 'pass'}
+
+ {:else if row.result === 'fail'}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+
+
+ {@const conditionTypeLabels = {
+ 'release_title': 'Release Title',
+ 'source': 'Source',
+ 'resolution': 'Resolution',
+ 'quality_modifier': 'Quality Modifier',
+ 'language': 'Language',
+ 'release_group': 'Release Group',
+ 'release_type': 'Release Type',
+ 'year': 'Year',
+ 'edition': 'Edition',
+ 'indexer_flag': 'Indexer Flag',
+ 'size': 'Size'
+ }}
+ {@const groupedConditions = row.conditions.reduce((acc, c) => {
+ if (!acc[c.conditionType]) acc[c.conditionType] = [];
+ acc[c.conditionType].push(c);
+ return acc;
+ }, {})}
+ {@const conditionTypes = Object.keys(groupedConditions)}
+ {@const allRequiredPass = row.conditions.filter(c => c.required).every(c => c.passes)}
+ {@const optionalConditions = row.conditions.filter(c => !c.required)}
+ {@const optionalPass = optionalConditions.length === 0 || optionalConditions.some(c => c.passes)}
+
+
+ {#if row.conditions.length > 0}
+
+
+
+
+ | Type |
+ Condition |
+ Expected |
+ Actual |
+ Pass |
+ Type Pass |
+ Expected |
+ Actual |
+ Result |
+
+
+
+ {#each conditionTypes as conditionType, typeIndex}
+ {@const conditions = groupedConditions[conditionType]}
+ {#each conditions as condition, condIndex}
+
+ {#if condIndex === 0}
+ |
+ {conditionTypeLabels[conditionType] || conditionType}
+ |
+ {/if}
+
+
+ {condition.conditionName}
+ {#if condition.required}
+ Required
+ {/if}
+
+ |
+
+ {condition.expected}
+ |
+
+ {condition.actual}
+ |
+
+ {#if condition.passes}
+
+
+
+ {:else}
+
+
+
+ {/if}
+ |
+ {#if condIndex === 0}
+ {@const requiredPass = conditions.filter(c => c.required).every(c => c.passes)}
+ {@const optionalConditions = conditions.filter(c => !c.required)}
+ {@const optionalPass = optionalConditions.length === 0 || optionalConditions.some(c => c.passes)}
+ {@const typePass = requiredPass && optionalPass}
+
+ {#if typePass}
+
+
+
+ {:else}
+
+
+
+ {/if}
+ |
+ {/if}
+ {#if typeIndex === 0 && condIndex === 0}
+
+ {#if row.should_match}
+
+
+
+ MATCH
+ {:else}
+
+
+
+ NO MATCH
+ {/if}
+ |
+
+ {#if row.actual_match}
+
+
+
+ MATCH
+ {:else}
+
+
+
+ NO MATCH
+ {/if}
+ |
+
+ {#if row.result === 'pass'}
+
+
+
+ PASS
+ {:else if row.result === 'fail'}
+
+
+
+ FAIL
+ {:else}
+
+ ?
+
+ UNKNOWN
+ {/if}
+ |
+ {/if}
+
+ {/each}
+ {/each}
+
+
+
+
+
+ {#if row.parsed}
+
+
+ Parsed Values
+
+
+
+ Source:
+ {row.parsed.source}
+
+
+ Resolution:
+ {row.parsed.resolution}
+
+
+ Modifier:
+ {row.parsed.modifier}
+
+
+ Languages:
+ {row.parsed.languages.length > 0 ? row.parsed.languages.join(', ') : 'None'}
+
+ {#if row.parsed.releaseGroup}
+
+ Release Group:
+ {row.parsed.releaseGroup}
+
+ {/if}
+ {#if row.parsed.year}
+
+ Year:
+ {row.parsed.year}
+
+ {/if}
+ {#if row.parsed.edition}
+
+ Edition:
+ {row.parsed.edition}
+
+ {/if}
+ {#if row.parsed.releaseType}
+
+ Release Type:
+ {row.parsed.releaseType}
+
+ {/if}
+
+
+ {/if}
+ {:else if !row.parsed}
+
+ Parser unavailable - unable to evaluate conditions
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.server.ts b/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.server.ts
new file mode 100644
index 0000000..ddd5f2f
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.server.ts
@@ -0,0 +1,167 @@
+import { error, redirect, fail } from '@sveltejs/kit';
+import type { ServerLoad, Actions } from '@sveltejs/kit';
+import { pcdManager } from '$pcd/pcd.ts';
+import { canWriteToBase, type OperationLayer } from '$pcd/writer.ts';
+import * as customFormatQueries from '$pcd/queries/customFormats/index.ts';
+
+export const load: ServerLoad = async ({ params }) => {
+ const { databaseId, id, testId } = params;
+
+ if (!databaseId || !id || !testId) {
+ throw error(400, 'Missing required parameters');
+ }
+
+ const currentDatabaseId = parseInt(databaseId, 10);
+ const formatId = parseInt(id, 10);
+ const currentTestId = parseInt(testId, 10);
+
+ if (isNaN(currentDatabaseId) || isNaN(formatId) || isNaN(currentTestId)) {
+ throw error(400, 'Invalid parameters');
+ }
+
+ const cache = pcdManager.getCache(currentDatabaseId);
+ if (!cache) {
+ throw error(500, 'Database cache not available');
+ }
+
+ const format = await customFormatQueries.getById(cache, formatId);
+ if (!format) {
+ throw error(404, 'Custom format not found');
+ }
+
+ const test = await customFormatQueries.getTestById(cache, currentTestId);
+ if (!test) {
+ throw error(404, 'Test not found');
+ }
+
+ return {
+ format,
+ test,
+ canWriteToBase: canWriteToBase(currentDatabaseId)
+ };
+};
+
+export const actions: Actions = {
+ update: async ({ request, params }) => {
+ const { databaseId, id, testId } = params;
+
+ if (!databaseId || !id || !testId) {
+ return fail(400, { error: 'Missing required parameters' });
+ }
+
+ const currentDatabaseId = parseInt(databaseId, 10);
+ const currentTestId = parseInt(testId, 10);
+
+ if (isNaN(currentDatabaseId) || isNaN(currentTestId)) {
+ return fail(400, { error: 'Invalid parameters' });
+ }
+
+ const cache = pcdManager.getCache(currentDatabaseId);
+ if (!cache) {
+ return fail(500, { error: 'Database cache not available' });
+ }
+
+ // Get current test for value guards
+ const current = await customFormatQueries.getTestById(cache, currentTestId);
+ if (!current) {
+ return fail(404, { error: 'Test not found' });
+ }
+
+ const formData = await request.formData();
+
+ const title = formData.get('title') as string;
+ const type = formData.get('type') as 'movie' | 'series';
+ const shouldMatch = formData.get('shouldMatch') === '1';
+ const description = (formData.get('description') as string) || null;
+ const formatName = formData.get('formatName') as string;
+ const layer = (formData.get('layer') as OperationLayer) || 'user';
+
+ if (!title?.trim()) {
+ return fail(400, { error: 'Title is required' });
+ }
+
+ if (type !== 'movie' && type !== 'series') {
+ return fail(400, { error: 'Invalid type' });
+ }
+
+ if (!formatName) {
+ return fail(400, { error: 'Format name is required' });
+ }
+
+ // Check layer permission
+ if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
+ return fail(403, { error: 'Cannot write to base layer without personal access token' });
+ }
+
+ const result = await customFormatQueries.updateTest({
+ databaseId: currentDatabaseId,
+ layer,
+ formatName,
+ current,
+ input: {
+ title: title.trim(),
+ type,
+ should_match: shouldMatch,
+ description: description?.trim() || null
+ }
+ });
+
+ if (!result.success) {
+ return fail(500, { error: result.error || 'Failed to update test' });
+ }
+
+ throw redirect(303, `/custom-formats/${databaseId}/${id}/testing`);
+ },
+
+ delete: async ({ request, params }) => {
+ const { databaseId, id, testId } = params;
+
+ if (!databaseId || !id || !testId) {
+ return fail(400, { error: 'Missing required parameters' });
+ }
+
+ const currentDatabaseId = parseInt(databaseId, 10);
+ const currentTestId = parseInt(testId, 10);
+
+ if (isNaN(currentDatabaseId) || isNaN(currentTestId)) {
+ return fail(400, { error: 'Invalid parameters' });
+ }
+
+ const cache = pcdManager.getCache(currentDatabaseId);
+ if (!cache) {
+ return fail(500, { error: 'Database cache not available' });
+ }
+
+ // Get current test for value guards
+ const current = await customFormatQueries.getTestById(cache, currentTestId);
+ if (!current) {
+ return fail(404, { error: 'Test not found' });
+ }
+
+ const formData = await request.formData();
+ const formatName = formData.get('formatName') as string;
+ const layer = (formData.get('layer') as OperationLayer) || 'user';
+
+ if (!formatName) {
+ return fail(400, { error: 'Format name is required' });
+ }
+
+ // Check layer permission
+ if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
+ return fail(403, { error: 'Cannot write to base layer without personal access token' });
+ }
+
+ const result = await customFormatQueries.deleteTest({
+ databaseId: currentDatabaseId,
+ layer,
+ formatName,
+ current
+ });
+
+ if (!result.success) {
+ return fail(500, { error: result.error || 'Failed to delete test' });
+ }
+
+ throw redirect(303, `/custom-formats/${databaseId}/${id}/testing`);
+ }
+};
diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte b/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte
new file mode 100644
index 0000000..d45d8fe
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/testing/[testId]/+page.svelte
@@ -0,0 +1,33 @@
+
+
+
+ Edit Test - {data.format.name} - Profilarr
+
+
+
diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte b/src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte
new file mode 100644
index 0000000..b2ace2e
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/testing/components/TestForm.svelte
@@ -0,0 +1,310 @@
+
+
+
+
+
+ {pageTitle}
+
+ {pageDescription}
+
+
+
+
+
+
+ {#if mode === 'edit'}
+
+ {/if}
+
+
+
+{#if canWriteToBase}
+ (showSaveTargetModal = false)}
+ />
+
+
+ (showDeleteTargetModal = false)}
+ />
+{/if}
diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.server.ts b/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.server.ts
new file mode 100644
index 0000000..feb8689
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.server.ts
@@ -0,0 +1,100 @@
+import { error, redirect, fail } from '@sveltejs/kit';
+import type { ServerLoad, Actions } from '@sveltejs/kit';
+import { pcdManager } from '$pcd/pcd.ts';
+import { canWriteToBase, type OperationLayer } from '$pcd/writer.ts';
+import * as customFormatQueries from '$pcd/queries/customFormats/index.ts';
+
+export const load: ServerLoad = async ({ params }) => {
+ const { databaseId, id } = params;
+
+ if (!databaseId || !id) {
+ throw error(400, 'Missing required parameters');
+ }
+
+ const currentDatabaseId = parseInt(databaseId, 10);
+ const formatId = parseInt(id, 10);
+
+ if (isNaN(currentDatabaseId) || isNaN(formatId)) {
+ throw error(400, 'Invalid parameters');
+ }
+
+ const cache = pcdManager.getCache(currentDatabaseId);
+ if (!cache) {
+ throw error(500, 'Database cache not available');
+ }
+
+ const format = await customFormatQueries.getById(cache, formatId);
+ if (!format) {
+ throw error(404, 'Custom format not found');
+ }
+
+ return {
+ format,
+ canWriteToBase: canWriteToBase(currentDatabaseId)
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ request, params }) => {
+ const { databaseId, id } = params;
+
+ if (!databaseId || !id) {
+ return fail(400, { error: 'Missing required parameters' });
+ }
+
+ const currentDatabaseId = parseInt(databaseId, 10);
+
+ if (isNaN(currentDatabaseId)) {
+ return fail(400, { error: 'Invalid parameters' });
+ }
+
+ const cache = pcdManager.getCache(currentDatabaseId);
+ if (!cache) {
+ return fail(500, { error: 'Database cache not available' });
+ }
+
+ const formData = await request.formData();
+
+ const title = formData.get('title') as string;
+ const type = formData.get('type') as 'movie' | 'series';
+ const shouldMatch = formData.get('shouldMatch') === '1';
+ const description = (formData.get('description') as string) || null;
+ const formatName = formData.get('formatName') as string;
+ const layer = (formData.get('layer') as OperationLayer) || 'user';
+
+ if (!title?.trim()) {
+ return fail(400, { error: 'Title is required' });
+ }
+
+ if (type !== 'movie' && type !== 'series') {
+ return fail(400, { error: 'Invalid type' });
+ }
+
+ if (!formatName) {
+ return fail(400, { error: 'Format name is required' });
+ }
+
+ // Check layer permission
+ if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
+ return fail(403, { error: 'Cannot write to base layer without personal access token' });
+ }
+
+ const result = await customFormatQueries.createTest({
+ databaseId: currentDatabaseId,
+ layer,
+ formatName,
+ input: {
+ title: title.trim(),
+ type,
+ should_match: shouldMatch,
+ description: description?.trim() || null
+ }
+ });
+
+ if (!result.success) {
+ return fail(500, { error: result.error || 'Failed to create test' });
+ }
+
+ throw redirect(303, `/custom-formats/${databaseId}/${id}/testing`);
+ }
+};
diff --git a/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.svelte b/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.svelte
new file mode 100644
index 0000000..24533cf
--- /dev/null
+++ b/src/routes/custom-formats/[databaseId]/[id]/testing/new/+page.svelte
@@ -0,0 +1,32 @@
+
+
+
+ New Test - {data.format.name} - Profilarr
+
+
+
|