feat: add upgrade filter preview (#693)

This commit is contained in:
santiagosayshey
2026-06-05 20:15:17 +09:30
committed by GitHub
parent e278aa73b1
commit 8587cf7d1b
8 changed files with 1159 additions and 12 deletions

View File

@@ -16,6 +16,7 @@ letting Arr's own upgrade logic decide whether to grab them.
- [Filters](#filters)
- [Dynamic Filter Values](#dynamic-filter-values)
- [Selectors](#selectors)
- [Filter Preview](#filter-preview)
- [Scheduling](#scheduling)
- [Cooldown](#cooldown)
- [Dry Run](#dry-run)
@@ -152,6 +153,24 @@ specifies a selector strategy and a count (items per run).
Selector definitions live in `src/lib/shared/upgrades/selectors.ts`.
## Filter Preview
The upgrades page can preview a single filter without running a dry run. The
preview fetches the current Arr library, quality profiles, file metadata where
needed, and tags, then applies the same normalization, filter evaluation,
cooldown tag check, and selector ordering used by the upgrade processor.
Preview is read-only. It does not search indexers, does not use the dry-run
cooldown, and does not apply or reset cooldown tags. Results are grouped as
selected, selectable, cooldown, and filtered out so users can inspect the
filtered pool before triggering a real run.
Preview library data is cached in memory for 5 minutes per Arr instance. An
expired entry is treated as a cache miss: preview fetches fresh library data
from Arr and replaces the cached entry. This keeps repeated filter edits fast
while limiting stale preview data to a short window. Real upgrade runs do not
use this preview cache.
## Scheduling
Each Arr instance has a single upgrade config with a global cron schedule.

View File

@@ -21,10 +21,12 @@
export let onRowClick: ((row: T) => void) | null = null;
export let primaryColumnKey: string | null = null;
export let disableExpandWhen: ((row: T) => boolean) | null = null;
export let rowClass: ((row: T) => string) | null = null;
// Mobile responsive mode - switches to card layout on small screens
export let responsive: boolean = false;
// Progressive loading - render items in batches as user scrolls
export let pageSize: number | undefined = undefined;
export let fixedLayout: boolean = false;
let isMobile = false;
let mediaQuery: MediaQueryList | null = null;
@@ -200,7 +202,9 @@
{#each displayData as row, index}
{@const rowId = getRowId(row)}
<div
class="overflow-hidden rounded-xl border border-neutral-300 bg-white dark:border-neutral-700/60 dark:bg-neutral-800/50"
class="overflow-hidden rounded-xl border border-neutral-300 bg-white dark:border-neutral-700/60 dark:bg-neutral-800/50 {rowClass
? rowClass(row)
: ''}"
>
<!-- Card Header - clickable to expand -->
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
@@ -305,14 +309,14 @@
? 'rounded-b-none border-b-0'
: ''}"
>
<table class="w-full">
<table class="w-full {fixedLayout ? 'table-fixed' : ''}">
<thead
class="border-b border-neutral-300 bg-neutral-50 dark:border-neutral-700/60 dark:bg-neutral-800/50"
>
<tr>
<!-- Expand column (left) -->
{#if chevronPosition === 'left'}
<th class="{compact ? 'px-2 py-2.5' : 'px-3 py-3'} w-8"></th>
<th class="{compact ? 'px-3 py-2.5' : 'px-4 py-3'} w-12"></th>
{/if}
{#each columns as column}
<th
@@ -358,7 +362,7 @@
{/if}
<!-- Expand column (right) -->
{#if chevronPosition === 'right'}
<th class="{compact ? 'px-2 py-2.5' : 'px-3 py-3'} w-8"></th>
<th class="{compact ? 'px-3 py-2.5' : 'px-4 py-3'} w-12"></th>
{/if}
</tr>
</thead>
@@ -412,12 +416,14 @@
<!-- Main Row -->
<tr
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800"
class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800 {rowClass
? rowClass(row)
: ''}"
on:click={() => handleRowClick(rowId, row)}
>
<!-- Expand Icon (left) -->
{#if chevronPosition === 'left'}
<td class="{compact ? 'px-2 py-2' : 'px-3 py-3'} text-neutral-400">
<td class="{compact ? 'px-3 py-2' : 'px-4 py-3'} text-neutral-400">
{#if !shouldDisableExpand(row)}
<Button
icon={expandedRows.has(rowId) ? ChevronUp : ChevronDown}
@@ -456,7 +462,7 @@
<!-- Expand Icon (right) -->
{#if chevronPosition === 'right'}
<td class="{compact ? 'px-2 py-2' : 'px-3 py-3'} text-right text-neutral-400">
<td class="{compact ? 'px-3 py-2' : 'px-4 py-3'} text-right text-neutral-400">
{#if !shouldDisableExpand(row)}
<Button
icon={expandedRows.has(rowId) ? ChevronUp : ChevronDown}

View File

@@ -7,10 +7,31 @@ import type {
RadarrMovie,
RadarrMovieFile,
SonarrSeries,
ArrQualityProfile
ArrQualityProfile,
CustomFormatRef,
QualityProfileFormatItem,
ScoreBreakdownItem
} from '$lib/server/utils/arr/types.ts';
import type { UpgradeItem } from './types.ts';
function getFileName(path: string | undefined): string {
if (!path) return '';
return path.split('/').pop() ?? path;
}
function computeScoreBreakdown(
customFormats: CustomFormatRef[],
profileFormatItems: QualityProfileFormatItem[]
): ScoreBreakdownItem[] {
return customFormats.map((format) => {
const profileItem = profileFormatItems.find((item) => item.format === format.id);
return {
name: format.name,
score: profileItem?.score ?? 0
};
});
}
/**
* Normalize a Radarr movie to an UpgradeItem for filter evaluation
*
@@ -67,14 +88,20 @@ export function normalizeRadarrItem(
monitored: movie.monitored ?? false,
cutoff_met: cutoffMet,
quality_profile: profile?.name ?? 'Unknown',
quality_name: movieFile?.quality?.quality?.name ?? '',
file_name: getFileName(movieFile?.relativePath ?? movieFile?.path),
original_language: movie.originalLanguage?.name ?? '',
genres: movie.genres?.join(', ') ?? '',
tags,
custom_formats: movieFile?.customFormats.map((cf) => cf.name) ?? [],
score_breakdown: movieFile
? computeScoreBreakdown(movieFile.customFormats, profile?.formatItems ?? [])
: [],
rating: tmdbRating,
runtime: movie.runtime ?? 0,
size_on_disk: sizeOnDiskGB,
date_added: dateAdded,
path: movie.path ?? '',
// Radarr-specific fields
minimum_availability: movie.minimumAvailability ?? 'released',
@@ -168,14 +195,18 @@ export function normalizeSonarrItem(
monitored: series.monitored,
cutoff_met: cutoffMet,
quality_profile: profile?.name ?? 'Unknown',
quality_name: '',
file_name: '',
original_language: series.originalLanguage?.name ?? '',
genres: series.genres?.join(', ') ?? '',
tags,
custom_formats: [],
score_breakdown: [],
rating: series.ratings?.value ?? 0,
runtime: series.runtime ?? 0,
size_on_disk: sizeOnDiskGB,
date_added: dateAdded,
path: series.path ?? '',
// Radarr fields (defaults for Sonarr)
minimum_availability: '',

View File

@@ -0,0 +1,419 @@
import type { ArrInstance } from '$db/queries/arrInstances.ts';
import { RadarrClient } from '$utils/arr/clients/radarr.ts';
import { SonarrClient } from '$utils/arr/clients/sonarr.ts';
import type {
ArrQualityProfile,
ArrTag,
RadarrMovie,
RadarrMovieFile,
SonarrSeries
} from '$utils/arr/types.ts';
import {
evaluateGroup,
evaluateRule,
getFilterField,
isGroup,
isRule,
type FilterConfig,
type FilterField,
type FilterGroup,
type FilterRule,
type UpgradeAppType
} from '$shared/upgrades/filters.ts';
import { getSelector } from '$shared/upgrades/selectors.ts';
import { hasFilterTag, resolveTagLabel } from './cooldown.ts';
import { normalizeRadarrItems, normalizeSonarrItems } from './normalize.ts';
import type { UpgradeItem } from './types.ts';
export type UpgradeFilterPreviewStatus = 'selected' | 'selectable' | 'cooldown' | 'filtered_out';
export interface UpgradeFilterPreviewFormat {
name: string;
score: number;
}
export interface UpgradeFilterPreviewDetails {
qualityProfile: string;
fileName: string;
customFormats: UpgradeFilterPreviewFormat[];
score: number;
tags: string[];
monitored: boolean;
dateAdded: string;
sizeOnDisk: number;
releaseGroup: string;
status: string;
}
export interface UpgradeFilterPreviewItem {
id: number;
title: string;
year: number;
status: UpgradeFilterPreviewStatus;
reason: string;
details: UpgradeFilterPreviewDetails;
}
export interface UpgradeFilterPreviewResult {
filterName: string;
totalItems: number;
matchedCount: number;
cooldownCount: number;
selectableCount: number;
selectedCount: number;
requestedCount: number;
selector: string;
items: UpgradeFilterPreviewItem[];
}
interface RadarrPreviewCacheEntry {
expiresAt: number;
movies: RadarrMovie[];
profiles: ArrQualityProfile[];
movieFiles: RadarrMovieFile[];
tags: ArrTag[];
}
interface SonarrPreviewCacheEntry {
expiresAt: number;
series: SonarrSeries[];
profiles: ArrQualityProfile[];
tags: ArrTag[];
}
interface PreviewLoadResult {
items: UpgradeItem[];
tags: ArrTag[];
}
const PREVIEW_CACHE_TTL_MS = 5 * 60 * 1000;
const radarrPreviewCache = new Map<number, RadarrPreviewCacheEntry>();
const sonarrPreviewCache = new Map<number, SonarrPreviewCacheEntry>();
function toPreviewItem(
item: UpgradeItem,
status: UpgradeFilterPreviewStatus,
reason: string
): UpgradeFilterPreviewItem {
return {
id: item.id,
title: item.title,
year: item.year,
status,
reason,
details: {
qualityProfile: item.quality_profile,
fileName: item.file_name,
customFormats: item.score_breakdown,
score: item.score,
tags: item.tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
monitored: item.monitored,
dateAdded: item.date_added,
sizeOnDisk: item.size_on_disk,
releaseGroup: item.release_group,
status: item.status
}
};
}
function formatValue(value: unknown, field?: FilterField): string {
if (value === null || value === undefined || value === '') {
return 'empty';
}
const matchingValue = field?.values?.find((option) => Object.is(option.value, value));
if (matchingValue) {
return matchingValue.label;
}
if (Array.isArray(value)) {
return value.length > 0 ? value.join(', ') : 'none';
}
if (typeof value === 'boolean') {
return value ? 'True' : 'False';
}
return String(value);
}
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
return `${count} ${count === 1 ? singular : plural}`;
}
function formatFailedRule(item: UpgradeItem, rule: FilterRule, appType: UpgradeAppType): string {
const field = getFilterField(rule.field, appType);
const fieldLabel = field?.label ?? rule.field;
const actualValue =
rule.field === 'custom_format'
? item.custom_formats
: (item as unknown as Record<string, unknown>)[rule.field];
const expectedValue = formatValue(rule.value, field);
const actualLabel = formatValue(actualValue, field);
switch (rule.operator) {
case 'is':
case 'eq':
return `${fieldLabel} is ${actualLabel}, not ${expectedValue}`;
case 'is_not':
case 'neq':
return `${fieldLabel} is ${expectedValue}`;
case 'contains':
case 'includes':
return `${fieldLabel} does not contain ${expectedValue}`;
case 'not_contains':
case 'does_not_include':
return `${fieldLabel} contains ${expectedValue}`;
case 'starts_with':
return `${fieldLabel} does not start with ${expectedValue}`;
case 'ends_with':
return `${fieldLabel} does not end with ${expectedValue}`;
case 'is_only':
return `${fieldLabel} is not only ${expectedValue}`;
case 'has_any':
return `${fieldLabel} has none`;
case 'has_none':
return `${fieldLabel} has values`;
case 'gte':
return field?.valueType === 'number'
? `${fieldLabel} is below ${expectedValue}`
: `${fieldLabel} has not reached ${expectedValue}`;
case 'lte':
return field?.valueType === 'number'
? `${fieldLabel} is above ${expectedValue}`
: `${fieldLabel} has passed ${expectedValue}`;
case 'gt':
return `${fieldLabel} is not above ${expectedValue}`;
case 'lt':
return `${fieldLabel} is not below ${expectedValue}`;
case 'before':
return `${fieldLabel} is not before ${expectedValue}`;
case 'after':
return `${fieldLabel} is not after ${expectedValue}`;
case 'in_last':
return `${fieldLabel} is not in the last ${expectedValue} days`;
case 'not_in_last':
return `${fieldLabel} is in the last ${expectedValue} days`;
default:
return `${fieldLabel} did not match`;
}
}
function explainGroupMismatch(
item: UpgradeItem,
group: FilterGroup,
appType: UpgradeAppType
): string {
const record = item as unknown as Record<string, unknown>;
if (group.match === 'all') {
for (const child of group.children) {
if (isRule(child) && !evaluateRule(record, child)) {
return formatFailedRule(item, child, appType);
}
if (isGroup(child) && !evaluateGroup(record, child)) {
return explainGroupMismatch(item, child, appType);
}
}
}
if (group.match === 'any' && group.children.length > 0) {
for (const child of group.children) {
if (isRule(child) && !evaluateRule(record, child)) {
return `No option matched: ${formatFailedRule(item, child, appType)}`;
}
if (isGroup(child) && !evaluateGroup(record, child)) {
return `No option matched: ${explainGroupMismatch(item, child, appType)}`;
}
}
}
return 'Filter rules did not match';
}
async function loadRadarrItems(
instanceId: number,
client: RadarrClient,
filter: FilterConfig
): Promise<PreviewLoadResult> {
const cached = radarrPreviewCache.get(instanceId);
const now = Date.now();
let movies: RadarrMovie[];
let profiles: ArrQualityProfile[];
let movieFiles: RadarrMovieFile[];
let tags: ArrTag[];
if (cached && cached.expiresAt > now) {
movies = cached.movies;
profiles = cached.profiles;
movieFiles = cached.movieFiles;
tags = cached.tags;
} else {
const [loadedMovies, loadedProfiles] = await Promise.all([
client.getMovies(),
client.getQualityProfiles()
]);
const movieIdsWithFiles = loadedMovies
.filter((movie) => movie.hasFile)
.map((movie) => movie.id);
const loadedMovieFiles = await client.getMovieFiles(movieIdsWithFiles);
const loadedTags = await client.getTags();
movies = loadedMovies;
profiles = loadedProfiles;
movieFiles = loadedMovieFiles;
tags = loadedTags;
radarrPreviewCache.set(instanceId, {
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
movies,
profiles,
movieFiles,
tags
});
}
const movieFileMap = new Map<number, RadarrMovieFile>(
movieFiles.map((movieFile) => [movieFile.movieId, movieFile])
);
const profileMap = new Map(profiles.map((profile) => [profile.id, profile]));
const tagMap = new Map(tags.map((tag) => [tag.id, tag.label]));
const items = normalizeRadarrItems(movies, movieFileMap, profileMap, filter.cutoff, tagMap);
return {
items,
tags
};
}
async function loadSonarrItems(
instanceId: number,
client: SonarrClient,
filter: FilterConfig
): Promise<PreviewLoadResult> {
const cached = sonarrPreviewCache.get(instanceId);
const now = Date.now();
let series: SonarrSeries[];
let profiles: ArrQualityProfile[];
let tags: ArrTag[];
if (cached && cached.expiresAt > now) {
series = cached.series;
profiles = cached.profiles;
tags = cached.tags;
} else {
const [loadedSeries, loadedProfiles] = await Promise.all([
client.getAllSeries(),
client.getQualityProfiles()
]);
const loadedTags = await client.getTags();
series = loadedSeries;
profiles = loadedProfiles;
tags = loadedTags;
sonarrPreviewCache.set(instanceId, {
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
series,
profiles,
tags
});
}
const profileMap = new Map(profiles.map((profile) => [profile.id, profile]));
const tagMap = new Map(tags.map((tag) => [tag.id, tag.label]));
const items = normalizeSonarrItems(series, profileMap, filter.cutoff, tagMap);
return {
items,
tags
};
}
function buildPreview(
filter: FilterConfig,
normalizedItems: UpgradeItem[],
tags: ArrTag[],
appType: UpgradeAppType
): UpgradeFilterPreviewResult {
const matchedItems = normalizedItems.filter((item) =>
evaluateGroup(item as unknown as Record<string, unknown>, filter.group)
);
const matchedIds = new Set(matchedItems.map((item) => item.id));
const tagLabel = resolveTagLabel(filter);
const cooldownItems = matchedItems.filter((item) => hasFilterTag(item._tags, tags, tagLabel));
const cooldownIds = new Set(cooldownItems.map((item) => item.id));
const selectableItems = matchedItems.filter((item) => !cooldownIds.has(item.id));
const selector = getSelector(filter.selector);
const orderedSelectableItems = selector
? selector.select(selectableItems, selectableItems.length)
: selectableItems;
const selectedItems = orderedSelectableItems.slice(0, Math.max(0, filter.count));
const selectedIds = new Set(selectedItems.map((item) => item.id));
const remainingSelectableItems = orderedSelectableItems.filter(
(item) => !selectedIds.has(item.id)
);
const filteredOutItems = normalizedItems.filter((item) => !matchedIds.has(item.id));
const selectorLabel = selector?.label ?? filter.selector;
const cooldownRemaining = pluralize(selectableItems.length, 'item');
const cooldownReason =
selectableItems.length > 0
? `Already searched by this filter. ${cooldownRemaining} still eligible before the cooldown resets.`
: 'Already searched by this filter. Cooldown will reset on the next run.';
return {
filterName: filter.name,
totalItems: normalizedItems.length,
matchedCount: matchedItems.length,
cooldownCount: cooldownItems.length,
selectableCount: selectableItems.length,
selectedCount: selectedItems.length,
requestedCount: filter.count,
selector: filter.selector,
items: [
...selectedItems.map((item) =>
toPreviewItem(item, 'selected', `Will search next using ${selectorLabel}`)
),
...remainingSelectableItems.map((item) =>
toPreviewItem(item, 'selectable', 'Matches this filter and is waiting for a later run')
),
...cooldownItems.map((item) => toPreviewItem(item, 'cooldown', cooldownReason)),
...filteredOutItems.map((item) =>
toPreviewItem(item, 'filtered_out', explainGroupMismatch(item, filter.group, appType))
)
]
};
}
export async function previewUpgradeFilter(
instance: ArrInstance,
filter: FilterConfig
): Promise<UpgradeFilterPreviewResult> {
if (instance.type !== 'radarr' && instance.type !== 'sonarr') {
throw new Error(`Upgrade preview is not supported for ${instance.type}`);
}
const appType = instance.type as UpgradeAppType;
const client =
instance.type === 'radarr'
? new RadarrClient(instance.url, instance.api_key, { timeout: 120000 })
: new SonarrClient(instance.url, instance.api_key, { timeout: 120000 });
try {
const { items, tags } =
instance.type === 'radarr'
? await loadRadarrItems(instance.id, client as RadarrClient, filter)
: await loadSonarrItems(instance.id, client as SonarrClient, filter);
return buildPreview(filter, items, tags, appType);
} finally {
client.close();
}
}

View File

@@ -2,7 +2,7 @@
* Types for the upgrade processing system
*/
import type { RadarrMovie, SonarrSeries } from '$lib/server/utils/arr/types.ts';
import type { RadarrMovie, ScoreBreakdownItem, SonarrSeries } from '$lib/server/utils/arr/types.ts';
import type { FilterGroup } from '$shared/upgrades/filters.ts';
/**
@@ -17,14 +17,18 @@ export interface UpgradeItem {
monitored: boolean;
cutoff_met: boolean;
quality_profile: string;
quality_name: string;
file_name: string;
original_language: string;
genres: string;
tags: string;
custom_formats: string[];
score_breakdown: ScoreBreakdownItem[];
rating: number;
runtime: number;
size_on_disk: number;
date_added: string;
path: string;
// Radarr-only fields (empty/zero defaults for Sonarr)
minimum_availability: string;
@@ -92,7 +96,7 @@ export interface UpgradeNewRelease {
release: string; // release title
formats: string[];
score: number;
seasonNumber?: number; // Sonarr only which season this grab is for
seasonNumber?: number; // Sonarr only, which season this grab is for
}
/**

View File

@@ -0,0 +1,581 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte';
import { CircleDot, Loader2 } from 'lucide-svelte';
import type { FilterConfig } from '$shared/upgrades/filters';
import { selectors } from '$shared/upgrades/selectors';
import { createSearchStore, type SearchStore } from '$lib/client/stores/search';
import { serverTimezone } from '$lib/client/stores/timezone';
import { formatDate } from '$shared/utils/dates';
import type { Readable } from 'svelte/store';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import DropdownHeader from '$ui/dropdown/DropdownHeader.svelte';
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
import Button from '$ui/button/Button.svelte';
import Modal from '$ui/modal/Modal.svelte';
import Label from '$ui/label/Label.svelte';
import CustomFormatBadge from '$ui/arr/CustomFormatBadge.svelte';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import type { Column } from '$ui/table/types';
export let open = false;
export let filter: FilterConfig | null = null;
export let instanceId: string | undefined = undefined;
type PreviewStatus = 'selected' | 'selectable' | 'cooldown' | 'filtered_out';
type PreviewLabelVariant = 'secondary' | 'success' | 'warning' | 'info';
interface PreviewItem {
id: number;
title: string;
year: number;
status: PreviewStatus;
reason: string;
details: PreviewDetails;
}
interface PreviewFormat {
name: string;
score: number;
}
interface PreviewDetails {
qualityProfile: string;
fileName: string;
customFormats: PreviewFormat[];
score: number;
tags: string[];
monitored: boolean;
dateAdded: string;
sizeOnDisk: number;
releaseGroup: string;
status: string;
}
interface PreviewResult {
filterName: string;
totalItems: number;
matchedCount: number;
cooldownCount: number;
selectableCount: number;
selectedCount: number;
requestedCount: number;
selector: string;
items: PreviewItem[];
}
const dispatch = createEventDispatcher<{ close: void }>();
let searchStore: SearchStore = createSearchStore();
let debouncedQuery: Readable<string> = searchStore.debouncedQuery;
let loading = false;
let loadingText = 'Loading library data...';
let error: string | null = null;
let data: PreviewResult | null = null;
let statusFilters: Set<PreviewStatus> = new Set();
let expandedIds: Set<string | number> = new Set();
let requestId = 0;
let stepTimer: ReturnType<typeof setTimeout> | undefined;
let activePreviewKey = '';
const columns: Column<PreviewItem>[] = [
{ key: 'title', header: 'Title', sortable: true, width: 'w-64' },
{ key: 'status', header: 'Status', width: 'w-32' },
{ key: 'reason', header: 'Reason' }
];
const statusMeta: Record<PreviewStatus, { label: string; variant: PreviewLabelVariant }> = {
selected: { label: 'Will search next', variant: 'success' },
selectable: { label: 'Eligible later', variant: 'info' },
cooldown: { label: 'Already searched', variant: 'warning' },
filtered_out: { label: "Doesn't match", variant: 'secondary' }
};
const statusOptions: { value: PreviewStatus; label: string }[] = [
{ value: 'selected', label: 'Will search next' },
{ value: 'selectable', label: 'Eligible later' },
{ value: 'cooldown', label: 'Already searched' },
{ value: 'filtered_out', label: "Doesn't match" }
];
const skeletonRows = Array.from({ length: 8 });
$: filteredItems = filterItems(data?.items ?? [], $debouncedQuery, statusFilters);
$: previewKey = open && filter && instanceId ? `${instanceId}:${filter.id}` : '';
$: if (previewKey && previewKey !== activePreviewKey && filter && instanceId) {
activePreviewKey = previewKey;
void loadPreview(filter, instanceId);
}
$: if (!open && activePreviewKey) {
activePreviewKey = '';
requestId += 1;
clearStepTimer();
loading = false;
}
function clearStepTimer() {
if (stepTimer) {
clearTimeout(stepTimer);
stepTimer = undefined;
}
}
function resetFilters() {
searchStore.clear();
statusFilters = new Set();
expandedIds = new Set();
}
function filterItems(
items: PreviewItem[],
query: string,
statuses: Set<PreviewStatus>
): PreviewItem[] {
let result = items;
const queryLower = query.trim().toLowerCase();
if (queryLower) {
result = result.filter(
(item) =>
item.title.toLowerCase().includes(queryLower) ||
item.reason.toLowerCase().includes(queryLower) ||
statusMeta[item.status].label.toLowerCase().includes(queryLower)
);
}
if (statuses.size > 0) {
result = result.filter((item) => statuses.has(item.status));
}
return result;
}
function toggleStatus(status: PreviewStatus) {
if (statusFilters.has(status)) {
statusFilters.delete(status);
} else {
statusFilters.add(status);
}
statusFilters = new Set(statusFilters);
}
function clearStatusFilters() {
statusFilters = new Set();
}
async function loadPreview(targetFilter: FilterConfig, targetInstanceId: string) {
const currentRequestId = requestId + 1;
requestId = currentRequestId;
loading = true;
loadingText = 'Loading library data...';
error = null;
data = null;
resetFilters();
clearStepTimer();
stepTimer = setTimeout(() => {
if (currentRequestId === requestId) {
loadingText = 'Evaluating filter...';
}
}, 700);
try {
const response = await fetch(`/arr/${targetInstanceId}/upgrades/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filter: structuredClone(targetFilter) })
});
const result = await response.json().catch(() => ({}));
if (currentRequestId !== requestId) {
return;
}
if (!response.ok) {
throw new Error(result.error ?? 'Failed to preview filter');
}
data = result as PreviewResult;
} catch (err) {
if (currentRequestId === requestId) {
error = err instanceof Error ? err.message : 'Failed to preview filter';
}
} finally {
if (currentRequestId === requestId) {
clearStepTimer();
loading = false;
}
}
}
function closePreview() {
requestId += 1;
clearStepTimer();
loading = false;
data = null;
dispatch('close');
}
function formatSize(sizeGb: number): string {
if (!sizeGb) return 'None';
if (sizeGb >= 1) return `${sizeGb.toFixed(1)} GB`;
return `${Math.round(sizeGb * 1024)} MB`;
}
function formatDateValue(value: string): string {
if (!value) return 'Unknown';
const result = formatDate(value, $serverTimezone);
return result === '-' ? 'Unknown' : result;
}
function formatScore(score: number): string {
return score.toLocaleString();
}
function formatStatus(status: string): string {
if (!status) return 'Unknown';
return status
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function statusVariant(status: string): PreviewLabelVariant {
switch (status) {
case 'released':
case 'continuing':
return 'success';
case 'inCinemas':
case 'upcoming':
return 'info';
default:
return 'secondary';
}
}
function formatCount(value: number): string {
return value.toLocaleString();
}
function formatItemCount(count: number, singular: string, plural: string): string {
return `${formatCount(count)} ${count === 1 ? singular : plural}`;
}
function getSelectorLabel(selectorId: string): string {
return selectors.find((selector) => selector.id === selectorId)?.label ?? selectorId;
}
function getEligibleLaterCount(result: PreviewResult): number {
return Math.max(0, result.selectableCount - result.selectedCount);
}
function getDoesNotMatchCount(result: PreviewResult): number {
return Math.max(0, result.totalItems - result.matchedCount);
}
onDestroy(() => {
clearStepTimer();
});
</script>
<Modal
{open}
header={filter ? `Preview: ${filter.name}` : 'Preview Filter'}
size="2xl"
height="xl"
on:cancel={closePreview}
>
<svelte:fragment slot="body">
{#if loading}
<div class="space-y-4">
<div class="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300">
<Loader2 size={16} class="animate-spin text-blue-600 dark:text-blue-400" />
<span>{loadingText}</span>
</div>
<div
class="overflow-hidden rounded-lg border border-neutral-300 dark:border-neutral-700/60"
>
{#each skeletonRows as _}
<div
class="flex gap-4 border-b border-neutral-200 p-4 last:border-0 dark:border-neutral-800"
>
<div class="h-4 w-2/5 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800"></div>
<div class="h-4 w-24 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800"></div>
<div class="h-4 w-28 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800"></div>
<div class="h-4 w-16 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800"></div>
</div>
{/each}
</div>
</div>
{:else if error}
<div
class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-300"
>
{error}
</div>
{:else if data}
<div class="space-y-4">
<div
class="space-y-3 rounded-lg border border-neutral-300 bg-white p-4 dark:border-neutral-700/60 dark:bg-neutral-900"
>
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">
Library
</span>
<span class="text-sm text-neutral-900 dark:text-neutral-100">
{formatItemCount(data.totalItems, 'item', 'items')} checked
</span>
</div>
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">
Filter
</span>
<span class="text-sm text-neutral-900 dark:text-neutral-100">
"{data.filterName}"
<span class="mx-1 text-neutral-400">&rarr;</span>
<span class="font-mono font-medium">{formatCount(data.matchedCount)}</span>
matched
<span class="mx-1 text-neutral-400">&rarr;</span>
<span class="font-mono font-medium">{formatCount(data.selectableCount)}</span>
after cooldown
</span>
</div>
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">
Selection
</span>
<span class="text-sm text-neutral-900 dark:text-neutral-100">
{getSelectorLabel(data.selector)}
<span class="font-mono font-medium">{formatCount(data.selectedCount)}</span>
of <span class="font-mono">{formatCount(data.requestedCount)}</span>
will search next
{#if getEligibleLaterCount(data) > 0}
<span class="mx-1 text-neutral-400">&rarr;</span>
{formatItemCount(getEligibleLaterCount(data), 'item', 'items')}
eligible later
{/if}
</span>
</div>
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">
Cooldown
</span>
<span class="text-sm text-neutral-900 dark:text-neutral-100">
{formatItemCount(data.cooldownCount, 'item', 'items')} already searched,
{formatItemCount(getDoesNotMatchCount(data), 'item does', 'items do')}
not match
</span>
</div>
</div>
<ActionsBar className="md:justify-start">
<SearchAction {searchStore} placeholder="Search items..." responsive />
<ActionButton icon={CircleDot} hasDropdown square title="Filter by status">
<svelte:fragment slot="dropdown">
<Dropdown position="right" mobilePosition="middle" minWidth="12rem">
<DropdownHeader label="Status" />
<DropdownItem
label="All statuses"
selected={statusFilters.size === 0}
on:click={clearStatusFilters}
/>
{#each statusOptions as option}
<DropdownItem
label={option.label}
selected={statusFilters.has(option.value)}
on:click={() => toggleStatus(option.value)}
/>
{/each}
</Dropdown>
</svelte:fragment>
</ActionButton>
</ActionsBar>
<ExpandableTable
{columns}
data={filteredItems}
getRowId={(row) => row.id}
bind:expandedRows={expandedIds}
compact
responsive
flushExpanded
fixedLayout
pageSize={100}
emptyMessage="No preview items match."
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'title'}
<div class="min-w-0">
<div class="truncate font-medium">{row.title}</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">{row.year}</div>
</div>
{:else if column.key === 'status'}
<Label variant={statusMeta[row.status].variant} size="sm" rounded="md">
{statusMeta[row.status].label}
</Label>
{:else if column.key === 'reason'}
<span class="break-words text-neutral-600 dark:text-neutral-300">
{row.reason}
</span>
{/if}
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
<div class="min-w-0 overflow-hidden p-4">
<div class="divide-y divide-neutral-200 dark:divide-neutral-800">
<div
class="grid grid-cols-1 gap-1 py-3 md:grid-cols-[10rem_minmax(0,1fr)] md:items-center"
>
<div class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
File on Disk
</div>
<div class="min-w-0">
{#if row.details.fileName}
<code
class="font-mono text-xs break-all text-neutral-600 dark:text-neutral-400"
>
{row.details.fileName}
</code>
{:else}
<div class="text-xs text-neutral-500 dark:text-neutral-400">No file</div>
{/if}
</div>
</div>
<div
class="grid grid-cols-1 gap-2 py-3 md:grid-cols-[10rem_minmax(0,1fr)] md:items-center"
>
<div class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Custom Formats
</div>
<div class="min-w-0">
{#if row.details.customFormats.length > 0}
<div class="flex min-w-0 flex-wrap items-center gap-2">
{#each [...row.details.customFormats].sort((a, b) => b.score - a.score) as item}
<CustomFormatBadge name={item.name} score={item.score} />
{/each}
</div>
{:else}
<div class="text-xs text-neutral-500 dark:text-neutral-400">
No custom formats matched
</div>
{/if}
</div>
</div>
<div
class="grid grid-cols-1 gap-1 py-3 md:grid-cols-[10rem_minmax(0,1fr)] md:items-center"
>
<div class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Profile
</div>
<div class="min-w-0 break-words text-sm text-neutral-900 dark:text-neutral-100">
{row.details.qualityProfile}
</div>
</div>
<div
class="grid grid-cols-1 gap-1 py-3 md:grid-cols-[10rem_minmax(0,1fr)] md:items-center"
>
<div class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Score
</div>
<div class="font-mono text-sm text-neutral-900 dark:text-neutral-100">
{formatScore(row.details.score)}
</div>
</div>
<div
class="grid grid-cols-1 gap-1 py-3 md:grid-cols-[10rem_minmax(0,1fr)] md:items-center"
>
<div class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Monitored
</div>
<div>
<Label
variant={row.details.monitored ? 'success' : 'secondary'}
size="sm"
rounded="md"
>
{row.details.monitored ? 'Yes' : 'No'}
</Label>
</div>
</div>
<div
class="grid grid-cols-1 gap-1 py-3 md:grid-cols-[10rem_minmax(0,1fr)] md:items-center"
>
<div class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Status
</div>
<div>
<Label variant={statusVariant(row.details.status)} size="sm" rounded="md">
{formatStatus(row.details.status)}
</Label>
</div>
</div>
<div
class="grid grid-cols-1 gap-1 py-3 md:grid-cols-[10rem_minmax(0,1fr)] md:items-center"
>
<div class="text-xs font-medium text-neutral-500 dark:text-neutral-400">Size</div>
<div class="font-mono text-sm text-neutral-900 dark:text-neutral-100">
{formatSize(row.details.sizeOnDisk)}
</div>
</div>
<div
class="grid grid-cols-1 gap-1 py-3 md:grid-cols-[10rem_minmax(0,1fr)] md:items-center"
>
<div class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Added
</div>
<div class="font-mono text-sm text-neutral-900 dark:text-neutral-100">
{formatDateValue(row.details.dateAdded)}
</div>
</div>
<div
class="grid grid-cols-1 gap-1 py-3 md:grid-cols-[10rem_minmax(0,1fr)] md:items-center"
>
<div class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Release Group
</div>
<div
class="min-w-0 break-words font-mono text-sm text-neutral-900 dark:text-neutral-100"
>
{row.details.releaseGroup || 'None'}
</div>
</div>
<div
class="grid grid-cols-1 gap-2 py-3 md:grid-cols-[10rem_minmax(0,1fr)] md:items-center"
>
<div class="text-xs font-medium text-neutral-500 dark:text-neutral-400">Tags</div>
<div class="min-w-0">
{#if row.details.tags.length > 0}
<div class="flex min-w-0 flex-wrap items-center gap-2">
{#each row.details.tags as tag}
<Label variant="secondary" size="sm" rounded="md">{tag}</Label>
{/each}
</div>
{:else}
<div class="text-xs text-neutral-500 dark:text-neutral-400">No tags</div>
{/if}
</div>
</div>
</div>
</div>
</svelte:fragment>
</ExpandableTable>
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="footer">
<div class="flex w-full justify-end">
<Button text="Close" on:click={closePreview} />
</div>
</svelte:fragment>
</Modal>

View File

@@ -7,7 +7,8 @@
FileJson,
FileText,
Trash2,
Pencil
Pencil,
Eye
} from 'lucide-svelte';
import {
createEmptyFilterConfig,
@@ -15,7 +16,6 @@
getFilterField,
isGroup,
isRule,
searchRateLimits,
resolveTagLabel,
type DynamicFilterOptions,
type FilterConfig,
@@ -32,6 +32,7 @@
import type { Readable } from 'svelte/store';
import { page } from '$app/stores';
import FilterGroupComponent from './FilterGroup.svelte';
import FilterPreviewModal from './FilterPreviewModal.svelte';
import FormInput from '$ui/form/FormInput.svelte';
import NumberInput from '$ui/form/NumberInput.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
@@ -117,6 +118,8 @@
let deleteModalOpen = false;
let filterToDelete: FilterConfig | null = null;
let pasteModalOpen = false;
let previewModalOpen = false;
let previewFilter: FilterConfig | null = null;
function confirmDelete(filter: FilterConfig) {
filterToDelete = filter;
@@ -348,6 +351,16 @@
function handlePasteCancel() {
pasteModalOpen = false;
}
function openPreview(filter: FilterConfig) {
previewFilter = filter;
previewModalOpen = true;
}
function closePreview() {
previewModalOpen = false;
previewFilter = null;
}
</script>
<div class="-mx-4 bg-neutral-50 px-4 pt-2 pb-2 md:-mx-8 md:px-8 dark:bg-neutral-900">
@@ -421,6 +434,12 @@
</div>
<!-- Mobile: buttons below name -->
<div class="flex flex-wrap items-center gap-1 md:hidden">
<Button
icon={Eye}
iconColor="text-blue-600 dark:text-blue-400"
tooltip="Preview"
on:click={() => openPreview(row)}
/>
<Button
icon={Power}
iconColor={row.enabled
@@ -455,6 +474,12 @@
<!-- Desktop: buttons in actions slot -->
<svelte:fragment slot="actions" let:row>
<div class="hidden items-center gap-1 md:flex">
<Button
icon={Eye}
iconColor="text-blue-600 dark:text-blue-400"
tooltip="Preview"
on:click={() => openPreview(row)}
/>
<Button
icon={Power}
iconColor={row.enabled
@@ -619,6 +644,13 @@
on:cancel={handleDeleteCancel}
/>
<FilterPreviewModal
open={previewModalOpen}
filter={previewFilter}
instanceId={$page.params.id}
on:close={closePreview}
/>
<PasteModal
open={pasteModalOpen}
header="Import Filter"

View File

@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import type { FilterConfig } from '$shared/upgrades/filters.ts';
import { previewUpgradeFilter } from '$lib/server/upgrades/preview.ts';
function isFilterConfig(value: unknown): value is FilterConfig {
if (!value || typeof value !== 'object') {
return false;
}
const filter = value as Partial<FilterConfig>;
return (
typeof filter.id === 'string' &&
typeof filter.name === 'string' &&
typeof filter.enabled === 'boolean' &&
typeof filter.selector === 'string' &&
typeof filter.count === 'number' &&
typeof filter.cutoff === 'number' &&
!!filter.group &&
typeof filter.group === 'object'
);
}
export const POST: RequestHandler = async ({ params, request }) => {
const instanceId = parseInt(params.id ?? '', 10);
if (!Number.isFinite(instanceId)) {
return json({ error: 'Invalid instance ID' }, { status: 400 });
}
const instance = arrInstancesQueries.getById(instanceId);
if (!instance) {
return json({ error: 'Instance not found' }, { status: 404 });
}
if (instance.type !== 'radarr' && instance.type !== 'sonarr') {
return json(
{ error: `Upgrade preview is not supported for ${instance.type}` },
{ status: 400 }
);
}
const body = await request.json().catch(() => null);
if (!body || typeof body !== 'object' || !('filter' in body) || !isFilterConfig(body.filter)) {
return json({ error: 'Invalid filter' }, { status: 400 });
}
try {
const preview = await previewUpgradeFilter(instance, body.filter);
return json(preview);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to preview filter';
return json({ error: message }, { status: 500 });
}
};