diff --git a/src/lib/shared/filters.ts b/src/lib/shared/filters.ts new file mode 100644 index 0000000..c7c005d --- /dev/null +++ b/src/lib/shared/filters.ts @@ -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'; +} diff --git a/src/lib/shared/selectors.ts b/src/lib/shared/selectors.ts new file mode 100644 index 0000000..c88c88f --- /dev/null +++ b/src/lib/shared/selectors.ts @@ -0,0 +1,100 @@ +/** + * Shared selector types for both backend and frontend + * Defines all available selectors for upgrade item selection + */ + +export interface Selector { + 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); +} diff --git a/src/routes/arr/[id]/+layout.svelte b/src/routes/arr/[id]/+layout.svelte index b8ddc79..2bfb836 100644 --- a/src/routes/arr/[id]/+layout.svelte +++ b/src/routes/arr/[id]/+layout.svelte @@ -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 }, { diff --git a/src/routes/arr/[id]/search-priority/+page.svelte b/src/routes/arr/[id]/search-priority/+page.svelte deleted file mode 100644 index 78b0ee4..0000000 --- a/src/routes/arr/[id]/search-priority/+page.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - - {data.instance.name} - Search Priority - Profilarr - - -
-
-

Search Priority

-

- Configure search priority settings for this {data.instance.type} instance. -

-
- Search priority configuration coming soon... -
-
-
diff --git a/src/routes/arr/[id]/search-priority/+page.server.ts b/src/routes/arr/[id]/upgrades/+page.server.ts similarity index 100% rename from src/routes/arr/[id]/search-priority/+page.server.ts rename to src/routes/arr/[id]/upgrades/+page.server.ts diff --git a/src/routes/arr/[id]/upgrades/+page.svelte b/src/routes/arr/[id]/upgrades/+page.svelte new file mode 100644 index 0000000..4ca5482 --- /dev/null +++ b/src/routes/arr/[id]/upgrades/+page.svelte @@ -0,0 +1,46 @@ + + + + {data.instance.name} - Upgrades - Profilarr + + +
+ +
+
+

Upgrade Configuration

+

+ Automatically search for better quality releases for your library items. +

+
+ +
+ + + +
+ + diff --git a/src/routes/arr/[id]/upgrades/components/CoreSettings.svelte b/src/routes/arr/[id]/upgrades/components/CoreSettings.svelte new file mode 100644 index 0000000..c327ba0 --- /dev/null +++ b/src/routes/arr/[id]/upgrades/components/CoreSettings.svelte @@ -0,0 +1,98 @@ + + +
+

Core Settings

+ +
+ +
+ +
+ + +
+ + +
+ + +
+
+ + +
+
+ +

+ Enable or disable this upgrade configuration +

+
+ +
+
+
diff --git a/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte b/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte new file mode 100644 index 0000000..20f769c --- /dev/null +++ b/src/routes/arr/[id]/upgrades/components/FilterGroup.svelte @@ -0,0 +1,203 @@ + + +
+ +
+
+ Match + + of the following rules +
+ {#if onRemove} + + {/if} +
+ + + {#if group.children.length === 0} +
+ No rules configured. Add a rule to start filtering. +
+ {:else} +
+ {#each group.children as child, childIndex} + {#if isRule(child)} + {@const field = getFilterField(child.field)} +
+ + + + + + + + {#if field?.valueType === 'boolean' || field?.valueType === 'select'} + + {:else if field?.valueType === 'text'} + + {:else if field?.valueType === 'number'} +
+ +
+ {:else if field?.valueType === 'date'} + {#if child.operator === 'in_last' || child.operator === 'not_in_last'} +
+
+ +
+ days +
+ {:else} + + {/if} + {/if} + + + +
+ {:else if isGroup(child)} + +
+ removeChild(childIndex)} + on:change={handleNestedChange} + /> +
+ {/if} + {/each} +
+ {/if} + + +
+ + +
+
diff --git a/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte b/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte new file mode 100644 index 0000000..472338b --- /dev/null +++ b/src/routes/arr/[id]/upgrades/components/FilterSettings.svelte @@ -0,0 +1,282 @@ + + +
+
+

Filters

+
+ + +
+
+ + {#if filters.length === 0} +
+ No filters configured. Add a filter to start. +
+ {:else} +
+ {#each filters as filter (filter.id)} +
+ +
+ +
+ {#if editingId === filter.id} + + {:else} + + + + {/if} + +
+
+ + + {#if expandedIds.has(filter.id)} +
+ + + +
+

Settings

+
+
+ +
+ +
+

+ Score threshold for "cutoff met" +

+
+
+ +
+ +
+

+ Skip if searched recently +

+
+
+ + +
+
+ +
+ +
+

+ Items to select per run +

+
+
+
+
+ {/if} +
+ {/each} +
+ {/if} +
+ + diff --git a/src/routes/arr/[id]/upgrades/components/FiltersInfoModal.svelte b/src/routes/arr/[id]/upgrades/components/FiltersInfoModal.svelte new file mode 100644 index 0000000..049cbf1 --- /dev/null +++ b/src/routes/arr/[id]/upgrades/components/FiltersInfoModal.svelte @@ -0,0 +1,165 @@ + + + +
+

+ Use these fields to build filter rules. Combine multiple rules with AND/OR logic to create + complex filters. +

+ + {#if booleanFields.length > 0} +
+
Yes/No Fields
+
+ {#each booleanFields as field} +
+ {field.label}: + + {#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} + +
+ {/each} +
+
+ {/if} + + {#if selectFields.length > 0} +
+
Selection Fields
+
+ {#each selectFields as field} +
+ {field.label}: + + {#if field.id === 'minimum_availability'} + The minimum availability status ({field.values?.map((v) => v.label).join(', ')}) + {/if} + +
+ {/each} +
+
+ {/if} + + {#if textFields.length > 0} +
+
Text Fields
+

All text comparisons are case-insensitive.

+
+ {#each textFields as field} +
+ {field.label}: + + {#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} + +
+ {/each} +
+
+ {/if} + + {#if numberFields.length > 0} +
+
Number Fields
+
+ {#each numberFields as field} +
+ {field.label}: + + {#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} + +
+ {/each} +
+
+ {/if} + + {#if dateFields.length > 0} +
+
Date Fields
+
+ {#each dateFields as field} +
+ {field.label}: + + {#if field.id === 'date_added'} + When the movie was added to your library + {/if} + +
+ {/each} +
+
+ {/if} + +
+
Filter Settings
+
+
+ Cutoff %: + Score threshold (0-100%) for the "Cutoff Met" filter +
+
+ Cooldown: + Skip items that were searched within this many hours +
+
+ Method: + How to select items from the filtered results +
+
+ Count: + Number of items to search per run +
+
+
+
+
diff --git a/src/routes/arr/[id]/upgrades/components/UpgradesInfoModal.svelte b/src/routes/arr/[id]/upgrades/components/UpgradesInfoModal.svelte new file mode 100644 index 0000000..b3f62ef --- /dev/null +++ b/src/routes/arr/[id]/upgrades/components/UpgradesInfoModal.svelte @@ -0,0 +1,64 @@ + + + +
+
+
Why is this needed?
+
+

+ *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. +

+

+ This module lets you define exactly what to upgrade, when, and how — all within Profilarr. +

+
+
+ +
+
Core Settings
+
+ Configure the basic behavior: set how often it runs and choose how to cycle through + your filters (round robin or random shuffle). +
+
+ +
+
Filters
+
+ 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. +
+
+ +
+
Filter Mode
+
+

Round Robin: Cycle through filters in order, one per run.

+

Random Shuffle: Randomly shuffle filters, use each once before repeating.

+
+
+ +
+
Selection
+
+ 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. +
+
+ +
+
How it runs
+
+ 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. +
+
+
+