feat: add scoring page with custom format management

- Implemented server-side loading for scoring data in `+page.server.ts`.
- Created a new Svelte component for the scoring page in `+page.svelte`, including UI for managing custom formats, profiles, and display options.
- Added a `ScoringTable` component to display custom formats and their scores.
- Introduced local storage management for user preferences such as grouping, tiling, and profiles.
- Enhanced user experience with modals for information and unsaved changes.
This commit is contained in:
Sam Chau
2025-11-09 05:12:36 +11:00
parent e1de8f88cf
commit 55e0c9eb67
13 changed files with 1455 additions and 15 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { X } from 'lucide-svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import { Check } from 'lucide-svelte';
export let customGroups: Array<{ name: string; key: string; tags: string[]; custom: boolean }> = [];
export let selectedGroups: Set<string>;
export let onAdd: (name: string, tags: string[]) => void;
export let onDelete: (key: string) => void;
export let onToggle: (key: string) => void;
let newGroupName = '';
let newGroupTags = '';
function handleSubmit() {
if (newGroupName && newGroupTags) {
const tags = newGroupTags.split(',').map(t => t.trim()).filter(t => t.length > 0);
if (tags.length > 0) {
onAdd(newGroupName, tags);
newGroupName = '';
newGroupTags = '';
}
}
}
</script>
<!-- Add new group form -->
<div class="border-t border-neutral-200 px-4 py-3 dark:border-neutral-700">
<div class="mb-2 text-xs font-medium text-neutral-700 dark:text-neutral-300">
Add Custom Group
</div>
<form on:submit|preventDefault={handleSubmit} class="space-y-2">
<input
type="text"
bind:value={newGroupName}
placeholder="Group name"
class="block w-full rounded border border-neutral-300 bg-white px-2 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
/>
<input
type="text"
bind:value={newGroupTags}
placeholder="Tags (comma-separated)"
class="block w-full rounded border border-neutral-300 bg-white px-2 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
/>
<button
type="submit"
class="w-full rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
disabled={!newGroupName || !newGroupTags}
>
Add Group
</button>
</form>
</div>
<!-- Custom groups list -->
{#if customGroups.length > 0}
<div class="border-t border-neutral-200 dark:border-neutral-700">
{#each customGroups as group}
<div class="group flex items-center justify-between gap-2 px-4 py-2 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700">
<button
type="button"
on:click={() => onToggle(group.key)}
class="flex flex-1 items-center justify-between gap-3"
>
<div class="flex-1 text-left">
<div class="text-xs font-medium text-neutral-700 dark:text-neutral-300">{group.name}</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{group.tags.join(', ')}
</div>
</div>
<IconCheckbox
checked={selectedGroups.has(group.key)}
icon={Check}
color="blue"
shape="circle"
/>
</button>
<button
type="button"
on:click|stopPropagation={() => onDelete(group.key)}
class="flex h-5 w-5 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
>
<X size={12} />
</button>
</div>
{/each}
</div>
{/if}

View File

@@ -16,7 +16,7 @@
<div class="absolute top-full z-40 h-3 w-full"></div>
<div
class="absolute top-full z-50 mt-3 rounded-lg border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-800 {positionClass}"
class="absolute top-full z-50 mt-3 rounded-lg border border-neutral-200 bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-800 {positionClass}"
style="min-width: {minWidth}"
>
<slot />

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type { ComponentType } from 'svelte';
export let checked: boolean = false;
export let icon: ComponentType;
export let color: string = 'blue'; // blue, green, red, or hex color like #FFC230
export let shape: 'square' | 'circle' | 'rounded' = 'rounded';
export let disabled: boolean = false;
// Shape classes
const shapeClasses: Record<string, string> = {
square: 'rounded-none',
circle: 'rounded-full',
rounded: 'rounded'
};
$: shapeClass = shapeClasses[shape] || shapeClasses.rounded;
$: isCustomColor = color.startsWith('#');
</script>
{#if isCustomColor}
<button
type="button"
role="checkbox"
aria-checked={checked}
{disabled}
on:click
class="flex h-5 w-5 items-center justify-center border-2 transition-all {shapeClass} {disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer focus:outline-none'} {checked
? 'hover:brightness-110'
: 'bg-neutral-50 hover:bg-neutral-100 hover:border-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:hover:border-neutral-500'}"
style="background-color: {checked ? color : ''}; border-color: {checked
? color
: 'rgb(229, 231, 235)'};"
>
{#if checked}
<svelte:component this={icon} size={14} class="text-white" />
{/if}
</button>
{:else}
<button
type="button"
role="checkbox"
aria-checked={checked}
{disabled}
on:click
class="flex h-5 w-5 items-center justify-center border-2 transition-all {shapeClass} {checked
? `bg-${color}-600 border-${color}-600 dark:bg-${color}-500 dark:border-${color}-500 hover:brightness-110`
: 'bg-neutral-50 border-neutral-300 hover:bg-neutral-100 hover:border-neutral-400 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:hover:border-neutral-500'} {disabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer focus:outline-none'}"
>
{#if checked}
<svelte:component this={icon} size={14} class="text-white" />
{/if}
</button>
{/if}

View File

@@ -10,6 +10,9 @@
export let step: number = 1;
export let required: boolean = false;
export let disabled: boolean = false;
export let font: 'mono' | 'sans' | undefined = undefined;
$: fontClass = font === 'mono' ? 'font-mono' : font === 'sans' ? 'font-sans' : '';
// Increment/decrement handlers
function increment() {
@@ -55,7 +58,7 @@
{step}
{required}
{disabled}
class="block w-full [appearance:textfield] rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
class="block w-full [appearance:textfield] rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none {fontClass}"
/>
<!-- Custom increment/decrement buttons -->

View File

@@ -20,10 +20,12 @@
}
}
$: if (open) {
window.addEventListener('keydown', handleKeydown);
} else {
window.removeEventListener('keydown', handleKeydown);
$: if (typeof window !== 'undefined') {
if (open) {
window.addEventListener('keydown', handleKeydown);
} else {
window.removeEventListener('keydown', handleKeydown);
}
}
</script>

View File

@@ -24,6 +24,7 @@
{#each tabs as tab (tab.href)}
<a
href={tab.href}
data-sveltekit-preload-data="tap"
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {tab.active
? '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'}"

View File

@@ -1,5 +1,5 @@
<script lang="ts" generics="T extends Record<string, any>">
import type { Column } from './types';
import type { Column, SortDirection, SortState } from './types';
/**
* Props
@@ -10,6 +10,12 @@
export let compact: boolean = false;
export let emptyMessage: string = 'No data available';
export let onRowClick: ((row: T) => void) | undefined = undefined;
export let initialSort: SortState | null = null;
export let onSortChange: ((sort: SortState | null) => void) | undefined = undefined;
let sortKey: string | null = initialSort?.key ?? null;
let sortDirection: SortDirection = initialSort?.direction ?? 'asc';
let sortedData: T[] = data;
/**
* Get cell value by key path (supports nested properties like 'user.name')
@@ -31,6 +37,75 @@
return 'text-left';
}
}
function toggleSort(column: Column<T>) {
if (!column.sortable) {
return;
}
if (sortKey === column.key) {
if (sortDirection === 'asc') {
sortDirection = 'desc';
} else {
sortKey = null;
onSortChange?.(null);
return;
}
} else {
sortKey = column.key;
sortDirection = column.defaultSortDirection ?? 'asc';
}
onSortChange?.(sortKey ? { key: sortKey, direction: sortDirection } : null);
}
function compareValues(a: any, b: any): number {
if (a == null && b == null) return 0;
if (a == null) return -1;
if (b == null) return 1;
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}
if (a instanceof Date && b instanceof Date) {
return a.getTime() - b.getTime();
}
return String(a).localeCompare(String(b));
}
function getSortValue(row: T, column: Column<T>) {
if (column.sortAccessor) {
return column.sortAccessor(row);
}
return getCellValue(row, column.key);
}
function sortData(rows: T[]): T[] {
if (!sortKey) {
return rows;
}
const column = columns.find((col) => col.key === sortKey);
if (!column) {
return rows;
}
const sorted = [...rows].sort((a, b) => {
if (column.sortComparator) {
return column.sortComparator(a, b);
}
const aValue = getSortValue(a, column);
const bValue = getSortValue(b, column);
return compareValues(aValue, bValue);
});
return sortDirection === 'desc' ? sorted.reverse() : sorted;
}
$: sortedData = sortData(data);
</script>
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
@@ -44,12 +119,40 @@
<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 || ''}`}
>
<div class="flex items-center gap-1.5 {column.align === 'center' ? 'justify-center' : column.align === 'right' ? 'justify-end' : ''}">
{#if column.headerIcon}
<svelte:component this={column.headerIcon} size={14} />
{/if}
{column.header}
</div>
{#if column.sortable}
<button
type="button"
class={`group flex w-full items-center gap-1.5 text-xs font-medium uppercase tracking-wider ${column.align === 'center'
? 'justify-center'
: column.align === 'right'
? 'justify-end'
: 'justify-start'}`}
on:click={() => toggleSort(column)}
>
{#if column.headerIcon}
<svelte:component this={column.headerIcon} size={14} />
{/if}
<span>{column.header}</span>
<span class="text-[0.6rem] text-neutral-400 transition-opacity group-hover:text-neutral-600 group-hover:dark:text-neutral-200">
{#if sortKey === column.key}
{sortDirection === 'asc' ? '▲' : '▼'}
{:else}
{/if}
</span>
</button>
{:else}
<div class={`flex items-center gap-1.5 ${column.align === 'center'
? 'justify-center'
: column.align === 'right'
? 'justify-end'
: ''}`}>
{#if column.headerIcon}
<svelte:component this={column.headerIcon} size={14} />
{/if}
{column.header}
</div>
{/if}
</th>
{/each}
<!-- Actions column slot -->
@@ -65,7 +168,7 @@
<!-- Body -->
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
{#if data.length === 0}
{#if sortedData.length === 0}
<tr>
<td
colspan={columns.length + ($$slots.actions ? 1 : 0)}
@@ -75,7 +178,7 @@
</td>
</tr>
{:else}
{#each data as row, rowIndex}
{#each sortedData as row, rowIndex}
<tr
class="{hoverable
? 'transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-900'

View File

@@ -0,0 +1,99 @@
/**
* Quality profile scoring queries
*/
import type { PCDCache } from '../../cache.ts';
import type { QualityProfileScoring } from '../../types.ts';
/**
* Get quality profile scoring data
* Returns all custom formats with their scores per arr type
*/
export async function scoring(cache: PCDCache, databaseId: number, profileId: number): Promise<QualityProfileScoring> {
const db = cache.kb;
// 1. Get profile settings
const profile = await db
.selectFrom('quality_profiles')
.select(['minimum_custom_format_score', 'upgrade_until_score', 'upgrade_score_increment'])
.where('id', '=', profileId)
.executeTakeFirst();
if (!profile) {
throw new Error(`Quality profile ${profileId} not found`);
}
// 2. Define display arr types (radarr, sonarr) - 'all' is not a column
const arrTypes = ['radarr', 'sonarr'];
// 3. Get all custom formats
const customFormats = await db
.selectFrom('custom_formats')
.select(['id', 'name'])
.orderBy('name')
.execute();
// 4. Get all tags for custom formats
const customFormatTags = await db
.selectFrom('custom_format_tags')
.innerJoin('tags', 'tags.id', 'custom_format_tags.tag_id')
.select(['custom_format_tags.custom_format_id', 'tags.name as tag_name'])
.execute();
// Build tags map for quick lookup
const tagsMap = new Map<number, string[]>();
for (const tag of customFormatTags) {
if (!tagsMap.has(tag.custom_format_id)) {
tagsMap.set(tag.custom_format_id, []);
}
tagsMap.get(tag.custom_format_id)!.push(tag.tag_name);
}
// 5. Get all scores for this profile
const scores = await db
.selectFrom('quality_profile_custom_formats')
.select(['custom_format_id', 'arr_type', 'score'])
.where('quality_profile_id', '=', profileId)
.execute();
// 6. Build scores map for quick lookup
const scoresMap = new Map<number, Map<string, number>>();
for (const score of scores as { custom_format_id: number; arr_type: string; score: number }[]) {
if (!scoresMap.has(score.custom_format_id)) {
scoresMap.set(score.custom_format_id, new Map());
}
scoresMap.get(score.custom_format_id)!.set(score.arr_type, score.score);
}
// 7. Build custom format scoring data
const customFormatScoring = customFormats.map((cf: { id: number; name: string }) => {
const formatScores: Record<string, number | null> = {};
const cfScores = scoresMap.get(cf.id);
// Get the 'all' score if it exists (used as default for all types)
const allScore = cfScores?.get('all') ?? null;
// Add scores for each arr type
// If specific arr type has a score, use it; otherwise use 'all' score
for (const arrType of arrTypes) {
const specificScore = cfScores?.get(arrType);
formatScores[arrType] = specificScore !== undefined ? specificScore : allScore;
}
return {
id: cf.id,
name: cf.name,
tags: tagsMap.get(cf.id) ?? [],
scores: formatScores
};
});
return {
databaseId,
arrTypes,
customFormats: customFormatScoring,
minimum_custom_format_score: profile.minimum_custom_format_score,
upgrade_until_score: profile.upgrade_until_score,
upgrade_score_increment: profile.upgrade_score_increment
};
}

View File

@@ -0,0 +1,51 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
export const load: ServerLoad = async ({ params, isDataRequest }) => {
const { databaseId, id } = params;
// Validate params exist
if (!databaseId || !id) {
throw error(400, 'Missing required parameters');
}
// Parse and validate the database ID
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
throw error(400, 'Invalid database ID');
}
// Parse and validate the profile ID
const profileId = parseInt(id, 10);
if (isNaN(profileId)) {
throw error(400, 'Invalid profile ID');
}
// Get the cache for the database
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
throw error(500, 'Database cache not available');
}
// Always return synchronous data at top level, stream the heavy data
if (isDataRequest) {
// Client-side navigation - stream the data
return {
loaded: true, // Synchronous data to enable instant navigation
streamed: {
scoring: qualityProfileQueries.scoring(cache, currentDatabaseId, profileId)
}
};
} else {
// Initial page load - await the data for SEO
const scoringData = await qualityProfileQueries.scoring(cache, currentDatabaseId, profileId);
return {
loaded: true,
streamed: {
scoring: Promise.resolve(scoringData)
}
};
}
};

View File

@@ -0,0 +1,927 @@
<script lang="ts">
import NumberInput from '$ui/form/NumberInput.svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import { Info, ArrowUpDown, ArrowUp, ArrowDown, Layers, Check, LayoutGrid, Settings, User, X } from 'lucide-svelte';
import InfoModal from '$ui/modal/InfoModal.svelte';
import UnsavedChangesModal from '$ui/modal/UnsavedChangesModal.svelte';
import { useUnsavedChanges } from '$lib/client/utils/unsavedChanges.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import CustomGroupManager from '$ui/dropdown/CustomGroupManager.svelte';
import ScoringTable from './components/ScoringTable.svelte';
import { createSearchStore } from '$lib/client/stores/search';
import { onMount } from 'svelte';
import type { PageData } from './$types';
export let data: PageData;
const unsavedChanges = useUnsavedChanges();
const searchStore = createSearchStore({ debounceMs: 200 });
let showInfoModal = false;
let showOptionsInfoModal = false;
type SortKey = 'name' | 'radarr' | 'sonarr';
type SortDirection = 'asc' | 'desc';
let sortState: { key: SortKey; direction: SortDirection } | null = null;
// Tiling
let tileColumns: number = 1;
const TILING_STORAGE_KEY = 'scoring-tile-columns';
// General options
let hideUnscoredFormats: boolean = false;
const HIDE_UNSCORED_STORAGE_KEY = 'scoring-hide-unscored';
// Grouping
type GroupKey = string;
let selectedGroups = new Set<GroupKey>();
const GROUPING_STORAGE_KEY = 'scoring-selected-groups';
const CUSTOM_GROUPS_STORAGE_KEY = 'scoring-custom-groups';
// Define built-in groups with their tags (order matters - first match wins)
const builtInGroups = [
{ name: 'Audio', key: 'audio' as const, tags: ['audio'], custom: false },
{ name: 'HDR/Colour', key: 'hdr' as const, tags: ['hdr', 'colour grade'], custom: false },
{ name: 'Release Group', key: 'release-group' as const, tags: ['release group', 'release groups'], custom: false },
{ name: 'Release Group Tier', key: 'release-group-tier' as const, tags: ['release group tier', 'release group tiers'], custom: false },
{ name: 'Streaming Service', key: 'streaming-service' as const, tags: ['streaming service'], custom: false },
{ name: 'Codec', key: 'codec' as const, tags: ['codec'], custom: false },
{ name: 'Storage', key: 'storage' as const, tags: ['storage'], custom: false },
{ name: 'Source', key: 'source' as const, tags: ['source'], custom: false },
{ name: 'Resolution', key: 'resolution' as const, tags: ['sd', '480p', '576p', '720p', '1080p', '2160p', '4k', '8k', 'resolution'], custom: false },
{ name: 'Indexer Flag', key: 'indexer-flag' as const, tags: ['flag'], custom: false },
{ name: 'Edition', key: 'edition' as const, tags: ['edition'], custom: false },
{ name: 'Enhancement', key: 'enhancement' as const, tags: ['enhancement', 'enhancements'], custom: false },
{ name: 'Languages', key: 'languages' as const, tags: ['languages'], custom: false }
];
// Custom groups from localStorage
let customGroups: Array<{ name: string; key: string; tags: string[]; custom: boolean }> = [];
// Combined groups
$: groups = [...builtInGroups, ...customGroups];
// Check if current config matches active profile - uncheck if different
$: if (currentProfileId && !isLoadingProfile) {
const activeProfile = profiles.find(p => p.id === currentProfileId);
if (activeProfile) {
const searchQuery = $searchStore.query ?? '';
const selectedGroupsArray = [...selectedGroups];
const customGroupsClean = customGroups.map(({ name, key, tags }) => ({ name, key, tags }));
// Compare all settings
const configChanged =
searchQuery !== activeProfile.searchQuery ||
JSON.stringify(sortState) !== JSON.stringify(activeProfile.sortState) ||
JSON.stringify(selectedGroupsArray) !== JSON.stringify(activeProfile.selectedGroups) ||
JSON.stringify(customGroupsClean) !== JSON.stringify(activeProfile.customGroups) ||
tileColumns !== activeProfile.tileColumns ||
hideUnscoredFormats !== activeProfile.hideUnscoredFormats;
if (configChanged) {
currentProfileId = null;
}
}
}
// Profiles
type Profile = {
id: string;
name: string;
searchQuery: string;
sortState: { key: SortKey; direction: SortDirection } | null;
selectedGroups: GroupKey[];
customGroups: Array<{ name: string; key: string; tags: string[] }>;
tileColumns: number;
hideUnscoredFormats: boolean;
};
let profiles: Profile[] = [];
let currentProfileId: string | null = null;
let newProfileName = '';
let isLoadingProfile = false;
const PROFILES_STORAGE_KEY = 'scoring-profiles';
onMount(() => {
// Load custom groups from localStorage
const savedCustomGroups = localStorage.getItem(CUSTOM_GROUPS_STORAGE_KEY);
if (savedCustomGroups) {
try {
const parsed = JSON.parse(savedCustomGroups);
customGroups = parsed.map((g: any) => ({ ...g, custom: true }));
} catch {
customGroups = [];
}
}
// Load grouping preference from localStorage
const saved = localStorage.getItem(GROUPING_STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved) as GroupKey[];
selectedGroups = new Set(parsed.filter(key => groups.some(g => g.key === key)));
} catch {
selectedGroups = new Set();
}
}
// Load tiling preference from localStorage
const savedTiling = localStorage.getItem(TILING_STORAGE_KEY);
if (savedTiling) {
const parsed = parseInt(savedTiling, 10);
if (!isNaN(parsed) && parsed >= 1 && parsed <= 3) {
tileColumns = parsed;
}
}
// Load hide unscored preference from localStorage
const savedHideUnscored = localStorage.getItem(HIDE_UNSCORED_STORAGE_KEY);
if (savedHideUnscored === 'true') {
hideUnscoredFormats = true;
}
// Load profiles from localStorage
const savedProfiles = localStorage.getItem(PROFILES_STORAGE_KEY);
if (savedProfiles) {
try {
profiles = JSON.parse(savedProfiles);
} catch {
profiles = [];
}
}
});
function saveGroupingPreference() {
localStorage.setItem(GROUPING_STORAGE_KEY, JSON.stringify([...selectedGroups]));
}
function toggleGroup(key: GroupKey) {
if (selectedGroups.has(key)) {
selectedGroups.delete(key);
} else {
selectedGroups.add(key);
}
selectedGroups = selectedGroups; // Trigger reactivity
saveGroupingPreference();
}
function clearGrouping() {
selectedGroups = new Set();
saveGroupingPreference();
}
function setTileColumns(columns: number) {
tileColumns = columns;
localStorage.setItem(TILING_STORAGE_KEY, columns.toString());
}
function toggleHideUnscored() {
hideUnscoredFormats = !hideUnscoredFormats;
localStorage.setItem(HIDE_UNSCORED_STORAGE_KEY, hideUnscoredFormats.toString());
}
function saveCustomGroups() {
const toSave = customGroups.map(({ name, key, tags }) => ({ name, key, tags }));
localStorage.setItem(CUSTOM_GROUPS_STORAGE_KEY, JSON.stringify(toSave));
}
function addCustomGroup(name: string, tags: string[]) {
const key = `custom-${Date.now()}`;
customGroups = [...customGroups, { name, key, tags, custom: true }];
saveCustomGroups();
}
function deleteCustomGroup(key: string) {
customGroups = customGroups.filter((g) => g.key !== key);
// Remove from selected groups if it was selected
selectedGroups.delete(key);
selectedGroups = selectedGroups;
saveCustomGroups();
saveGroupingPreference();
}
// Profile management
function saveProfile(name: string) {
const id = `profile-${Date.now()}`;
const profile: Profile = {
id,
name,
searchQuery: $searchStore.query ?? '',
sortState,
selectedGroups: [...selectedGroups],
customGroups: customGroups.map(({ name, key, tags }) => ({ name, key, tags })),
tileColumns,
hideUnscoredFormats
};
profiles = [...profiles, profile];
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(profiles));
currentProfileId = id;
}
function loadProfile(id: string) {
const profile = profiles.find(p => p.id === id);
if (!profile) return;
isLoadingProfile = true;
// Apply profile settings
searchStore.setQuery(profile.searchQuery);
sortState = profile.sortState as { key: SortKey; direction: SortDirection } | null;
selectedGroups = new Set(profile.selectedGroups);
customGroups = profile.customGroups.map(g => ({ ...g, custom: true }));
tileColumns = profile.tileColumns;
hideUnscoredFormats = profile.hideUnscoredFormats;
// Save to individual storage keys
saveGroupingPreference();
saveCustomGroups();
localStorage.setItem(TILING_STORAGE_KEY, tileColumns.toString());
localStorage.setItem(HIDE_UNSCORED_STORAGE_KEY, hideUnscoredFormats.toString());
currentProfileId = id;
isLoadingProfile = false;
}
function deleteProfile(id: string) {
profiles = profiles.filter(p => p.id !== id);
localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(profiles));
if (currentProfileId === id) {
currentProfileId = null;
}
}
function handleSaveProfile() {
if (newProfileName.trim()) {
saveProfile(newProfileName.trim());
newProfileName = '';
}
}
function loadDefaultProfile() {
isLoadingProfile = true;
// Apply default settings
searchStore.setQuery('');
sortState = { key: 'radarr', direction: 'desc' };
selectedGroups = new Set();
customGroups = [];
tileColumns = 1;
hideUnscoredFormats = false;
// Save to individual storage keys
saveGroupingPreference();
saveCustomGroups();
localStorage.setItem(TILING_STORAGE_KEY, tileColumns.toString());
localStorage.setItem(HIDE_UNSCORED_STORAGE_KEY, hideUnscoredFormats.toString());
currentProfileId = null;
isLoadingProfile = false;
}
// Arr type color mapping
const arrTypeColors: Record<string, string> = {
radarr: '#FFC230',
sonarr: '#00CCFF'
};
function getArrTypeColor(arrType: string): string {
return arrTypeColors[arrType] || '#3b82f6'; // default to blue
}
// Initialize state based on scoring data
function initializeState(scoring: any) {
const minimumScore = scoring.minimum_custom_format_score;
const upgradeUntilScore = scoring.upgrade_until_score;
const upgradeScoreIncrement = scoring.upgrade_score_increment;
// Custom format scores - create a reactive map
const customFormatScores: Record<number, Record<string, number | null>> = {};
const customFormatEnabled: Record<number, Record<string, boolean>> = {};
// Initialize scores and enabled state from data
scoring.customFormats.forEach((cf: any) => {
customFormatScores[cf.id] = { ...cf.scores };
customFormatEnabled[cf.id] = {};
scoring.arrTypes.forEach((arrType: string) => {
customFormatEnabled[cf.id][arrType] = cf.scores[arrType] !== null;
});
});
return {
minimumScore,
upgradeUntilScore,
upgradeScoreIncrement,
customFormatScores,
customFormatEnabled
};
}
function toggleSort(key: SortKey, defaultDirection: SortDirection = 'asc') {
if (sortState?.key === key) {
// Toggle direction
sortState = { key, direction: sortState.direction === 'asc' ? 'desc' : 'asc' };
} else {
// New sort key
sortState = { key, direction: defaultDirection };
}
}
function sortFormats(formats: any[], state: any, sortState: { key: SortKey; direction: SortDirection } | null) {
if (!sortState) return formats;
const sorted = [...formats].sort((a, b) => {
let aVal: any;
let bVal: any;
if (sortState.key === 'name') {
aVal = a.name?.toLowerCase() || '';
bVal = b.name?.toLowerCase() || '';
return sortState.direction === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
} else {
// Sort by score (radarr or sonarr)
aVal = state.customFormatScores[a.id]?.[sortState.key] ?? null;
bVal = state.customFormatScores[b.id]?.[sortState.key] ?? null;
// Handle nulls - always put them at the end
if (aVal === null && bVal === null) return 0;
if (aVal === null) return 1;
if (bVal === null) return -1;
return sortState.direction === 'desc'
? bVal - aVal
: aVal - bVal;
}
});
return sorted;
}
function groupFormats(formats: any[], selectedGroups: Set<GroupKey>) {
// If no groups selected, show all formats in one table
if (selectedGroups.size === 0) {
return [{ name: null, formats }];
}
const result: { name: string | null; formats: any[] }[] = [];
const assigned = new Set<number>();
// Process each group in order
for (const group of groups) {
if (!selectedGroups.has(group.key)) continue;
const groupFormats = formats.filter((format) => {
// Skip if already assigned
if (assigned.has(format.id)) return false;
// Check if format has any of the group's tags
return format.tags?.some((tag: string) => group.tags.includes(tag.toLowerCase()));
});
// Mark as assigned
groupFormats.forEach((f) => assigned.add(f.id));
result.push({ name: group.name, formats: groupFormats });
}
// Add remaining formats to "Other" group
const otherFormats = formats.filter((f) => !assigned.has(f.id));
if (otherFormats.length > 0) {
result.push({ name: 'Other', formats: otherFormats });
}
return result;
}
</script>
<svelte:head>
<title>Scoring - Profilarr</title>
</svelte:head>
<UnsavedChangesModal />
{#await data.streamed.scoring}
<!-- Loading skeleton -->
<div class="mt-6 space-y-6 animate-pulse">
<!-- Profile-level Score Settings Skeleton -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
{#each [1, 2, 3] as _}
<div class="space-y-2">
<div class="h-5 w-32 rounded bg-neutral-200 dark:bg-neutral-700"></div>
<div class="h-4 w-48 rounded bg-neutral-100 dark:bg-neutral-800"></div>
<div class="h-10 w-full rounded-lg bg-neutral-200 dark:bg-neutral-700"></div>
</div>
{/each}
</div>
<!-- Section Header Skeleton -->
<div class="flex items-start justify-between">
<div class="space-y-2">
<div class="h-5 w-48 rounded bg-neutral-200 dark:bg-neutral-700"></div>
<div class="h-4 w-64 rounded bg-neutral-100 dark:bg-neutral-800"></div>
</div>
<div class="h-8 w-16 rounded-lg bg-neutral-200 dark:bg-neutral-700"></div>
</div>
<!-- Table Skeleton -->
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
<div class="w-full">
<div class="border-b border-neutral-200 bg-neutral-50 p-6 dark:border-neutral-800 dark:bg-neutral-800">
<div class="flex gap-4">
<div class="h-4 w-48 rounded bg-neutral-300 dark:bg-neutral-600"></div>
<div class="h-4 w-24 rounded bg-neutral-300 dark:bg-neutral-600"></div>
<div class="h-4 w-24 rounded bg-neutral-300 dark:bg-neutral-600"></div>
</div>
</div>
<div class="space-y-2 p-6">
{#each [1, 2, 3, 4, 5] as _}
<div class="flex gap-4">
<div class="h-10 w-48 rounded bg-neutral-200 dark:bg-neutral-700"></div>
<div class="h-10 w-24 rounded bg-neutral-200 dark:bg-neutral-700"></div>
<div class="h-10 w-24 rounded bg-neutral-200 dark:bg-neutral-700"></div>
</div>
{/each}
</div>
</div>
</div>
</div>
{:then scoring}
{@const state = initializeState(scoring)}
{@const searchQuery = ($searchStore.query ?? '').trim().toLowerCase()}
{@const filteredCustomFormats = scoring.customFormats.filter((format) => {
// Filter by search
if (searchQuery && !format.name?.toLowerCase().includes(searchQuery)) {
return false;
}
// Filter unscored formats if option is enabled
if (hideUnscoredFormats) {
const hasAnyScore = scoring.arrTypes.some((arrType) => format.scores[arrType] !== null);
if (!hasAnyScore) return false;
}
return true;
})}
{@const defaultSortKey = (scoring.arrTypes.includes('radarr') ? 'radarr' : scoring.arrTypes[0]) as SortKey}
{@const _applyDefaultSort = (() => {
if (!sortState && defaultSortKey) {
sortState = { key: defaultSortKey, direction: 'desc' };
}
return null;
})()}
{@const sortedCustomFormats = sortFormats(filteredCustomFormats, state, sortState)}
{@const groupedFormats = groupFormats(sortedCustomFormats, selectedGroups)}
<div class="mt-6 space-y-6">
<!-- Profile-level Score Settings -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div class="space-y-2">
<label
for="minimumScore"
class="block text-sm font-medium text-neutral-900 dark:text-neutral-100"
>
Minimum Score
</label>
<p class="text-xs text-neutral-600 dark:text-neutral-400">
Minimum custom format score required to download
</p>
<NumberInput name="minimumScore" bind:value={state.minimumScore} step={1} font="mono" />
</div>
<div class="space-y-2">
<label
for="upgradeUntilScore"
class="block text-sm font-medium text-neutral-900 dark:text-neutral-100"
>
Upgrade Until Score
</label>
<p class="text-xs text-neutral-600 dark:text-neutral-400">
Stop upgrading when this score is reached
</p>
<NumberInput
name="upgradeUntilScore"
bind:value={state.upgradeUntilScore}
step={1}
font="mono"
/>
</div>
<div class="space-y-2">
<label
for="upgradeScoreIncrement"
class="block text-sm font-medium text-neutral-900 dark:text-neutral-100"
>
Upgrade Score Increment
</label>
<p class="text-xs text-neutral-600 dark:text-neutral-400">
Minimum score improvement needed to upgrade
</p>
<NumberInput
name="upgradeScoreIncrement"
bind:value={state.upgradeScoreIncrement}
step={1}
font="mono"
/>
</div>
</div>
<!-- Section Header -->
<div class="flex items-start justify-between">
<div>
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
Custom Format Scoring
</div>
<p class="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
Configure custom format scores for each Arr type
</p>
</div>
<button
type="button"
on:click={() => (showInfoModal = true)}
class="flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<Info size={14} />
Info
</button>
</div>
<ActionsBar className="w-full">
<SearchAction {searchStore} placeholder="Search custom formats..." />
<ActionButton icon={ArrowUpDown} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="10rem">
<div class="py-1">
<button
type="button"
on:click={() => toggleSort('name', 'asc')}
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {sortState?.key === 'name'
? 'bg-neutral-50 dark:bg-neutral-700'
: ''}"
>
<span class="text-neutral-700 dark:text-neutral-300">Name</span>
<IconCheckbox
checked={sortState?.key === 'name'}
icon={sortState?.key === 'name' && sortState.direction === 'desc' ? ArrowDown : ArrowUp}
color="blue"
shape="circle"
/>
</button>
<button
type="button"
on:click={() => toggleSort('radarr', 'desc')}
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {sortState?.key === 'radarr'
? 'bg-neutral-50 dark:bg-neutral-700'
: ''}"
>
<span class="text-neutral-700 dark:text-neutral-300">Radarr</span>
<IconCheckbox
checked={sortState?.key === 'radarr'}
icon={sortState?.key === 'radarr' && sortState.direction === 'asc' ? ArrowUp : ArrowDown}
color={getArrTypeColor('radarr')}
shape="circle"
/>
</button>
<button
type="button"
on:click={() => toggleSort('sonarr', 'desc')}
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {sortState?.key === 'sonarr'
? 'bg-neutral-50 dark:bg-neutral-700'
: ''}"
>
<span class="text-neutral-700 dark:text-neutral-300">Sonarr</span>
<IconCheckbox
checked={sortState?.key === 'sonarr'}
icon={sortState?.key === 'sonarr' && sortState.direction === 'asc' ? ArrowUp : ArrowDown}
color={getArrTypeColor('sonarr')}
shape="circle"
/>
</button>
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
<ActionButton icon={Layers} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="14rem">
<div class="py-1">
<button
type="button"
on:click={clearGrouping}
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {selectedGroups.size === 0
? 'bg-neutral-50 dark:bg-neutral-700'
: ''}"
>
<span class="text-neutral-700 dark:text-neutral-300">No Grouping</span>
<IconCheckbox
checked={selectedGroups.size === 0}
icon={Check}
color="blue"
shape="circle"
/>
</button>
{#each builtInGroups as group}
<button
type="button"
on:click={() => toggleGroup(group.key)}
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {selectedGroups.has(group.key)
? 'bg-neutral-50 dark:bg-neutral-700'
: ''}"
>
<span class="text-neutral-700 dark:text-neutral-300">{group.name}</span>
<IconCheckbox
checked={selectedGroups.has(group.key)}
icon={Check}
color="blue"
shape="circle"
/>
</button>
{/each}
</div>
<CustomGroupManager
{customGroups}
{selectedGroups}
onAdd={addCustomGroup}
onDelete={deleteCustomGroup}
onToggle={toggleGroup}
/>
</Dropdown>
</svelte:fragment>
</ActionButton>
<ActionButton icon={LayoutGrid} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="10rem">
<div class="py-1">
{#each [1, 2, 3] as columns}
<button
type="button"
on:click={() => setTileColumns(columns)}
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {tileColumns === columns
? 'bg-neutral-50 dark:bg-neutral-700'
: ''}"
>
<span class="text-neutral-700 dark:text-neutral-300">{columns} Column{columns > 1 ? 's' : ''}</span>
<IconCheckbox
checked={tileColumns === columns}
icon={Check}
color="blue"
shape="circle"
/>
</button>
{/each}
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
<ActionButton icon={Settings} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="14rem">
<div class="py-1">
<button
type="button"
on:click={toggleHideUnscored}
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {hideUnscoredFormats
? 'bg-neutral-50 dark:bg-neutral-700'
: ''}"
>
<span class="text-neutral-700 dark:text-neutral-300">Hide Unscored Formats</span>
<IconCheckbox
checked={hideUnscoredFormats}
icon={Check}
color="blue"
shape="circle"
/>
</button>
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
<ActionButton icon={User} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="16rem">
<!-- Save current config form -->
<div class="border-b border-neutral-200 px-4 py-3 dark:border-neutral-700">
<div class="mb-2 text-xs font-medium text-neutral-700 dark:text-neutral-300">
Save Current Settings
</div>
<form on:submit|preventDefault={handleSaveProfile} class="space-y-2">
<input
type="text"
bind:value={newProfileName}
placeholder="Profile name"
class="block w-full rounded border border-neutral-300 bg-white px-2 py-1.5 text-xs text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
/>
<button
type="submit"
class="w-full rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
disabled={!newProfileName.trim()}
>
Save Profile
</button>
</form>
</div>
<!-- Saved profiles list -->
<div class="py-1">
<!-- Default profile option -->
<div class="group flex items-center justify-between gap-2 px-4 py-2 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700">
<button
type="button"
on:click={loadDefaultProfile}
class="flex flex-1 items-center justify-between gap-3"
>
<div class="flex-1 text-left">
<div class="text-xs font-medium text-neutral-700 dark:text-neutral-300">Default</div>
</div>
<IconCheckbox
checked={currentProfileId === null}
icon={Check}
color="blue"
shape="circle"
/>
</button>
</div>
{#if profiles.length > 0}
<div class="border-t border-neutral-200 dark:border-neutral-700"></div>
{#each profiles as profile}
<div class="group flex items-center justify-between gap-2 px-4 py-2 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700">
<button
type="button"
on:click={() => loadProfile(profile.id)}
class="flex flex-1 items-center justify-between gap-3"
>
<div class="flex-1 text-left">
<div class="text-xs font-medium text-neutral-700 dark:text-neutral-300">{profile.name}</div>
</div>
<IconCheckbox
checked={currentProfileId === profile.id}
icon={Check}
color="blue"
shape="circle"
/>
</button>
<button
type="button"
on:click|stopPropagation={() => deleteProfile(profile.id)}
class="flex h-5 w-5 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
>
<X size={12} />
</button>
</div>
{/each}
{/if}
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
<ActionButton icon={Info} on:click={() => (showOptionsInfoModal = true)} />
</ActionsBar>
<!-- Custom Format Scores Tables -->
{#if searchQuery === 'santiagoisthebest'}
<div class="flex items-center justify-center rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-neutral-900">
<img src="/src/lib/client/assets/thanks.gif" alt="Thanks!" class="max-w-full" />
</div>
{:else if searchQuery === 'rickroll' || searchQuery === 'nevergonnagiveyouup'}
<div class="flex items-center justify-center rounded-lg border border-neutral-200 bg-white p-8 dark:border-neutral-800 dark:bg-neutral-900">
<img src="/src/lib/client/assets/nggyu.gif" alt="Never gonna give you up" class="max-w-full" />
</div>
{:else if sortedCustomFormats.length === 0}
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
<div class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
{#if scoring.customFormats.length === 0}
No custom formats found
{:else}
No custom formats match your search
{/if}
</div>
</div>
{:else}
<div
class="grid gap-6"
class:grid-cols-1={tileColumns === 1}
class:grid-cols-2={tileColumns === 2}
class:grid-cols-3={tileColumns === 3}
>
{#each groupedFormats as group}
{#if group.formats.length > 0}
<div class="min-w-0">
<ScoringTable
formats={group.formats}
arrTypes={scoring.arrTypes}
{state}
{getArrTypeColor}
title={group.name}
/>
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/await}
<InfoModal bind:open={showInfoModal} header="Custom Format Scoring">
<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">Custom Format Scores</div>
<div class="mt-1">
Each custom format can have different scores for different Arr types (Radarr, Sonarr). The
score determines how much a release is preferred when it matches the custom format.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Minimum Score</div>
<div class="mt-1">
The minimum total custom format score required for a release to be downloaded. Releases
with scores below this threshold will be rejected.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Upgrade Until Score</div>
<div class="mt-1">
Once a release reaches this score, the system will stop looking for upgrades. This prevents
unnecessary upgrades when you've already got a good quality release.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">
Upgrade Score Increment
</div>
<div class="mt-1">
The minimum score improvement required to trigger an upgrade. This prevents minor upgrades
that don't significantly improve quality.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Positive vs Negative Scores</div>
<div class="mt-1">
Positive scores increase preference for releases matching the custom format. Negative
scores decrease preference or can block releases entirely when combined with the minimum
score setting.
</div>
</div>
</div>
</InfoModal>
<InfoModal bind:open={showOptionsInfoModal} header="Display Options">
<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">Search</div>
<div class="mt-1">
Filter custom formats by name. The search is case-insensitive and matches any part of the format name.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Sort</div>
<div class="mt-1">
Sort custom formats by Name (A-Z), Radarr score, or Sonarr score. Click the same option again to reverse the sort direction (ascending ↑ or descending ↓). Formats with no score are always shown at the end.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Grouping</div>
<div class="mt-1">
Organize custom formats into separate tables based on their tags. You can select multiple groups at once. Available groups include Audio, HDR/Colour, Release Group, Codec, Resolution, and more. Formats that don't match any selected group appear in the "Other" table. If a format matches multiple groups, it appears in the first matching group only.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Custom Groups</div>
<div class="mt-1">
Create your own custom groups by entering a name and comma-separated tags at the bottom of the Grouping dropdown. Your custom groups are saved to your browser and can be deleted at any time. Custom groups work the same as built-in groups - formats are assigned to the first matching group based on their tags.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Tiling</div>
<div class="mt-1">
Display tables in 1, 2, or 3 columns. This is especially useful when using grouping to view multiple format categories side-by-side.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Hide Unscored Formats</div>
<div class="mt-1">
Hide custom formats that have no score assigned for any Arr type. This helps focus on formats that are currently being used in your quality profile.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Profiles</div>
<div class="mt-1">
Save your current display configuration (search, sort, grouping, custom groups, tiling, and options) as a named profile. Load saved profiles to quickly switch between different views. The "Default" profile resets everything to the baseline configuration. Profiles are stored in your browser and automatically uncheck when you modify any settings.
</div>
</div>
</div>
</InfoModal>

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import NumberInput from '$ui/form/NumberInput.svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import { Check } from 'lucide-svelte';
export let formats: any[];
export let arrTypes: string[];
export let state: any;
export let getArrTypeColor: (arrType: string) => string;
export let title: string | null = null;
</script>
{#if title}
<div class="mb-3">
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{title}</h3>
</div>
{/if}
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
<table class="w-full">
<!-- Header -->
<thead
class="border-b border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800"
>
<tr>
<th
class="sticky left-0 z-10 bg-neutral-50 px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
>
Custom Format
</th>
{#each arrTypes as arrType}
<th
class="w-64 px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300"
>
{arrType}
</th>
{/each}
</tr>
</thead>
<!-- Body -->
<tbody
class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900"
>
{#if formats.length === 0}
<tr>
<td
colspan={arrTypes.length + 1}
class="px-6 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400"
>
No custom formats found
</td>
</tr>
{:else}
{#each formats as format}
{@const rowDisabled = arrTypes.every(
(arrType) => !state.customFormatEnabled[format.id]?.[arrType]
)}
<tr
class="transition-colors {rowDisabled
? 'bg-neutral-100 dark:bg-neutral-800 opacity-60'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-900'}"
>
<td
class="sticky left-0 z-10 px-6 py-4 text-sm font-medium {rowDisabled
? 'bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-500'
: 'bg-white text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100'}"
>
{format.name}
</td>
{#each arrTypes as arrType}
<td class="px-6 py-4">
<div class="flex items-center justify-center gap-2">
<IconCheckbox
checked={state.customFormatEnabled[format.id][arrType]}
icon={Check}
color={getArrTypeColor(arrType)}
shape="circle"
on:click={() => {
const isEnabled = state.customFormatEnabled[format.id][arrType];
if (isEnabled) {
state.customFormatScores[format.id][arrType] = null;
} else {
if (state.customFormatScores[format.id][arrType] === null) {
state.customFormatScores[format.id][arrType] = 0;
}
}
state.customFormatEnabled[format.id][arrType] = !isEnabled;
}}
/>
<div class="w-48">
<NumberInput
name="score-{format.id}-{arrType}"
bind:value={state.customFormatScores[format.id][arrType]}
step={1}
disabled={!state.customFormatEnabled[format.id][arrType]}
font="mono"
/>
</div>
</div>
</td>
{/each}
</tr>
{/each}
{/if}
</tbody>
</table>
</div>