mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-30 06:10:56 +01:00
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:
317
src/lib/shared/filters.ts
Normal file
317
src/lib/shared/filters.ts
Normal 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
100
src/lib/shared/selectors.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
46
src/routes/arr/[id]/upgrades/+page.svelte
Normal file
46
src/routes/arr/[id]/upgrades/+page.svelte
Normal 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} />
|
||||
98
src/routes/arr/[id]/upgrades/components/CoreSettings.svelte
Normal file
98
src/routes/arr/[id]/upgrades/components/CoreSettings.svelte
Normal 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>
|
||||
203
src/routes/arr/[id]/upgrades/components/FilterGroup.svelte
Normal file
203
src/routes/arr/[id]/upgrades/components/FilterGroup.svelte
Normal 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>
|
||||
282
src/routes/arr/[id]/upgrades/components/FilterSettings.svelte
Normal file
282
src/routes/arr/[id]/upgrades/components/FilterSettings.svelte
Normal 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} />
|
||||
165
src/routes/arr/[id]/upgrades/components/FiltersInfoModal.svelte
Normal file
165
src/routes/arr/[id]/upgrades/components/FiltersInfoModal.svelte
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user