feat(upgrades): implement frontend upgrade configuration and filtering system

- Added shared selectors for item selection methods in upgrades.
- Updated navigation to point to the new upgrades page.
- Removed obsolete search priority page.
- Created server-side loading for upgrades page to fetch instance data.
- Developed upgrades page layout with core settings and filter settings components.
- Implemented core settings component for upgrade scheduling and filter mode selection.
- Added filter group component to manage complex filtering rules.
- Created filter settings component to manage multiple filters with detailed configurations.
- Introduced info modals for filters and upgrades to guide users on functionality.
This commit is contained in:
Sam Chau
2025-12-27 06:04:06 +10:30
parent 0d99680414
commit a740937246
11 changed files with 1277 additions and 23 deletions

317
src/lib/shared/filters.ts Normal file
View File

@@ -0,0 +1,317 @@
/**
* Shared filter types for both backend and frontend
* Defines all available filter fields for upgrade filtering
*/
export interface FilterOperator {
id: string;
label: string;
}
export interface FilterValue {
value: any;
label: string;
}
export interface FilterField {
id: string;
label: string;
operators: FilterOperator[];
valueType: 'boolean' | 'select' | 'text' | 'number' | 'date';
values?: FilterValue[];
}
export interface FilterRule {
type: 'rule';
field: string;
operator: string;
value: any;
}
export interface FilterGroup {
type: 'group';
match: 'all' | 'any';
children: (FilterRule | FilterGroup)[];
}
export interface FilterConfig {
id: string;
name: string;
enabled: boolean;
group: FilterGroup;
selector: string;
count: number;
cutoff: number;
searchCooldown: number; // hours - skip items searched within this time
}
export type FilterMode = 'round_robin' | 'random';
export const filterModes: { id: FilterMode; label: string; description: string }[] = [
{
id: 'round_robin',
label: 'Round Robin',
description: 'Cycle through filters in order, one per run'
},
{
id: 'random',
label: 'Random Shuffle',
description: 'Shuffle filters, cycle through all before repeating'
}
];
/**
* Common operator sets
*/
const booleanOperators: FilterOperator[] = [
{ id: 'is', label: 'is' },
{ id: 'is_not', label: 'is not' }
];
const numberOperators: FilterOperator[] = [
{ id: 'eq', label: 'equals' },
{ id: 'neq', label: 'does not equal' },
{ id: 'gt', label: 'is greater than' },
{ id: 'gte', label: 'is greater than or equal' },
{ id: 'lt', label: 'is less than' },
{ id: 'lte', label: 'is less than or equal' }
];
const textOperators: FilterOperator[] = [
{ id: 'contains', label: 'contains' },
{ id: 'not_contains', label: 'does not contain' },
{ id: 'starts_with', label: 'starts with' },
{ id: 'ends_with', label: 'ends with' },
{ id: 'eq', label: 'equals' },
{ id: 'neq', label: 'does not equal' }
];
const dateOperators: FilterOperator[] = [
{ id: 'before', label: 'is before' },
{ id: 'after', label: 'is after' },
{ id: 'in_last', label: 'in the last' },
{ id: 'not_in_last', label: 'not in the last' }
];
/**
* All available filter fields
*/
export const filterFields: FilterField[] = [
// Boolean fields
{
id: 'monitored',
label: 'Monitored',
operators: booleanOperators,
valueType: 'boolean',
values: [
{ value: true, label: 'True' },
{ value: false, label: 'False' }
]
},
{
id: 'cutoff_met',
label: 'Cutoff Met',
operators: booleanOperators,
valueType: 'boolean',
values: [
{ value: true, label: 'True' },
{ value: false, label: 'False' }
]
},
// Select fields
{
id: 'minimum_availability',
label: 'Minimum Availability',
operators: booleanOperators,
valueType: 'select',
values: [
{ value: 'announced', label: 'Announced' },
{ value: 'inCinemas', label: 'In Cinemas' },
{ value: 'released', label: 'Released' }
]
},
// Text fields
{
id: 'title',
label: 'Title',
operators: textOperators,
valueType: 'text'
},
{
id: 'quality_profile',
label: 'Quality Profile',
operators: textOperators,
valueType: 'text'
},
{
id: 'collection',
label: 'Collection',
operators: textOperators,
valueType: 'text'
},
{
id: 'studio',
label: 'Studio',
operators: textOperators,
valueType: 'text'
},
{
id: 'original_language',
label: 'Original Language',
operators: textOperators,
valueType: 'text'
},
{
id: 'genres',
label: 'Genres',
operators: textOperators,
valueType: 'text'
},
{
id: 'keywords',
label: 'Keywords',
operators: textOperators,
valueType: 'text'
},
{
id: 'release_group',
label: 'Release Group',
operators: textOperators,
valueType: 'text'
},
// Number fields
{
id: 'year',
label: 'Year',
operators: numberOperators,
valueType: 'number'
},
{
id: 'popularity',
label: 'Popularity',
operators: numberOperators,
valueType: 'number'
},
{
id: 'runtime',
label: 'Runtime (minutes)',
operators: numberOperators,
valueType: 'number'
},
{
id: 'size_on_disk',
label: 'Size on Disk (GB)',
operators: numberOperators,
valueType: 'number'
},
{
id: 'tmdb_rating',
label: 'TMDb Rating',
operators: numberOperators,
valueType: 'number'
},
{
id: 'imdb_rating',
label: 'IMDb Rating',
operators: numberOperators,
valueType: 'number'
},
{
id: 'tomato_rating',
label: 'Rotten Tomatoes Rating',
operators: numberOperators,
valueType: 'number'
},
{
id: 'trakt_rating',
label: 'Trakt Rating',
operators: numberOperators,
valueType: 'number'
},
// Date fields
{
id: 'date_added',
label: 'Date Added',
operators: dateOperators,
valueType: 'date'
}
];
/**
* Get a filter field by ID
*/
export function getFilterField(id: string): FilterField | undefined {
return filterFields.find((f) => f.id === id);
}
/**
* Get all filter field IDs
*/
export function getAllFilterFieldIds(): string[] {
return filterFields.map((f) => f.id);
}
/**
* Validate if a filter field ID exists
*/
export function isValidFilterField(id: string): boolean {
return filterFields.some((f) => f.id === id);
}
/**
* Create an empty filter group
*/
export function createEmptyGroup(): FilterGroup {
return {
type: 'group',
match: 'all',
children: []
};
}
/**
* Create an empty filter config
*/
export function createEmptyFilterConfig(name: string = 'New Filter'): FilterConfig {
return {
id: crypto.randomUUID(),
name,
enabled: true,
group: createEmptyGroup(),
selector: 'random',
count: 5,
cutoff: 80,
searchCooldown: 24 // default 24 hours
};
}
/**
* Create an empty filter rule with defaults
*/
export function createEmptyRule(): FilterRule {
const firstField = filterFields[0];
return {
type: 'rule',
field: firstField.id,
operator: firstField.operators[0].id,
value: firstField.values?.[0]?.value ?? null
};
}
/**
* Check if a child is a rule
*/
export function isRule(child: FilterRule | FilterGroup): child is FilterRule {
return child.type === 'rule';
}
/**
* Check if a child is a group
*/
export function isGroup(child: FilterRule | FilterGroup): child is FilterGroup {
return child.type === 'group';
}

100
src/lib/shared/selectors.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* Shared selector types for both backend and frontend
* Defines all available selectors for upgrade item selection
*/
export interface Selector<T = any> {
id: string;
label: string;
description: string;
select: (items: T[], count: number) => T[];
}
/**
* All available selectors
*/
export const selectors: Selector[] = [
{
id: 'random',
label: 'Random',
description: 'Randomly select items',
select: (items, count) => {
const shuffled = [...items].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
}
},
{
id: 'oldest',
label: 'Oldest',
description: 'Select oldest items first (by date added)',
select: (items, count) => {
const sorted = [...items].sort((a, b) => {
const dateA = new Date(a.dateAdded || 0).getTime();
const dateB = new Date(b.dateAdded || 0).getTime();
return dateA - dateB;
});
return sorted.slice(0, count);
}
},
{
id: 'newest',
label: 'Newest',
description: 'Select newest items first (by date added)',
select: (items, count) => {
const sorted = [...items].sort((a, b) => {
const dateA = new Date(a.dateAdded || 0).getTime();
const dateB = new Date(b.dateAdded || 0).getTime();
return dateB - dateA;
});
return sorted.slice(0, count);
}
},
{
id: 'lowest_score',
label: 'Lowest Score',
description: 'Select items with lowest custom format score',
select: (items, count) => {
const sorted = [...items].sort((a, b) => (a.score || 0) - (b.score || 0));
return sorted.slice(0, count);
}
},
{
id: 'most_popular',
label: 'Most Popular',
description: 'Select most popular items first',
select: (items, count) => {
const sorted = [...items].sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
return sorted.slice(0, count);
}
},
{
id: 'least_popular',
label: 'Least Popular',
description: 'Select least popular items first',
select: (items, count) => {
const sorted = [...items].sort((a, b) => (a.popularity || 0) - (b.popularity || 0));
return sorted.slice(0, count);
}
}
];
/**
* Get a selector by ID
*/
export function getSelector(id: string): Selector | undefined {
return selectors.find((s) => s.id === id);
}
/**
* Get all selector IDs
*/
export function getAllSelectorIds(): string[] {
return selectors.map((s) => s.id);
}
/**
* Validate if a selector ID exists
*/
export function isValidSelector(id: string): boolean {
return selectors.some((s) => s.id === id);
}

View File

@@ -15,8 +15,8 @@
},
{
label: 'Upgrades',
href: `/arr/${instanceId}/search-priority`,
active: currentPath.includes('/search-priority'),
href: `/arr/${instanceId}/upgrades`,
active: currentPath.includes('/upgrades'),
icon: ArrowUpCircle
},
{

View File

@@ -1,21 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<title>{data.instance.name} - Search Priority - Profilarr</title>
</svelte:head>
<div class="mt-6">
<div class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Search Priority</h2>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Configure search priority settings for this {data.instance.type} instance.
</p>
<div class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">
Search priority configuration coming soon...
</div>
</div>
</div>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { PageData } from './$types';
import type { FilterConfig, FilterMode } from '$lib/shared/filters';
import { Info } from 'lucide-svelte';
import CoreSettings from './components/CoreSettings.svelte';
import FilterSettings from './components/FilterSettings.svelte';
import UpgradesInfoModal from './components/UpgradesInfoModal.svelte';
export let data: PageData;
let enabled = true;
let schedule = '360'; // 6 hours in minutes
let filterMode: FilterMode = 'round_robin';
let filters: FilterConfig[] = [];
let showInfoModal = false;
</script>
<svelte:head>
<title>{data.instance.name} - Upgrades - Profilarr</title>
</svelte:head>
<div class="mt-6 space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-50">Upgrade Configuration</h1>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Automatically search for better quality releases for your library items.
</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} />
How it works
</button>
</div>
<CoreSettings bind:enabled bind:schedule bind:filterMode />
<FilterSettings bind:filters />
</div>
<UpgradesInfoModal bind:open={showInfoModal} />

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { filterModes, type FilterMode } from '$lib/shared/filters';
export let enabled: boolean = true;
export let schedule: string = '360';
export let filterMode: FilterMode = 'round_robin';
const scheduleOptions = [
{ value: '30', label: 'Every 30 minutes' },
{ value: '60', label: 'Every hour' },
{ value: '120', label: 'Every 2 hours' },
{ value: '240', label: 'Every 4 hours' },
{ value: '360', label: 'Every 6 hours' },
{ value: '480', label: 'Every 8 hours' },
{ value: '720', label: 'Every 12 hours' },
{ value: '1440', label: 'Every day' }
];
</script>
<div
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
>
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">Core Settings</h2>
<div class="space-y-4">
<!-- Schedule, Filter Mode Row -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Schedule -->
<div>
<label
for="schedule"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Schedule <span class="text-red-500">*</span>
</label>
<select
id="schedule"
bind:value={schedule}
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{#each scheduleOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Filter Mode -->
<div>
<label
for="filterMode"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Filter Mode
</label>
<select
id="filterMode"
bind:value={filterMode}
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{#each filterModes as mode}
<option value={mode.id}>{mode.label} - {mode.description}</option>
{/each}
</select>
</div>
</div>
<!-- Enabled Toggle -->
<div class="flex items-center justify-between">
<div>
<label
for="enabled"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Enabled
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Enable or disable this upgrade configuration
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="Toggle enabled status"
on:click={() => (enabled = !enabled)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {enabled
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-neutral-200 dark:bg-neutral-700'}"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {enabled
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import { Plus, X, FolderPlus } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import {
filterFields,
getFilterField,
createEmptyGroup,
createEmptyRule,
isRule,
isGroup,
type FilterGroup,
type FilterRule
} from '$lib/shared/filters';
import NumberInput from '$ui/form/NumberInput.svelte';
export let group: FilterGroup;
export let onRemove: (() => void) | null = null;
export let depth: number = 0;
const dispatch = createEventDispatcher<{ change: void }>();
function notifyChange() {
dispatch('change');
}
function addRule() {
group.children = [...group.children, createEmptyRule()];
notifyChange();
}
function addNestedGroup() {
const newGroup = createEmptyGroup();
newGroup.children.push(createEmptyRule());
group.children = [...group.children, newGroup];
notifyChange();
}
function removeChild(index: number) {
group.children = group.children.filter((_, i) => i !== index);
notifyChange();
}
function onFieldChange(rule: FilterRule, fieldId: string) {
const field = getFilterField(fieldId);
if (field) {
rule.field = fieldId;
rule.operator = field.operators[0].id;
rule.value = field.values?.[0]?.value ?? null;
notifyChange();
}
}
function handleNestedChange() {
notifyChange();
}
</script>
<div
class="rounded-lg border p-4 {depth === 0
? 'border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800/50'
: 'border-neutral-300 bg-white dark:border-neutral-700 dark:bg-neutral-800'}"
>
<!-- Group Header -->
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Match</span>
<select
bind:value={group.match}
class="rounded-lg border border-neutral-300 bg-white px-2 py-1 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
<option value="all">All (AND)</option>
<option value="any">Any (OR)</option>
</select>
<span class="text-sm text-neutral-500 dark:text-neutral-400">of the following rules</span>
</div>
{#if onRemove}
<button
type="button"
on:click={onRemove}
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-200 hover:text-neutral-600 dark:hover:bg-neutral-700 dark:hover:text-neutral-200"
>
<X size={16} />
</button>
{/if}
</div>
<!-- Children (Rules and Nested Groups) -->
{#if group.children.length === 0}
<div class="text-sm text-neutral-500 dark:text-neutral-400">
No rules configured. Add a rule to start filtering.
</div>
{:else}
<div class="space-y-2">
{#each group.children as child, childIndex}
{#if isRule(child)}
{@const field = getFilterField(child.field)}
<div class="flex items-center gap-2">
<!-- Field -->
<select
value={child.field}
on:change={(e) => onFieldChange(child, e.currentTarget.value)}
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{#each filterFields as f}
<option value={f.id}>{f.label}</option>
{/each}
</select>
<!-- Operator -->
<select
bind:value={child.operator}
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{#if field}
{#each field.operators as op}
<option value={op.id}>{op.label}</option>
{/each}
{/if}
</select>
<!-- Value -->
{#if field?.valueType === 'boolean' || field?.valueType === 'select'}
<select
bind:value={child.value}
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{#if field.values}
{#each field.values as v}
<option value={v.value}>{v.label}</option>
{/each}
{/if}
</select>
{:else if field?.valueType === 'text'}
<input
type="text"
bind:value={child.value}
class="flex-1 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
{:else if field?.valueType === 'number'}
<div class="w-32">
<NumberInput name="value-{childIndex}" bind:value={child.value} font="mono" />
</div>
{:else if field?.valueType === 'date'}
{#if child.operator === 'in_last' || child.operator === 'not_in_last'}
<div class="flex items-center gap-2">
<div class="w-24">
<NumberInput name="value-{childIndex}" bind:value={child.value} min={1} font="mono" />
</div>
<span class="text-sm text-neutral-500 dark:text-neutral-400">days</span>
</div>
{:else}
<input
type="date"
bind:value={child.value}
class="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
{/if}
{/if}
<!-- Remove Rule -->
<button
type="button"
on:click={() => removeChild(childIndex)}
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-200 hover:text-neutral-600 dark:hover:bg-neutral-700 dark:hover:text-neutral-200"
>
<X size={16} />
</button>
</div>
{:else if isGroup(child)}
<!-- Nested Group (recursive) -->
<div class="ml-4">
<svelte:self
group={child}
depth={depth + 1}
onRemove={() => removeChild(childIndex)}
on:change={handleNestedChange}
/>
</div>
{/if}
{/each}
</div>
{/if}
<!-- Add Buttons -->
<div class="mt-3 flex items-center gap-2">
<button
type="button"
on:click={addRule}
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"
>
<Plus size={14} />
Add Rule
</button>
<button
type="button"
on:click={addNestedGroup}
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"
>
<FolderPlus size={14} />
Add Group
</button>
</div>
</div>

View File

@@ -0,0 +1,282 @@
<script lang="ts">
import { Plus, X, ChevronDown, Pencil, Check, Info, Power, Copy } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import { createEmptyFilterConfig, type FilterConfig } from '$lib/shared/filters';
import { selectors } from '$lib/shared/selectors';
import FilterGroupComponent from './FilterGroup.svelte';
import NumberInput from '$ui/form/NumberInput.svelte';
import FiltersInfoModal from './FiltersInfoModal.svelte';
export let filters: FilterConfig[] = [];
let showInfoModal = false;
let expandedIds: Set<string> = new Set();
let editingId: string | null = null;
let editingName: string = '';
function addFilter() {
const newFilter = createEmptyFilterConfig(`Filter ${filters.length + 1}`);
filters = [...filters, newFilter];
expandedIds.add(newFilter.id);
expandedIds = expandedIds;
}
function removeFilter(id: string, event: MouseEvent) {
event.stopPropagation();
filters = filters.filter((f) => f.id !== id);
expandedIds.delete(id);
expandedIds = expandedIds;
}
function toggleExpanded(id: string) {
if (expandedIds.has(id)) {
expandedIds.delete(id);
} else {
expandedIds.add(id);
}
expandedIds = expandedIds;
}
function startEditing(filter: FilterConfig, event: MouseEvent) {
event.stopPropagation();
editingId = filter.id;
editingName = filter.name;
}
function saveEditing(event?: MouseEvent) {
event?.stopPropagation();
if (editingId) {
const filter = filters.find((f) => f.id === editingId);
if (filter) {
filter.name = editingName;
filters = filters;
}
}
editingId = null;
editingName = '';
}
function handleChange() {
filters = filters;
}
function toggleEnabled(id: string, event: MouseEvent) {
event.stopPropagation();
const filter = filters.find((f) => f.id === id);
if (filter) {
filter.enabled = !filter.enabled;
filters = filters;
}
}
function duplicateFilter(id: string, event: MouseEvent) {
event.stopPropagation();
const filter = filters.find((f) => f.id === id);
if (filter) {
const duplicate: FilterConfig = {
...structuredClone(filter),
id: crypto.randomUUID(),
name: `${filter.name} (Copy)`
};
filters = [...filters, duplicate];
expandedIds.add(duplicate.id);
expandedIds = expandedIds;
}
}
</script>
<div
class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Filters</h2>
<div class="flex items-center gap-2">
<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} />
Fields
</button>
<button
type="button"
on:click={addFilter}
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"
>
<Plus size={14} />
Add Filter
</button>
</div>
</div>
{#if filters.length === 0}
<div class="text-sm text-neutral-500 dark:text-neutral-400">
No filters configured. Add a filter to start.
</div>
{:else}
<div class="space-y-2">
{#each filters as filter (filter.id)}
<div class="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
<!-- Accordion Header -->
<div class="flex cursor-pointer items-center justify-between bg-neutral-50 px-4 py-3 dark:bg-neutral-800/50">
<button
type="button"
on:click={() => toggleExpanded(filter.id)}
class="flex flex-1 cursor-pointer items-center gap-3 text-left"
>
<ChevronDown
size={16}
class="text-neutral-400 transition-transform {expandedIds.has(filter.id) ? 'rotate-180' : ''}"
/>
{#if editingId === filter.id}
<input
type="text"
bind:value={editingName}
on:click|stopPropagation
on:keydown={(e) => e.key === 'Enter' && saveEditing()}
on:blur={() => saveEditing()}
class="w-40 rounded border border-neutral-300 bg-white px-2 py-1 text-sm font-medium text-neutral-900 focus:border-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
autofocus
/>
{:else}
<span class="text-sm font-medium {filter.enabled ? 'text-neutral-900 dark:text-neutral-100' : 'text-neutral-400 dark:text-neutral-500'}">
{filter.name}
</span>
{#if !filter.enabled}
<span class="rounded bg-neutral-200 px-1.5 py-0.5 text-xs text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400">
Disabled
</span>
{/if}
{/if}
</button>
<div class="flex items-center gap-1">
{#if editingId === filter.id}
<button
type="button"
on:click={(e) => saveEditing(e)}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-green-300 bg-white text-green-600 transition-colors hover:bg-green-50 dark:border-green-700 dark:bg-neutral-800 dark:text-green-400 dark:hover:bg-green-700"
title="Save"
>
<Check size={14} />
</button>
{:else}
<button
type="button"
on:click={(e) => toggleEnabled(filter.id, e)}
class="inline-flex h-7 w-7 items-center justify-center rounded border transition-colors {filter.enabled
? 'border-green-300 bg-white text-green-600 hover:bg-green-50 dark:border-green-700 dark:bg-neutral-800 dark:text-green-400 dark:hover:bg-green-700'
: 'border-neutral-300 bg-white text-neutral-400 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-500 dark:hover:bg-neutral-700'}"
title={filter.enabled ? 'Disable filter' : 'Enable filter'}
>
<Power size={14} />
</button>
<button
type="button"
on:click={(e) => startEditing(filter, e)}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white 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"
title="Rename"
>
<Pencil size={14} />
</button>
<button
type="button"
on:click={(e) => duplicateFilter(filter.id, e)}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white 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"
title="Duplicate"
>
<Copy size={14} />
</button>
{/if}
<button
type="button"
on:click={(e) => removeFilter(filter.id, e)}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:border-red-300 hover:bg-red-50 hover:text-red-600 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:border-red-700 dark:hover:bg-red-900/30 dark:hover:text-red-400"
title="Delete filter"
>
<X size={14} />
</button>
</div>
</div>
<!-- Accordion Content -->
{#if expandedIds.has(filter.id)}
<div transition:slide={{ duration: 200 }} class="space-y-4 border-t border-neutral-200 p-4 dark:border-neutral-800">
<FilterGroupComponent group={filter.group} on:change={handleChange} />
<!-- Selection Settings -->
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-800/50">
<h3 class="mb-3 text-sm font-medium text-neutral-700 dark:text-neutral-300">Settings</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label
for="cutoff-{filter.id}"
class="block text-sm font-medium text-neutral-600 dark:text-neutral-400"
>
Cutoff %
</label>
<div class="mt-1">
<NumberInput name="cutoff-{filter.id}" bind:value={filter.cutoff} min={0} max={100} font="mono" on:change={handleChange} />
</div>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Score threshold for "cutoff met"
</p>
</div>
<div>
<label
for="cooldown-{filter.id}"
class="block text-sm font-medium text-neutral-600 dark:text-neutral-400"
>
Cooldown (hours)
</label>
<div class="mt-1">
<NumberInput name="cooldown-{filter.id}" bind:value={filter.searchCooldown} min={24} font="mono" on:change={handleChange} />
</div>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Skip if searched recently
</p>
</div>
<div>
<label
for="selector-{filter.id}"
class="block text-sm font-medium text-neutral-600 dark:text-neutral-400"
>
Method
</label>
<select
id="selector-{filter.id}"
bind:value={filter.selector}
on:change={handleChange}
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{#each selectors as s}
<option value={s.id}>{s.label} - {s.description}</option>
{/each}
</select>
</div>
<div>
<label
for="count-{filter.id}"
class="block text-sm font-medium text-neutral-600 dark:text-neutral-400"
>
Count
</label>
<div class="mt-1">
<NumberInput name="count-{filter.id}" bind:value={filter.count} min={1} max={5} font="mono" on:change={handleChange} />
</div>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Items to select per run
</p>
</div>
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<FiltersInfoModal bind:open={showInfoModal} />

View File

@@ -0,0 +1,165 @@
<script lang="ts">
import InfoModal from '$ui/modal/InfoModal.svelte';
import { filterFields } from '$lib/shared/filters';
export let open: boolean = false;
// Group fields by type for better organization
const booleanFields = filterFields.filter((f) => f.valueType === 'boolean');
const selectFields = filterFields.filter((f) => f.valueType === 'select');
const textFields = filterFields.filter((f) => f.valueType === 'text');
const numberFields = filterFields.filter((f) => f.valueType === 'number');
const dateFields = filterFields.filter((f) => f.valueType === 'date');
</script>
<InfoModal bind:open header="Available Filters">
<div class="space-y-5 text-sm text-neutral-600 dark:text-neutral-400">
<p>
Use these fields to build filter rules. Combine multiple rules with AND/OR logic to create
complex filters.
</p>
{#if booleanFields.length > 0}
<div>
<div class="mb-2 font-medium text-neutral-900 dark:text-neutral-100">Yes/No Fields</div>
<div class="space-y-1.5">
{#each booleanFields as field}
<div class="flex items-start gap-2">
<span class="font-medium text-neutral-700 dark:text-neutral-300">{field.label}:</span>
<span>
{#if field.id === 'monitored'}
Whether the item is being monitored for upgrades
{:else if field.id === 'cutoff_met'}
Whether the item's score meets the filter's cutoff percentage
{/if}
</span>
</div>
{/each}
</div>
</div>
{/if}
{#if selectFields.length > 0}
<div>
<div class="mb-2 font-medium text-neutral-900 dark:text-neutral-100">Selection Fields</div>
<div class="space-y-1.5">
{#each selectFields as field}
<div class="flex items-start gap-2">
<span class="font-medium text-neutral-700 dark:text-neutral-300">{field.label}:</span>
<span>
{#if field.id === 'minimum_availability'}
The minimum availability status ({field.values?.map((v) => v.label).join(', ')})
{/if}
</span>
</div>
{/each}
</div>
</div>
{/if}
{#if textFields.length > 0}
<div>
<div class="mb-2 font-medium text-neutral-900 dark:text-neutral-100">Text Fields</div>
<p class="mb-2 text-xs">All text comparisons are case-insensitive.</p>
<div class="space-y-1.5">
{#each textFields as field}
<div class="flex items-start gap-2">
<span class="font-medium text-neutral-700 dark:text-neutral-300">{field.label}:</span>
<span>
{#if field.id === 'title'}
The title of the movie
{:else if field.id === 'quality_profile'}
The assigned quality profile name
{:else if field.id === 'collection'}
The collection the movie belongs to (e.g., "Marvel Cinematic Universe")
{:else if field.id === 'studio'}
The production studio
{:else if field.id === 'original_language'}
The original language of the movie
{:else if field.id === 'genres'}
Movie genres (Action, Comedy, Drama, etc.)
{:else if field.id === 'keywords'}
TMDb keywords associated with the movie
{:else if field.id === 'release_group'}
The release group of the current file
{/if}
</span>
</div>
{/each}
</div>
</div>
{/if}
{#if numberFields.length > 0}
<div>
<div class="mb-2 font-medium text-neutral-900 dark:text-neutral-100">Number Fields</div>
<div class="space-y-1.5">
{#each numberFields as field}
<div class="flex items-start gap-2">
<span class="font-medium text-neutral-700 dark:text-neutral-300">{field.label}:</span>
<span>
{#if field.id === 'year'}
The release year
{:else if field.id === 'popularity'}
TMDb popularity score
{:else if field.id === 'runtime'}
Movie runtime in minutes
{:else if field.id === 'size_on_disk'}
Current file size in GB
{:else if field.id === 'tmdb_rating'}
TMDb rating (0-10)
{:else if field.id === 'imdb_rating'}
IMDb rating (0-10)
{:else if field.id === 'tomato_rating'}
Rotten Tomatoes score (0-100)
{:else if field.id === 'trakt_rating'}
Trakt rating (0-100)
{/if}
</span>
</div>
{/each}
</div>
</div>
{/if}
{#if dateFields.length > 0}
<div>
<div class="mb-2 font-medium text-neutral-900 dark:text-neutral-100">Date Fields</div>
<div class="space-y-1.5">
{#each dateFields as field}
<div class="flex items-start gap-2">
<span class="font-medium text-neutral-700 dark:text-neutral-300">{field.label}:</span>
<span>
{#if field.id === 'date_added'}
When the movie was added to your library
{/if}
</span>
</div>
{/each}
</div>
</div>
{/if}
<div>
<div class="mb-2 font-medium text-neutral-900 dark:text-neutral-100">Filter Settings</div>
<div class="space-y-1.5">
<div class="flex items-start gap-2">
<span class="font-medium text-neutral-700 dark:text-neutral-300">Cutoff %:</span>
<span>Score threshold (0-100%) for the "Cutoff Met" filter</span>
</div>
<div class="flex items-start gap-2">
<span class="font-medium text-neutral-700 dark:text-neutral-300">Cooldown:</span>
<span>Skip items that were searched within this many hours</span>
</div>
<div class="flex items-start gap-2">
<span class="font-medium text-neutral-700 dark:text-neutral-300">Method:</span>
<span>How to select items from the filtered results</span>
</div>
<div class="flex items-start gap-2">
<span class="font-medium text-neutral-700 dark:text-neutral-300">Count:</span>
<span>Number of items to search per run</span>
</div>
</div>
</div>
</div>
</InfoModal>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import InfoModal from '$ui/modal/InfoModal.svelte';
export let open: boolean = false;
</script>
<InfoModal bind:open header="How Upgrades Work">
<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">Why is this needed?</div>
<div class="mt-1 space-y-2">
<p>
*arr apps only upgrade via RSS feeds, which are incremental and only show what's new.
RSS doesn't search for the best available release — it just grabs whatever comes through.
</p>
<p>
This module lets you define exactly what to upgrade, when, and how — all within Profilarr.
</p>
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Core Settings</div>
<div class="mt-1">
Configure the basic behavior: set how often it runs and choose how to cycle through
your filters (round robin or random shuffle).
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Filters</div>
<div class="mt-1">
Define which items to consider for upgrades. Create multiple named filters with
different criteria (e.g., "Marvel Movies", "2024 Releases", "Low Quality").
Each filter can have complex rules with AND/OR logic and nested groups.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Filter Mode</div>
<div class="mt-1 space-y-1">
<p><strong>Round Robin:</strong> Cycle through filters in order, one per run.</p>
<p><strong>Random Shuffle:</strong> Randomly shuffle filters, use each once before repeating.</p>
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Selection</div>
<div class="mt-1">
Each filter has its own selection settings. Choose how to pick items for upgrade
(e.g., random) and how many items to process per run. This prevents overwhelming
your indexers with too many searches at once.
</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">How it runs</div>
<div class="mt-1">
On each scheduled run: pick a filter (based on mode) → apply filter rules to your library
→ select items (based on selection method) → trigger searches in your *arr app.
</div>
</div>
</div>
</InfoModal>