feat(quality-profiles): add quality profile management with views and queries

This commit is contained in:
Sam Chau
2025-11-05 07:30:42 +10:30
parent 2abc9aa86a
commit e7fac48962
9 changed files with 720 additions and 18 deletions

View File

@@ -0,0 +1,245 @@
/**
* Quality Profile queries for PCD cache
*/
import type { PCDCache } from '../cache.ts';
import type { Tag } from '../types.ts';
import { parseMarkdown } from '$utils/markdown/markdown.ts';
// Type for quality/group items in the hierarchy
interface QualityItem {
position: number;
type: 'quality' | 'group';
id: number;
name: string;
is_upgrade_until: boolean;
}
// Type for language configuration
interface ProfileLanguage {
id: number;
name: string;
type: 'must' | 'only' | 'not' | 'simple';
}
// Type for custom format counts
interface CustomFormatCounts {
all: number;
radarr: number;
sonarr: number;
total: number;
}
/**
* Quality profile data for table view with all relationships
*/
export interface QualityProfileTableRow {
// Basic info
id: number;
name: string;
description: string; // Parsed HTML from markdown
// Tags
tags: Tag[];
// Upgrade settings
upgrades_allowed: boolean;
minimum_custom_format_score: number;
upgrade_until_score?: number; // Only if upgrades_allowed
upgrade_score_increment?: number; // Only if upgrades_allowed
// Custom format counts by arr type
custom_formats: CustomFormatCounts;
// Quality hierarchy (in order)
qualities: QualityItem[];
// Single language configuration
language?: ProfileLanguage;
}
/**
* Get quality profiles with full data for table/card views
* Optimized to minimize database queries
*/
export function list(cache: PCDCache): QualityProfileTableRow[] {
// 1. Get all quality profiles
const profiles = cache.query<{
id: number;
name: string;
description: string | null;
upgrades_allowed: number;
minimum_custom_format_score: number;
upgrade_until_score: number;
upgrade_score_increment: number;
}>(`
SELECT
id,
name,
description,
upgrades_allowed,
minimum_custom_format_score,
upgrade_until_score,
upgrade_score_increment
FROM quality_profiles
ORDER BY name
`);
if (profiles.length === 0) return [];
const profileIds = profiles.map(p => p.id);
const idPlaceholders = profileIds.map(() => '?').join(',');
// 2. Get all tags for all profiles in one query
const allTags = cache.query<{
quality_profile_id: number;
tag_id: number;
tag_name: string;
tag_created_at: string;
}>(`
SELECT
qpt.quality_profile_id,
t.id as tag_id,
t.name as tag_name,
t.created_at as tag_created_at
FROM quality_profile_tags qpt
JOIN tags t ON qpt.tag_id = t.id
WHERE qpt.quality_profile_id IN (${idPlaceholders})
ORDER BY qpt.quality_profile_id, t.name
`, ...profileIds);
// 3. Get custom format counts grouped by arr_type for all profiles
const formatCounts = cache.query<{
quality_profile_id: number;
arr_type: string;
count: number;
}>(`
SELECT
quality_profile_id,
arr_type,
COUNT(*) as count
FROM quality_profile_custom_formats
WHERE quality_profile_id IN (${idPlaceholders})
GROUP BY quality_profile_id, arr_type
`, ...profileIds);
// 4. Get all qualities for all profiles with names
const allQualities = cache.query<{
quality_profile_id: number;
position: number;
upgrade_until: number;
quality_id: number | null;
quality_group_id: number | null;
quality_name: string | null;
group_name: string | null;
}>(`
SELECT
qpq.quality_profile_id,
qpq.position,
qpq.upgrade_until,
qpq.quality_id,
qpq.quality_group_id,
q.name as quality_name,
qg.name as group_name
FROM quality_profile_qualities qpq
LEFT JOIN qualities q ON qpq.quality_id = q.id
LEFT JOIN quality_groups qg ON qpq.quality_group_id = qg.id
WHERE qpq.quality_profile_id IN (${idPlaceholders})
ORDER BY qpq.quality_profile_id, qpq.position
`, ...profileIds);
// 5. Get languages for all profiles (one per profile)
const allLanguages = cache.query<{
quality_profile_id: number;
language_id: number;
language_name: string;
type: string;
}>(`
SELECT
qpl.quality_profile_id,
l.id as language_id,
l.name as language_name,
qpl.type
FROM quality_profile_languages qpl
JOIN languages l ON qpl.language_id = l.id
WHERE qpl.quality_profile_id IN (${idPlaceholders})
`, ...profileIds);
// Build maps for efficient lookup
const tagsMap = new Map<number, Tag[]>();
for (const tag of allTags) {
if (!tagsMap.has(tag.quality_profile_id)) {
tagsMap.set(tag.quality_profile_id, []);
}
tagsMap.get(tag.quality_profile_id)!.push({
id: tag.tag_id,
name: tag.tag_name,
created_at: tag.tag_created_at
});
}
const formatCountsMap = new Map<number, Omit<CustomFormatCounts, 'total'>>();
for (const fc of formatCounts) {
if (!formatCountsMap.has(fc.quality_profile_id)) {
formatCountsMap.set(fc.quality_profile_id, {all: 0, radarr: 0, sonarr: 0});
}
const counts = formatCountsMap.get(fc.quality_profile_id)!;
if (fc.arr_type === 'all') counts.all = fc.count;
else if (fc.arr_type === 'radarr') counts.radarr = fc.count;
else if (fc.arr_type === 'sonarr') counts.sonarr = fc.count;
}
const qualitiesMap = new Map<number, QualityItem[]>();
for (const qual of allQualities) {
if (!qualitiesMap.has(qual.quality_profile_id)) {
qualitiesMap.set(qual.quality_profile_id, []);
}
qualitiesMap.get(qual.quality_profile_id)!.push({
position: qual.position,
type: qual.quality_id ? 'quality' : 'group',
id: qual.quality_id || qual.quality_group_id!,
name: qual.quality_name || qual.group_name!,
is_upgrade_until: qual.upgrade_until === 1
});
}
const languagesMap = new Map<number, ProfileLanguage>();
for (const lang of allLanguages) {
languagesMap.set(lang.quality_profile_id, {
id: lang.language_id,
name: lang.language_name,
type: lang.type as 'must' | 'only' | 'not' | 'simple'
});
}
// Build the final result
return profiles.map(profile => {
const counts = formatCountsMap.get(profile.id) || {all: 0, radarr: 0, sonarr: 0};
const result: QualityProfileTableRow = {
id: profile.id,
name: profile.name,
description: parseMarkdown(profile.description),
tags: tagsMap.get(profile.id) || [],
upgrades_allowed: profile.upgrades_allowed === 1,
minimum_custom_format_score: profile.minimum_custom_format_score,
custom_formats: {
all: counts.all,
radarr: counts.radarr,
sonarr: counts.sonarr,
total: counts.all + counts.radarr + counts.sonarr
},
qualities: qualitiesMap.get(profile.id) || [],
language: languagesMap.get(profile.id)
};
// Only include upgrade settings if upgrades are allowed
if (profile.upgrades_allowed === 1) {
result.upgrade_until_score = profile.upgrade_until_score;
result.upgrade_score_increment = profile.upgrade_score_increment;
}
return result;
});
}

View File

@@ -0,0 +1,21 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
/**
* GET /api/databases
* Returns all linked database instances
*/
export const GET: RequestHandler = () => {
try {
const databases = pcdManager.getAll();
return json(databases);
} catch (error) {
return json(
{
error: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
};

View File

@@ -1,29 +1,23 @@
<script lang="ts">
import type { PageData } from './$types';
import { Plus, Pencil } from 'lucide-svelte';
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
export let data: PageData;
// Build tabs from instances
const tabs = data.allInstances.map((instance) => ({
label: instance.name,
href: `/arr/${data.type}/${instance.id}`,
active: instance.id === data.instance.id
}));
</script>
<div class="p-8">
<!-- Tabs Section -->
<div class="mb-8">
<div class="border-b border-neutral-200 dark:border-neutral-800">
<nav class="-mb-px flex gap-2" aria-label="Tabs">
<!-- Instance Tabs -->
{#each data.allInstances as instance (instance.id)}
<a
href="/arr/{data.type}/{instance.id}"
class="border-b-2 px-4 py-3 text-sm font-medium transition-colors {data.instance.id ===
instance.id
? 'border-blue-600 text-blue-600 dark:border-blue-500 dark:text-blue-500'
: 'border-transparent text-neutral-600 hover:border-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-50'}"
>
{instance.name}
</a>
{/each}
<!-- Add Instance Tab (always visible) -->
<Tabs {tabs}>
<svelte:fragment slot="actions">
<a
href="/arr/new?type={data.type}"
class="flex items-center gap-2 border-b-2 border-transparent px-4 py-3 text-sm font-medium text-neutral-600 transition-colors hover:border-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-50"
@@ -31,8 +25,8 @@
<Plus size={16} />
Add Instance
</a>
</nav>
</div>
</svelte:fragment>
</Tabs>
</div>
<!-- Content Area -->

View File

@@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
export const load: ServerLoad = () => {
// Get all databases
const databases = pcdManager.getAll();
// If there are databases, redirect to the first one
if (databases.length > 0) {
throw redirect(303, `/quality-profiles/${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>Quality Profiles - Profilarr</title>
</svelte:head>
<EmptyState
icon={Database}
title="No Databases Linked"
description="Link a Profilarr Compliant Database to manage quality profiles."
buttonText="Link Database"
buttonHref="/databases/new"
buttonIcon={Plus}
/>

View File

@@ -0,0 +1,39 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles.ts';
export const load: ServerLoad = ({ params }) => {
const { databaseId } = params;
// 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 quality profiles for the current database
const qualityProfiles = qualityProfileQueries.list(cache);
return {
databases,
currentDatabase,
qualityProfiles
};
};

View File

@@ -0,0 +1,105 @@
<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 Dropdown from '$ui/dropdown/Dropdown.svelte';
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
import TableView from './views/TableView.svelte';
import CardView from './views/CardView.svelte';
import { createSearchStore } from '$lib/client/stores/search';
import { Eye, LayoutGrid, Table } from 'lucide-svelte';
import { browser } from '$app/environment';
import type { PageData } from './$types';
export let data: PageData;
// Initialize search store
const searchStore = createSearchStore({ debounceMs: 300 });
// View state - load from localStorage or default to 'table'
let currentView: 'cards' | 'table' = 'table';
if (browser) {
const savedView = localStorage.getItem('qualityProfilesView') as 'cards' | 'table' | null;
if (savedView) {
currentView = savedView;
}
}
// Save view to localStorage when it changes
$: if (browser && currentView) {
localStorage.setItem('qualityProfilesView', currentView);
}
// Map databases to tabs
$: tabs = data.databases.map((db) => ({
label: db.name,
href: `/quality-profiles/${db.id}`,
active: db.id === data.currentDatabase.id
}));
// Filter quality profiles based on search
$: filteredProfiles = data.qualityProfiles.filter((profile) => {
const query = $searchStore.query;
if (!query) return true;
const searchLower = query.toLowerCase();
return profile.name?.toLowerCase().includes(searchLower);
});
</script>
<svelte:head>
<title>Quality Profiles - {data.currentDatabase.name} - Profilarr</title>
</svelte:head>
<div class="space-y-6 p-8">
<!-- Tabs -->
<Tabs {tabs} />
<!-- Actions Bar -->
<ActionsBar>
<SearchAction {searchStore} placeholder="Search quality profiles..." />
<ActionButton icon={Eye} hasDropdown={true} dropdownPosition="right">
<Dropdown slot="dropdown" let:open position="right">
<DropdownItem
icon={LayoutGrid}
label="Cards"
selected={currentView === 'cards'}
on:click={() => (currentView = 'cards')}
/>
<DropdownItem
icon={Table}
label="Table"
selected={currentView === 'table'}
on:click={() => (currentView = 'table')}
/>
</Dropdown>
</ActionButton>
</ActionsBar>
<!-- Quality Profiles Content -->
<div class="mt-6">
{#if data.qualityProfiles.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 quality profiles found for {data.currentDatabase.name}
</p>
</div>
{:else if filteredProfiles.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 quality profiles match your search
</p>
</div>
{:else if currentView === 'table'}
<TableView profiles={filteredProfiles} />
{:else}
<CardView profiles={filteredProfiles} />
{/if}
</div>
</div>

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import type { QualityProfileTableRow } from '$lib/server/pcd/queries/qualityProfiles';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { BookOpenText, Gauge, Earth } from 'lucide-svelte';
export let profiles: QualityProfileTableRow[];
function handleCardClick(profile: QualityProfileTableRow) {
const databaseId = $page.params.databaseId;
goto(`/quality-profiles/${databaseId}/${profile.id}`);
}
</script>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each profiles as profile}
<button
on:click={() => handleCardClick(profile)}
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 tags -->
<div>
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{profile.name}</h3>
{#if profile.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each profile.tags as tag}
<span class="inline-flex items-center px-1.5 py-0.5 rounded font-mono text-[10px] bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
<!-- Description if exists -->
{#if profile.description}
<div class="description text-xs text-neutral-600 dark:text-neutral-400">
{@html profile.description}
</div>
{/if}
<!-- Qualities preview -->
<div class="flex flex-wrap items-center gap-1">
{#each profile.qualities.slice(0, 3) as quality, idx}
{#if idx > 0}
<span class="text-xs text-neutral-400"></span>
{/if}
<span class="inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-mono text-neutral-900 dark:text-neutral-100
{quality.is_upgrade_until
? 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950'
: 'border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800'}">
{quality.name}
</span>
{/each}
{#if profile.qualities.length > 3}
<span class="text-[10px] text-neutral-500 dark:text-neutral-400">
+{profile.qualities.length - 3} more
</span>
{/if}
</div>
<!-- Stats row -->
<div class="flex items-center gap-3 border-t border-neutral-100 pt-3 text-xs dark:border-neutral-800">
<!-- Custom Formats -->
<div class="flex items-center gap-1">
<BookOpenText size={12} class="text-neutral-400" />
<span class="font-mono text-[10px] text-neutral-900 dark:text-neutral-100 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">
{profile.custom_formats.total}
</span>
</div>
<!-- Scores -->
<div class="flex items-center gap-1">
<Gauge size={12} class="text-neutral-400" />
<span class="font-mono text-[10px] text-neutral-900 dark:text-neutral-100 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">
{profile.minimum_custom_format_score}
</span>
</div>
<!-- Language -->
{#if profile.language}
<div class="flex items-center gap-1">
<Earth size={12} class="text-neutral-400" />
<span class="font-mono text-[10px] text-neutral-900 dark:text-neutral-100 bg-neutral-100 dark:bg-neutral-800 px-1.5 py-0.5 rounded">
{profile.language.name}
</span>
</div>
{/if}
</div>
</button>
{/each}
</div>
<style>
.description :global(ul) {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.description :global(ol) {
list-style-type: decimal;
padding-left: 1.5rem;
margin: 0.5rem 0;
}
.description :global(li) {
margin: 0.25rem 0;
}
.description :global(p) {
margin: 0.5rem 0;
}
.description :global(strong) {
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import Table, { type Column } from '$ui/table/Table.svelte';
import type { QualityProfileTableRow } from '$lib/server/pcd/queries/qualityProfiles';
import { Tag, FileText, Layers, BookOpenText, Gauge, Earth } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
export let profiles: QualityProfileTableRow[];
function handleRowClick(row: QualityProfileTableRow) {
// Get the current database ID from the URL
const databaseId = $page.params.databaseId;
goto(`/quality-profiles/${databaseId}/${row.id}`);
}
// Define table columns for quality profiles
const columns: Column<QualityProfileTableRow>[] = [
{
key: 'name',
header: 'Name',
headerIcon: Tag,
align: 'left',
sortable: true,
cell: (row: QualityProfileTableRow) => ({
html: `
<div>
<div class="font-medium">${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-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
${tag.name}
</span>
`).join('')}
</div>
` : ''}
</div>
`
})
},
{
key: 'description',
header: 'Description',
headerIcon: FileText,
align: 'left',
cell: (row: QualityProfileTableRow) => ({ html: row.description || '<span class="text-neutral-400">No description</span>' })
},
{
key: 'qualities',
header: 'Qualities',
headerIcon: Layers,
align: 'left',
width: 'w-48',
cell: (row: QualityProfileTableRow) => {
return {
html: `
<div class="space-y-1 py-2">
${row.qualities.map(q => `
<div class="relative px-2 py-0.5 rounded border ${
q.is_upgrade_until
? 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950'
: 'border-neutral-200 bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800'
}">
<span class="font-mono text-xs">${q.name}</span>
</div>
`).join('')}
</div>
`
};
}
},
{
key: 'custom_formats',
header: 'Custom Formats',
headerIcon: BookOpenText,
align: 'left',
width: 'w-48',
cell: (row: QualityProfileTableRow) => ({
html: `
<div class="text-xs space-y-0.5">
<div>All: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.custom_formats.all}</span></div>
<div>Radarr: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.custom_formats.radarr}</span></div>
<div>Sonarr: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.custom_formats.sonarr}</span></div>
</div>
`
})
},
{
key: 'scores',
header: 'Scores',
headerIcon: Gauge,
align: 'left',
width: 'w-52',
cell: (row: QualityProfileTableRow) => ({
html: `
<div class="text-xs space-y-0.5">
<div>Minimum: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.minimum_custom_format_score}</span></div>
${row.upgrades_allowed ? `
<div>Upgrade Until: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.upgrade_until_score}</span></div>
<div>Increment: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.upgrade_score_increment}</span></div>
` : `
<div class="text-neutral-500 dark:text-neutral-400">No Upgrades</div>
`}
</div>
`
})
},
{
key: 'language',
header: 'Language',
headerIcon: Earth,
align: 'left',
width: 'w-40',
cell: (row: QualityProfileTableRow) => {
if (!row.language) return { html: '<span class="text-neutral-400">-</span>' };
const typePrefix = {
must: 'Must Include',
only: 'Must Only Be',
not: 'Does Not Include',
simple: ''
};
if (row.language.type === 'simple') {
return {
html: `<div class="text-xs"><span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.language.name}</span></div>`
};
}
return {
html: `<div class="text-xs">${typePrefix[row.language.type]} <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${row.language.name}</span></div>`
};
}
}
];
</script>
<Table
data={profiles}
{columns}
emptyMessage="No quality profiles found"
hoverable={true}
compact={false}
onRowClick={handleRowClick}
/>