feat(custom-formats): implement custom formats management with list, detail views, and search functionality

This commit is contained in:
Sam Chau
2025-12-30 08:23:36 +10:30
parent f8c62c51ba
commit 8a3f266593
11 changed files with 724 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { ComponentType } from 'svelte';
export let variant: 'accent' | 'neutral' | 'success' | 'warning' | 'danger' = 'accent';
export let size: 'sm' | 'md' = 'sm';
export let icon: ComponentType | null = null;
const variantClasses: Record<typeof variant, string> = {
accent: 'bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200',
neutral: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
success: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
danger: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
};
const sizeClasses: Record<typeof size, string> = {
sm: 'px-1.5 py-0.5 text-[10px]',
md: 'px-2 py-0.5 text-xs'
};
$: iconSize = size === 'sm' ? 10 : 12;
</script>
<span
class="inline-flex items-center gap-1 rounded font-medium {variantClasses[variant]} {sizeClasses[size]}"
>
{#if icon}
<svelte:component this={icon} size={iconSize} />
{/if}
<slot />
</span>

View File

@@ -0,0 +1,9 @@
/**
* Custom Format queries and mutations
*/
// Export all types
export type { CustomFormatTableRow, ConditionRef } from './types.ts';
// Export query functions
export { list } from './list.ts';

View File

@@ -0,0 +1,85 @@
/**
* Custom format list queries
*/
import type { PCDCache } from '../../cache.ts';
import type { Tag } from '../../types.ts';
import type { CustomFormatTableRow, ConditionRef } from './types.ts';
/**
* Get custom formats with full data for table/card views
*/
export async function list(cache: PCDCache): Promise<CustomFormatTableRow[]> {
const db = cache.kb;
// 1. Get all custom formats
const formats = await db
.selectFrom('custom_formats')
.select(['id', 'name', 'description'])
.orderBy('name')
.execute();
if (formats.length === 0) return [];
const formatIds = formats.map((f) => f.id);
// 2. Get all tags for all custom formats
const allTags = await db
.selectFrom('custom_format_tags as cft')
.innerJoin('tags as t', 't.id', 'cft.tag_id')
.select([
'cft.custom_format_id',
't.id as tag_id',
't.name as tag_name',
't.created_at as tag_created_at'
])
.where('cft.custom_format_id', 'in', formatIds)
.orderBy('cft.custom_format_id')
.orderBy('t.name')
.execute();
// 3. Get all conditions for all custom formats
const allConditions = await db
.selectFrom('custom_format_conditions')
.select(['id', 'custom_format_id', 'name', 'required', 'negate'])
.where('custom_format_id', 'in', formatIds)
.orderBy('custom_format_id')
.orderBy('name')
.execute();
// Build tags map
const tagsMap = new Map<number, Tag[]>();
for (const tag of allTags) {
if (!tagsMap.has(tag.custom_format_id)) {
tagsMap.set(tag.custom_format_id, []);
}
tagsMap.get(tag.custom_format_id)!.push({
id: tag.tag_id,
name: tag.tag_name,
created_at: tag.tag_created_at
});
}
// Build conditions map
const conditionsMap = new Map<number, ConditionRef[]>();
for (const condition of allConditions) {
if (!conditionsMap.has(condition.custom_format_id)) {
conditionsMap.set(condition.custom_format_id, []);
}
conditionsMap.get(condition.custom_format_id)!.push({
id: condition.id,
name: condition.name,
required: condition.required === 1,
negate: condition.negate === 1
});
}
// Build the final result
return formats.map((format) => ({
id: format.id,
name: format.name,
description: format.description,
tags: tagsMap.get(format.id) || [],
conditions: conditionsMap.get(format.id) || []
}));
}

View File

@@ -0,0 +1,22 @@
/**
* Custom Format query-specific types
*/
import type { Tag } from '../../types.ts';
/** Condition reference for display */
export interface ConditionRef {
id: number;
name: string;
required: boolean;
negate: boolean;
}
/** Custom format data for table/card views */
export interface CustomFormatTableRow {
id: number;
name: string;
description: string | null;
tags: Tag[];
conditions: ConditionRef[];
}

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import * as customFormatQueries from '$pcd/queries/customFormats/index.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId } = 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');
}
// Load custom formats for the current database
const customFormats = await customFormatQueries.list(cache);
return {
databases,
currentDatabase,
customFormats
};
};

View File

@@ -0,0 +1,181 @@
<script lang="ts">
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 ViewToggle from '$ui/actions/ViewToggle.svelte';
import InfoModal from '$ui/modal/InfoModal.svelte';
import TableView from './views/TableView.svelte';
import CardView from './views/CardView.svelte';
import SearchFilterAction from './components/SearchFilterAction.svelte';
import { createDataPageStore } from '$lib/client/stores/dataPage';
import { browser } from '$app/environment';
import { Info, Plus } from 'lucide-svelte';
import { goto } from '$app/navigation';
import type { CustomFormatTableRow } from '$pcd/queries/customFormats';
import type { PageData } from './$types';
export let data: PageData;
let infoModalOpen = false;
const SEARCH_FILTER_STORAGE_KEY = 'customFormatsSearchFilter';
// Default search filter options
const defaultSearchOptions = [
{ key: 'name', label: 'Name', enabled: true },
{ key: 'tags', label: 'Tags', enabled: true },
{ key: 'description', label: 'Description', enabled: false }
];
// Load saved preferences from localStorage or use defaults
function loadSearchOptions() {
if (!browser) return defaultSearchOptions;
try {
const saved = localStorage.getItem(SEARCH_FILTER_STORAGE_KEY);
if (saved) {
const savedMap = new Map(JSON.parse(saved) as [string, boolean][]);
return defaultSearchOptions.map((opt) => ({
...opt,
enabled: savedMap.has(opt.key) ? savedMap.get(opt.key)! : opt.enabled
}));
}
} catch {
// Ignore parse errors, use defaults
}
return defaultSearchOptions;
}
let searchOptions = loadSearchOptions();
// Save to localStorage when options change
$: if (browser) {
const enabledMap = searchOptions.map((opt) => [opt.key, opt.enabled] as [string, boolean]);
localStorage.setItem(SEARCH_FILTER_STORAGE_KEY, JSON.stringify(enabledMap));
}
// Initialize data page store (we'll use search and view, but do our own filtering)
const { search, view, setItems } = createDataPageStore(data.customFormats, {
storageKey: 'customFormatsView',
searchKeys: ['name'] // Placeholder, we do our own filtering
});
// Extract the debounced query store for reactive access
const debouncedQuery = search.debouncedQuery;
// Update items when data changes (e.g., switching databases)
$: setItems(data.customFormats);
// Custom filtering based on selected search options
$: filtered = filterFormats(data.customFormats, $debouncedQuery, searchOptions);
function filterFormats(
items: CustomFormatTableRow[],
query: string,
options: typeof searchOptions
): CustomFormatTableRow[] {
if (!query) return items;
const queryLower = query.toLowerCase();
const enabledKeys = options.filter((o) => o.enabled).map((o) => o.key);
return items.filter((item) => {
return enabledKeys.some((key) => {
if (key === 'tags') {
// Search within tag names
return item.tags.some((tag) => tag.name.toLowerCase().includes(queryLower));
}
const value = item[key as keyof CustomFormatTableRow];
if (value == null) return false;
return String(value).toLowerCase().includes(queryLower);
});
});
}
// Map databases to tabs
$: tabs = data.databases.map((db) => ({
label: db.name,
href: `/custom-formats/${db.id}`,
active: db.id === data.currentDatabase.id
}));
</script>
<svelte:head>
<title>Custom Formats - {data.currentDatabase.name} - Profilarr</title>
</svelte:head>
<div class="space-y-6 p-8">
<!-- Tabs -->
<Tabs {tabs} />
<!-- Actions Bar -->
<ActionsBar>
<SearchAction searchStore={search} placeholder="Search custom formats..." />
<ActionButton
icon={Plus}
on:click={() => goto(`/custom-formats/${data.currentDatabase.id}/new`)}
/>
<SearchFilterAction bind:options={searchOptions} />
<ViewToggle bind:value={$view} />
<ActionButton icon={Info} on:click={() => (infoModalOpen = true)} />
</ActionsBar>
<!-- Custom Formats Content -->
<div class="mt-6">
{#if data.customFormats.length === 0}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
<p class="text-neutral-600 dark:text-neutral-400">
No custom formats found for {data.currentDatabase.name}
</p>
</div>
{:else if filtered.length === 0}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
<p class="text-neutral-600 dark:text-neutral-400">
No custom formats match your search
</p>
</div>
{:else if $view === 'table'}
<TableView formats={filtered} />
{:else}
<CardView formats={filtered} />
{/if}
</div>
</div>
<!-- Info Modal -->
<InfoModal bind:open={infoModalOpen} header="About Custom Formats">
<div class="space-y-4 text-sm text-neutral-700 dark:text-neutral-300">
<section>
<h3 class="mb-2 font-semibold text-neutral-900 dark:text-neutral-100">What Are Custom Formats?</h3>
<p>
Custom formats are rules that match specific release characteristics like codec, resolution,
source, or release group. They're used to score releases and guide quality decisions.
</p>
</section>
<section>
<h3 class="mb-2 font-semibold text-neutral-900 dark:text-neutral-100">How They Work</h3>
<p>
Each custom format contains one or more conditions. A release must match all required
conditions (or at least one non-required condition) to be assigned the custom format.
Quality profiles then assign scores to determine which releases are preferred.
</p>
</section>
<section>
<h3 class="mb-2 font-semibold text-neutral-900 dark:text-neutral-100">Condition Types</h3>
<ul class="list-inside list-disc space-y-1">
<li><strong>Release Title</strong> - Match patterns in the release name</li>
<li><strong>Release Group</strong> - Match specific release groups</li>
<li><strong>Edition</strong> - Match edition names (Director's Cut, etc.)</li>
<li><strong>Language</strong> - Match audio language</li>
<li><strong>Source</strong> - Match release source (BluRay, WEB, etc.)</li>
<li><strong>Resolution</strong> - Match video resolution</li>
</ul>
</section>
</div>
</InfoModal>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { Binoculars, Check } from 'lucide-svelte';
import { fly } from 'svelte/transition';
import { createEventDispatcher } from 'svelte';
export let options: { key: string; label: string; enabled: boolean }[] = [];
const dispatch = createEventDispatcher<{ change: { key: string; enabled: boolean }[] }>();
let isHovered = false;
let leaveTimer: ReturnType<typeof setTimeout> | null = null;
function handleMouseEnter() {
if (leaveTimer) {
clearTimeout(leaveTimer);
leaveTimer = null;
}
isHovered = true;
}
function handleMouseLeave() {
leaveTimer = setTimeout(() => {
isHovered = false;
}, 100);
}
function toggleOption(key: string) {
options = options.map((opt) =>
opt.key === key ? { ...opt, enabled: !opt.enabled } : opt
);
dispatch('change', options);
}
$: enabledCount = options.filter((o) => o.enabled).length;
</script>
<div
class="relative flex"
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
role="group"
>
<button
class="flex h-10 w-10 items-center justify-center border border-neutral-200 bg-white transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700"
>
<div class="relative">
<Binoculars size={20} class="text-neutral-700 dark:text-neutral-300" />
{#if enabledCount < options.length}
<div
class="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-accent-600 text-[9px] font-bold text-white"
>
{enabledCount}
</div>
{/if}
</div>
</button>
{#if isHovered}
<div class="z-50" transition:fly={{ y: -8, duration: 150 }}>
<div class="absolute top-full z-40 h-3 w-full"></div>
<div
class="absolute right-0 top-full z-50 mt-3 min-w-48 rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<div class="px-3 py-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
Search in...
</div>
{#each options as option}
<button
class="flex w-full items-center gap-3 border-t border-neutral-200 px-3 py-2 text-left text-sm transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-700"
on:click={() => toggleOption(option.key)}
>
<div
class="flex h-4 w-4 items-center justify-center rounded border {option.enabled
? 'border-accent-600 bg-accent-600 dark:border-accent-500 dark:bg-accent-500'
: 'border-neutral-300 dark:border-neutral-600'}"
>
{#if option.enabled}
<Check size={12} class="text-white" />
{/if}
</div>
<span class="text-neutral-700 dark:text-neutral-300">{option.label}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import type { CustomFormatTableRow } from '$pcd/queries/customFormats';
import { Layers } from 'lucide-svelte';
import { marked } from 'marked';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
export let formats: CustomFormatTableRow[];
function handleCardClick(format: CustomFormatTableRow) {
const databaseId = $page.params.databaseId;
goto(`/custom-formats/${databaseId}/${format.id}`);
}
// Configure marked for inline parsing (no wrapping <p> tags for short text)
function parseMarkdown(text: string | null): string {
if (!text) return '';
return marked.parseInline(text) as string;
}
</script>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each formats as format}
<button
type="button"
on:click={() => handleCardClick(format)}
class="group relative flex flex-col gap-3 rounded-lg border border-neutral-200 bg-white p-4 text-left transition-all hover:border-neutral-300 hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-neutral-700 cursor-pointer"
>
<!-- Header with name and condition count -->
<div>
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{format.name}
</h3>
<div
class="flex flex-shrink-0 items-center gap-1 rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"
title="{format.conditions.length} condition{format.conditions.length !== 1 ? 's' : ''}"
>
<Layers size={12} />
<span>{format.conditions.length}</span>
</div>
</div>
{#if format.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each format.tags as tag}
<span
class="inline-flex items-center rounded px-1.5 py-0.5 font-mono text-[10px] bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200"
>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
<!-- Description -->
{#if format.description}
<div class="text-xs text-neutral-600 dark:text-neutral-400 line-clamp-2 prose-inline">
{@html parseMarkdown(format.description)}
</div>
{:else}
<div class="text-xs text-neutral-400 dark:text-neutral-500 italic">
No description
</div>
{/if}
</button>
{/each}
</div>
<style>
/* Inline prose styles for markdown content */
:global(.prose-inline code) {
background-color: rgb(229 231 235);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: ui-monospace, monospace;
}
:global(.dark .prose-inline code) {
background-color: rgb(38 38 38);
}
:global(.prose-inline strong) {
font-weight: 600;
}
:global(.prose-inline a) {
color: rgb(var(--color-accent-600));
text-decoration: underline;
}
:global(.dark .prose-inline a) {
color: rgb(var(--color-accent-400));
}
</style>

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import Table from '$ui/table/Table.svelte';
import type { Column } from '$ui/table/types';
import type { CustomFormatTableRow } from '$pcd/queries/customFormats';
import { Tag, FileText, Layers } from 'lucide-svelte';
import { marked } from 'marked';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
export let formats: CustomFormatTableRow[];
function handleRowClick(row: CustomFormatTableRow) {
const databaseId = $page.params.databaseId;
goto(`/custom-formats/${databaseId}/${row.id}`);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function parseMarkdown(text: string | null): string {
if (!text) return '';
return marked.parseInline(text) as string;
}
const columns: Column<CustomFormatTableRow>[] = [
{
key: 'name',
header: 'Name',
headerIcon: Tag,
align: 'left',
sortable: true,
width: 'w-48',
cell: (row: CustomFormatTableRow) => ({
html: `
<div>
<div class="font-medium">${escapeHtml(row.name)}</div>
${
row.tags.length > 0
? `
<div class="mt-1 flex flex-wrap gap-1">
${row.tags
.map(
(tag) => `
<span class="inline-flex items-center px-2 py-0.5 rounded font-mono text-[10px] bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200">
${escapeHtml(tag.name)}
</span>
`
)
.join('')}
</div>
`
: ''
}
</div>
`
})
},
{
key: 'description',
header: 'Description',
headerIcon: FileText,
align: 'left',
cell: (row: CustomFormatTableRow) => ({
html: row.description
? `<span class="text-sm text-neutral-600 dark:text-neutral-400 prose-inline">${parseMarkdown(row.description)}</span>`
: `<span class="text-neutral-400">-</span>`
})
},
{
key: 'conditions',
header: 'Conditions',
headerIcon: Layers,
align: 'left',
cell: (row: CustomFormatTableRow) => ({
html: row.conditions.length > 0
? `<div class="flex flex-wrap gap-1">${row.conditions.map((c) => {
// Color based on required/negate:
// required + negate = red (must NOT match)
// required + !negate = accent (must match)
// !required + negate = amber (optional negative)
// !required + !negate = neutral (optional)
let colorClass: string;
if (c.required && c.negate) {
colorClass = 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
} else if (c.required) {
colorClass = 'bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200';
} else if (c.negate) {
colorClass = 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200';
} else {
colorClass = 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300';
}
return `<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${colorClass}">${escapeHtml(c.name)}</span>`;
}).join('')}</div>`
: `<span class="text-neutral-400 text-xs">None</span>`
})
}
];
</script>
<Table data={formats} {columns} emptyMessage="No custom formats found" hoverable={true} compact={false} onRowClick={handleRowClick} />
<style>
/* Inline prose styles for markdown content */
:global(.prose-inline code) {
background-color: rgb(229 231 235);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: ui-monospace, monospace;
}
:global(.dark .prose-inline code) {
background-color: rgb(38 38 38);
}
:global(.prose-inline strong) {
font-weight: 600;
}
:global(.prose-inline a) {
color: rgb(var(--color-accent-600));
text-decoration: underline;
}
:global(.dark .prose-inline a) {
color: rgb(var(--color-accent-400));
}
</style>