mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-06-16 17:28:44 -04:00
feat: add upgrade filter preview (#693)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
419
src/lib/server/upgrades/preview.ts
Normal file
419
src/lib/server/upgrades/preview.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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">→</span>
|
||||
<span class="font-mono font-medium">{formatCount(data.matchedCount)}</span>
|
||||
matched
|
||||
<span class="mx-1 text-neutral-400">→</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">→</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>
|
||||
@@ -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"
|
||||
|
||||
55
src/routes/arr/[id]/upgrades/preview/+server.ts
Normal file
55
src/routes/arr/[id]/upgrades/preview/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user