refactor: moved upgrade/filter info into a seperate page on upgrades/info

This commit is contained in:
Sam Chau
2026-01-22 09:25:39 +10:30
parent e6d16d76be
commit 46e5e2a059
9 changed files with 282 additions and 285 deletions

View File

@@ -14,10 +14,7 @@
- maybe move langauges to general tab or put info directly below it to fill
space
- adding a database requires double click??? im not running into this personally
- default delay profiles upon adding an arr. different for radarr/sonarr
- wait for seraphys to give default profiles
- maybe need to get feedback and update over time
- on by default, turn off in settings > general
- pageinate the profilarr log pages
# Adaptive Backoff

View File

@@ -14,6 +14,8 @@
// Responsive: auto-switch to xs on smaller screens (< 1280px)
export let responsive: boolean = false;
export let fullWidth: boolean = false;
// Optional href - renders as anchor instead of button
export let href: string | undefined = undefined;
let isSmallScreen = false;
let mediaQuery: MediaQueryList | null = null;
@@ -64,15 +66,30 @@
$: classes = `${baseClasses} ${sizeClasses[effectiveSize]} ${variantClasses[variant]} ${widthClass}`;
</script>
<button {type} {disabled} class={classes} on:click on:mouseenter on:mouseleave>
{#if icon && iconPosition === 'left'}
<svelte:component this={icon} size={effectiveSize === 'xs' ? 12 : effectiveSize === 'sm' ? 14 : 16} class={baseIconColor} />
{/if}
{#if text}
<span class={baseTextColor}>{text}</span>
{/if}
<slot />
{#if icon && iconPosition === 'right'}
<svelte:component this={icon} size={effectiveSize === 'xs' ? 12 : effectiveSize === 'sm' ? 14 : 16} class={baseIconColor} />
{/if}
</button>
{#if href}
<a {href} class={classes} on:click on:mouseenter on:mouseleave>
{#if icon && iconPosition === 'left'}
<svelte:component this={icon} size={effectiveSize === 'xs' ? 12 : effectiveSize === 'sm' ? 14 : 16} class={baseIconColor} />
{/if}
{#if text}
<span class={baseTextColor}>{text}</span>
{/if}
<slot />
{#if icon && iconPosition === 'right'}
<svelte:component this={icon} size={effectiveSize === 'xs' ? 12 : effectiveSize === 'sm' ? 14 : 16} class={baseIconColor} />
{/if}
</a>
{:else}
<button {type} {disabled} class={classes} on:click on:mouseenter on:mouseleave>
{#if icon && iconPosition === 'left'}
<svelte:component this={icon} size={effectiveSize === 'xs' ? 12 : effectiveSize === 'sm' ? 14 : 16} class={baseIconColor} />
{/if}
{#if text}
<span class={baseTextColor}>{text}</span>
{/if}
<slot />
{#if icon && iconPosition === 'right'}
<svelte:component this={icon} size={effectiveSize === 'xs' ? 12 : effectiveSize === 'sm' ? 14 : 16} class={baseIconColor} />
{/if}
</button>
{/if}

View File

@@ -8,6 +8,7 @@ import { uuid } from './uuid.ts';
export interface FilterOperator {
id: string;
label: string;
description?: string;
}
export type FilterValueType = string | number | boolean | null;
@@ -20,6 +21,7 @@ export interface FilterValue {
export interface FilterField {
id: string;
label: string;
description: string;
operators: FilterOperator[];
valueType: 'boolean' | 'select' | 'text' | 'number' | 'date';
values?: FilterValue[];
@@ -82,42 +84,42 @@ export const filterModes: { id: FilterMode; label: string; description: string }
* Common operator sets
*/
const booleanOperators: FilterOperator[] = [
{ id: 'is', label: 'is' },
{ id: 'is_not', label: 'is not' }
{ id: 'is', label: 'is', description: 'Exact match' },
{ id: 'is_not', label: 'is not', description: 'Does not match' }
];
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' }
{ id: 'eq', label: 'equals', description: 'Exactly equals the value' },
{ id: 'neq', label: 'does not equal', description: 'Does not equal the value' },
{ id: 'gt', label: 'is greater than', description: 'Greater than the value' },
{ id: 'gte', label: 'is greater than or equal', description: 'Greater than or equal to the value' },
{ id: 'lt', label: 'is less than', description: 'Less than the value' },
{ id: 'lte', label: 'is less than or equal', description: 'Less than or equal to the value' }
];
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' }
{ id: 'contains', label: 'contains', description: 'Contains the text (case-insensitive)' },
{ id: 'not_contains', label: 'does not contain', description: 'Does not contain the text' },
{ id: 'starts_with', label: 'starts with', description: 'Starts with the text' },
{ id: 'ends_with', label: 'ends with', description: 'Ends with the text' },
{ id: 'eq', label: 'equals', description: 'Exactly equals the text (case-insensitive)' },
{ id: 'neq', label: 'does not equal', description: 'Does not equal the text' }
];
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' }
{ id: 'before', label: 'is before', description: 'The date is before the specified date' },
{ id: 'after', label: 'is after', description: 'The date is after the specified date' },
{ id: 'in_last', label: 'in the last', description: 'Within the last N days' },
{ id: 'not_in_last', label: 'not in the last', description: 'Not within the last N days' }
];
const ordinalOperators: FilterOperator[] = [
{ id: 'eq', label: 'is exactly' },
{ id: 'neq', label: 'is not' },
{ id: 'gte', label: 'has reached' },
{ id: 'lte', label: "hasn't passed" },
{ id: 'gt', label: 'is past' },
{ id: 'lt', label: 'is before' }
{ id: 'eq', label: 'is exactly', description: 'Exactly matches the status' },
{ id: 'neq', label: 'is not', description: 'Does not match the status' },
{ id: 'gte', label: 'has reached', description: 'Has reached this status or further in the progression' },
{ id: 'lte', label: "hasn't passed", description: 'Has not passed this status in the progression' },
{ id: 'gt', label: 'is past', description: 'Is past this status (further along)' },
{ id: 'lt', label: 'is before', description: 'Is before this status (not yet reached)' }
];
/**
@@ -139,6 +141,7 @@ export const filterFields: FilterField[] = [
{
id: 'monitored',
label: 'Monitored',
description: 'Whether the item is being monitored for upgrades',
operators: booleanOperators,
valueType: 'boolean',
values: [
@@ -149,6 +152,7 @@ export const filterFields: FilterField[] = [
{
id: 'cutoff_met',
label: 'Cutoff Met',
description: "Whether the item's quality score meets the filter's cutoff percentage",
operators: booleanOperators,
valueType: 'boolean',
values: [
@@ -161,6 +165,7 @@ export const filterFields: FilterField[] = [
{
id: 'minimum_availability',
label: 'Minimum Availability',
description: 'The minimum availability status set in Radarr. Progresses: TBA → Announced → In Cinemas → Released',
operators: ordinalOperators,
valueType: 'select',
values: [
@@ -175,48 +180,56 @@ export const filterFields: FilterField[] = [
{
id: 'title',
label: 'Title',
description: 'The title of the movie',
operators: textOperators,
valueType: 'text'
},
{
id: 'quality_profile',
label: 'Quality Profile',
description: 'The assigned quality profile name',
operators: textOperators,
valueType: 'text'
},
{
id: 'collection',
label: 'Collection',
description: 'The collection the movie belongs to (e.g., "Marvel Cinematic Universe")',
operators: textOperators,
valueType: 'text'
},
{
id: 'studio',
label: 'Studio',
description: 'The production studio',
operators: textOperators,
valueType: 'text'
},
{
id: 'original_language',
label: 'Original Language',
description: 'The original language of the movie (e.g., "en", "ja")',
operators: textOperators,
valueType: 'text'
},
{
id: 'genres',
label: 'Genres',
description: 'Movie genres (Action, Comedy, Drama, etc.)',
operators: textOperators,
valueType: 'text'
},
{
id: 'keywords',
label: 'Keywords',
description: 'TMDb keywords associated with the movie',
operators: textOperators,
valueType: 'text'
},
{
id: 'release_group',
label: 'Release Group',
description: 'The release group of the current file',
operators: textOperators,
valueType: 'text'
},
@@ -225,48 +238,56 @@ export const filterFields: FilterField[] = [
{
id: 'year',
label: 'Year',
description: 'The release year',
operators: numberOperators,
valueType: 'number'
},
{
id: 'popularity',
label: 'Popularity',
description: 'TMDb popularity score',
operators: numberOperators,
valueType: 'number'
},
{
id: 'runtime',
label: 'Runtime (minutes)',
label: 'Runtime',
description: 'Movie runtime in minutes',
operators: numberOperators,
valueType: 'number'
},
{
id: 'size_on_disk',
label: 'Size on Disk (GB)',
label: 'Size on Disk',
description: 'Current file size in GB',
operators: numberOperators,
valueType: 'number'
},
{
id: 'tmdb_rating',
label: 'TMDb Rating',
description: 'TMDb rating (0-10)',
operators: numberOperators,
valueType: 'number'
},
{
id: 'imdb_rating',
label: 'IMDb Rating',
description: 'IMDb rating (0-10)',
operators: numberOperators,
valueType: 'number'
},
{
id: 'tomato_rating',
label: 'Rotten Tomatoes Rating',
label: 'Rotten Tomatoes',
description: 'Rotten Tomatoes score (0-100)',
operators: numberOperators,
valueType: 'number'
},
{
id: 'trakt_rating',
label: 'Trakt Rating',
description: 'Trakt rating (0-100)',
operators: numberOperators,
valueType: 'number'
},
@@ -275,18 +296,21 @@ export const filterFields: FilterField[] = [
{
id: 'date_added',
label: 'Date Added',
description: 'When the movie was added to your library',
operators: dateOperators,
valueType: 'date'
},
{
id: 'digital_release',
label: 'Digital Release',
description: 'The digital release date from TMDb',
operators: dateOperators,
valueType: 'date'
},
{
id: 'physical_release',
label: 'Physical Release',
description: 'The physical release date from TMDb',
operators: dateOperators,
valueType: 'date'
}

View File

@@ -9,7 +9,6 @@
import CoreSettings from './components/CoreSettings.svelte';
import FilterSettings from './components/FilterSettings.svelte';
import RunHistory from './components/RunHistory.svelte';
import UpgradesInfoModal from './components/UpgradesInfoModal.svelte';
import DirtyModal from '$ui/modal/DirtyModal.svelte';
import StickyCard from '$ui/card/StickyCard.svelte';
import Button from '$ui/button/Button.svelte';
@@ -37,7 +36,6 @@
// Dev mode check - use VITE_CHANNEL which is explicitly set in dev mode
const isDev = import.meta.env.VITE_CHANNEL === 'dev';
let showInfoModal = false;
let saving = false;
let running = false;
let clearing = false;
@@ -86,7 +84,7 @@
</p>
</div>
<div slot="right" class="flex items-center gap-2">
<Button text="How it works" icon={Info} on:click={() => (showInfoModal = true)} />
<Button text="Info" icon={Info} href="/arr/{data.instance.id}/upgrades/info" />
{#if !isNewConfig && data.config?.dryRun}
<Button
text={clearing ? 'Clearing...' : 'Reset Cache'}
@@ -236,5 +234,4 @@
></form>
{/if}
<UpgradesInfoModal bind:open={showInfoModal} />
<DirtyModal />

View File

@@ -3,7 +3,6 @@
Plus,
Pencil,
Check,
Info,
Power,
Copy,
ClipboardCopy,
@@ -16,7 +15,6 @@
import { createSearchStore } from '$lib/client/stores/search';
import FilterGroupComponent from './FilterGroup.svelte';
import NumberInput from '$ui/form/NumberInput.svelte';
import FiltersInfoModal from './FiltersInfoModal.svelte';
import DropdownSelect from '$ui/dropdown/DropdownSelect.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
@@ -37,8 +35,6 @@
onFiltersChange?.(filters);
}
let showInfoModal = false;
// Filter the filters list based on search
$: filteredFilters = filterByName(filters, $debouncedQuery);
@@ -186,7 +182,6 @@
<div class="mb-4">
<ActionsBar>
<SearchAction {searchStore} placeholder="Search filters..." />
<ActionButton icon={Info} title="Fields" on:click={() => (showInfoModal = true)} />
<ActionButton icon={Plus} title="Add filter" on:click={addFilter} />
</ActionsBar>
</div>
@@ -390,8 +385,6 @@
</ExpandableTable>
</div>
<FiltersInfoModal bind:open={showInfoModal} />
<Modal
bind:open={deleteModalOpen}
header="Delete Filter"

View File

@@ -1,165 +0,0 @@
<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

@@ -1,66 +0,0 @@
<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>

View File

@@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
export const load: ServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
error(404, `Invalid instance ID: ${params.id}`);
}
const instance = arrInstancesQueries.getById(id);
if (!instance) {
error(404, `Instance not found: ${id}`);
}
return {
instance
};
};

View File

@@ -0,0 +1,179 @@
<script lang="ts">
import type { PageData } from './$types';
import { filterFields, filterModes } from '$lib/shared/filters';
import { selectors } from '$lib/shared/selectors';
import { ArrowLeft } from 'lucide-svelte';
import StickyCard from '$ui/card/StickyCard.svelte';
import Button from '$ui/button/Button.svelte';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import Table from '$ui/table/Table.svelte';
import type { Column } from '$ui/table/types';
export let data: PageData;
// Filter fields with type labels
const typeLabels: Record<string, string> = {
boolean: 'Yes/No',
select: 'Selection',
text: 'Text',
number: 'Number',
date: 'Date'
};
const badgeBase = 'inline-flex items-center rounded font-medium px-1.5 py-0.5 text-[10px]';
const badgeAccent = 'bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-200';
const badgeNeutral = 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300';
const fieldColumns: Column<(typeof filterFields)[0]>[] = [
{ key: 'label', header: 'Field', sortable: false },
{
key: 'valueType',
header: 'Type',
sortable: false,
cell: (row) => ({
html: `<span class="${badgeBase} ${badgeAccent}">${typeLabels[row.valueType] || row.valueType}</span>`
})
},
{ key: 'description', header: 'Description', sortable: false },
{
key: 'operators',
header: 'Operators',
sortable: false,
cell: (row) => ({
html: row.operators
.map((op) => `<span class="${badgeBase} ${badgeNeutral}">${op.label}</span>`)
.join(' ')
})
}
];
// Selectors table
const selectorColumns: Column<(typeof selectors)[0]>[] = [
{ key: 'label', header: 'Selector', sortable: false },
{ key: 'description', header: 'Description', sortable: false }
];
// Filter modes table
const modeColumns: Column<(typeof filterModes)[0]>[] = [
{ key: 'label', header: 'Mode', sortable: false },
{ key: 'description', header: 'Description', sortable: false }
];
// Concepts for the main explanation
const concepts = [
{
id: 'filters',
name: 'Filters',
summary: 'Rules that define what qualifies for upgrade',
details:
'Combine conditions with AND/OR logic. Example: monitored = true AND cutoff met = false AND (popularity > 30 OR year >= 2020). Rules can be nested into groups for complex logic.'
},
{
id: 'selectors',
name: 'Selectors',
summary: 'How to prioritize from filtered results',
details:
"After filtering narrows the pool, the selector picks which items actually get searched. Random spreads searches evenly over time. Oldest/Newest prioritizes by when items were added. Lowest Score targets items most in need of upgrades. Most/Least Popular lets you prioritize based on TMDb popularity."
},
{
id: 'count-cooldown',
name: 'Count & Cooldown',
summary: 'Batch size and search throttling',
details:
"Count limits how many items get searched per run - prevents overwhelming your indexers. Cooldown (in hours) prevents re-searching the same item too soon. Items are tagged with the search date so they're skipped until the cooldown expires."
},
{
id: 'cutoff',
name: 'Cutoff %',
summary: 'Quality score threshold for the Cutoff Met field',
details:
'The Cutoff Met filter field checks if an item\'s custom format score has reached this percentage of the profile\'s cutoff score. Set to 80% means items below 80% of their cutoff will have "Cutoff Met = false".'
},
{
id: 'multiple-filters',
name: 'Multiple Filters',
summary: 'Different strategies for different content',
details:
'Create separate filters for different upgrade strategies. A "High Priority" filter for popular recent content, a "Backlog" filter for older items. The filter mode controls which runs each cycle - round robin cycles through in order, random shuffle picks one at random.'
},
{
id: 'dry-run',
name: 'Dry Run',
summary: 'Test without triggering searches',
details:
"Enable dry run mode to test your filters without triggering actual searches. The full filter/select pipeline runs, but no searches are sent to your arr instance. Check the logs to see what would have been searched."
}
];
const conceptColumns: Column<(typeof concepts)[0]>[] = [
{ key: 'name', header: 'Concept', sortable: false },
{ key: 'summary', header: 'Summary', sortable: false }
];
</script>
<svelte:head>
<title>{data.instance.name} - Upgrades Info - Profilarr</title>
</svelte:head>
<StickyCard position="top">
<div slot="left">
<h1 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">How Upgrades Work</h1>
</div>
<div slot="right">
<Button text="Back" icon={ArrowLeft} href="/arr/{data.instance.id}/upgrades" />
</div>
</StickyCard>
<div class="mt-6 space-y-8 px-4">
<!-- Intro -->
<div class="text-neutral-600 dark:text-neutral-400">
<p>
Radarr and Sonarr don't search for the best release. They monitor RSS feeds and grab the
first thing that qualifies as an upgrade. To get optimal releases, you need manual searches.
This module automates that: <span class="font-medium text-neutral-700 dark:text-neutral-300"
>Filter</span
>
your library,
<span class="font-medium text-neutral-700 dark:text-neutral-300">Select</span> items to search,
then
<span class="font-medium text-neutral-700 dark:text-neutral-300">Search</span> for better releases.
</p>
</div>
<!-- Concepts -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Concepts</h2>
<ExpandableTable
columns={conceptColumns}
data={concepts}
getRowId={(row) => row.id}
emptyMessage="No concepts"
chevronPosition="right"
flushExpanded
>
<svelte:fragment slot="expanded" let:row>
<div class="px-6 py-4">
<p class="text-sm text-neutral-600 dark:text-neutral-400">{row.details}</p>
</div>
</svelte:fragment>
</ExpandableTable>
</section>
<!-- Selectors Reference -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Selectors</h2>
<Table columns={selectorColumns} data={selectors} emptyMessage="No selectors" />
</section>
<!-- Filter Modes Reference -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Filter Modes</h2>
<Table columns={modeColumns} data={filterModes} emptyMessage="No modes" />
</section>
<!-- Filter Fields Reference -->
<section>
<h2 class="mb-3 text-lg font-semibold text-neutral-900 dark:text-neutral-100">Filter Fields</h2>
<Table columns={fieldColumns} data={filterFields} emptyMessage="No fields" />
</section>
</div>