feat: add testing functionality for custom formats

- Implemented server-side logic for loading and managing tests in custom formats.
- Created new page for editing existing tests with form handling.
- Developed a reusable TestForm component for creating and editing test cases.
- Added functionality to create new tests with validation and error handling.
- Integrated layer permission checks for writing to base layer.
- Enhanced user experience with modals for save and delete actions.
This commit is contained in:
Sam Chau
2025-12-31 03:05:09 +10:30
parent af269b030f
commit 5d82cc910b
23 changed files with 2394 additions and 6 deletions

View File

@@ -56,7 +56,7 @@
<svelte:component this={icon} size={14} class="text-white" />
{/if}
</button>
{:else}
{:else if color === 'green'}
<button
type="button"
role="checkbox"
@@ -64,7 +64,59 @@
{disabled}
on:click
class="flex h-5 w-5 items-center justify-center border-2 transition-all {shapeClass} {checked
? `bg-${color}-600 border-${color}-600 dark:bg-${color}-500 dark:border-${color}-500 hover:brightness-110`
? 'bg-green-600 border-green-600 dark:bg-green-500 dark:border-green-500 hover:brightness-110'
: 'bg-neutral-50 border-neutral-300 hover:bg-neutral-100 hover:border-neutral-400 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:hover:border-neutral-500'} {disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer focus:outline-none'}"
>
{#if checked}
<svelte:component this={icon} size={14} class="text-white" />
{/if}
</button>
{:else if color === 'red'}
<button
type="button"
role="checkbox"
aria-checked={checked}
{disabled}
on:click
class="flex h-5 w-5 items-center justify-center border-2 transition-all {shapeClass} {checked
? 'bg-red-600 border-red-600 dark:bg-red-500 dark:border-red-500 hover:brightness-110'
: 'bg-neutral-50 border-neutral-300 hover:bg-neutral-100 hover:border-neutral-400 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:hover:border-neutral-500'} {disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer focus:outline-none'}"
>
{#if checked}
<svelte:component this={icon} size={14} class="text-white" />
{/if}
</button>
{:else if color === 'blue'}
<button
type="button"
role="checkbox"
aria-checked={checked}
{disabled}
on:click
class="flex h-5 w-5 items-center justify-center border-2 transition-all {shapeClass} {checked
? 'bg-blue-600 border-blue-600 dark:bg-blue-500 dark:border-blue-500 hover:brightness-110'
: 'bg-neutral-50 border-neutral-300 hover:bg-neutral-100 hover:border-neutral-400 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:hover:border-neutral-500'} {disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer focus:outline-none'}"
>
{#if checked}
<svelte:component this={icon} size={14} class="text-white" />
{/if}
</button>
{:else}
<!-- Fallback to accent for unknown colors -->
<button
type="button"
role="checkbox"
aria-checked={checked}
{disabled}
on:click
class="flex h-5 w-5 items-center justify-center border-2 transition-all {shapeClass} {checked
? 'bg-accent-600 border-accent-600 dark:bg-accent-500 dark:border-accent-500 hover:brightness-110'
: 'bg-neutral-50 border-neutral-300 hover:bg-neutral-100 hover:border-neutral-400 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:hover:border-neutral-500'} {disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer focus:outline-none'}"

View File

@@ -132,13 +132,18 @@
{/if}
</th>
{/each}
{#if $$slots.actions}
<th class="{compact ? 'px-4 py-2' : 'px-6 py-3'} text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300 text-right">
Actions
</th>
{/if}
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
{#if sortedData.length === 0}
<tr>
<td
colspan={columns.length + 1}
colspan={columns.length + 1 + ($$slots.actions ? 1 : 0)}
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
>
{emptyMessage}
@@ -171,12 +176,17 @@
</slot>
</td>
{/each}
{#if $$slots.actions}
<td class="{compact ? 'px-4 py-2' : 'px-6 py-4'} text-sm text-right" on:click|stopPropagation>
<slot name="actions" {row} />
</td>
{/if}
</tr>
<!-- Expanded Row -->
{#if expandedRows.has(rowId)}
<tr class="bg-neutral-50 dark:bg-neutral-800/30">
<td colspan={columns.length + 1} class="{flushExpanded ? '' : compact ? 'px-4 py-3' : 'px-6 py-4'}">
<td colspan={columns.length + 1 + ($$slots.actions ? 1 : 0)} class="{flushExpanded ? '' : compact ? 'px-4 py-3' : 'px-6 py-4'}">
<div class="{flushExpanded ? '' : 'ml-6'}">
<slot name="expanded" {row}>
<!-- Default expanded content -->

View File

@@ -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<ConditionData[]> {
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<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 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)
}));
}

View File

@@ -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, string> = {
[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, string> = {
[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, string> = {
[QualityModifier.None]: 'None',
[QualityModifier.Regional]: 'Regional',
[QualityModifier.Screener]: 'Screener',
[QualityModifier.RawHD]: 'RawHD',
[QualityModifier.BRDisk]: 'BRDisk',
[QualityModifier.Remux]: 'Remux'
};
const releaseTypeNames: Record<ReleaseType, string> = {
[ReleaseType.Unknown]: 'Unknown',
[ReleaseType.SingleEpisode]: 'SingleEpisode',
[ReleaseType.MultiEpisode]: 'MultiEpisode',
[ReleaseType.SeasonPack]: 'SeasonPack'
};
const languageNames: Record<Language, string> = {
[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
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CustomFormatBasic | null> {
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<CustomFormatTest[]> {
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<CustomFormatTest | null> {
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
};
}

View File

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

View File

@@ -189,6 +189,20 @@ export interface ConditionYearsTable {
max_year: number | null;
}
// ============================================================================
// CUSTOM FORMAT TESTING
// ============================================================================
export interface CustomFormatTestsTable {
id: Generated<number>;
custom_format_id: number;
title: string;
type: string;
should_match: number;
description: string | null;
created_at: Generated<string>;
}
// ============================================================================
// 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;

View File

@@ -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('/');

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import { page } from '$app/stores';
import { FileText, FlaskConical } from 'lucide-svelte';
$: databaseId = $page.params.databaseId;
$: formatId = $page.params.id;
$: currentPath = $page.url.pathname;
$: tabs = [
{
label: 'General',
href: `/custom-formats/${databaseId}/${formatId}/general`,
active: currentPath.includes('/general'),
icon: FileText
},
{
label: 'Testing',
href: `/custom-formats/${databaseId}/${formatId}/testing`,
active: currentPath.includes('/testing'),
icon: FlaskConical
}
];
$: backButton = {
label: 'Back',
href: `/custom-formats/${databaseId}`
};
</script>
<div class="p-8">
<Tabs {tabs} {backButton} />
<slot />
</div>

View File

@@ -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`);
};

View File

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

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<title>{data.format.name} - General - Profilarr</title>
</svelte:head>
<div class="mt-6 space-y-6">
<p class="text-neutral-600 dark:text-neutral-400">
General tab placeholder - conditions and settings will go here.
</p>
</div>

View File

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

View File

@@ -0,0 +1,429 @@
<script lang="ts">
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { enhance } from '$app/forms';
import { Plus, AlertTriangle, Check, X, Pencil, Trash2 } from 'lucide-svelte';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import Badge from '$ui/badge/Badge.svelte';
import Modal from '$ui/modal/Modal.svelte';
import type { Column } from '$ui/table/types';
import type { PageData } from './$types';
export let data: PageData;
type Test = (typeof data.tests)[number];
const columns: Column<Test>[] = [
{
key: 'title',
header: 'Release Title',
sortable: true
},
{
key: 'should_match',
header: 'Expected',
width: 'w-40',
align: 'center',
sortable: true
},
{
key: 'type',
header: 'Type',
width: 'w-24',
align: 'center',
sortable: true
},
{
key: 'result',
header: 'Result',
width: 'w-24',
align: 'center',
sortable: true
}
];
function handleAddTest() {
goto(`/custom-formats/${$page.params.databaseId}/${$page.params.id}/testing/new`);
}
function getRowId(test: Test) {
return test.id;
}
// Delete modal state
let showDeleteModal = false;
let testToDelete: Test | null = null;
let deleteForm: HTMLFormElement | null = null;
function handleDeleteClick(test: Test, form: HTMLFormElement) {
testToDelete = test;
deleteForm = form;
showDeleteModal = true;
}
function handleDeleteConfirm() {
if (deleteForm) {
deleteForm.requestSubmit();
}
showDeleteModal = false;
testToDelete = null;
deleteForm = null;
}
function handleDeleteCancel() {
showDeleteModal = false;
testToDelete = null;
deleteForm = null;
}
</script>
<svelte:head>
<title>{data.format.name} - Testing - Profilarr</title>
</svelte:head>
<div class="mt-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Test Cases
</h2>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
Test release titles against this custom format
</p>
</div>
<button
on:click={handleAddTest}
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white hover:bg-accent-700"
>
<Plus size={16} />
Add Test
</button>
</div>
<!-- Parser Warning -->
{#if !data.parserAvailable}
<div class="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
<AlertTriangle size={20} class="text-amber-600 dark:text-amber-400" />
<div>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
Parser service unavailable
</p>
<p class="text-xs text-amber-600 dark:text-amber-400">
Test results cannot be evaluated. Start the parser microservice to see pass/fail status.
</p>
</div>
</div>
{/if}
<!-- Tests List -->
{#if data.tests.length === 0}
<div class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900">
<p class="text-neutral-600 dark:text-neutral-400">
No test cases yet. Add a test to verify this custom format works correctly.
</p>
</div>
{:else}
<ExpandableTable
{columns}
data={data.tests}
{getRowId}
emptyMessage="No test cases found"
flushExpanded={true}
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'title'}
<code class="font-mono text-sm">{row.title}</code>
{#if row.description}
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{row.description}</p>
{/if}
{:else if column.key === 'should_match'}
{#if row.should_match}
<span class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200">Should Match</span>
{:else}
<span class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">Shouldn't Match</span>
{/if}
{:else if column.key === 'type'}
<span class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">{row.type}</span>
{:else if column.key === 'result'}
{#if row.result === 'pass'}
<div class="flex items-center justify-center">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900">
<Check size={14} class="text-emerald-600 dark:text-emerald-400" />
</div>
</div>
{:else if row.result === 'fail'}
<div class="flex items-center justify-center">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<X size={14} class="text-red-600 dark:text-red-400" />
</div>
</div>
{:else}
<div class="flex items-center justify-center">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900">
<span class="text-sm font-medium text-amber-600 dark:text-amber-400">?</span>
</div>
</div>
{/if}
{/if}
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
{@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)}
<div class="px-4 py-3">
{#if row.conditions.length > 0}
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
<table class="w-full text-sm">
<thead class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Type</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Condition</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Expected</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Actual</th>
<th class="px-3 py-2 text-center text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Pass</th>
<th class="px-3 py-2 text-center text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Type Pass</th>
<th class="px-3 py-2 text-center text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Expected</th>
<th class="px-3 py-2 text-center text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Actual</th>
<th class="px-3 py-2 text-center text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300">Result</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
{#each conditionTypes as conditionType, typeIndex}
{@const conditions = groupedConditions[conditionType]}
{#each conditions as condition, condIndex}
<tr>
{#if condIndex === 0}
<td
rowspan={conditions.length}
class="px-3 py-2 font-medium text-neutral-900 dark:text-neutral-100 align-top border-r border-neutral-200 dark:border-neutral-800"
>
{conditionTypeLabels[conditionType] || conditionType}
</td>
{/if}
<td class="px-3 py-2 text-neutral-700 dark:text-neutral-300">
<div class="flex items-center gap-2">
<span>{condition.conditionName}</span>
{#if condition.required}
<Badge variant={condition.negate ? 'danger' : 'success'}>Required</Badge>
{/if}
</div>
</td>
<td class="px-3 py-2 text-neutral-600 dark:text-neutral-400 font-mono text-xs max-w-48 truncate" title={condition.expected}>
{condition.expected}
</td>
<td class="px-3 py-2 text-neutral-600 dark:text-neutral-400 font-mono text-xs max-w-48 truncate" title={condition.actual}>
{condition.actual}
</td>
<td class="px-3 py-2 text-center">
{#if condition.passes}
<div class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900">
<Check size={12} class="text-emerald-600 dark:text-emerald-400" />
</div>
{:else}
<div class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<X size={12} class="text-red-600 dark:text-red-400" />
</div>
{/if}
</td>
{#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}
<td
rowspan={conditions.length}
class="px-3 py-2 text-center align-middle border-l border-neutral-200 dark:border-neutral-800"
>
{#if typePass}
<div class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900">
<Check size={14} class="text-emerald-600 dark:text-emerald-400" />
</div>
{:else}
<div class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<X size={14} class="text-red-600 dark:text-red-400" />
</div>
{/if}
</td>
{/if}
{#if typeIndex === 0 && condIndex === 0}
<td
rowspan={row.conditions.length}
class="px-3 py-2 text-center align-middle border-l border-neutral-200 dark:border-neutral-800"
>
{#if row.should_match}
<div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900">
<Check size={24} class="text-emerald-600 dark:text-emerald-400" />
</div>
<div class="mt-1 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">MATCH</div>
{:else}
<div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<X size={24} class="text-red-600 dark:text-red-400" />
</div>
<div class="mt-1 text-[10px] font-medium text-red-600 dark:text-red-400">NO MATCH</div>
{/if}
</td>
<td
rowspan={row.conditions.length}
class="px-3 py-2 text-center align-middle border-l border-neutral-200 dark:border-neutral-800"
>
{#if row.actual_match}
<div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900">
<Check size={24} class="text-emerald-600 dark:text-emerald-400" />
</div>
<div class="mt-1 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">MATCH</div>
{:else}
<div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<X size={24} class="text-red-600 dark:text-red-400" />
</div>
<div class="mt-1 text-[10px] font-medium text-red-600 dark:text-red-400">NO MATCH</div>
{/if}
</td>
<td
rowspan={row.conditions.length}
class="px-3 py-2 text-center align-middle border-l border-neutral-200 dark:border-neutral-800"
>
{#if row.result === 'pass'}
<div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900">
<Check size={24} class="text-emerald-600 dark:text-emerald-400" />
</div>
<div class="mt-1 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">PASS</div>
{:else if row.result === 'fail'}
<div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<X size={24} class="text-red-600 dark:text-red-400" />
</div>
<div class="mt-1 text-[10px] font-medium text-red-600 dark:text-red-400">FAIL</div>
{:else}
<div class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900">
<span class="text-lg font-medium text-amber-600 dark:text-amber-400">?</span>
</div>
<div class="mt-1 text-[10px] font-medium text-amber-600 dark:text-amber-400">UNKNOWN</div>
{/if}
</td>
{/if}
</tr>
{/each}
{/each}
</tbody>
</table>
</div>
<!-- Parsed Values (collapsed) -->
{#if row.parsed}
<details class="mt-4">
<summary class="cursor-pointer text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300">
Parsed Values
</summary>
<div class="mt-2 flex flex-wrap gap-2">
<div class="flex items-center gap-1.5">
<span class="text-xs text-neutral-500 dark:text-neutral-400">Source:</span>
<Badge variant="neutral" size="md">{row.parsed.source}</Badge>
</div>
<div class="flex items-center gap-1.5">
<span class="text-xs text-neutral-500 dark:text-neutral-400">Resolution:</span>
<Badge variant="neutral" size="md">{row.parsed.resolution}</Badge>
</div>
<div class="flex items-center gap-1.5">
<span class="text-xs text-neutral-500 dark:text-neutral-400">Modifier:</span>
<Badge variant="neutral" size="md">{row.parsed.modifier}</Badge>
</div>
<div class="flex items-center gap-1.5">
<span class="text-xs text-neutral-500 dark:text-neutral-400">Languages:</span>
<Badge variant="neutral" size="md">{row.parsed.languages.length > 0 ? row.parsed.languages.join(', ') : 'None'}</Badge>
</div>
{#if row.parsed.releaseGroup}
<div class="flex items-center gap-1.5">
<span class="text-xs text-neutral-500 dark:text-neutral-400">Release Group:</span>
<Badge variant="neutral" size="md">{row.parsed.releaseGroup}</Badge>
</div>
{/if}
{#if row.parsed.year}
<div class="flex items-center gap-1.5">
<span class="text-xs text-neutral-500 dark:text-neutral-400">Year:</span>
<Badge variant="neutral" size="md">{row.parsed.year}</Badge>
</div>
{/if}
{#if row.parsed.edition}
<div class="flex items-center gap-1.5">
<span class="text-xs text-neutral-500 dark:text-neutral-400">Edition:</span>
<Badge variant="neutral" size="md">{row.parsed.edition}</Badge>
</div>
{/if}
{#if row.parsed.releaseType}
<div class="flex items-center gap-1.5">
<span class="text-xs text-neutral-500 dark:text-neutral-400">Release Type:</span>
<Badge variant="neutral" size="md">{row.parsed.releaseType}</Badge>
</div>
{/if}
</div>
</details>
{/if}
{:else if !row.parsed}
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Parser unavailable - unable to evaluate conditions
</div>
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-1">
<button
type="button"
on:click={() => goto(`/custom-formats/${$page.params.databaseId}/${$page.params.id}/testing/${row.id}`)}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
title="Edit test case"
>
<Pencil size={14} />
</button>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="testId" value={row.id} />
<input type="hidden" name="formatName" value={data.format.name} />
<input type="hidden" name="layer" value={data.canWriteToBase ? 'base' : 'user'} />
<button
type="button"
on:click={(e) => handleDeleteClick(row, e.currentTarget.closest('form'))}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-red-50 hover:border-red-300 hover:text-red-600 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-red-900/20 dark:hover:border-red-700 dark:hover:text-red-400"
title="Delete test case"
>
<Trash2 size={14} />
</button>
</form>
</div>
</svelte:fragment>
</ExpandableTable>
{/if}
</div>
<!-- Delete Confirmation Modal -->
<Modal
open={showDeleteModal}
header="Delete Test Case"
bodyMessage={testToDelete ? `Are you sure you want to delete the test case "${testToDelete.title}"?` : ''}
confirmText="Delete"
cancelText="Cancel"
confirmDanger={true}
on:confirm={handleDeleteConfirm}
on:cancel={handleDeleteCancel}
/>

View File

@@ -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`);
}
};

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import TestForm from '../components/TestForm.svelte';
import type { PageData } from './$types';
export let data: PageData;
let title = data.test.title;
let type: 'movie' | 'series' = data.test.type as 'movie' | 'series';
let shouldMatch = data.test.should_match;
let description = data.test.description ?? '';
function handleCancel() {
goto(`/custom-formats/${$page.params.databaseId}/${$page.params.id}/testing`);
}
</script>
<svelte:head>
<title>Edit Test - {data.format.name} - Profilarr</title>
</svelte:head>
<TestForm
mode="edit"
formatName={data.format.name}
canWriteToBase={data.canWriteToBase}
actionUrl="?/update"
bind:title
bind:type
bind:shouldMatch
bind:description
onCancel={handleCancel}
/>

View File

@@ -0,0 +1,310 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import { alertStore } from '$alerts/store';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import { Save, Trash2, Loader2, Check, X } from 'lucide-svelte';
// Props
export let mode: 'create' | 'edit';
export let formatName: string;
export let canWriteToBase: boolean = false;
export let actionUrl: string = '';
// Form data
export let title: string = '';
export let type: 'movie' | 'series' = 'movie';
export let shouldMatch: boolean = true;
export let description: string = '';
// Event handlers
export let onCancel: () => void;
// Loading states
let saving = false;
let deleting = false;
// Layer selection
let selectedLayer: 'user' | 'base' = 'user';
let deleteLayer: 'user' | 'base' = 'user';
// Modal states
let showSaveTargetModal = false;
let showDeleteTargetModal = false;
// Form reference
let mainFormElement: HTMLFormElement;
let deleteFormElement: HTMLFormElement;
// Display text based on mode
$: pageTitle = mode === 'create' ? 'New Test Case' : 'Edit Test Case';
$: pageDescription = mode === 'create'
? `Add a test case for ${formatName}`
: `Update test case settings`;
$: submitButtonText = mode === 'create' ? 'Create' : 'Save Changes';
$: isValid = title.trim() !== '';
async function handleSaveClick() {
if (canWriteToBase) {
showSaveTargetModal = true;
} else {
selectedLayer = 'user';
await tick();
mainFormElement?.requestSubmit();
}
}
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
selectedLayer = event.detail;
showSaveTargetModal = false;
await tick();
mainFormElement?.requestSubmit();
}
async function handleDeleteClick() {
if (canWriteToBase) {
showDeleteTargetModal = true;
} else {
deleteLayer = 'user';
await tick();
deleteFormElement?.requestSubmit();
}
}
async function handleDeleteLayerSelect(event: CustomEvent<'user' | 'base'>) {
deleteLayer = event.detail;
showDeleteTargetModal = false;
await tick();
deleteFormElement?.requestSubmit();
}
// Options
const typeOptions = [
{ value: 'movie' as const, label: 'Movie', description: 'Parse as a movie release' },
{ value: 'series' as const, label: 'Series', description: 'Parse as a TV series release' }
];
const matchOptions = [
{ value: true, label: 'Should Match', description: 'This title should match the custom format' },
{ value: false, label: 'Should NOT Match', description: 'This title should not match the custom format' }
];
</script>
<div class="mt-6 space-y-6">
<!-- Header -->
<div class="space-y-2">
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-50">{pageTitle}</h1>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
{pageDescription}
</p>
</div>
<form
bind:this={mainFormElement}
method="POST"
action={actionUrl}
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || 'Operation failed');
} else if (result.type === 'redirect') {
alertStore.add('success', mode === 'create' ? 'Test case created!' : 'Test case updated!');
}
await update();
saving = false;
};
}}
>
<!-- Hidden fields -->
<input type="hidden" name="type" value={type} />
<input type="hidden" name="shouldMatch" value={shouldMatch ? '1' : '0'} />
<input type="hidden" name="formatName" value={formatName} />
<input type="hidden" name="layer" value={selectedLayer} />
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<div class="space-y-6 p-4">
<!-- Title -->
<div>
<label for="title" class="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Release Title <span class="text-red-500">*</span>
</label>
<input
type="text"
id="title"
name="title"
bind:value={title}
placeholder="e.g., Movie.Name.2024.1080p.BluRay.x264-GROUP"
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
</div>
<!-- Media Type -->
<div>
<div class="mb-3 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Media Type
</div>
<div class="flex gap-2">
{#each typeOptions as option}
<button
type="button"
on:click={() => (type = option.value)}
class="flex flex-1 cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox
icon={Check}
checked={type === option.value}
shape="circle"
/>
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{option.label}
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{option.description}
</div>
</div>
</button>
{/each}
</div>
</div>
<!-- Expected Result -->
<div>
<div class="mb-3 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Expected Result
</div>
<div class="flex gap-2">
{#each matchOptions as option}
<button
type="button"
on:click={() => (shouldMatch = option.value)}
class="flex flex-1 cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox
icon={option.value ? Check : X}
checked={shouldMatch === option.value}
color={option.value ? 'green' : 'red'}
shape="circle"
/>
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{option.label}
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{option.description}
</div>
</div>
</button>
{/each}
</div>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Description
</label>
<textarea
id="description"
name="description"
bind:value={description}
rows="2"
placeholder="Why this test exists or what edge case it covers"
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
></textarea>
</div>
</div>
<!-- Actions -->
<div class="flex justify-between gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-neutral-800 dark:bg-neutral-800/50">
<div>
{#if mode === 'edit'}
<button
type="button"
on:click={handleDeleteClick}
disabled={deleting}
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 disabled:opacity-50 dark:border-red-700 dark:bg-neutral-800 dark:text-red-300 dark:hover:bg-red-900"
>
{#if deleting}
<Loader2 size={14} class="animate-spin" />
{:else}
<Trash2 size={14} />
{/if}
Delete
</button>
{/if}
</div>
<div class="flex gap-2">
<button
type="button"
on:click={onCancel}
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<X size={14} />
Cancel
</button>
<button
type="button"
on:click={handleSaveClick}
disabled={saving || !isValid}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
{#if saving}
<Loader2 size={14} class="animate-spin" />
Saving...
{:else}
<Check size={14} />
{submitButtonText}
{/if}
</button>
</div>
</div>
</div>
</form>
<!-- Hidden delete form -->
{#if mode === 'edit'}
<form
bind:this={deleteFormElement}
method="POST"
action="?/delete"
class="hidden"
use:enhance={() => {
deleting = true;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || 'Failed to delete');
} else if (result.type === 'redirect') {
alertStore.add('success', 'Test case deleted');
}
await update();
deleting = false;
};
}}
>
<input type="hidden" name="formatName" value={formatName} />
<input type="hidden" name="layer" value={deleteLayer} />
</form>
{/if}
</div>
<!-- Save Target Modal -->
{#if canWriteToBase}
<SaveTargetModal
open={showSaveTargetModal}
mode="save"
on:select={handleLayerSelect}
on:cancel={() => (showSaveTargetModal = false)}
/>
<!-- Delete Target Modal -->
<SaveTargetModal
open={showDeleteTargetModal}
mode="delete"
on:select={handleDeleteLayerSelect}
on:cancel={() => (showDeleteTargetModal = false)}
/>
{/if}

View File

@@ -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`);
}
};

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import TestForm from '../components/TestForm.svelte';
import type { PageData } from './$types';
export let data: PageData;
let title = '';
let type: 'movie' | 'series' = 'movie';
let shouldMatch = true;
let description = '';
function handleCancel() {
goto(`/custom-formats/${$page.params.databaseId}/${$page.params.id}/testing`);
}
</script>
<svelte:head>
<title>New Test - {data.format.name} - Profilarr</title>
</svelte:head>
<TestForm
mode="create"
formatName={data.format.name}
canWriteToBase={data.canWriteToBase}
bind:title
bind:type
bind:shouldMatch
bind:description
onCancel={handleCancel}
/>