feat: add entity and release management components

- Created EntityTable component for displaying test entities with expandable rows for releases.
- Implemented ReleaseTable component to manage and display test releases with actions for editing and deleting.
- Added ReleaseModal component for creating and editing releases
- Introduced types for TestEntity, TestRelease, and related evaluations
- Enhanced general settings page to include TMDB API configuration with connection testing functionality.
- Added TMDBSettings component for managing TMDB API access token with reset and test connection features.
This commit is contained in:
Sam Chau
2026-01-14 23:50:20 +10:30
parent aec6d79695
commit 74b38df686
47 changed files with 4000 additions and 102 deletions

151
docs/CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,151 @@
# Contributing to Profilarr
Profilarr is a work-in-progress rewrite, so please coordinate larger changes first. This guide explains how the repo is organized and the expected contribution workflows.
## Project Overview
Profilarr is a SvelteKit + Deno app that manages and syncs configurations across \*arr apps using Profilarr Compliant Databases (PCDs). It compiles to standalone binaries.
- **Frontend:** `src/routes/`, `src/lib/client/`
- **Backend:** `src/lib/server/`
- **PCDs:** git repositories cloned under `data/databases/` and compiled into an in-memory SQLite cache
## Prerequisites
- **Deno 2.x**
- **Node + npm** only if you want to run ESLint/Prettier (`deno task lint` or `deno task format`).
- **.NET 8** only if you work on the parser microservice in `services/parser/`.
## Development Commands
- `deno task dev` (default port 6969)
- `deno task test`
- `deno task lint`
- `deno task format`
Useful environment variables:
- `APP_BASE_PATH` (defaults to the compiled binary location)
- `PARSER_HOST`, `PARSER_PORT` (C# parser microservice)
- `PORT`, `HOST`
## Repo Tour
- `docs/ARCHITECTURE.md` — system overview
- `docs/PCD SPEC.md` — operational SQL & layering model
- `docs/manifest.md``pcd.json` schema
- `docs/PARSER_PORT_DESIGN.md` — parser microservice
- `services/parser/` — C# parser microservice
## App Database vs PCD Databases
**Profilarr app database**
- SQLite file: `data/profilarr.db`
- Boot sequence initializes config, opens DB, runs migrations, starts job system.
- Migrations live in `src/lib/server/db/migrations/` and are run on startup.
**PCD databases**
- Git repos cloned into `data/databases/<uuid>`.
- Compiled into an in-memory SQLite cache (`PCDCache`) using ordered SQL operations.
- Layers in order: `schema``base``tweaks``user`.
- SQL helper functions available inside PCD ops: `qp`, `cf`, `dp`, `tag`.
## Adding a Migration
1. Copy `src/lib/server/db/migrations/_template.ts` to a new file like `021_add_foo.ts`.
2. Update `version` and `name`, then fill out `up` SQL and (ideally) `down` SQL.
3. Add a static import in `src/lib/server/db/migrations.ts`.
4. Add the new migration to `loadMigrations()` (keep sequential ordering).
Notes:
- Versions must be unique and sequential.
- Never edit an applied migration; create a new one instead.
- Migrations run automatically on server startup.
## Working with PCDs
**PCD layout**
```
my-pcd/
├── pcd.json
├── ops/
└── tweaks/
```
**Authoring operations**
- Follow the append-only Operational SQL approach.
- Use expected-value guards in `UPDATE` statements to surface conflicts.
- New ops go in `ops/` or `tweaks/` depending on intent.
**User ops**
Profilarr writes user edits via `src/lib/server/pcd/writer.ts` into `user_ops/`, rebuilding the in-memory cache after write.
## Client UI Components
Shared UI lives in `src/lib/client/ui/`. Route-specific components live next to their routes.
**Alerts and toasts**
- Store: `src/lib/client/alerts/store.ts`
- Use the alert store for success/error/info toasts in `enhance` actions and API responses.
**Actions and toolbars**
- `src/lib/client/ui/actions/ActionsBar.svelte`
- `src/lib/client/ui/actions/ActionButton.svelte`
- `src/lib/client/ui/actions/SearchAction.svelte`
- `src/lib/client/ui/actions/ViewToggle.svelte`
**Dropdowns**
- `src/lib/client/ui/dropdown/Dropdown.svelte`
- `src/lib/client/ui/dropdown/DropdownItem.svelte`
**Buttons**
- `src/lib/client/ui/button/Button.svelte` (variants + sizes)
**Forms**
- `FormInput`, `NumberInput`, `TagInput`, `IconCheckbox`
**Tables and lists**
- `Table`, `ExpandableTable`, `ReorderableList`
**Modals**
- `Modal`, `SaveTargetModal`, `UnsavedChangesModal`, `InfoModal`
**Navigation**
- `navbar`, `pageNav`, `tabs`
**State and empty views**
- `EmptyState`
## Svelte Conventions
- Use Svelte 4 syntax (`export let`, `$:`) even though Svelte 5 is installed.
- Avoid Svelte 5 runes unless explicitly used in that module.
- Route-specific components should be colocated under their route directory.
## Tests
- Tests live in `src/tests/` and run with `deno task test`.
- Base test utilities are in `src/tests/base/BaseTest.ts`.
- Many tests create temp dirs under `/tmp/profilarr-tests`.
## Parser Microservice (Optional)
If you touch parser-related code, see `docs/PARSER_PORT_DESIGN.md` and `services/parser/`.
- `dotnet run` from `services/parser/`
- Configure `PARSER_HOST` / `PARSER_PORT` in Profilarr

View File

@@ -1,5 +1,9 @@
using Parser.Core;
// Bump this version when parser logic changes (regex patterns, parsing behavior, etc.)
// This invalidates the parse result cache in Profilarr
const string ParserVersion = "1.0.0";
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
@@ -93,7 +97,47 @@ app.MapPost("/parse", (ParseRequest request) =>
}
});
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
app.MapGet("/health", () => Results.Ok(new { status = "healthy", version = ParserVersion }));
app.MapPost("/match", (MatchRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Text))
{
return Results.BadRequest(new { error = "Text is required" });
}
if (request.Patterns == null || request.Patterns.Count == 0)
{
return Results.BadRequest(new { error = "At least one pattern is required" });
}
var results = new Dictionary<string, bool>();
foreach (var pattern in request.Patterns)
{
try
{
var regex = new System.Text.RegularExpressions.Regex(
pattern,
System.Text.RegularExpressions.RegexOptions.IgnoreCase,
TimeSpan.FromMilliseconds(100) // Timeout to prevent ReDoS
);
results[pattern] = regex.IsMatch(request.Text);
}
catch (System.Text.RegularExpressions.RegexMatchTimeoutException)
{
// Pattern took too long, treat as no match
results[pattern] = false;
}
catch (System.ArgumentException)
{
// Invalid regex pattern
results[pattern] = false;
}
}
return Results.Ok(new MatchResponse { Results = results });
});
app.Run();
@@ -140,3 +184,10 @@ public record EpisodeResponse
public bool Special { get; init; }
public string ReleaseType { get; init; } = "Unknown";
}
public record MatchRequest(string Text, List<string> Patterns);
public record MatchResponse
{
public Dictionary<string, bool> Results { get; init; } = new();
}

View File

@@ -1,9 +1,14 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { Search, X } from 'lucide-svelte';
import type { SearchStore } from '$lib/client/stores/search';
import Badge from '$ui/badge/Badge.svelte';
export let searchStore: SearchStore;
export let placeholder: string = 'Search...';
export let activeQuery: string = '';
const dispatch = createEventDispatcher<{ submit: string; clearQuery: void }>();
let inputRef: HTMLInputElement;
let isFocused = false;
@@ -16,10 +21,23 @@
searchStore.setQuery(target.value);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && query.trim()) {
dispatch('submit', query.trim());
} else if (e.key === 'Backspace' && !query && activeQuery) {
dispatch('clearQuery');
}
}
function handleClear() {
searchStore.clear();
inputRef?.focus();
}
function handleClearQuery() {
dispatch('clearQuery');
inputRef?.focus();
}
</script>
<div class="relative flex flex-1">
@@ -31,16 +49,24 @@
<Search size={18} class="text-neutral-500 dark:text-neutral-400" />
</div>
<!-- Active query badge -->
{#if activeQuery}
<div class="ml-10 flex h-full flex-shrink-0 items-center">
<Badge variant="accent" size="sm">{activeQuery}</Badge>
</div>
{/if}
<!-- Input -->
<input
bind:this={inputRef}
type="text"
value={query}
on:input={handleInput}
on:keydown={handleKeydown}
on:focus={() => (isFocused = true)}
on:blur={() => (isFocused = false)}
{placeholder}
class="h-full w-full bg-transparent pl-10 pr-10 text-sm text-neutral-900 placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400"
placeholder={activeQuery ? '' : placeholder}
class="h-full w-full bg-transparent pr-10 text-sm text-neutral-900 placeholder-neutral-500 outline-none dark:text-neutral-100 dark:placeholder-neutral-400 {activeQuery ? 'pl-2' : 'pl-10'}"
/>
<!-- Clear button -->
@@ -51,6 +77,13 @@
>
<X size={14} class="text-neutral-500 dark:text-neutral-400" />
</button>
{:else if activeQuery}
<button
on:click={handleClearQuery}
class="absolute right-2 flex h-6 w-6 items-center justify-center rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
<X size={14} class="text-neutral-500 dark:text-neutral-400" />
</button>
{/if}
</div>
</div>

View File

@@ -22,14 +22,14 @@
bind:value
{placeholder}
rows="6"
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 transition-colors 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-50 dark:placeholder-neutral-500"
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
></textarea>
{:else}
<input
type="text"
bind:value
{placeholder}
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 transition-colors 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-50 dark:placeholder-neutral-500"
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 transition-colors focus:border-neutral-400 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:focus:border-neutral-500"
/>
{/if}
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import Badge from '$ui/badge/Badge.svelte';
export let tags: string[] = [];
export let placeholder = 'Type and press Enter to add tags';
@@ -36,22 +37,20 @@
</script>
<div
class="flex min-h-[2.5rem] flex-wrap gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-800"
class="flex min-h-[2.5rem] flex-wrap items-center gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-2 transition-colors focus-within:border-neutral-400 dark:border-neutral-700 dark:bg-neutral-800 dark:focus-within:border-neutral-500"
>
{#each tags as tag, index (tag)}
<div
class="flex items-center gap-1 rounded bg-accent-100 px-2 py-1 text-sm text-accent-800 dark:bg-accent-900/30 dark:text-accent-300"
>
<span>{tag}</span>
<span class="inline-flex items-center gap-1">
<Badge variant="accent" size="md">{tag}</Badge>
<button
type="button"
on:click={() => removeTag(index)}
class="hover:text-accent-900 dark:hover:text-accent-100"
class="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
aria-label="Remove tag"
>
<X size={14} />
</button>
</div>
</span>
{/each}
<input

View File

@@ -10,6 +10,7 @@
export let cancelText = 'Cancel';
export let confirmDanger = false; // If true, confirm button is styled as danger (red)
export let size: 'sm' | 'md' | 'lg' | 'xl' | '2xl' = 'md';
export let height: 'auto' | 'md' | 'lg' | 'xl' | 'full' = 'auto';
const sizeClasses = {
sm: 'max-w-sm',
@@ -19,6 +20,14 @@
'2xl': 'max-w-6xl'
};
const heightClasses = {
auto: '',
md: 'h-[50vh]',
lg: 'h-[70vh]',
xl: 'h-[85vh]',
full: 'h-[95vh]'
};
const dispatch = createEventDispatcher();
function handleConfirm() {
@@ -62,15 +71,15 @@
>
<!-- Modal -->
<div
class="relative w-full {sizeClasses[size]} rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
class="relative flex w-full flex-col {sizeClasses[size]} {heightClasses[height]} rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
>
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<div class="flex-shrink-0 border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">{header}</h2>
</div>
<!-- Body -->
<div class="px-6 py-4">
<div class="flex-1 overflow-auto px-6 py-4">
<slot name="body">
<p class="text-sm text-neutral-600 dark:text-neutral-400">{bodyMessage}</p>
</slot>
@@ -78,7 +87,7 @@
<!-- Footer -->
<div
class="flex justify-between border-t border-neutral-200 px-6 py-4 dark:border-neutral-800"
class="flex flex-shrink-0 justify-between border-t border-neutral-200 px-6 py-4 dark:border-neutral-800"
>
<button
type="button"

View File

@@ -16,7 +16,9 @@
<GroupItem label="Arrs" href="/arr" />
</Group>
<Group label="⚡ Quality Profiles" href="/quality-profiles" initialOpen={false} />
<Group label="⚡ Quality Profiles" href="/quality-profiles" initialOpen={true} hasItems={true}>
<GroupItem label="Entity Testing" href="/quality-profiles/entity-testing" />
</Group>
<Group label="🎨 Custom Formats" href="/custom-formats" initialOpen={false} />

View File

@@ -11,6 +11,7 @@
export let flushExpanded: boolean = false;
export let flushBottom: boolean = false;
export let expandedRows: Set<string | number> = new Set();
export let chevronPosition: 'left' | 'right' = 'left';
let sortState: SortState | null = defaultSort;
function toggleRow(id: string | number) {
@@ -104,8 +105,10 @@
<table class="w-full">
<thead class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800">
<tr>
<!-- Expand column -->
<th class="{compact ? 'px-2 py-2' : 'px-3 py-3'} w-8"></th>
<!-- Expand column (left) -->
{#if chevronPosition === 'left'}
<th class="{compact ? 'px-2 py-2' : 'px-3 py-3'} w-8"></th>
{/if}
{#each columns as column}
<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 {getAlignClass(column.align)} {column.width || ''}"
@@ -133,10 +136,14 @@
</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">
<th class="{compact ? 'px-4 py-2' : 'px-6 py-3'} w-20 text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300 text-right">
Actions
</th>
{/if}
<!-- Expand column (right) -->
{#if chevronPosition === 'right'}
<th class="{compact ? 'px-2 py-2' : 'px-3 py-3'} w-8"></th>
{/if}
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
@@ -158,14 +165,16 @@
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
on:click={() => toggleRow(rowId)}
>
<!-- Expand Icon -->
<td class="{compact ? 'px-2 py-2' : 'px-3 py-3'} text-neutral-400">
{#if expandedRows.has(rowId)}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</td>
<!-- Expand Icon (left) -->
{#if chevronPosition === 'left'}
<td class="{compact ? 'px-2 py-2' : 'px-3 py-3'} text-neutral-400">
{#if expandedRows.has(rowId)}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</td>
{/if}
{#each columns as column}
<td
@@ -181,6 +190,17 @@
<slot name="actions" {row} />
</td>
{/if}
<!-- Expand Icon (right) -->
{#if chevronPosition === 'right'}
<td class="{compact ? 'px-2 py-2' : 'px-3 py-3'} text-neutral-400 text-right">
{#if expandedRows.has(rowId)}
<ChevronUp size={16} class="inline-block" />
{:else}
<ChevronDown size={16} class="inline-block" />
{/if}
</td>
{/if}
</tr>
<!-- Expanded Row -->

View File

@@ -21,6 +21,8 @@ import { migration as migration016 } from './migrations/016_add_should_sync_flag
import { migration as migration017 } from './migrations/017_create_regex101_cache.ts';
import { migration as migration018 } from './migrations/018_create_app_info.ts';
import { migration as migration019 } from './migrations/019_default_log_level_debug.ts';
import { migration as migration020 } from './migrations/020_create_tmdb_settings.ts';
import { migration as migration021 } from './migrations/021_create_parsed_release_cache.ts';
export interface Migration {
version: number;
@@ -254,7 +256,9 @@ export function loadMigrations(): Migration[] {
migration016,
migration017,
migration018,
migration019
migration019,
migration020,
migration021
];
// Sort by version number

View File

@@ -0,0 +1,36 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 020: Create tmdb_settings table
*
* Creates a table to store TMDB API configuration.
* Uses a singleton pattern (single row with id=1).
*
* Settings:
* - api_key: TMDB API key for authentication
*/
export const migration: Migration = {
version: 20,
name: 'Create tmdb_settings table',
up: `
CREATE TABLE tmdb_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
-- TMDB Configuration
api_key TEXT NOT NULL DEFAULT '',
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Insert default settings
INSERT INTO tmdb_settings (id) VALUES (1);
`,
down: `
DROP TABLE IF EXISTS tmdb_settings;
`
};

View File

@@ -0,0 +1,28 @@
import type { Migration } from '../migrations.ts';
export const migration: Migration = {
version: 21,
name: 'create_parsed_release_cache',
up: `
-- Cache for parsed release titles
-- Stores parser microservice responses to avoid redundant HTTP calls
-- Used by both custom format testing and quality profile entity testing
CREATE TABLE parsed_release_cache (
cache_key TEXT PRIMARY KEY, -- "{title}:{type}" e.g. "Movie.2024.1080p.WEB-DL:movie"
parser_version TEXT NOT NULL, -- Parser version when cached (for invalidation)
parsed_result TEXT NOT NULL, -- Full JSON ParseResult from parser
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Index for cleanup queries by version (delete old versions)
CREATE INDEX idx_parsed_release_cache_version ON parsed_release_cache(parser_version);
-- Index for potential cleanup queries by age
CREATE INDEX idx_parsed_release_cache_created_at ON parsed_release_cache(created_at);
`,
down: `
DROP INDEX IF EXISTS idx_parsed_release_cache_created_at;
DROP INDEX IF EXISTS idx_parsed_release_cache_version;
DROP TABLE IF EXISTS parsed_release_cache;
`
};

View File

@@ -0,0 +1,89 @@
import { db } from '../db.ts';
/**
* Types for parsed_release_cache table
*/
export interface ParsedReleaseCache {
cache_key: string;
parser_version: string;
parsed_result: string;
created_at: string;
}
/**
* All queries for parsed_release_cache table
*/
export const parsedReleaseCacheQueries = {
/**
* Get cached parse result by key and version
* Returns undefined if not found or version doesn't match
*/
get(cacheKey: string, parserVersion: string): ParsedReleaseCache | undefined {
return db.queryFirst<ParsedReleaseCache>(
'SELECT * FROM parsed_release_cache WHERE cache_key = ? AND parser_version = ?',
cacheKey,
parserVersion
);
},
/**
* Store parse result in cache (insert or replace)
*/
set(cacheKey: string, parserVersion: string, parsedResult: string): void {
db.execute(
'INSERT OR REPLACE INTO parsed_release_cache (cache_key, parser_version, parsed_result) VALUES (?, ?, ?)',
cacheKey,
parserVersion,
parsedResult
);
},
/**
* Delete a cached entry
*/
delete(cacheKey: string): boolean {
const affected = db.execute(
'DELETE FROM parsed_release_cache WHERE cache_key = ?',
cacheKey
);
return affected > 0;
},
/**
* Delete all entries for old parser versions
* Call this periodically or on startup to clean up stale cache entries
*/
deleteOldVersions(currentVersion: string): number {
return db.execute(
'DELETE FROM parsed_release_cache WHERE parser_version != ?',
currentVersion
);
},
/**
* Clear all cached entries
*/
clear(): number {
return db.execute('DELETE FROM parsed_release_cache');
},
/**
* Get cache stats
*/
getStats(): { total: number; byVersion: Record<string, number> } {
const total = db.queryFirst<{ count: number }>(
'SELECT COUNT(*) as count FROM parsed_release_cache'
)?.count ?? 0;
const versionCounts = db.query<{ parser_version: string; count: number }>(
'SELECT parser_version, COUNT(*) as count FROM parsed_release_cache GROUP BY parser_version'
);
const byVersion: Record<string, number> = {};
for (const row of versionCounts) {
byVersion[row.parser_version] = row.count;
}
return { total, byVersion };
}
};

View File

@@ -0,0 +1,70 @@
import { db } from '../db.ts';
/**
* Types for tmdb_settings table
*/
export interface TMDBSettings {
id: number;
api_key: string;
created_at: string;
updated_at: string;
}
export interface UpdateTMDBSettingsInput {
apiKey?: string;
}
/**
* All queries for tmdb_settings table
* Singleton pattern - only one settings record exists
*/
export const tmdbSettingsQueries = {
/**
* Get the TMDB settings (singleton)
*/
get(): TMDBSettings | undefined {
return db.queryFirst<TMDBSettings>('SELECT * FROM tmdb_settings WHERE id = 1');
},
/**
* Update TMDB settings
*/
update(input: UpdateTMDBSettingsInput): boolean {
const updates: string[] = [];
const params: (string | number)[] = [];
if (input.apiKey !== undefined) {
updates.push('api_key = ?');
params.push(input.apiKey);
}
if (updates.length === 0) {
return false;
}
// Add updated_at
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(1); // id is always 1
const affected = db.execute(
`UPDATE tmdb_settings SET ${updates.join(', ')} WHERE id = ?`,
...params
);
return affected > 0;
},
/**
* Reset TMDB settings to defaults
*/
reset(): boolean {
const affected = db.execute(`
UPDATE tmdb_settings SET
api_key = '',
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`);
return affected > 0;
}
};

View File

@@ -45,7 +45,8 @@ export class PCDCache {
try {
// 1. Create in-memory database
this.db = new Database(':memory:');
// Enable int64 mode to properly handle large integers (e.g., file sizes in bytes)
this.db = new Database(':memory:', { int64: true });
// Enable foreign keys
this.db.exec('PRAGMA foreign_keys = ON');

View File

@@ -0,0 +1,221 @@
/**
* Get all custom format conditions for batch evaluation
* Used by entity testing to evaluate releases against all CFs at once
*/
import type { PCDCache } from '../../cache.ts';
import type { ConditionData } from './conditions.ts';
export interface CustomFormatWithConditions {
id: number;
name: string;
conditions: ConditionData[];
}
/**
* Get all custom formats with their conditions for batch evaluation
* Optimized to fetch all data in minimal queries
*/
export async function getAllConditionsForEvaluation(
cache: PCDCache
): Promise<CustomFormatWithConditions[]> {
const db = cache.kb;
// Get all custom formats
const formats = await db
.selectFrom('custom_formats')
.select(['id', 'name'])
.orderBy('name')
.execute();
if (formats.length === 0) return [];
// Get all conditions for all formats
const conditions = await db
.selectFrom('custom_format_conditions')
.select(['id', 'custom_format_id', 'name', 'type', 'negate', 'required'])
.execute();
if (conditions.length === 0) {
return formats.map((f) => ({ id: f.id, name: f.name, conditions: [] }));
}
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 for condition data
const patternsMap = new Map<number, { id: number; pattern: string }[]>();
for (const p of patterns) {
if (!patternsMap.has(p.custom_format_condition_id)) {
patternsMap.set(p.custom_format_condition_id, []);
}
patternsMap.get(p.custom_format_condition_id)!.push({ id: p.id, pattern: p.pattern });
}
const languagesMap = new Map<number, { id: number; name: string; except: boolean }[]>();
for (const l of languages) {
if (!languagesMap.has(l.custom_format_condition_id)) {
languagesMap.set(l.custom_format_condition_id, []);
}
languagesMap.get(l.custom_format_condition_id)!.push({
id: l.id,
name: l.name,
except: l.except_language === 1
});
}
const sourcesMap = new Map<number, string[]>();
for (const s of sources) {
if (!sourcesMap.has(s.custom_format_condition_id)) {
sourcesMap.set(s.custom_format_condition_id, []);
}
sourcesMap.get(s.custom_format_condition_id)!.push(s.source);
}
const resolutionsMap = new Map<number, string[]>();
for (const r of resolutions) {
if (!resolutionsMap.has(r.custom_format_condition_id)) {
resolutionsMap.set(r.custom_format_condition_id, []);
}
resolutionsMap.get(r.custom_format_condition_id)!.push(r.resolution);
}
const qualityModifiersMap = new Map<number, string[]>();
for (const q of qualityModifiers) {
if (!qualityModifiersMap.has(q.custom_format_condition_id)) {
qualityModifiersMap.set(q.custom_format_condition_id, []);
}
qualityModifiersMap.get(q.custom_format_condition_id)!.push(q.quality_modifier);
}
const releaseTypesMap = new Map<number, string[]>();
for (const r of releaseTypes) {
if (!releaseTypesMap.has(r.custom_format_condition_id)) {
releaseTypesMap.set(r.custom_format_condition_id, []);
}
releaseTypesMap.get(r.custom_format_condition_id)!.push(r.release_type);
}
const indexerFlagsMap = new Map<number, string[]>();
for (const f of indexerFlags) {
if (!indexerFlagsMap.has(f.custom_format_condition_id)) {
indexerFlagsMap.set(f.custom_format_condition_id, []);
}
indexerFlagsMap.get(f.custom_format_condition_id)!.push(f.flag);
}
const sizesMap = new Map<number, { minBytes: number | null; maxBytes: number | null }>();
for (const s of sizes) {
sizesMap.set(s.custom_format_condition_id, {
minBytes: s.min_bytes,
maxBytes: s.max_bytes
});
}
const yearsMap = new Map<number, { minYear: number | null; maxYear: number | null }>();
for (const y of years) {
yearsMap.set(y.custom_format_condition_id, {
minYear: y.min_year,
maxYear: y.max_year
});
}
// Build conditions by format
const conditionsByFormat = new Map<number, ConditionData[]>();
for (const c of conditions) {
if (!conditionsByFormat.has(c.custom_format_id)) {
conditionsByFormat.set(c.custom_format_id, []);
}
conditionsByFormat.get(c.custom_format_id)!.push({
id: c.id,
name: c.name,
type: c.type,
negate: c.negate === 1,
required: c.required === 1,
patterns: patternsMap.get(c.id),
languages: languagesMap.get(c.id),
sources: sourcesMap.get(c.id),
resolutions: resolutionsMap.get(c.id),
qualityModifiers: qualityModifiersMap.get(c.id),
releaseTypes: releaseTypesMap.get(c.id),
indexerFlags: indexerFlagsMap.get(c.id),
size: sizesMap.get(c.id),
years: yearsMap.get(c.id)
});
}
// Build final result
return formats.map((f) => ({
id: f.id,
name: f.name,
conditions: conditionsByFormat.get(f.id) || []
}));
}

View File

@@ -42,22 +42,59 @@ export interface ParsedInfo {
releaseType: string | null;
}
// Name mappings
/** Custom format with conditions for evaluation */
export interface CustomFormatWithConditions {
id: number;
name: string;
conditions: ConditionData[];
}
/**
* Extract all unique regex patterns from custom format conditions
* These are patterns that need to be matched against release titles
*/
export function extractAllPatterns(customFormats: CustomFormatWithConditions[]): string[] {
const patterns = new Set<string>();
for (const cf of customFormats) {
for (const condition of cf.conditions) {
// Pattern-based conditions: release_title, edition, release_group
if (condition.patterns) {
for (const p of condition.patterns) {
if (p.pattern) {
patterns.add(p.pattern);
}
}
}
}
}
return Array.from(patterns);
}
/**
* Normalize a value for comparison by removing hyphens, spaces, underscores, and lowercasing
*/
function normalize(value: string): string {
return value.toLowerCase().replace(/[-_\s]/g, '');
}
// Canonical value mappings (matches src/lib/shared/conditionTypes.ts)
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'
[QualitySource.Unknown]: 'unknown',
[QualitySource.Cam]: 'cam',
[QualitySource.Telesync]: 'telesync',
[QualitySource.Telecine]: 'telecine',
[QualitySource.Workprint]: 'workprint',
[QualitySource.DVD]: 'dvd',
[QualitySource.TV]: 'television',
[QualitySource.WebDL]: 'webdl',
[QualitySource.WebRip]: 'webrip',
[QualitySource.Bluray]: 'bluray'
};
const resolutionNames: Record<Resolution, string> = {
[Resolution.Unknown]: 'Unknown',
[Resolution.Unknown]: 'unknown',
[Resolution.R360p]: '360p',
[Resolution.R480p]: '480p',
[Resolution.R540p]: '540p',
@@ -68,19 +105,19 @@ const resolutionNames: Record<Resolution, string> = {
};
const modifierNames: Record<QualityModifier, string> = {
[QualityModifier.None]: 'None',
[QualityModifier.Regional]: 'Regional',
[QualityModifier.Screener]: 'Screener',
[QualityModifier.RawHD]: 'RawHD',
[QualityModifier.BRDisk]: 'BRDisk',
[QualityModifier.Remux]: 'Remux'
[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'
[ReleaseType.Unknown]: 'unknown',
[ReleaseType.SingleEpisode]: 'single_episode',
[ReleaseType.MultiEpisode]: 'multi_episode',
[ReleaseType.SeasonPack]: 'season_pack'
};
const languageNames: Record<Language, string> = {
@@ -173,11 +210,12 @@ interface ConditionEvalResult {
function evaluateCondition(
condition: ConditionData,
parsed: ParseResult,
title: string
title: string,
patternMatches?: Map<string, boolean>
): ConditionEvalResult {
switch (condition.type) {
case 'release_title':
return evaluatePattern(condition, title);
return evaluatePattern(condition, title, patternMatches);
case 'language':
return evaluateLanguage(condition, parsed);
@@ -198,10 +236,10 @@ function evaluateCondition(
return evaluateYear(condition, parsed);
case 'edition':
return evaluateEdition(condition, parsed, title);
return evaluateEdition(condition, parsed);
case 'release_group':
return evaluateReleaseGroup(condition, parsed, title);
return evaluateReleaseGroup(condition, parsed);
// These require additional data we don't have
case 'indexer_flag':
@@ -210,15 +248,18 @@ function evaluateCondition(
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
* Evaluate regex pattern against title using pre-computed pattern matches
*/
function evaluatePattern(condition: ConditionData, title: string): ConditionEvalResult {
function evaluatePattern(
condition: ConditionData,
title: string,
patternMatches?: Map<string, boolean>
): ConditionEvalResult {
if (!condition.patterns || condition.patterns.length === 0) {
return { matched: false, expected: 'No patterns defined', actual: title };
}
@@ -227,13 +268,22 @@ function evaluatePattern(condition: ConditionData, title: string): ConditionEval
const expected = patternStrs.join(' OR ');
for (const pattern of condition.patterns) {
try {
const regex = new RegExp(pattern.pattern, 'i');
if (regex.test(title)) {
// Use pre-computed pattern matches if available
if (patternMatches) {
const matched = patternMatches.get(pattern.pattern);
if (matched) {
return { matched: true, expected, actual: `Matched: ${pattern.pattern}` };
}
} catch (e) {
logger.warn(`Invalid regex pattern: ${pattern.pattern}`, e);
} else {
// Fallback to JS regex (may not work for .NET-specific patterns)
try {
const regex = new RegExp(pattern.pattern, 'i');
if (regex.test(title)) {
return { matched: true, expected, actual: `Matched: ${pattern.pattern}` };
}
} catch {
// Invalid JS regex - skip this pattern
}
}
}
return { matched: false, expected, actual: 'No match' };
@@ -287,9 +337,9 @@ function evaluateSource(condition: ConditionData, parsed: ParseResult): Conditio
return { matched: false, expected: 'No sources defined', actual: 'N/A' };
}
const actual = sourceNames[parsed.source] || 'Unknown';
const actual = sourceNames[parsed.source] || 'unknown';
const expected = condition.sources.join(' OR ');
const matched = condition.sources.some((s) => s.toLowerCase() === actual.toLowerCase());
const matched = condition.sources.some((s) => normalize(s) === normalize(actual));
return { matched, expected, actual };
}
@@ -302,9 +352,9 @@ function evaluateResolution(condition: ConditionData, parsed: ParseResult): Cond
return { matched: false, expected: 'No resolutions defined', actual: 'N/A' };
}
const actual = resolutionNames[parsed.resolution] || 'Unknown';
const actual = resolutionNames[parsed.resolution] || 'unknown';
const expected = condition.resolutions.join(' OR ');
const matched = condition.resolutions.some((r) => r.toLowerCase() === actual.toLowerCase());
const matched = condition.resolutions.some((r) => normalize(r) === normalize(actual));
return { matched, expected, actual };
}
@@ -317,15 +367,15 @@ function evaluateQualityModifier(condition: ConditionData, parsed: ParseResult):
return { matched: false, expected: 'No modifiers defined', actual: 'N/A' };
}
const actual = modifierNames[parsed.modifier] || 'None';
const actual = modifierNames[parsed.modifier] || 'none';
const expected = condition.qualityModifiers.join(' OR ');
const matched = condition.qualityModifiers.some((m) => m.toLowerCase() === actual.toLowerCase());
const matched = condition.qualityModifiers.some((m) => normalize(m) === normalize(actual));
return { matched, expected, actual };
}
/**
* Evaluate release type condition (SingleEpisode, SeasonPack, etc.)
* Evaluate release type condition (single_episode, season_pack, etc.)
*/
function evaluateReleaseType(condition: ConditionData, parsed: ParseResult): ConditionEvalResult {
if (!condition.releaseTypes || condition.releaseTypes.length === 0) {
@@ -338,8 +388,8 @@ function evaluateReleaseType(condition: ConditionData, parsed: ParseResult): Con
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());
const actual = releaseTypeNames[parsed.episode.releaseType] || 'unknown';
const matched = condition.releaseTypes.some((t) => normalize(t) === normalize(actual));
return { matched, expected, actual };
}
@@ -372,26 +422,35 @@ function evaluateYear(condition: ConditionData, parsed: ParseResult): ConditionE
}
/**
* Evaluate edition condition (regex on edition or title)
* Evaluate edition condition
* Matches patterns against the PARSED edition only (not full title)
*/
function evaluateEdition(condition: ConditionData, parsed: ParseResult, title: string): ConditionEvalResult {
function evaluateEdition(
condition: ConditionData,
parsed: ParseResult
): 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 ');
// If no edition was parsed, can't match
if (!parsed.edition) {
return { matched: false, expected, actual };
}
// Match patterns against parsed edition only
for (const pattern of condition.patterns) {
try {
const regex = new RegExp(pattern.pattern, 'i');
if (regex.test(textToCheck)) {
if (regex.test(parsed.edition)) {
return { matched: true, expected, actual };
}
} catch (e) {
logger.warn(`Invalid regex pattern: ${pattern.pattern}`, e);
} catch {
// Invalid regex - skip
}
}
return { matched: false, expected, actual };
@@ -399,25 +458,34 @@ function evaluateEdition(condition: ConditionData, parsed: ParseResult, title: s
/**
* Evaluate release group condition
* Matches patterns against the PARSED release group only (not full title)
*/
function evaluateReleaseGroup(condition: ConditionData, parsed: ParseResult, title: string): ConditionEvalResult {
function evaluateReleaseGroup(
condition: ConditionData,
parsed: ParseResult
): 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 ');
// If no release group was parsed, can't match
if (!parsed.releaseGroup) {
return { matched: false, expected, actual };
}
// Match patterns against parsed release group only
for (const pattern of condition.patterns) {
try {
const regex = new RegExp(pattern.pattern, 'i');
if (regex.test(textToCheck)) {
if (regex.test(parsed.releaseGroup)) {
return { matched: true, expected, actual };
}
} catch (e) {
logger.warn(`Invalid regex pattern: ${pattern.pattern}`, e);
} catch {
// Invalid regex - skip
}
}
return { matched: false, expected, actual };
@@ -426,19 +494,28 @@ function evaluateReleaseGroup(condition: ConditionData, parsed: ParseResult, tit
/**
* 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)
* Custom format matching logic (matches Radarr/Sonarr behavior):
* - Conditions are grouped by type (release_title, resolution, source, etc.)
* - Between types → AND: every type must pass
* - Within a type → OR: any condition can satisfy it
* - Required modifier: turns that type's logic from OR to AND
* (if any condition in a type is required, ALL required conditions must pass)
*
* @param conditions - The conditions to evaluate
* @param parsed - The parsed release result
* @param title - The release title
* @param patternMatches - Pre-computed pattern matches from .NET regex (optional)
*/
export function evaluateCustomFormat(
conditions: ConditionData[],
parsed: ParseResult,
title: string
title: string,
patternMatches?: Map<string, boolean>
): EvaluationResult {
const results: ConditionResult[] = [];
for (const condition of conditions) {
const evalResult = evaluateCondition(condition, parsed, title);
const evalResult = evaluateCondition(condition, parsed, title, patternMatches);
const passes = condition.negate ? !evalResult.matched : evalResult.matched;
results.push({
@@ -454,20 +531,42 @@ export function evaluateCustomFormat(
});
}
// Check if format matches
const requiredConditions = results.filter((r) => r.required);
const optionalConditions = results.filter((r) => !r.required);
// Group results by condition type
const typeGroups = new Map<string, ConditionResult[]>();
for (const result of results) {
if (!typeGroups.has(result.conditionType)) {
typeGroups.set(result.conditionType, []);
}
typeGroups.get(result.conditionType)!.push(result);
}
// All required must pass
const allRequiredPass = requiredConditions.every((r) => r.passes);
// Evaluate each type group
// Between types → AND: every type must pass
// Within a type:
// - If any condition is required: ALL required must pass (AND), optional ignored
// - If no conditions are required: at least ONE must pass (OR)
let allTypesPass = true;
// 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);
for (const [, groupResults] of typeGroups) {
const requiredInGroup = groupResults.filter((r) => r.required);
let typeGroupPasses: boolean;
if (requiredInGroup.length > 0) {
// AND logic: all required conditions must pass
typeGroupPasses = requiredInGroup.every((r) => r.passes);
} else {
// OR logic: at least one condition must pass
typeGroupPasses = groupResults.some((r) => r.passes);
}
if (!typeGroupPasses) {
allTypesPass = false;
break;
}
}
return {
matches: allRequiredPass && optionalPass,
matches: allTypesPass,
conditions: results
};
}

View File

@@ -0,0 +1,93 @@
/**
* Create test entity operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
export interface CreateTestEntityInput {
type: 'movie' | 'series';
tmdbId: number;
title: string;
year: number | null;
posterPath: string | null;
}
export interface CreateTestEntitiesOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
inputs: CreateTestEntityInput[];
}
/**
* Create test entities by writing an operation to the specified layer
* Skips entities that already exist (by type + tmdb_id)
*/
export async function create(options: CreateTestEntitiesOptions) {
const { databaseId, cache, layer, inputs } = options;
const db = cache.kb;
// Check for existing entities
const existingEntities = await db
.selectFrom('test_entities')
.select(['type', 'tmdb_id'])
.execute();
const existingKeys = new Set(
existingEntities.map((e) => `${e.type}-${e.tmdb_id}`)
);
// Filter out duplicates
const newInputs = inputs.filter(
(input) => !existingKeys.has(`${input.type}-${input.tmdbId}`)
);
const skippedCount = inputs.length - newInputs.length;
// If all entities already exist, return early
if (newInputs.length === 0) {
return {
success: true,
added: 0,
skipped: skippedCount
};
}
const queries = [];
for (const input of newInputs) {
const insertEntity = db
.insertInto('test_entities')
.values({
type: input.type,
tmdb_id: input.tmdbId,
title: input.title,
year: input.year,
poster_path: input.posterPath
})
.compile();
queries.push(insertEntity);
}
const name = newInputs.length === 1 ? newInputs[0].title : `${newInputs.length} entities`;
const result = await writeOperation({
databaseId,
layer,
description: `create-test-entities`,
queries,
metadata: {
operation: 'create',
entity: 'test_entity',
name
}
});
return {
...result,
added: newInputs.length,
skipped: skippedCount
};
}

View File

@@ -0,0 +1,56 @@
/**
* Create test release operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
export interface CreateTestReleaseInput {
entityId: number;
title: string;
size_bytes: number | null;
languages: string[];
indexers: string[];
flags: string[];
}
export interface CreateTestReleaseOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
input: CreateTestReleaseInput;
}
/**
* Create a test release by writing an operation to the specified layer
*/
export async function createRelease(options: CreateTestReleaseOptions) {
const { databaseId, cache, layer, input } = options;
const db = cache.kb;
const insertRelease = db
.insertInto('test_releases')
.values({
test_entity_id: input.entityId,
title: input.title,
size_bytes: input.size_bytes,
languages: JSON.stringify(input.languages),
indexers: JSON.stringify(input.indexers),
flags: JSON.stringify(input.flags)
})
.compile();
const result = await writeOperation({
databaseId,
layer,
description: `create-test-release`,
queries: [insertRelease],
metadata: {
operation: 'create',
entity: 'test_release',
name: input.title.substring(0, 50)
}
});
return result;
}

View File

@@ -0,0 +1,44 @@
/**
* Delete test entity operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
export interface DeleteTestEntityOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
entityId: number;
}
/**
* Delete a test entity and its releases by writing an operation to the specified layer
*/
export async function remove(options: DeleteTestEntityOptions) {
const { databaseId, cache, layer, entityId } = options;
const db = cache.kb;
// Delete releases first (foreign key constraint)
const deleteReleases = db
.deleteFrom('test_releases')
.where('test_entity_id', '=', entityId)
.compile();
// Delete the entity
const deleteEntity = db.deleteFrom('test_entities').where('id', '=', entityId).compile();
const result = await writeOperation({
databaseId,
layer,
description: `delete-test-entity-${entityId}`,
queries: [deleteReleases, deleteEntity],
metadata: {
operation: 'delete',
entity: 'test_entity',
name: `id:${entityId}`
}
});
return result;
}

View File

@@ -0,0 +1,40 @@
/**
* Delete test release operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
export interface DeleteTestReleaseOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
releaseId: number;
}
/**
* Delete a test release by writing an operation to the specified layer
*/
export async function deleteRelease(options: DeleteTestReleaseOptions) {
const { databaseId, cache, layer, releaseId } = options;
const db = cache.kb;
const deleteQuery = db
.deleteFrom('test_releases')
.where('id', '=', releaseId)
.compile();
const result = await writeOperation({
databaseId,
layer,
description: `delete-test-release-${releaseId}`,
queries: [deleteQuery],
metadata: {
operation: 'delete',
entity: 'test_release',
name: `id:${releaseId}`
}
});
return result;
}

View File

@@ -0,0 +1,21 @@
/**
* Entity Test queries and mutations
*/
// Export types
export type { CreateTestEntityInput, CreateTestEntitiesOptions } from './create.ts';
export type { CreateTestReleaseInput, CreateTestReleaseOptions } from './createRelease.ts';
export type { UpdateTestReleaseInput, UpdateTestReleaseOptions } from './updateRelease.ts';
export type { DeleteTestReleaseOptions } from './deleteRelease.ts';
// Export query functions
export { list } from './list.ts';
// Export entity mutation functions
export { create } from './create.ts';
export { remove } from './delete.ts';
// Export release mutation functions
export { createRelease } from './createRelease.ts';
export { updateRelease } from './updateRelease.ts';
export { deleteRelease } from './deleteRelease.ts';

View File

@@ -0,0 +1,75 @@
/**
* Entity test list queries
*/
import type { PCDCache } from '../../cache.ts';
/**
* Get all test entities with their releases
*/
export async function list(cache: PCDCache) {
const db = cache.kb;
// 1. Get all test entities
const entities = await db
.selectFrom('test_entities')
.select([
'id',
'type',
'tmdb_id',
'title',
'year',
'poster_path',
'created_at',
'updated_at'
])
.orderBy('title')
.execute();
if (entities.length === 0) return [];
const entityIds = entities.map((e) => e.id);
// 2. Get all releases for all entities
const allReleases = await db
.selectFrom('test_releases')
.select([
'id',
'test_entity_id',
'title',
'size_bytes',
'languages',
'indexers',
'flags',
'created_at',
'updated_at'
])
.where('test_entity_id', 'in', entityIds)
.orderBy('test_entity_id')
.orderBy('title')
.execute();
// Build releases map
const releasesMap = new Map<number, typeof allReleases>();
for (const release of allReleases) {
if (!releasesMap.has(release.test_entity_id)) {
releasesMap.set(release.test_entity_id, []);
}
releasesMap.get(release.test_entity_id)!.push(release);
}
// Build the final result
return entities.map((entity) => ({
...entity,
releases: (releasesMap.get(entity.id) || []).map((r) => ({
id: r.id,
title: r.title,
size_bytes: r.size_bytes !== null ? Number(r.size_bytes) : null,
languages: JSON.parse(r.languages) as string[],
indexers: JSON.parse(r.indexers) as string[],
flags: JSON.parse(r.flags) as string[],
created_at: r.created_at,
updated_at: r.updated_at
}))
}));
}

View File

@@ -0,0 +1,56 @@
/**
* Update test release operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
export interface UpdateTestReleaseInput {
id: number;
title: string;
size_bytes: number | null;
languages: string[];
indexers: string[];
flags: string[];
}
export interface UpdateTestReleaseOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
input: UpdateTestReleaseInput;
}
/**
* Update a test release by writing an operation to the specified layer
*/
export async function updateRelease(options: UpdateTestReleaseOptions) {
const { databaseId, cache, layer, input } = options;
const db = cache.kb;
const updateQuery = db
.updateTable('test_releases')
.set({
title: input.title,
size_bytes: input.size_bytes,
languages: JSON.stringify(input.languages),
indexers: JSON.stringify(input.indexers),
flags: JSON.stringify(input.flags)
})
.where('id', '=', input.id)
.compile();
const result = await writeOperation({
databaseId,
layer,
description: `update-test-release-${input.id}`,
queries: [updateQuery],
metadata: {
operation: 'update',
entity: 'test_release',
name: input.title.substring(0, 50)
}
});
return result;
}

View File

@@ -0,0 +1,86 @@
/**
* Get all custom format scores for all quality profiles
* Used by entity testing to calculate scores client-side
*/
import type { PCDCache } from '../../cache.ts';
export interface ProfileCfScores {
profileId: number;
/** Map of custom format ID to score (by arr type) */
scores: Record<number, { radarr: number | null; sonarr: number | null }>;
}
export interface AllCfScoresResult {
/** All custom formats with their names */
customFormats: Array<{ id: number; name: string }>;
/** CF scores per profile */
profiles: ProfileCfScores[];
}
/**
* Get all custom format scores for all quality profiles
*/
export async function allCfScores(cache: PCDCache): Promise<AllCfScoresResult> {
const db = cache.kb;
// Get all custom formats
const customFormats = await db
.selectFrom('custom_formats')
.select(['id', 'name'])
.orderBy('name')
.execute();
// Get all quality profiles
const profiles = await db
.selectFrom('quality_profiles')
.select(['id'])
.execute();
// Get all CF scores for all profiles
const allScores = await db
.selectFrom('quality_profile_custom_formats')
.select(['quality_profile_id', 'custom_format_id', 'arr_type', 'score'])
.execute();
// Build scores map: profileId -> cfId -> arrType -> score
const scoresMap = new Map<number, Map<number, Map<string, number>>>();
for (const score of allScores) {
if (!scoresMap.has(score.quality_profile_id)) {
scoresMap.set(score.quality_profile_id, new Map());
}
const profileScores = scoresMap.get(score.quality_profile_id)!;
if (!profileScores.has(score.custom_format_id)) {
profileScores.set(score.custom_format_id, new Map());
}
profileScores.get(score.custom_format_id)!.set(score.arr_type, score.score);
}
// Build result
const profilesResult: ProfileCfScores[] = profiles.map((profile) => {
const profileScores = scoresMap.get(profile.id);
const scores: Record<number, { radarr: number | null; sonarr: number | null }> = {};
for (const cf of customFormats) {
const cfScores = profileScores?.get(cf.id);
const allScore = cfScores?.get('all') ?? null;
scores[cf.id] = {
radarr: cfScores?.get('radarr') ?? allScore,
sonarr: cfScores?.get('sonarr') ?? allScore
};
}
return {
profileId: profile.id,
scores
};
});
return {
customFormats,
profiles: profilesResult
};
}

View File

@@ -26,10 +26,14 @@ export type {
// Export query functions
export { list } from './list.ts';
export { names } from './names.ts';
export { select } from './select.ts';
export type { QualityProfileOption } from './select.ts';
export { general } from './general.ts';
export { languages } from './languages.ts';
export { qualities } from './qualities.ts';
export { scoring } from './scoring.ts';
export { allCfScores } from './allCfScores.ts';
export type { ProfileCfScores, AllCfScoresResult } from './allCfScores.ts';
export { create } from './create.ts';
export { updateGeneral } from './updateGeneral.ts';
export { updateScoring } from './updateScoring.ts';

View File

@@ -0,0 +1,25 @@
/**
* Quality profile select options query
*/
import type { PCDCache } from '../../cache.ts';
export interface QualityProfileOption {
id: number;
name: string;
}
/**
* Get quality profile options for select/dropdown components
*/
export async function select(cache: PCDCache): Promise<QualityProfileOption[]> {
const db = cache.kb;
const profiles = await db
.selectFrom('quality_profiles')
.select(['id', 'name'])
.orderBy('name')
.execute();
return profiles;
}

View File

@@ -204,6 +204,33 @@ export interface CustomFormatTestsTable {
created_at: Generated<string>;
}
// ============================================================================
// QUALITY PROFILE TESTING
// ============================================================================
export interface TestEntitiesTable {
id: Generated<number>;
type: 'movie' | 'series';
tmdb_id: number;
title: string;
year: number | null;
poster_path: string | null;
created_at: Generated<string>;
updated_at: Generated<string>;
}
export interface TestReleasesTable {
id: Generated<number>;
test_entity_id: number;
title: string;
size_bytes: number | null;
languages: string; // JSON array
indexers: string; // JSON array
flags: string; // JSON array
created_at: Generated<string>;
updated_at: Generated<string>;
}
// ============================================================================
// DELAY PROFILES
// ============================================================================
@@ -321,6 +348,8 @@ export interface PCDDatabase {
condition_release_types: ConditionReleaseTypesTable;
condition_years: ConditionYearsTable;
custom_format_tests: CustomFormatTestsTable;
test_entities: TestEntitiesTable;
test_releases: TestReleasesTable;
delay_profiles: DelayProfilesTable;
delay_profile_tags: DelayProfileTagsTable;
quality_api_mappings: QualityApiMappingsTable;

View File

@@ -1,9 +1,11 @@
/**
* Parser Service Client
* Calls the C# parser microservice
* Calls the C# parser microservice with optional caching
*/
import { config } from '$config';
import { logger } from '$logger/logger.ts';
import { parsedReleaseCacheQueries } from '$db/queries/parsedReleaseCache.ts';
import {
QualitySource,
QualityModifier,
@@ -16,6 +18,9 @@ import {
type MediaType
} from './types.ts';
// Cached parser version (fetched once per session)
let cachedParserVersion: string | null = null;
interface EpisodeResponse {
seriesTitle: string | null;
seasonNumber: number;
@@ -134,3 +139,220 @@ export async function isParserHealthy(): Promise<boolean> {
return false;
}
}
/**
* Get the parser version from the health endpoint
* Caches the version for the session to avoid repeated calls
*/
export async function getParserVersion(): Promise<string | null> {
if (cachedParserVersion) {
return cachedParserVersion;
}
try {
const res = await fetch(`${config.parserUrl}/health`);
if (!res.ok) {
await logger.warn('Parser health check failed', {
source: 'ParserClient',
meta: { status: res.status }
});
return null;
}
const data: { status: string; version: string } = await res.json();
cachedParserVersion = data.version;
await logger.debug(`Parser version: ${data.version}`, { source: 'ParserClient' });
return cachedParserVersion;
} catch (err) {
await logger.warn('Failed to connect to parser service', {
source: 'ParserClient',
meta: { error: err instanceof Error ? err.message : 'Unknown error' }
});
return null;
}
}
/**
* Clear the cached parser version
* Call this if you need to re-fetch the version (e.g., after parser restart)
*/
export function clearParserVersionCache(): void {
cachedParserVersion = null;
}
/**
* Generate cache key for a release title
*/
function getCacheKey(title: string, type: MediaType): string {
return `${title}:${type}`;
}
/**
* Parse a release title with caching
* First checks the cache, falls back to parser service on miss
* Automatically handles version invalidation
*
* @param title - The release title to parse
* @param type - The media type: 'movie' or 'series'
* @returns ParseResult or null if parser unavailable
*/
export async function parseWithCache(
title: string,
type: MediaType
): Promise<ParseResult | null> {
const parserVersion = await getParserVersion();
if (!parserVersion) {
// Parser not available
return null;
}
const cacheKey = getCacheKey(title, type);
// Check cache first
const cached = parsedReleaseCacheQueries.get(cacheKey, parserVersion);
if (cached) {
return JSON.parse(cached.parsed_result) as ParseResult;
}
// Cache miss - parse and store
try {
const result = await parse(title, type);
// Store in cache
parsedReleaseCacheQueries.set(cacheKey, parserVersion, JSON.stringify(result));
return result;
} catch {
// Parser error
return null;
}
}
/**
* Parse multiple release titles with caching (batch operation)
* More efficient than calling parseWithCache in a loop
*
* @param items - Array of { title, type } to parse
* @returns Map of cache key to ParseResult (null for failures)
*/
export async function parseWithCacheBatch(
items: Array<{ title: string; type: MediaType }>
): Promise<Map<string, ParseResult | null>> {
const results = new Map<string, ParseResult | null>();
const parserVersion = await getParserVersion();
if (!parserVersion) {
// Parser not available - return all nulls
await logger.debug(`Parser unavailable, skipping ${items.length} items`, {
source: 'ParserCache'
});
for (const item of items) {
results.set(getCacheKey(item.title, item.type), null);
}
return results;
}
// Separate cached vs uncached
const uncached: Array<{ title: string; type: MediaType; cacheKey: string }> = [];
for (const item of items) {
const cacheKey = getCacheKey(item.title, item.type);
const cached = parsedReleaseCacheQueries.get(cacheKey, parserVersion);
if (cached) {
results.set(cacheKey, JSON.parse(cached.parsed_result) as ParseResult);
} else {
uncached.push({ ...item, cacheKey });
}
}
const cacheHits = items.length - uncached.length;
// Parse uncached items in parallel
if (uncached.length > 0) {
const parsePromises = uncached.map(async (item) => {
try {
const result = await parse(item.title, item.type);
// Store in cache
parsedReleaseCacheQueries.set(item.cacheKey, parserVersion, JSON.stringify(result));
return { cacheKey: item.cacheKey, result };
} catch {
return { cacheKey: item.cacheKey, result: null };
}
});
const parsed = await Promise.all(parsePromises);
for (const { cacheKey, result } of parsed) {
results.set(cacheKey, result);
}
}
await logger.debug(`Parsed ${items.length} releases: ${cacheHits} cache hits, ${uncached.length} parsed`, {
source: 'ParserCache',
meta: { total: items.length, cacheHits, parsed: uncached.length, version: parserVersion }
});
return results;
}
/**
* Clean up old cache entries from previous parser versions
* Call this on startup or periodically
*/
export async function cleanupOldCacheEntries(): Promise<number> {
const parserVersion = await getParserVersion();
if (!parserVersion) {
return 0;
}
const deleted = parsedReleaseCacheQueries.deleteOldVersions(parserVersion);
if (deleted > 0) {
await logger.info(`Cleaned up ${deleted} stale parser cache entries`, {
source: 'ParserCache',
meta: { deleted, currentVersion: parserVersion }
});
}
return deleted;
}
/**
* Match multiple regex patterns against a text string using .NET regex
* This ensures patterns work exactly as they do in Sonarr/Radarr
*
* @param text - The text to match against (e.g., release title)
* @param patterns - Array of regex patterns to test
* @returns Map of pattern -> matched (true/false), or null if parser unavailable
*/
export async function matchPatterns(
text: string,
patterns: string[]
): Promise<Map<string, boolean> | null> {
if (patterns.length === 0) {
return new Map();
}
try {
const res = await fetch(`${config.parserUrl}/match`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, patterns })
});
if (!res.ok) {
await logger.warn('Pattern match request failed', {
source: 'ParserClient',
meta: { status: res.status }
});
return null;
}
const data: { results: Record<string, boolean> } = await res.json();
return new Map(Object.entries(data.results));
} catch (err) {
await logger.warn('Failed to connect to parser for pattern matching', {
source: 'ParserClient',
meta: { error: err instanceof Error ? err.message : 'Unknown error' }
});
return null;
}
}

View File

@@ -1,7 +1,17 @@
/**
* Release Title Parser
* Client for the C# parser microservice
* Client for the C# parser microservice with caching support
*/
export * from './types.ts';
export { parse, parseQuality, isParserHealthy } from './client.ts';
export {
parse,
parseQuality,
isParserHealthy,
getParserVersion,
clearParserVersionCache,
parseWithCache,
parseWithCacheBatch,
cleanupOldCacheEntries,
matchPatterns
} from './client.ts';

View File

@@ -0,0 +1,53 @@
import { BaseHttpClient } from '../http/client.ts';
import { logger } from '../logger/logger.ts';
import type { TMDBMovieSearchResponse, TMDBTVSearchResponse, TMDBAuthResponse } from './types.ts';
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
/**
* TMDB API client
*/
export class TMDBClient extends BaseHttpClient {
constructor(apiKey: string) {
super(TMDB_BASE_URL, {
headers: {
Authorization: `Bearer ${apiKey}`
}
});
}
/**
* Validate the API key
*/
async validateKey(): Promise<TMDBAuthResponse> {
return this.get<TMDBAuthResponse>('/authentication');
}
/**
* Search for movies
*/
async searchMovies(query: string, page = 1): Promise<TMDBMovieSearchResponse> {
logger.debug(`Searching movies: "${query}"`, { source: 'TMDB' });
const params = new URLSearchParams({
query,
include_adult: 'false',
language: 'en-US',
page: String(page)
});
return this.get<TMDBMovieSearchResponse>(`/search/movie?${params}`);
}
/**
* Search for TV shows
*/
async searchTVShows(query: string, page = 1): Promise<TMDBTVSearchResponse> {
logger.debug(`Searching TV shows: "${query}"`, { source: 'TMDB' });
const params = new URLSearchParams({
query,
include_adult: 'false',
language: 'en-US',
page: String(page)
});
return this.get<TMDBTVSearchResponse>(`/search/tv?${params}`);
}
}

View File

@@ -0,0 +1,57 @@
/**
* TMDB API response types
*/
export interface TMDBMovie {
adult: boolean;
backdrop_path: string | null;
genre_ids: number[];
id: number;
original_language: string;
original_title: string;
overview: string;
popularity: number;
poster_path: string | null;
release_date: string;
title: string;
video: boolean;
vote_average: number;
vote_count: number;
}
export interface TMDBMovieSearchResponse {
page: number;
results: TMDBMovie[];
total_pages: number;
total_results: number;
}
export interface TMDBTVShow {
adult: boolean;
backdrop_path: string | null;
genre_ids: number[];
id: number;
origin_country: string[];
original_language: string;
original_name: string;
overview: string;
popularity: number;
poster_path: string | null;
first_air_date: string;
name: string;
vote_average: number;
vote_count: number;
}
export interface TMDBTVSearchResponse {
page: number;
results: TMDBTVShow[];
total_pages: number;
total_results: number;
}
export interface TMDBAuthResponse {
success: boolean;
status_code: number;
status_message: string;
}

View File

@@ -0,0 +1,108 @@
/**
* API endpoint for parsing and evaluating release titles against custom formats
* Used by entity testing to get CF matches for scoring
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { pcdManager } from '$pcd/pcd.ts';
import { parseWithCacheBatch, isParserHealthy } from '$lib/server/utils/arr/parser/index.ts';
import type { ParseResult, MediaType } from '$lib/server/utils/arr/parser/types.ts';
import { getAllConditionsForEvaluation } from '$pcd/queries/customFormats/allConditions.ts';
import { evaluateCustomFormat, getParsedInfo, type ParsedInfo } from '$pcd/queries/customFormats/evaluator.ts';
export interface ReleaseEvaluation {
releaseId: number;
title: string;
parsed: ParsedInfo | null;
/** Map of custom format ID to whether it matches */
cfMatches: Record<number, boolean>;
}
export interface EvaluateResponse {
parserAvailable: boolean;
evaluations: ReleaseEvaluation[];
}
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const { databaseId, releases } = body as {
databaseId: number;
releases: Array<{ id: number; title: string; type: MediaType }>;
};
if (!databaseId) {
throw error(400, 'Missing databaseId');
}
if (!releases || !Array.isArray(releases) || releases.length === 0) {
throw error(400, 'Missing or empty releases array');
}
// Check parser health
const parserAvailable = await isParserHealthy();
if (!parserAvailable) {
return json({
parserAvailable: false,
evaluations: releases.map((r) => ({
releaseId: r.id,
title: r.title,
parsed: null,
cfMatches: {}
}))
} satisfies EvaluateResponse);
}
// Get the PCD cache
const cache = pcdManager.getCache(databaseId);
if (!cache) {
throw error(500, 'Database cache not available');
}
// Parse all releases in batch (uses cache)
const parseItems = releases.map((r) => ({ title: r.title, type: r.type }));
const parseResults = await parseWithCacheBatch(parseItems);
// Get all custom formats with conditions
const customFormats = await getAllConditionsForEvaluation(cache);
// Evaluate each release against all custom formats
const evaluations: ReleaseEvaluation[] = releases.map((release) => {
const cacheKey = `${release.title}:${release.type}`;
const parsed = parseResults.get(cacheKey);
if (!parsed) {
return {
releaseId: release.id,
title: release.title,
parsed: null,
cfMatches: {}
};
}
// Evaluate against all custom formats
const cfMatches: Record<number, boolean> = {};
for (const cf of customFormats) {
if (cf.conditions.length === 0) {
// No conditions = doesn't match
cfMatches[cf.id] = false;
continue;
}
const result = evaluateCustomFormat(cf.conditions, parsed, release.title);
cfMatches[cf.id] = result.matches;
}
return {
releaseId: release.id,
title: release.title,
parsed: getParsedInfo(parsed),
cfMatches
};
});
return json({
parserAvailable: true,
evaluations
} satisfies EvaluateResponse);
};

View File

@@ -0,0 +1,97 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { TMDBClient } from '$lib/server/utils/tmdb/client.ts';
import { tmdbSettingsQueries } from '$db/queries/tmdbSettings.ts';
import { logger } from '$lib/server/utils/logger/logger.ts';
export const GET: RequestHandler = async ({ url }) => {
const query = url.searchParams.get('query');
const type = url.searchParams.get('type') || 'both'; // 'movie', 'tv', or 'both'
const page = parseInt(url.searchParams.get('page') || '1', 10);
if (!query) {
return json({ error: 'Query is required' }, { status: 400 });
}
const settings = tmdbSettingsQueries.get();
if (!settings?.api_key) {
return json({ error: 'TMDB API key not configured' }, { status: 400 });
}
try {
const client = new TMDBClient(settings.api_key);
if (type === 'movie') {
const result = await client.searchMovies(query, page);
return json({
results: result.results.map((m) => ({
id: m.id,
type: 'movie' as const,
title: m.title,
overview: m.overview,
posterPath: m.poster_path,
releaseDate: m.release_date,
voteAverage: m.vote_average
})),
totalPages: result.total_pages,
totalResults: result.total_results,
page: result.page
});
} else if (type === 'tv') {
const result = await client.searchTVShows(query, page);
return json({
results: result.results.map((t) => ({
id: t.id,
type: 'series' as const,
title: t.name,
overview: t.overview,
posterPath: t.poster_path,
releaseDate: t.first_air_date,
voteAverage: t.vote_average
})),
totalPages: result.total_pages,
totalResults: result.total_results,
page: result.page
});
} else {
// Search both and combine
const [movies, tvShows] = await Promise.all([
client.searchMovies(query, page),
client.searchTVShows(query, page)
]);
const combined = [
...movies.results.map((m) => ({
id: m.id,
type: 'movie' as const,
title: m.title,
overview: m.overview,
posterPath: m.poster_path,
releaseDate: m.release_date,
voteAverage: m.vote_average
})),
...tvShows.results.map((t) => ({
id: t.id,
type: 'series' as const,
title: t.name,
overview: t.overview,
posterPath: t.poster_path,
releaseDate: t.first_air_date,
voteAverage: t.vote_average
}))
].sort((a, b) => b.voteAverage - a.voteAverage);
return json({
results: combined,
totalPages: Math.max(movies.total_pages, tvShows.total_pages),
totalResults: movies.total_results + tvShows.total_results,
page
});
}
} catch (error) {
logger.error(`TMDB search failed: ${error instanceof Error ? error.message : 'Unknown error'}`, { source: 'TMDB' });
return json({
error: error instanceof Error ? error.message : 'Search failed'
}, { status: 500 });
}
};

View File

@@ -0,0 +1,27 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { TMDBClient } from '$lib/server/utils/tmdb/client.ts';
export const POST: RequestHandler = async ({ request }) => {
const { apiKey } = await request.json();
if (!apiKey) {
return json({ success: false, error: 'API key is required' }, { status: 400 });
}
try {
const client = new TMDBClient(apiKey);
const result = await client.validateKey();
if (result.success) {
return json({ success: true });
} else {
return json({ success: false, error: result.status_message }, { status: 400 });
}
} catch (error) {
return json({
success: false,
error: error instanceof Error ? error.message : 'Connection failed'
}, { status: 400 });
}
};

View File

@@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
export const load: ServerLoad = () => {
// Get all databases
const databases = pcdManager.getAll();
// If there are databases, redirect to the first one
if (databases.length > 0) {
throw redirect(303, `/quality-profiles/entity-testing/${databases[0].id}`);
}
// If no databases, return empty array (page will show empty state)
return {
databases
};
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Database, Plus } from 'lucide-svelte';
import EmptyState from '$ui/state/EmptyState.svelte';
</script>
<svelte:head>
<title>Entity Testing - Profilarr</title>
</svelte:head>
<EmptyState
icon={Database}
title="No Databases Linked"
description="Link a Profilarr Compliant Database to test quality profiles."
buttonText="Link Database"
buttonHref="/databases/new"
buttonIcon={Plus}
/>

View File

@@ -0,0 +1,388 @@
import { error, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { tmdbSettingsQueries } from '$db/queries/tmdbSettings.ts';
import * as entityTestQueries from '$pcd/queries/entityTests/index.ts';
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
import { isParserHealthy, parseWithCacheBatch, matchPatterns } from '$lib/server/utils/arr/parser/index.ts';
import { getAllConditionsForEvaluation } from '$pcd/queries/customFormats/allConditions.ts';
import { evaluateCustomFormat, getParsedInfo, extractAllPatterns } from '$pcd/queries/customFormats/evaluator.ts';
import type { MediaType } from '$lib/server/utils/arr/parser/types.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId } = params;
// Validate params exist
if (!databaseId) {
throw error(400, 'Missing database ID');
}
// Get all databases for tabs
const databases = pcdManager.getAll();
// Parse and validate the database ID
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
throw error(400, 'Invalid database ID');
}
// Get the current database instance
const currentDatabase = databases.find((db) => db.id === currentDatabaseId);
if (!currentDatabase) {
throw error(404, 'Database not found');
}
// Get the cache for the database
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
throw error(500, 'Database cache not available');
}
const testEntities = await entityTestQueries.list(cache);
const qualityProfiles = await qualityProfileQueries.select(cache);
const cfScoresData = await qualityProfileQueries.allCfScores(cache);
// Check if TMDB API key is configured
const tmdbSettings = tmdbSettingsQueries.get();
const tmdbConfigured = !!tmdbSettings?.api_key;
// Check parser availability
const parserAvailable = await isParserHealthy();
// Evaluate all releases against all custom formats
type ReleaseEvaluation = {
releaseId: number;
parsed: ReturnType<typeof getParsedInfo> | null;
cfMatches: Record<number, boolean>;
};
const evaluations: Record<number, ReleaseEvaluation> = {};
if (parserAvailable && testEntities.length > 0) {
// Collect all releases with their entity type
const allReleases: Array<{ id: number; title: string; type: MediaType }> = [];
for (const entity of testEntities) {
for (const release of entity.releases) {
allReleases.push({
id: release.id,
title: release.title,
type: entity.type
});
}
}
if (allReleases.length > 0) {
// Parse all releases (uses cache)
const parseResults = await parseWithCacheBatch(
allReleases.map((r) => ({ title: r.title, type: r.type }))
);
// Get all custom formats with conditions
const customFormats = await getAllConditionsForEvaluation(cache);
// Extract all unique patterns for batch matching
const allPatterns = extractAllPatterns(customFormats);
// Pre-compute pattern matches for each release title using .NET regex
const patternMatchesByRelease = new Map<number, Map<string, boolean>>();
if (allPatterns.length > 0) {
for (const release of allReleases) {
const matches = await matchPatterns(release.title, allPatterns);
if (matches) {
patternMatchesByRelease.set(release.id, matches);
}
}
}
// Evaluate each release
for (const release of allReleases) {
const cacheKey = `${release.title}:${release.type}`;
const parsed = parseResults.get(cacheKey);
if (!parsed) {
evaluations[release.id] = {
releaseId: release.id,
parsed: null,
cfMatches: {}
};
continue;
}
// Get pre-computed pattern matches for this release
const patternMatches = patternMatchesByRelease.get(release.id);
// Evaluate against all custom formats
const cfMatches: Record<number, boolean> = {};
for (const cf of customFormats) {
if (cf.conditions.length === 0) {
cfMatches[cf.id] = false;
continue;
}
const result = evaluateCustomFormat(cf.conditions, parsed, release.title, patternMatches);
cfMatches[cf.id] = result.matches;
}
evaluations[release.id] = {
releaseId: release.id,
parsed: getParsedInfo(parsed),
cfMatches
};
}
}
}
return {
databases,
currentDatabase,
tmdbConfigured,
parserAvailable,
testEntities,
qualityProfiles,
cfScoresData,
evaluations
};
};
export const actions: Actions = {
addEntities: async ({ request, params }) => {
const { databaseId } = params;
if (!databaseId) {
return fail(400, { error: 'Missing database ID' });
}
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 entitiesJson = formData.get('entities') as string;
let entities: Array<{
type: 'movie' | 'series';
tmdbId: number;
title: string;
year: number | null;
posterPath: string | null;
}>;
try {
entities = JSON.parse(entitiesJson || '[]');
} catch {
return fail(400, { error: 'Invalid entities format' });
}
if (entities.length === 0) {
return fail(400, { error: 'No entities to add' });
}
const result = await entityTestQueries.create({
databaseId: currentDatabaseId,
cache,
layer: 'user',
inputs: entities
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to add entities' });
}
return {
success: true,
added: result.added,
skipped: result.skipped
};
},
deleteEntity: async ({ request, params }) => {
const { databaseId } = params;
if (!databaseId) {
return fail(400, { error: 'Missing database ID' });
}
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 entityId = parseInt(formData.get('entityId') as string, 10);
if (isNaN(entityId)) {
return fail(400, { error: 'Invalid entity ID' });
}
const result = await entityTestQueries.remove({
databaseId: currentDatabaseId,
cache,
layer: 'user',
entityId
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to delete entity' });
}
return { success: true };
},
createRelease: async ({ request, params }) => {
const { databaseId } = params;
if (!databaseId) {
return fail(400, { error: 'Missing database ID' });
}
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 releaseJson = formData.get('release') as string;
let release: {
entityId: number;
title: string;
size_bytes: number | null;
languages: string[];
indexers: string[];
flags: string[];
};
try {
release = JSON.parse(releaseJson || '{}');
} catch {
return fail(400, { error: 'Invalid release format' });
}
if (!release.title) {
return fail(400, { error: 'Release title is required' });
}
const result = await entityTestQueries.createRelease({
databaseId: currentDatabaseId,
cache,
layer: 'user',
input: release
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to create release' });
}
return { success: true };
},
updateRelease: async ({ request, params }) => {
const { databaseId } = params;
if (!databaseId) {
return fail(400, { error: 'Missing database ID' });
}
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 releaseJson = formData.get('release') as string;
let release: {
id: number;
title: string;
size_bytes: number | null;
languages: string[];
indexers: string[];
flags: string[];
};
try {
release = JSON.parse(releaseJson || '{}');
} catch {
return fail(400, { error: 'Invalid release format' });
}
if (!release.id) {
return fail(400, { error: 'Release ID is required' });
}
if (!release.title) {
return fail(400, { error: 'Release title is required' });
}
const result = await entityTestQueries.updateRelease({
databaseId: currentDatabaseId,
cache,
layer: 'user',
input: release
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to update release' });
}
return { success: true };
},
deleteRelease: async ({ request, params }) => {
const { databaseId } = params;
if (!databaseId) {
return fail(400, { error: 'Missing database ID' });
}
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 releaseId = parseInt(formData.get('releaseId') as string, 10);
if (isNaN(releaseId)) {
return fail(400, { error: 'Invalid release ID' });
}
const result = await entityTestQueries.deleteRelease({
databaseId: currentDatabaseId,
cache,
layer: 'user',
releaseId
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to delete release' });
}
return { success: true };
}
};

View File

@@ -0,0 +1,342 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Info, Clapperboard, Film, Tv, Plus, AlertTriangle, Sliders, Check } from 'lucide-svelte';
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import InfoModal from '$ui/modal/InfoModal.svelte';
import Modal from '$ui/modal/Modal.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
import AddEntityModal from './components/AddEntityModal.svelte';
import ReleaseModal from './components/ReleaseModal.svelte';
import EntityTable from './components/EntityTable.svelte';
import { createDataPageStore } from '$lib/client/stores/dataPage';
import { alertStore } from '$lib/client/alerts/store';
import type { PageData } from './$types';
import type { TestEntity, TestRelease } from './components/types';
export let data: PageData;
// Show warning if parser is unavailable
onMount(() => {
if (!data.parserAvailable) {
alertStore.add('warning', 'Parser service unavailable. Release scoring disabled.', 0);
}
// Restore selected profile from localStorage
const stored = localStorage.getItem('entityTesting.selectedProfileId');
if (stored) {
const id = parseInt(stored, 10);
// Verify profile exists in current database
if (data.qualityProfiles.some((p) => p.id === id)) {
selectedProfileId = id;
}
}
});
// Persist selected profile to localStorage
function setSelectedProfile(id: number | null) {
selectedProfileId = id;
if (id !== null) {
localStorage.setItem('entityTesting.selectedProfileId', String(id));
} else {
localStorage.removeItem('entityTesting.selectedProfileId');
}
}
// Quality profile selection
let selectedProfileId: number | null = null;
// Calculate score for a release based on selected profile
// Reactive so it updates when selectedProfileId changes
$: calculateScore = (releaseId: number, entityType: 'movie' | 'series'): number | null => {
if (!selectedProfileId) return null;
const evaluation = data.evaluations[releaseId];
if (!evaluation || !evaluation.cfMatches) return null;
const profileScores = data.cfScoresData.profiles.find((p) => p.profileId === selectedProfileId);
if (!profileScores) return null;
const arrType = entityType === 'movie' ? 'radarr' : 'sonarr';
let totalScore = 0;
for (const [cfIdStr, matches] of Object.entries(evaluation.cfMatches)) {
if (!matches) continue;
const cfId = parseInt(cfIdStr, 10);
const cfScore = profileScores.scores[cfId];
if (cfScore) {
const score = cfScore[arrType];
if (score !== null) {
totalScore += score;
}
}
}
return totalScore;
};
$: selectedProfile = selectedProfileId
? data.qualityProfiles.find((p) => p.id === selectedProfileId)
: null;
// Modal state
let showInfoModal = false;
let showAddModal = false;
// Entity delete modal state
let showDeleteModal = false;
let entityToDelete: TestEntity | null = null;
let deleteFormRef: HTMLFormElement | null = null;
// Release modal state
let showReleaseModal = false;
let releaseModalMode: 'create' | 'edit' = 'create';
let releaseEntityId: number = 0;
let currentRelease: TestRelease | null = null;
// Release delete modal state
let showDeleteReleaseModal = false;
let releaseToDelete: TestRelease | null = null;
let deleteReleaseFormRef: HTMLFormElement | null = null;
// Entity type selection (both selected by default)
let moviesSelected = true;
let seriesSelected = true;
// Prevent unchecking if it's the only one selected
function toggleMovies() {
if (moviesSelected && !seriesSelected) return;
moviesSelected = !moviesSelected;
}
function toggleSeries() {
if (seriesSelected && !moviesSelected) return;
seriesSelected = !seriesSelected;
}
// Dynamic search placeholder based on selection
$: searchPlaceholder = (() => {
if (moviesSelected && seriesSelected) return 'Search movies, TV series...';
if (moviesSelected) return 'Search movies...';
if (seriesSelected) return 'Search TV series...';
return 'Search...';
})();
// Initialize data page store
const { search, filtered, setItems } = createDataPageStore(data.testEntities, {
storageKey: 'entityTestingView',
searchKeys: ['title']
});
// Update items when data changes (e.g., switching databases)
$: setItems(data.testEntities);
// Filter by type selection
$: typeFilteredEntities = ($filtered as TestEntity[]).filter((entity) => {
if (moviesSelected && seriesSelected) return true;
if (moviesSelected) return entity.type === 'movie';
if (seriesSelected) return entity.type === 'series';
return true;
});
// Map databases to tabs
$: tabs = data.databases.map((db) => ({
label: db.name,
href: `/quality-profiles/entity-testing/${db.id}`,
active: db.id === data.currentDatabase.id
}));
// Entity delete handlers
function handleConfirmDelete(e: CustomEvent<{ entity: TestEntity; formRef: HTMLFormElement }>) {
entityToDelete = e.detail.entity;
deleteFormRef = e.detail.formRef;
showDeleteModal = true;
}
function handleDeleteConfirm() {
if (deleteFormRef) {
deleteFormRef.requestSubmit();
}
showDeleteModal = false;
entityToDelete = null;
deleteFormRef = null;
}
function handleDeleteCancel() {
showDeleteModal = false;
entityToDelete = null;
deleteFormRef = null;
}
// Release modal handlers
function handleAddRelease(e: CustomEvent<{ entityId: number }>) {
releaseEntityId = e.detail.entityId;
releaseModalMode = 'create';
currentRelease = null;
showReleaseModal = true;
}
function handleEditRelease(e: CustomEvent<{ entityId: number; release: TestRelease }>) {
releaseEntityId = e.detail.entityId;
releaseModalMode = 'edit';
currentRelease = e.detail.release;
showReleaseModal = true;
}
// Release delete handlers
function handleConfirmDeleteRelease(e: CustomEvent<{ release: TestRelease; formRef: HTMLFormElement }>) {
releaseToDelete = e.detail.release;
deleteReleaseFormRef = e.detail.formRef;
showDeleteReleaseModal = true;
}
function handleDeleteReleaseConfirm() {
if (deleteReleaseFormRef) {
deleteReleaseFormRef.requestSubmit();
}
showDeleteReleaseModal = false;
releaseToDelete = null;
deleteReleaseFormRef = null;
}
function handleDeleteReleaseCancel() {
showDeleteReleaseModal = false;
releaseToDelete = null;
deleteReleaseFormRef = null;
}
</script>
<svelte:head>
<title>Entity Testing - {data.currentDatabase.name} - Profilarr</title>
</svelte:head>
<div class="space-y-6 p-8">
<!-- Database Tabs -->
<Tabs {tabs} />
<!-- Actions Bar -->
<ActionsBar>
<SearchAction searchStore={search} placeholder={searchPlaceholder} />
<ActionButton icon={Sliders} hasDropdown={true} dropdownPosition="right" square={!selectedProfile}>
{#if selectedProfile}
<span class="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{selectedProfile.name}</span>
{/if}
<Dropdown slot="dropdown" position="right">
<DropdownItem
label="No Profile"
selected={selectedProfileId === null}
on:click={() => setSelectedProfile(null)}
/>
{#each data.qualityProfiles as profile}
<DropdownItem
label={profile.name}
selected={selectedProfileId === profile.id}
on:click={() => setSelectedProfile(profile.id)}
/>
{/each}
</Dropdown>
</ActionButton>
<ActionButton icon={Clapperboard} hasDropdown={true} dropdownPosition="right">
<Dropdown slot="dropdown" position="right">
<DropdownItem
icon={Film}
label="Movies"
selected={moviesSelected}
on:click={toggleMovies}
/>
<DropdownItem
icon={Tv}
label="TV Series"
selected={seriesSelected}
on:click={toggleSeries}
/>
</Dropdown>
</ActionButton>
<ActionButton icon={Info} on:click={() => (showInfoModal = true)} />
<ActionButton icon={Plus} on:click={() => (showAddModal = true)} />
</ActionsBar>
<!-- Entity Testing Content -->
<div class="mt-6">
{#if data.testEntities.length === 0}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
{#if !data.tmdbConfigured}
<div class="flex flex-col items-center gap-2">
<AlertTriangle size={24} class="text-amber-500" />
<p class="text-neutral-600 dark:text-neutral-400">
TMDB API key not configured. <a
href="/settings/general"
class="text-accent-600 hover:underline dark:text-accent-400"
>Configure in Settings</a
>
</p>
</div>
{:else}
<p class="text-neutral-600 dark:text-neutral-400">
No entity tests found for {data.currentDatabase.name}
</p>
{/if}
</div>
{:else}
<EntityTable
entities={typeFilteredEntities}
evaluations={data.evaluations}
{selectedProfileId}
cfScoresData={data.cfScoresData}
{calculateScore}
on:confirmDelete={handleConfirmDelete}
on:addRelease={handleAddRelease}
on:editRelease={handleEditRelease}
on:confirmDeleteRelease={handleConfirmDeleteRelease}
/>
{/if}
</div>
</div>
<InfoModal bind:open={showInfoModal} header="How Entity Testing Works">
<div class="space-y-4 text-sm text-neutral-600 dark:text-neutral-400">
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Test Quality Profiles</div>
<p class="mt-1">
Entity testing allows you to test your quality profiles against sample media to see how they
would score and match.
</p>
</div>
</div>
</InfoModal>
<AddEntityModal bind:open={showAddModal} existingEntities={data.testEntities} />
<Modal
bind:open={showDeleteModal}
header="Delete Entity"
bodyMessage="Are you sure you want to delete {entityToDelete?.title}? This will also remove all associated test releases."
confirmText="Delete"
confirmDanger={true}
size="sm"
on:confirm={handleDeleteConfirm}
on:cancel={handleDeleteCancel}
/>
<ReleaseModal
bind:open={showReleaseModal}
mode={releaseModalMode}
entityId={releaseEntityId}
release={currentRelease}
/>
<Modal
bind:open={showDeleteReleaseModal}
header="Delete Release"
bodyMessage="Are you sure you want to delete this test release?"
confirmText="Delete"
confirmDanger={true}
size="sm"
on:confirm={handleDeleteReleaseConfirm}
on:cancel={handleDeleteReleaseCancel}
/>

View File

@@ -0,0 +1,355 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import { Film, Tv, Star, Loader2, Clapperboard, Check, X } from 'lucide-svelte';
import Modal from '$ui/modal/Modal.svelte';
import Badge from '$ui/badge/Badge.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import { createSearchStore } from '$stores/search';
import { alertStore } from '$alerts/store';
export let open = false;
export let actionUrl: string = '?/addEntities';
export let existingEntities: Array<{ type: 'movie' | 'series'; tmdb_id: number }> = [];
let saving = false;
let formRef: HTMLFormElement;
type ResultItem = {
id: number;
type: 'movie' | 'series';
title: string;
overview: string;
posterPath: string | null;
releaseDate: string;
voteAverage: number;
};
// Build set of existing entity keys for quick lookup
$: existingKeys = new Set(existingEntities.map((e) => `${e.type}-${e.tmdb_id}`));
function isAlreadyAdded(item: ResultItem): boolean {
return existingKeys.has(`${item.type}-${item.id}`);
}
const searchStore = createSearchStore();
let activeQuery = '';
let isSearching = false;
let results: ResultItem[] = [];
let selectedItems: Map<string, ResultItem> = new Map();
let selectedKeys: Set<string> = new Set();
function getItemKey(item: ResultItem): string {
return `${item.type}-${item.id}`;
}
function toggleItem(item: ResultItem) {
const key = getItemKey(item);
if (selectedItems.has(key)) {
selectedItems.delete(key);
selectedKeys.delete(key);
} else {
selectedItems.set(key, item);
selectedKeys.add(key);
}
selectedItems = selectedItems;
selectedKeys = selectedKeys;
}
function removeItem(item: ResultItem) {
const key = getItemKey(item);
selectedItems.delete(key);
selectedKeys.delete(key);
selectedItems = selectedItems;
selectedKeys = selectedKeys;
}
// Filter state
let moviesSelected = true;
let seriesSelected = true;
function toggleMovies() {
if (moviesSelected && !seriesSelected) return;
moviesSelected = !moviesSelected;
}
function toggleSeries() {
if (seriesSelected && !moviesSelected) return;
seriesSelected = !seriesSelected;
}
$: searchType = moviesSelected && seriesSelected ? 'both' : moviesSelected ? 'movie' : 'tv';
async function handleSubmit(e: CustomEvent<string>) {
const query = e.detail;
if (!query) return;
activeQuery = query;
searchStore.clear();
isSearching = true;
try {
const params = new URLSearchParams({
query,
type: searchType
});
const response = await fetch(`/api/tmdb/search?${params}`);
const data = await response.json();
if (data.error) {
alertStore.add('error', data.error);
results = [];
} else {
results = data.results;
}
} catch (err) {
alertStore.add('error', err instanceof Error ? err.message : 'Search failed');
results = [];
} finally {
isSearching = false;
}
}
function clearSearch() {
activeQuery = '';
results = [];
}
async function handleConfirm() {
if (selectedItems.size === 0) return;
await tick();
formRef?.requestSubmit();
}
function handleCancel() {
open = false;
resetState();
}
function resetState() {
searchStore.clear();
activeQuery = '';
results = [];
selectedItems = new Map();
selectedKeys = new Set();
}
function getYear(dateString: string): string {
if (!dateString) return '';
return dateString.split('-')[0];
}
function getPosterUrl(path: string | null): string {
if (!path) return '';
return `https://image.tmdb.org/t/p/w92${path}`;
}
$: entitiesJson = JSON.stringify(
Array.from(selectedItems.values()).map((item) => ({
type: item.type,
tmdbId: item.id,
title: item.title,
year: item.releaseDate ? parseInt(item.releaseDate.split('-')[0], 10) : null,
posterPath: item.posterPath
}))
);
</script>
<Modal
bind:open
header="Add Test Entity"
confirmText="Add"
size="xl"
on:cancel={handleCancel}
on:confirm={handleConfirm}
>
<div slot="body" class="space-y-4">
<!-- Search Bar -->
<ActionsBar>
<SearchAction
{searchStore}
placeholder="Search TMDB... (press Enter)"
{activeQuery}
on:submit={handleSubmit}
on:clearQuery={clearSearch}
/>
<ActionButton icon={Clapperboard} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown" let:dropdownPosition>
<Dropdown position={dropdownPosition}>
<DropdownItem
icon={Film}
label="Movies"
selected={moviesSelected}
on:click={toggleMovies}
/>
<DropdownItem
icon={Tv}
label="TV Series"
selected={seriesSelected}
on:click={toggleSeries}
/>
</Dropdown>
</svelte:fragment>
</ActionButton>
</ActionsBar>
<!-- Results -->
{#if isSearching || activeQuery || results.length > 0}
<div class="max-h-96 overflow-y-auto rounded-lg border border-neutral-200 dark:border-neutral-700">
{#if isSearching}
<div class="flex items-center justify-center p-8">
<Loader2 size={24} class="animate-spin text-neutral-400" />
</div>
{:else if results.length === 0}
<div class="p-8 text-center text-neutral-500 dark:text-neutral-400">
No results found
</div>
{:else}
<div class="divide-y divide-neutral-200 dark:divide-neutral-700">
{#each results as item}
{@const alreadyAdded = isAlreadyAdded(item)}
<button
type="button"
class="flex w-full gap-3 p-3 text-left transition-colors {alreadyAdded
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-800'}"
on:click={() => !alreadyAdded && toggleItem(item)}
disabled={alreadyAdded}
>
<!-- Poster -->
<div class="h-24 w-16 flex-shrink-0 overflow-hidden rounded bg-neutral-200 dark:bg-neutral-700">
{#if item.posterPath}
<img
src={getPosterUrl(item.posterPath)}
alt={item.title}
class="h-full w-full object-cover"
/>
{:else}
<div class="flex h-full w-full items-center justify-center">
{#if item.type === 'movie'}
<Film size={24} class="text-neutral-400" />
{:else}
<Tv size={24} class="text-neutral-400" />
{/if}
</div>
{/if}
</div>
<!-- Info -->
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<h4 class="truncate font-medium text-neutral-900 dark:text-neutral-100">
{item.title}
</h4>
<div class="flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
<span class="flex items-center gap-1">
{#if item.type === 'movie'}
<Film size={12} />
Movie
{:else}
<Tv size={12} />
TV Series
{/if}
</span>
{#if getYear(item.releaseDate)}
<span></span>
<span>{getYear(item.releaseDate)}</span>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
{#if item.voteAverage > 0}
<Badge variant="accent" size="sm" icon={Star}>
{item.voteAverage.toFixed(1)}
</Badge>
{/if}
{#if alreadyAdded}
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Added
</span>
{:else}
<IconCheckbox
checked={selectedKeys.has(getItemKey(item))}
icon={Check}
/>
{/if}
</div>
</div>
<p class="mt-1 line-clamp-2 text-xs text-neutral-600 dark:text-neutral-400">
{item.overview || 'No description available'}
</p>
</div>
</button>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Selected Items -->
{#if selectedItems.size > 0}
<div class="space-y-2">
<div class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Selected ({selectedItems.size})
</div>
<div class="flex flex-wrap gap-2">
{#each Array.from(selectedItems.values()) as item}
<button
type="button"
on:click={() => removeItem(item)}
class="flex items-center gap-1.5 rounded-full bg-accent-100 py-1 pl-2 pr-1.5 text-xs font-medium text-accent-800 hover:bg-accent-200 dark:bg-accent-900 dark:text-accent-200 dark:hover:bg-accent-800"
>
{#if item.type === 'movie'}
<Film size={12} />
{:else}
<Tv size={12} />
{/if}
{item.title}
<X size={12} />
</button>
{/each}
</div>
</div>
{/if}
<form
bind:this={formRef}
method="POST"
action={actionUrl}
class="hidden"
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || 'Failed to add entities');
} else if (result.type === 'success') {
const data = result.data as { added?: number; skipped?: number };
const added = data?.added ?? 0;
const skipped = data?.skipped ?? 0;
if (added === 0 && skipped > 0) {
alertStore.add('info', `All ${skipped} ${skipped === 1 ? 'entity already exists' : 'entities already exist'}`);
} else if (skipped > 0) {
alertStore.add('success', `Added ${added} ${added === 1 ? 'entity' : 'entities'}, skipped ${skipped} duplicate${skipped === 1 ? '' : 's'}`);
} else {
alertStore.add('success', `Added ${added} test ${added === 1 ? 'entity' : 'entities'}`);
}
open = false;
resetState();
}
await update();
saving = false;
};
}}
>
<input type="hidden" name="entities" value={entitiesJson} />
</form>
</div>
</Modal>

View File

@@ -0,0 +1,166 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Film, Tv, Trash2 } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import ReleaseTable from './ReleaseTable.svelte';
import { alertStore } from '$lib/client/alerts/store';
import type { Column } from '$ui/table/types';
import type { TestEntity, TestRelease, ReleaseEvaluation, ProfileCfScores, CustomFormatInfo } from './types';
export let entities: TestEntity[];
export let evaluations: Record<number, ReleaseEvaluation>;
export let selectedProfileId: number | null;
export let cfScoresData: { customFormats: CustomFormatInfo[]; profiles: ProfileCfScores[] };
export let calculateScore: (releaseId: number, entityType: 'movie' | 'series') => number | null;
const dispatch = createEventDispatcher<{
confirmDelete: { entity: TestEntity; formRef: HTMLFormElement };
addRelease: { entityId: number };
editRelease: { entityId: number; release: TestRelease };
confirmDeleteRelease: { release: TestRelease; formRef: HTMLFormElement };
}>();
const columns: Column<TestEntity>[] = [
{
key: 'poster_path',
header: '',
width: 'w-12'
},
{
key: 'title',
header: 'Title',
sortable: true
},
{
key: 'type',
header: 'Type',
width: 'w-24',
sortable: true
},
{
key: 'releases',
header: 'Releases',
width: 'w-28',
align: 'center',
sortable: true,
sortAccessor: (row) => row.releases.length
}
];
function getRowId(row: TestEntity): number {
return row.id;
}
</script>
<ExpandableTable
{columns}
data={entities}
{getRowId}
compact={true}
flushExpanded={true}
emptyMessage="No entities match your search"
chevronPosition="right"
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'poster_path'}
{#if row.poster_path}
<div class="h-12 w-8">
<img
src="https://image.tmdb.org/t/p/w92{row.poster_path}"
alt={row.title}
class="h-full w-full rounded object-cover"
/>
</div>
{:else}
<div
class="flex h-12 w-8 items-center justify-center rounded bg-neutral-200 dark:bg-neutral-700"
>
{#if row.type === 'movie'}
<Film size={16} class="text-neutral-400" />
{:else}
<Tv size={16} class="text-neutral-400" />
{/if}
</div>
{/if}
{:else if column.key === 'title'}
<div class="flex flex-col">
<span class="font-medium">{row.title}</span>
{#if row.year}
<span class="text-xs text-neutral-500 dark:text-neutral-400">{row.year}</span>
{/if}
</div>
{:else if column.key === 'type'}
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {row.type ===
'movie'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'}"
>
{#if row.type === 'movie'}
<Film size={12} />
Movie
{:else}
<Tv size={12} />
Series
{/if}
</span>
{:else if column.key === 'releases'}
<span class="text-neutral-600 dark:text-neutral-400">
{row.releases.length}
</span>
{/if}
</svelte:fragment>
<svelte:fragment slot="actions" let:row>
{@const formId = `delete-form-${row.id}`}
<form
id={formId}
method="POST"
action="?/deleteEntity"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to delete entity'
);
} else if (result.type === 'success') {
alertStore.add('success', `Deleted ${row.title}`);
}
await update();
};
}}
>
<input type="hidden" name="entityId" value={row.id} />
<button
type="button"
on:click={() => {
const form = document.getElementById(formId) as HTMLFormElement;
dispatch('confirmDelete', { entity: row, formRef: form });
}}
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
title="Delete entity"
>
<Trash2 size={16} />
</button>
</form>
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
<div class="px-4 py-3">
<ReleaseTable
entityId={row.id}
entityType={row.type}
releases={row.releases}
{evaluations}
{selectedProfileId}
{cfScoresData}
{calculateScore}
on:add={(e) => dispatch('addRelease', e.detail)}
on:edit={(e) => dispatch('editRelease', e.detail)}
on:confirmDelete={(e) => dispatch('confirmDeleteRelease', e.detail)}
/>
</div>
</svelte:fragment>
</ExpandableTable>

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Modal from '$ui/modal/Modal.svelte';
import FormInput from '$ui/form/FormInput.svelte';
import TagInput from '$ui/form/TagInput.svelte';
import { alertStore } from '$alerts/store';
export let open = false;
export let mode: 'create' | 'edit' = 'create';
export let entityId: number;
export let release: {
id?: number;
title: string;
size_bytes: number | null;
languages: string[];
indexers: string[];
flags: string[];
} | null = null;
let saving = false;
let formRef: HTMLFormElement;
// Form state
let title = '';
let sizeGb = '';
let languages: string[] = [];
let indexers: string[] = [];
let flags: string[] = [];
// Reset form when modal opens or release changes
$: if (open) {
if (mode === 'edit' && release) {
title = release.title;
sizeGb = release.size_bytes ? (release.size_bytes / (1024 * 1024 * 1024)).toFixed(2) : '';
languages = [...release.languages];
indexers = [...release.indexers];
flags = [...release.flags];
} else {
title = '';
sizeGb = '';
languages = [];
indexers = [];
flags = [];
}
}
function handleConfirm() {
formRef?.requestSubmit();
}
function handleCancel() {
open = false;
}
// Convert GB to bytes
function gbToBytes(gb: string): number | null {
const num = parseFloat(gb);
if (isNaN(num) || num <= 0) return null;
return Math.round(num * 1024 * 1024 * 1024);
}
$: actionUrl = mode === 'create' ? '?/createRelease' : '?/updateRelease';
$: modalHeader = mode === 'create' ? 'Add Test Release' : 'Edit Test Release';
$: confirmText = mode === 'create' ? 'Add' : 'Save';
// Build JSON for form submission
$: releaseJson = JSON.stringify({
id: release?.id,
entityId,
title,
size_bytes: gbToBytes(sizeGb),
languages,
indexers,
flags
});
</script>
<Modal
bind:open
header={modalHeader}
{confirmText}
size="lg"
on:cancel={handleCancel}
on:confirm={handleConfirm}
>
<div slot="body" class="space-y-4">
<FormInput
label="Release Title"
description="The full release title (e.g., Movie.2024.1080p.BluRay.REMUX-GROUP)"
bind:value={title}
placeholder="Movie.2024.1080p.BluRay.REMUX-GROUP"
required
/>
<FormInput
label="Size (GB)"
description="File size in gigabytes"
bind:value={sizeGb}
placeholder="15.5"
type="number"
/>
<div class="space-y-1">
<label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Languages
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Press Enter to add languages
</p>
<TagInput bind:tags={languages} placeholder="Type language and press Enter" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Indexers
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Press Enter to add indexers
</p>
<TagInput bind:tags={indexers} placeholder="Type indexer and press Enter" />
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Flags
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Press Enter to add flags (e.g., freeleech, scene)
</p>
<TagInput bind:tags={flags} placeholder="Type flag and press Enter" />
</div>
<form
bind:this={formRef}
method="POST"
action={actionUrl}
class="hidden"
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || `Failed to ${mode} release`
);
} else if (result.type === 'success') {
alertStore.add('success', mode === 'create' ? 'Release added' : 'Release updated');
open = false;
}
await update();
saving = false;
};
}}
>
<input type="hidden" name="release" value={releaseJson} />
</form>
</div>
</Modal>

View File

@@ -0,0 +1,287 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Plus, Trash2, Pencil, HardDrive, Tag, Users, Bookmark, Earth, Layers } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import Badge from '$ui/badge/Badge.svelte';
import { alertStore } from '$lib/client/alerts/store';
import type { Column } from '$ui/table/types';
import type { TestRelease, ReleaseEvaluation, ProfileCfScores, CustomFormatInfo } from './types';
export let entityId: number;
export let entityType: 'movie' | 'series';
export let releases: TestRelease[];
export let evaluations: Record<number, ReleaseEvaluation>;
export let selectedProfileId: number | null;
export let cfScoresData: { customFormats: CustomFormatInfo[]; profiles: ProfileCfScores[] };
export let calculateScore: (releaseId: number, entityType: 'movie' | 'series') => number | null;
// Get matching custom formats for a release with their scores
function getMatchingFormats(releaseId: number): Array<{ id: number; name: string; score: number }> {
const evaluation = evaluations[releaseId];
if (!evaluation || !evaluation.cfMatches || !selectedProfileId) return [];
const profileScores = cfScoresData.profiles.find((p) => p.profileId === selectedProfileId);
if (!profileScores) return [];
const arrType = entityType === 'movie' ? 'radarr' : 'sonarr';
const matches: Array<{ id: number; name: string; score: number }> = [];
for (const [cfIdStr, matched] of Object.entries(evaluation.cfMatches)) {
if (!matched) continue;
const cfId = parseInt(cfIdStr, 10);
const cf = cfScoresData.customFormats.find((f) => f.id === cfId);
const cfScore = profileScores.scores[cfId];
if (cf && cfScore) {
const score = cfScore[arrType];
if (score !== null && score !== 0) {
matches.push({ id: cfId, name: cf.name, score });
}
}
}
// Sort by absolute score (highest impact first)
return matches.sort((a, b) => Math.abs(b.score) - Math.abs(a.score));
}
const dispatch = createEventDispatcher<{
add: { entityId: number };
edit: { entityId: number; release: TestRelease };
confirmDelete: { release: TestRelease; formRef: HTMLFormElement };
}>();
// Reactive columns - recalculates when calculateScore changes (profile switch)
$: columns = [
{
key: 'title',
header: 'Release Title',
sortable: true
},
{
key: 'size_bytes',
header: 'Size',
width: 'w-24',
align: 'right',
sortable: true
},
{
key: 'indexers',
header: 'Indexers',
width: 'w-32'
},
{
key: 'languages',
header: 'Languages',
width: 'w-32'
},
{
key: 'score',
header: 'Score',
width: 'w-20',
align: 'right',
sortable: true,
sortAccessor: (row) => calculateScore(row.id, entityType) ?? -Infinity
}
] as Column<TestRelease>[];
function getRowId(row: TestRelease): number {
return row.id;
}
function formatSize(bytes: number | null): string {
if (bytes === null) return '—';
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) return `${gb.toFixed(1)} GB`;
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(0)} MB`;
}
</script>
<div class="space-y-3">
{#if releases.length > 0}
{#key selectedProfileId}
<ExpandableTable
{columns}
data={releases}
{getRowId}
compact={true}
flushExpanded={true}
emptyMessage="No releases"
chevronPosition="right"
defaultSort={{ key: 'score', direction: 'desc' }}
>
<svelte:fragment slot="cell" let:row={release} let:column>
{#if column.key === 'title'}
<span class="font-mono text-[11px]">
{release.title}
</span>
{:else if column.key === 'size_bytes'}
<span class="font-mono text-[11px] text-neutral-600 dark:text-neutral-400">
{formatSize(release.size_bytes)}
</span>
{:else if column.key === 'indexers'}
{#if release.indexers.length > 0}
<div class="flex flex-wrap gap-1">
{#each release.indexers as indexer}
<Badge variant="neutral" size="sm">{indexer}</Badge>
{/each}
</div>
{:else}
<span class="text-neutral-400"></span>
{/if}
{:else if column.key === 'languages'}
{#if release.languages.length > 0}
<div class="flex flex-wrap gap-1">
{#each release.languages as lang}
<Badge variant="neutral" size="sm">{lang}</Badge>
{/each}
</div>
{:else}
<span class="text-neutral-400"></span>
{/if}
{:else if column.key === 'score'}
{@const score = calculateScore(release.id, entityType)}
{#if score !== null}
<span class="font-mono text-sm font-medium {score > 0 ? 'text-emerald-600 dark:text-emerald-400' : score < 0 ? 'text-red-600 dark:text-red-400' : 'text-neutral-500'}">
{score > 0 ? '+' : ''}{score.toLocaleString()}
</span>
{:else}
<span class="text-neutral-400"></span>
{/if}
{/if}
</svelte:fragment>
<svelte:fragment slot="actions" let:row={release}>
{@const releaseFormId = `delete-release-form-${release.id}`}
<div class="flex items-center gap-1">
<button
type="button"
on:click={() => dispatch('edit', { entityId, release })}
class="rounded p-1 text-neutral-400 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
title="Edit release"
>
<Pencil size={14} />
</button>
<form
id={releaseFormId}
method="POST"
action="?/deleteRelease"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to delete release'
);
} else if (result.type === 'success') {
alertStore.add('success', `Deleted release`);
}
await update();
};
}}
>
<input type="hidden" name="releaseId" value={release.id} />
<button
type="button"
on:click={() => {
const form = document.getElementById(releaseFormId) as HTMLFormElement;
dispatch('confirmDelete', { release, formRef: form });
}}
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
title="Delete release"
>
<Trash2 size={14} />
</button>
</form>
</div>
</svelte:fragment>
<svelte:fragment slot="expanded" let:row={release}>
{@const evaluation = evaluations[release.id]}
{@const matchingFormats = getMatchingFormats(release.id)}
<div class="px-4 py-4">
<div class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-xs">
<!-- Parsed Info Row -->
{#if evaluation?.parsed}
<div class="text-neutral-500 dark:text-neutral-400 font-medium pt-0.5">Parsed</div>
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2 py-1 dark:border-neutral-700 dark:bg-neutral-800">
<HardDrive size={12} class="text-blue-500" />
<span class="text-neutral-500 dark:text-neutral-400">Source</span>
<span class="font-medium text-neutral-800 dark:text-neutral-100">{evaluation.parsed.source}</span>
</span>
<span class="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2 py-1 dark:border-neutral-700 dark:bg-neutral-800">
<Layers size={12} class="text-indigo-500" />
<span class="text-neutral-500 dark:text-neutral-400">Resolution</span>
<span class="font-medium text-neutral-800 dark:text-neutral-100">{evaluation.parsed.resolution}</span>
</span>
{#if evaluation.parsed.modifier !== 'None'}
<span class="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2 py-1 dark:border-neutral-700 dark:bg-neutral-800">
<Tag size={12} class="text-amber-500" />
<span class="text-neutral-500 dark:text-neutral-400">Modifier</span>
<span class="font-medium text-neutral-800 dark:text-neutral-100">{evaluation.parsed.modifier}</span>
</span>
{/if}
{#if evaluation.parsed.releaseGroup}
<span class="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2 py-1 dark:border-neutral-700 dark:bg-neutral-800">
<Users size={12} class="text-teal-500" />
<span class="text-neutral-500 dark:text-neutral-400">Group</span>
<span class="font-medium text-neutral-800 dark:text-neutral-100">{evaluation.parsed.releaseGroup}</span>
</span>
{/if}
{#if evaluation.parsed.edition}
<span class="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2 py-1 dark:border-neutral-700 dark:bg-neutral-800">
<Bookmark size={12} class="text-orange-500" />
<span class="text-neutral-500 dark:text-neutral-400">Edition</span>
<span class="font-medium text-neutral-800 dark:text-neutral-100">{evaluation.parsed.edition}</span>
</span>
{/if}
{#if evaluation.parsed.languages.length > 0}
<span class="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2 py-1 dark:border-neutral-700 dark:bg-neutral-800">
<Earth size={12} class="text-emerald-500" />
<span class="text-neutral-500 dark:text-neutral-400">Languages</span>
<span class="font-medium text-neutral-800 dark:text-neutral-100">{evaluation.parsed.languages.join(', ')}</span>
</span>
{/if}
</div>
{/if}
<!-- Custom Formats Row -->
<div class="text-neutral-500 dark:text-neutral-400 font-medium pt-0.5">Formats</div>
<div>
{#if !selectedProfileId}
<span class="text-neutral-400 italic">Select a quality profile to see scores.</span>
{:else if matchingFormats.length === 0}
<span class="text-neutral-400 italic">No custom formats matched with non-zero scores.</span>
{:else}
<div class="flex flex-wrap gap-2">
{#each matchingFormats as cf}
<span class="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2 py-1 dark:border-neutral-700 dark:bg-neutral-800">
<span class="text-neutral-500 dark:text-neutral-400">{cf.name}</span>
<span class="font-mono font-medium {cf.score > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}">{cf.score > 0 ? '+' : ''}{cf.score.toLocaleString()}</span>
</span>
{/each}
</div>
{/if}
</div>
</div>
</div>
</svelte:fragment>
</ExpandableTable>
{/key}
{/if}
<!-- Clickable add row -->
<button
type="button"
on:click={() => dispatch('add', { entityId })}
class="w-full rounded-lg border-2 border-dashed border-neutral-200 py-3 text-sm text-neutral-400 transition-colors hover:border-accent-300 hover:bg-accent-50/50 hover:text-accent-600 dark:border-neutral-700 dark:hover:border-accent-600 dark:hover:bg-accent-900/10 dark:hover:text-accent-400"
>
<span class="inline-flex items-center gap-1">
<Plus size={14} />
Add test release
</span>
</button>
</div>

View File

@@ -0,0 +1,57 @@
export interface TestRelease {
id: number;
title: string;
size_bytes: number | null;
languages: string[];
indexers: string[];
flags: string[];
}
export interface TestEntity {
id: number;
type: 'movie' | 'series';
tmdb_id: number;
title: string;
year: number | null;
poster_path: string | null;
releases: TestRelease[];
}
/** Parsed info from parser service */
export interface ParsedInfo {
source: string;
resolution: string;
modifier: string;
languages: string[];
releaseGroup: string | null;
year: number;
edition: string | null;
releaseType: string | null;
}
/** Evaluation result for a single release */
export interface ReleaseEvaluation {
releaseId: number;
title: string;
parsed: ParsedInfo | null;
/** Map of custom format ID to whether it matches */
cfMatches: Record<number, boolean>;
}
/** CF score for a specific arr type */
export interface CfScore {
radarr: number | null;
sonarr: number | null;
}
/** Profile CF scores data */
export interface ProfileCfScores {
profileId: number;
scores: Record<number, CfScore>;
}
/** Custom format info */
export interface CustomFormatInfo {
id: number;
name: string;
}

View File

@@ -3,6 +3,7 @@ import { fail } from '@sveltejs/kit';
import { logSettingsQueries } from '$db/queries/logSettings.ts';
import { backupSettingsQueries } from '$db/queries/backupSettings.ts';
import { aiSettingsQueries } from '$db/queries/aiSettings.ts';
import { tmdbSettingsQueries } from '$db/queries/tmdbSettings.ts';
import { logSettings } from '$logger/settings.ts';
import { logger } from '$logger/logger.ts';
@@ -10,6 +11,7 @@ export const load = () => {
const logSetting = logSettingsQueries.get();
const backupSetting = backupSettingsQueries.get();
const aiSetting = aiSettingsQueries.get();
const tmdbSetting = tmdbSettingsQueries.get();
if (!logSetting) {
throw new Error('Log settings not found in database');
@@ -23,6 +25,10 @@ export const load = () => {
throw new Error('AI settings not found in database');
}
if (!tmdbSetting) {
throw new Error('TMDB settings not found in database');
}
return {
logSettings: {
retention_days: logSetting.retention_days,
@@ -43,6 +49,9 @@ export const load = () => {
api_url: aiSetting.api_url,
api_key: aiSetting.api_key,
model: aiSetting.model
},
tmdbSettings: {
api_key: tmdbSetting.api_key
}
};
};
@@ -209,6 +218,31 @@ export const actions: Actions = {
}
});
return { success: true };
},
updateTMDB: async ({ request }: RequestEvent) => {
const formData = await request.formData();
// Parse form data
const apiKey = formData.get('api_key') as string;
// Update settings
const updated = tmdbSettingsQueries.update({
apiKey: apiKey || ''
});
if (!updated) {
await logger.error('Failed to update TMDB settings', {
source: 'settings/general'
});
return fail(500, { error: 'Failed to update settings' });
}
await logger.info('TMDB settings updated', {
source: 'settings/general'
});
return { success: true };
}
};

View File

@@ -2,6 +2,7 @@
import LoggingSettings from './components/LoggingSettings.svelte';
import BackupSettings from './components/BackupSettings.svelte';
import AISettings from './components/AISettings.svelte';
import TMDBSettings from './components/TMDBSettings.svelte';
import type { PageData } from './$types';
export let data: PageData;
@@ -24,5 +25,8 @@
<!-- AI Configuration -->
<AISettings settings={data.aiSettings} />
<!-- TMDB Configuration -->
<TMDBSettings settings={data.tmdbSettings} />
</div>
</div>

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { alertStore } from '$alerts/store';
import { Save, RotateCcw, Eye, EyeOff, FlaskConical, Loader2 } from 'lucide-svelte';
import type { TMDBSettings } from './types';
export let settings: TMDBSettings;
let showApiKey = false;
let isTesting = false;
// Default values
const DEFAULTS = {
api_key: ''
};
function resetToDefaults() {
settings.api_key = DEFAULTS.api_key;
}
async function testConnection() {
if (!settings.api_key) {
alertStore.add('error', 'Please enter an API key first');
return;
}
isTesting = true;
try {
const response = await fetch('/api/tmdb/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: settings.api_key })
});
const data = await response.json();
if (data.success) {
alertStore.add('success', 'TMDB connection successful!');
} else {
alertStore.add('error', data.error || 'Connection failed');
}
} catch {
alertStore.add('error', 'Failed to test connection');
} finally {
isTesting = false;
}
}
</script>
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">
TMDB Configuration
</h2>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Configure TMDB API access for searching movies and TV series.
</p>
</div>
<!-- Form -->
<form
method="POST"
action="?/updateTMDB"
class="p-6"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || 'Failed to save');
} else if (result.type === 'success') {
alertStore.add('success', 'TMDB settings saved successfully!');
}
await update();
};
}}
>
<div class="space-y-6">
<!-- API Read Access Token -->
<div>
<label
for="tmdb_api_key"
class="mb-2 block text-sm font-semibold text-neutral-900 dark:text-neutral-50"
>
API Read Access Token
</label>
<div class="relative">
<input
type={showApiKey ? 'text' : 'password'}
id="tmdb_api_key"
name="api_key"
bind:value={settings.api_key}
placeholder="eyJhbGciOiJIUzI1NiJ9..."
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 font-mono text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400 focus:outline-none dark:focus:border-neutral-500 dark:focus:ring-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
/>
<button
type="button"
on:click={() => (showApiKey = !showApiKey)}
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
>
{#if showApiKey}
<EyeOff size={16} />
{:else}
<Eye size={16} />
{/if}
</button>
</div>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Use the API Read Access Token (not API Key) from <a href="https://www.themoviedb.org/settings/api" target="_blank" rel="noopener noreferrer" class="text-accent-600 hover:underline dark:text-accent-400">themoviedb.org</a>
</p>
</div>
</div>
<!-- Action buttons -->
<div
class="mt-6 flex items-center justify-between border-t border-neutral-200 pt-6 dark:border-neutral-800"
>
<button
type="button"
on:click={resetToDefaults}
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<RotateCcw size={16} />
Reset to Defaults
</button>
<div class="flex items-center gap-2">
<button
type="button"
on:click={testConnection}
disabled={isTesting}
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
{#if isTesting}
<Loader2 size={16} class="animate-spin" />
{:else}
<FlaskConical size={16} />
{/if}
Test
</button>
<button
type="submit"
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 dark:bg-accent-500 dark:hover:bg-accent-600"
>
<Save size={16} />
Save Settings
</button>
</div>
</div>
</form>
</div>

View File

@@ -24,3 +24,7 @@ export interface AISettings {
api_key: string;
model: string;
}
export interface TMDBSettings {
api_key: string;
}