mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
151
docs/CONTRIBUTING.md
Normal file
151
docs/CONTRIBUTING.md
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
36
src/lib/server/db/migrations/020_create_tmdb_settings.ts
Normal file
36
src/lib/server/db/migrations/020_create_tmdb_settings.ts
Normal 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;
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
`
|
||||
};
|
||||
89
src/lib/server/db/queries/parsedReleaseCache.ts
Normal file
89
src/lib/server/db/queries/parsedReleaseCache.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
70
src/lib/server/db/queries/tmdbSettings.ts
Normal file
70
src/lib/server/db/queries/tmdbSettings.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
221
src/lib/server/pcd/queries/customFormats/allConditions.ts
Normal file
221
src/lib/server/pcd/queries/customFormats/allConditions.ts
Normal 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) || []
|
||||
}));
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
93
src/lib/server/pcd/queries/entityTests/create.ts
Normal file
93
src/lib/server/pcd/queries/entityTests/create.ts
Normal 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
|
||||
};
|
||||
}
|
||||
56
src/lib/server/pcd/queries/entityTests/createRelease.ts
Normal file
56
src/lib/server/pcd/queries/entityTests/createRelease.ts
Normal 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;
|
||||
}
|
||||
44
src/lib/server/pcd/queries/entityTests/delete.ts
Normal file
44
src/lib/server/pcd/queries/entityTests/delete.ts
Normal 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;
|
||||
}
|
||||
40
src/lib/server/pcd/queries/entityTests/deleteRelease.ts
Normal file
40
src/lib/server/pcd/queries/entityTests/deleteRelease.ts
Normal 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;
|
||||
}
|
||||
21
src/lib/server/pcd/queries/entityTests/index.ts
Normal file
21
src/lib/server/pcd/queries/entityTests/index.ts
Normal 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';
|
||||
75
src/lib/server/pcd/queries/entityTests/list.ts
Normal file
75
src/lib/server/pcd/queries/entityTests/list.ts
Normal 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
|
||||
}))
|
||||
}));
|
||||
}
|
||||
56
src/lib/server/pcd/queries/entityTests/updateRelease.ts
Normal file
56
src/lib/server/pcd/queries/entityTests/updateRelease.ts
Normal 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;
|
||||
}
|
||||
86
src/lib/server/pcd/queries/qualityProfiles/allCfScores.ts
Normal file
86
src/lib/server/pcd/queries/qualityProfiles/allCfScores.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
25
src/lib/server/pcd/queries/qualityProfiles/select.ts
Normal file
25
src/lib/server/pcd/queries/qualityProfiles/select.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
53
src/lib/server/utils/tmdb/client.ts
Normal file
53
src/lib/server/utils/tmdb/client.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
57
src/lib/server/utils/tmdb/types.ts
Normal file
57
src/lib/server/utils/tmdb/types.ts
Normal 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;
|
||||
}
|
||||
108
src/routes/api/entity-testing/evaluate/+server.ts
Normal file
108
src/routes/api/entity-testing/evaluate/+server.ts
Normal 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);
|
||||
};
|
||||
97
src/routes/api/tmdb/search/+server.ts
Normal file
97
src/routes/api/tmdb/search/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
27
src/routes/api/tmdb/test/+server.ts
Normal file
27
src/routes/api/tmdb/test/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
18
src/routes/quality-profiles/entity-testing/+page.server.ts
Normal file
18
src/routes/quality-profiles/entity-testing/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
17
src/routes/quality-profiles/entity-testing/+page.svelte
Normal file
17
src/routes/quality-profiles/entity-testing/+page.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
152
src/routes/settings/general/components/TMDBSettings.svelte
Normal file
152
src/routes/settings/general/components/TMDBSettings.svelte
Normal 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>
|
||||
@@ -24,3 +24,7 @@ export interface AISettings {
|
||||
api_key: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface TMDBSettings {
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user