mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 19:01:02 +01:00
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:
@@ -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'}"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
209
src/lib/server/pcd/queries/customFormats/conditions.ts
Normal file
209
src/lib/server/pcd/queries/customFormats/conditions.ts
Normal 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)
|
||||
}));
|
||||
}
|
||||
473
src/lib/server/pcd/queries/customFormats/evaluator.ts
Normal file
473
src/lib/server/pcd/queries/customFormats/evaluator.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
57
src/lib/server/pcd/queries/customFormats/testCreate.ts
Normal file
57
src/lib/server/pcd/queries/customFormats/testCreate.ts
Normal 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;
|
||||
}
|
||||
51
src/lib/server/pcd/queries/customFormats/testDelete.ts
Normal file
51
src/lib/server/pcd/queries/customFormats/testDelete.ts
Normal 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;
|
||||
}
|
||||
67
src/lib/server/pcd/queries/customFormats/testUpdate.ts
Normal file
67
src/lib/server/pcd/queries/customFormats/testUpdate.ts
Normal 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;
|
||||
}
|
||||
60
src/lib/server/pcd/queries/customFormats/tests.ts
Normal file
60
src/lib/server/pcd/queries/customFormats/tests.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
|
||||
34
src/routes/custom-formats/[databaseId]/[id]/+layout.svelte
Normal file
34
src/routes/custom-formats/[databaseId]/[id]/+layout.svelte
Normal 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>
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
429
src/routes/custom-formats/[databaseId]/[id]/testing/+page.svelte
Normal file
429
src/routes/custom-formats/[databaseId]/[id]/testing/+page.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
Reference in New Issue
Block a user