feat(upgrades): enhance upgrade logs and configuration management

- Added filtering options for upgrade runs based on their status (all, success, partial, failed, skipped).
- Implemented a refresh button to reload the logs.
- Created a new component `UpgradeRunCard` to display individual upgrade run details.
- Introduced a cooldown tracker to show the next scheduled run time and progress.
- Added a dry run toggle to the upgrade configuration settings.
- Implemented clipboard functionality to copy and paste filter configurations.
- Updated the upgrade run action to support dry run mode and validate configurations.
- Refactored various components for improved readability and maintainability.
This commit is contained in:
Sam Chau
2025-12-27 11:23:36 +10:30
parent 6dbdd9a0f0
commit 926da00858
34 changed files with 2572 additions and 401 deletions

View File

@@ -1,5 +1,6 @@
{
"imports": {
"$lib/": "./src/lib/",
"$config": "./src/lib/server/utils/config/config.ts",
"$logger/": "./src/lib/server/utils/logger/",
"$shared/": "./src/lib/shared/",
@@ -15,6 +16,7 @@
"$http/": "./src/lib/server/utils/http/",
"$utils/": "./src/lib/server/utils/",
"$notifications/": "./src/lib/server/notifications/",
"$cache/": "./src/lib/server/utils/cache/",
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0",
"@std/assert": "jsr:@std/assert@^1.0.0",
"marked": "npm:marked@^15.0.6",

57
deno.lock generated
View File

@@ -1,11 +1,22 @@
{
"version": "5",
"specifiers": {
"jsr:@db/sqlite@0.12": "0.12.0",
"jsr:@denosaurs/plug@1": "1.1.0",
"jsr:@soapbox/kysely-deno-sqlite@*": "2.2.0",
"jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0",
"jsr:@std/assert@*": "1.0.15",
"jsr:@std/assert@0.217": "0.217.0",
"jsr:@std/assert@1": "1.0.15",
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/fmt@1": "1.0.8",
"jsr:@std/fs@1": "1.0.19",
"jsr:@std/internal@^1.0.10": "1.0.12",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/internal@^1.0.9": "1.0.12",
"jsr:@std/path@0.217": "0.217.0",
"jsr:@std/path@1": "1.1.2",
"jsr:@std/path@^1.1.1": "1.1.2",
"npm:@deno/vite-plugin@^1.0.5": "1.0.5_vite@7.1.12__@types+node@22.19.0__picomatch@4.0.3_@types+node@22.19.0",
"npm:@eslint/compat@^1.4.0": "1.4.1_eslint@9.39.1",
"npm:@eslint/js@^9.36.0": "9.39.1",
@@ -36,20 +47,64 @@
"npm:vite@^7.1.7": "7.1.12_@types+node@22.19.0_picomatch@4.0.3"
},
"jsr": {
"@db/sqlite@0.12.0": {
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
"dependencies": [
"jsr:@denosaurs/plug",
"jsr:@std/path@0.217"
]
},
"@denosaurs/plug@1.1.0": {
"integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
"dependencies": [
"jsr:@std/encoding",
"jsr:@std/fmt",
"jsr:@std/fs",
"jsr:@std/path@1"
]
},
"@soapbox/kysely-deno-sqlite@2.2.0": {
"integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a",
"dependencies": [
"npm:kysely@~0.27.2"
]
},
"@std/assert@0.217.0": {
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
},
"@std/assert@1.0.15": {
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b",
"dependencies": [
"jsr:@std/internal"
"jsr:@std/internal@^1.0.12"
]
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/fmt@1.0.8": {
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
},
"@std/fs@1.0.19": {
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06",
"dependencies": [
"jsr:@std/internal@^1.0.9",
"jsr:@std/path@^1.1.1"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/path@0.217.0": {
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
"dependencies": [
"jsr:@std/assert@0.217"
]
},
"@std/path@1.1.2": {
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
"dependencies": [
"jsr:@std/internal@^1.0.10"
]
}
},
"npm": {

View File

@@ -1,14 +1,16 @@
<script lang="ts" generics="T extends Record<string, any>">
import { ChevronDown, ChevronUp } from 'lucide-svelte';
import type { Column } from './types';
import { ChevronDown, ChevronUp, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-svelte';
import type { Column, SortState } from './types';
export let columns: Column<T>[];
export let data: T[];
export let getRowId: (row: T) => string | number;
export let compact: boolean = false;
export let emptyMessage: string = 'No data available';
export let defaultSort: SortState | null = null;
let expandedRows: Set<string | number> = new Set();
let sortState: SortState | null = defaultSort;
function toggleRow(id: string | number) {
if (expandedRows.has(id)) {
@@ -19,6 +21,68 @@
expandedRows = expandedRows;
}
function handleSort(column: Column<T>) {
if (!column.sortable) return;
if (sortState?.key === column.key) {
// Toggle direction or clear
if (sortState.direction === 'asc') {
sortState = { key: column.key, direction: 'desc' };
} else {
sortState = null; // Clear sort on third click
}
} else {
// New column - use default direction or 'asc'
sortState = {
key: column.key,
direction: column.defaultSortDirection ?? 'asc'
};
}
}
function getSortedData(items: T[], sort: SortState | null): T[] {
if (!sort) return items;
const column = columns.find((c) => c.key === sort.key);
if (!column) return items;
return [...items].sort((a, b) => {
let comparison = 0;
if (column.sortComparator) {
comparison = column.sortComparator(a, b);
} else {
const aVal = column.sortAccessor ? column.sortAccessor(a) : getCellValue(a, column.key);
const bVal = column.sortAccessor ? column.sortAccessor(b) : getCellValue(b, column.key);
// Handle null/undefined
if (aVal == null && bVal == null) comparison = 0;
else if (aVal == null) comparison = 1;
else if (bVal == null) comparison = -1;
// String comparison (case-insensitive)
else if (typeof aVal === 'string' && typeof bVal === 'string') {
comparison = aVal.localeCompare(bVal, undefined, { sensitivity: 'base' });
}
// Number/Date comparison
else if (typeof aVal === 'number' && typeof bVal === 'number') {
comparison = aVal - bVal;
}
// Boolean
else if (typeof aVal === 'boolean' && typeof bVal === 'boolean') {
comparison = aVal === bVal ? 0 : aVal ? -1 : 1;
}
// Fallback to string
else {
comparison = String(aVal).localeCompare(String(bVal));
}
}
return sort.direction === 'desc' ? -comparison : comparison;
});
}
$: sortedData = getSortedData(data, sortState);
function getAlignClass(align?: 'left' | 'center' | 'right'): string {
switch (align) {
case 'center':
@@ -31,7 +95,7 @@
}
function getCellValue(row: T, key: string): unknown {
return key.split('.').reduce((obj, k) => obj?.[k], row as Record<string, unknown>);
return key.split('.').reduce<unknown>((obj, k) => (obj as Record<string, unknown>)?.[k], row);
}
</script>
@@ -45,13 +109,32 @@
<th
class="{compact ? 'px-4 py-2' : 'px-6 py-3'} text-xs font-medium uppercase tracking-wider text-neutral-700 dark:text-neutral-300 {getAlignClass(column.align)} {column.width || ''}"
>
{column.header}
{#if column.sortable}
<button
type="button"
on:click={() => handleSort(column)}
class="inline-flex items-center gap-1 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors {column.align === 'right' ? 'flex-row-reverse' : ''}"
>
{column.header}
{#if sortState?.key === column.key}
{#if sortState.direction === 'asc'}
<ArrowUp size={12} class="text-blue-500" />
{:else}
<ArrowDown size={12} class="text-blue-500" />
{/if}
{:else}
<ArrowUpDown size={12} class="opacity-30" />
{/if}
</button>
{:else}
{column.header}
{/if}
</th>
{/each}
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 bg-white dark:divide-neutral-800 dark:bg-neutral-900">
{#if data.length === 0}
{#if sortedData.length === 0}
<tr>
<td
colspan={columns.length + 1}
@@ -61,7 +144,7 @@
</td>
</tr>
{:else}
{#each data as row}
{#each sortedData as row}
{@const rowId = getRowId(row)}
<!-- Main Row -->

View File

@@ -14,6 +14,7 @@ import { migration as migration009 } from './migrations/009_add_personal_access_
import { migration as migration010 } from './migrations/010_add_is_private.ts';
import { migration as migration011 } from './migrations/011_create_upgrade_configs.ts';
import { migration as migration012 } from './migrations/012_add_upgrade_last_run.ts';
import { migration as migration013 } from './migrations/013_add_upgrade_dry_run.ts';
export interface Migration {
version: number;
@@ -243,7 +244,8 @@ export function loadMigrations(): Migration[] {
migration009,
migration010,
migration011,
migration012
migration012,
migration013
];
// Sort by version number

View File

@@ -0,0 +1,47 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 013: Add dry_run to upgrade_configs
*
* Adds a dry_run flag that allows running upgrade jobs in test mode.
* When enabled, the job will log what it would do without actually
* triggering searches in the arr instance.
*/
export const migration: Migration = {
version: 13,
name: 'Add dry_run to upgrade_configs',
up: `
ALTER TABLE upgrade_configs
ADD COLUMN dry_run INTEGER NOT NULL DEFAULT 0;
`,
down: `
-- SQLite doesn't support DROP COLUMN easily, so we recreate the table
CREATE TABLE upgrade_configs_backup (
id INTEGER PRIMARY KEY AUTOINCREMENT,
arr_instance_id INTEGER NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 0,
schedule INTEGER NOT NULL DEFAULT 360,
filter_mode TEXT NOT NULL DEFAULT 'round_robin',
filters TEXT NOT NULL DEFAULT '[]',
current_filter_index INTEGER NOT NULL DEFAULT 0,
last_run_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (arr_instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
);
INSERT INTO upgrade_configs_backup
SELECT id, arr_instance_id, enabled, schedule, filter_mode, filters,
current_filter_index, last_run_at, created_at, updated_at
FROM upgrade_configs;
DROP TABLE upgrade_configs;
ALTER TABLE upgrade_configs_backup RENAME TO upgrade_configs;
CREATE INDEX idx_upgrade_configs_arr_instance ON upgrade_configs(arr_instance_id);
`
};

View File

@@ -108,6 +108,7 @@ export const databaseInstancesQueries = {
/**
* Get databases that need auto-sync check
* Note: last_synced_at may be ISO format (with T and Z), normalize for datetime()
*/
getDueForSync(): DatabaseInstance[] {
return db.query<DatabaseInstance>(
@@ -116,7 +117,7 @@ export const databaseInstancesQueries = {
AND sync_strategy > 0
AND (
last_synced_at IS NULL
OR datetime(last_synced_at, '+' || sync_strategy || ' minutes') <= datetime('now')
OR datetime(replace(replace(last_synced_at, 'T', ' '), 'Z', ''), '+' || sync_strategy || ' minutes') <= datetime('now')
)
ORDER BY last_synced_at ASC NULLS FIRST`
);

View File

@@ -54,12 +54,15 @@ export const jobsQueries = {
/**
* Get jobs that need to run (next_run_at <= now)
* Note: next_run_at is stored as ISO string (2025-12-27T08:35:00.000Z)
* datetime('now') returns space-separated format (2025-12-27 08:35:00)
* We normalize by replacing T with space and comparing first 19 chars
*/
getDueJobs(): Job[] {
return db.query<Job>(
`SELECT * FROM jobs
WHERE enabled = 1
AND (next_run_at IS NULL OR next_run_at <= datetime('now'))
AND (next_run_at IS NULL OR substr(replace(next_run_at, 'T', ' '), 1, 19) <= datetime('now'))
ORDER BY next_run_at`
);
},

View File

@@ -8,6 +8,7 @@ interface UpgradeConfigRow {
id: number;
arr_instance_id: number;
enabled: number;
dry_run: number;
schedule: number;
filter_mode: string;
filters: string;
@@ -22,6 +23,7 @@ interface UpgradeConfigRow {
*/
export interface UpgradeConfigInput {
enabled?: boolean;
dryRun?: boolean;
schedule?: number;
filterMode?: FilterMode;
filters?: FilterConfig[];
@@ -36,6 +38,7 @@ function rowToConfig(row: UpgradeConfigRow): UpgradeConfig {
id: row.id,
arrInstanceId: row.arr_instance_id,
enabled: row.enabled === 1,
dryRun: row.dry_run === 1,
schedule: row.schedule,
filterMode: row.filter_mode as FilterMode,
filters: JSON.parse(row.filters) as FilterConfig[],
@@ -94,6 +97,7 @@ export const upgradeConfigsQueries = {
// Create new
const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : 0;
const dryRun = input.dryRun !== undefined ? (input.dryRun ? 1 : 0) : 0;
const schedule = input.schedule ?? 360;
const filterMode = input.filterMode ?? 'round_robin';
const filters = JSON.stringify(input.filters ?? []);
@@ -101,10 +105,11 @@ export const upgradeConfigsQueries = {
db.execute(
`INSERT INTO upgrade_configs
(arr_instance_id, enabled, schedule, filter_mode, filters, current_filter_index)
VALUES (?, ?, ?, ?, ?, ?)`,
(arr_instance_id, enabled, dry_run, schedule, filter_mode, filters, current_filter_index)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
arrInstanceId,
enabled,
dryRun,
schedule,
filterMode,
filters,
@@ -125,6 +130,10 @@ export const upgradeConfigsQueries = {
updates.push('enabled = ?');
params.push(input.enabled ? 1 : 0);
}
if (input.dryRun !== undefined) {
updates.push('dry_run = ?');
params.push(input.dryRun ? 1 : 0);
}
if (input.schedule !== undefined) {
updates.push('schedule = ?');
params.push(input.schedule);
@@ -213,6 +222,7 @@ export const upgradeConfigsQueries = {
/**
* Get all enabled configs that are due to run
* A config is due if: last_run_at is null OR (now - last_run_at) >= schedule minutes
* Note: last_run_at may be stored as ISO string with T and Z, normalize for julianday
*/
getDueConfigs(): UpgradeConfig[] {
const rows = db.query<UpgradeConfigRow>(`
@@ -220,7 +230,7 @@ export const upgradeConfigsQueries = {
WHERE enabled = 1
AND (
last_run_at IS NULL
OR (julianday('now') - julianday(last_run_at)) * 24 * 60 >= schedule
OR (julianday('now') - julianday(replace(replace(last_run_at, 'T', ' '), 'Z', ''))) * 24 * 60 >= schedule
)
`);
return rows.map(rowToConfig);

View File

@@ -6,7 +6,8 @@
import { upgradeConfigsQueries } from '$db/queries/upgradeConfigs.ts';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { logger } from '$logger/logger.ts';
import type { FilterConfig, UpgradeConfig } from '$lib/shared/filters';
import { processUpgradeConfig } from '$lib/server/upgrades/processor.ts';
import type { UpgradeConfig } from '$lib/shared/filters.ts';
export interface UpgradeInstanceStatus {
instanceId: number;
@@ -26,30 +27,9 @@ export interface UpgradeManagerResult {
}
/**
* Get the next filter to run based on the config's mode
* Process a single upgrade config and convert result to status
*/
function getNextFilter(config: UpgradeConfig): FilterConfig | null {
const enabledFilters = config.filters.filter((f) => f.enabled);
if (enabledFilters.length === 0) {
return null;
}
if (config.filterMode === 'random') {
// Random: pick a random filter
const randomIndex = Math.floor(Math.random() * enabledFilters.length);
return enabledFilters[randomIndex];
}
// Round robin: use currentFilterIndex
const index = config.currentFilterIndex % enabledFilters.length;
return enabledFilters[index];
}
/**
* Process a single upgrade config
*/
async function processUpgradeConfig(config: UpgradeConfig): Promise<UpgradeInstanceStatus> {
async function processConfig(config: UpgradeConfig): Promise<UpgradeInstanceStatus> {
const instance = arrInstancesQueries.getById(config.arrInstanceId);
if (!instance) {
@@ -70,68 +50,36 @@ async function processUpgradeConfig(config: UpgradeConfig): Promise<UpgradeInsta
};
}
// Get the filter to run
const filter = getNextFilter(config);
if (!filter) {
// Only process Radarr for now
if (instance.type !== 'radarr') {
return {
instanceId: config.arrInstanceId,
instanceName: instance.name,
success: false,
error: 'No enabled filters'
error: `Upgrade not yet supported for ${instance.type}`
};
}
await logger.info(`Processing upgrade for "${instance.name}" with filter "${filter.name}"`, {
source: 'UpgradeManager',
meta: {
instanceId: instance.id,
instanceType: instance.type,
filterName: filter.name,
selector: filter.selector,
count: filter.count
}
});
try {
// TODO: Implement actual upgrade logic:
// 1. Fetch library items from arr instance
// 2. Apply filter rules to get matching items
// 3. Apply selector to pick items (random, oldest, etc.)
// 4. Check search cooldown (via arr tags)
// 5. Trigger search for selected items
// 6. Tag items with search timestamp
// Process using the upgrade processor
const log = await processUpgradeConfig(config, instance);
await logger.debug('Upgrade config details', {
source: 'UpgradeManager',
meta: {
instanceId: instance.id,
filter: {
id: filter.id,
name: filter.name,
cutoff: filter.cutoff,
searchCooldown: filter.searchCooldown,
selector: filter.selector,
count: filter.count,
rulesCount: filter.group.children.length
}
}
});
// Update filter index for round-robin mode
if (config.filterMode === 'round_robin') {
// Update filter index for round-robin mode after successful processing
if (log.status !== 'failed' && config.filterMode === 'round_robin') {
upgradeConfigsQueries.incrementFilterIndex(config.arrInstanceId);
}
// Update last run timestamp
upgradeConfigsQueries.updateLastRun(config.arrInstanceId);
// Convert log to status
return {
instanceId: instance.id,
instanceName: instance.name,
success: true,
filterName: filter.name,
itemsSearched: 0 // TODO: Return actual count when implemented
success: log.status === 'success' || log.status === 'partial',
filterName: log.config.selectedFilter,
itemsSearched: log.selection.actualCount,
error: log.status === 'failed' ? log.results.errors.join('; ') : undefined
};
} catch (error) {
await logger.error(`Failed to process upgrade for "${instance.name}"`, {
@@ -146,7 +94,6 @@ async function processUpgradeConfig(config: UpgradeConfig): Promise<UpgradeInsta
instanceId: instance.id,
instanceName: instance.name,
success: false,
filterName: filter.name,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
@@ -187,12 +134,12 @@ export async function runUpgradeManager(): Promise<UpgradeManagerResult> {
});
for (const config of dueConfigs) {
const status = await processUpgradeConfig(config);
const status = await processConfig(config);
statuses.push(status);
if (status.success) {
successCount++;
} else if (status.error?.includes('disabled') || status.error?.includes('No enabled')) {
} else if (status.error?.includes('disabled') || status.error?.includes('not yet supported') || status.error?.includes('No enabled')) {
skippedCount++;
} else {
failureCount++;

View File

@@ -0,0 +1,145 @@
/**
* Tag-based cooldown tracking for upgrade searches
*
* Uses tags in the format: profilarr-searched-YYYY-MM-DD
* This allows checking if an item was searched within the cooldown window
*/
import type { RadarrTag, RadarrMovie } from '$lib/server/utils/arr/types.ts';
import type { RadarrClient } from '$lib/server/utils/arr/clients/radarr.ts';
const TAG_PREFIX = 'profilarr-searched-';
/**
* Get today's cooldown tag label
*/
export function getTodayTagLabel(): string {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${TAG_PREFIX}${year}-${month}-${day}`;
}
/**
* Parse a date from a profilarr search tag label
* Returns null if not a valid profilarr tag
*/
function parseDateFromTagLabel(label: string): Date | null {
if (!label.startsWith(TAG_PREFIX)) {
return null;
}
const dateStr = label.slice(TAG_PREFIX.length);
const parsed = new Date(dateStr);
// Check if valid date
if (isNaN(parsed.getTime())) {
return null;
}
return parsed;
}
/**
* Check if an item is on cooldown based on its tags
*
* @param itemTagIds - The tag IDs on the item
* @param allTags - All tags from the arr instance
* @param cooldownHours - The cooldown period in hours
*/
export function isOnCooldown(
itemTagIds: number[],
allTags: RadarrTag[],
cooldownHours: number
): boolean {
const now = new Date();
const cooldownMs = cooldownHours * 60 * 60 * 1000;
// Create a lookup for tag IDs to labels
const tagMap = new Map(allTags.map((t) => [t.id, t.label]));
for (const tagId of itemTagIds) {
const label = tagMap.get(tagId);
if (!label) continue;
const tagDate = parseDateFromTagLabel(label);
if (!tagDate) continue;
// Check if the tag date is within the cooldown window
const diffMs = now.getTime() - tagDate.getTime();
if (diffMs <= cooldownMs) {
return true;
}
}
return false;
}
/**
* Filter items that are NOT on cooldown
*
* @param items - Items with _tags property
* @param allTags - All tags from the arr instance
* @param cooldownHours - The cooldown period in hours
*/
export function filterByCooldown<T extends { _tags: number[] }>(
items: T[],
allTags: RadarrTag[],
cooldownHours: number
): T[] {
if (cooldownHours <= 0) {
// No cooldown, return all items
return items;
}
return items.filter((item) => !isOnCooldown(item._tags, allTags, cooldownHours));
}
/**
* Apply today's search tag to a movie
* Adds the tag to the movie's existing tags and updates via API
*/
export async function applySearchTag(
client: RadarrClient,
movie: RadarrMovie,
tagId: number
): Promise<RadarrMovie> {
// Get current tags, add new one if not present
const currentTags = movie.tags ?? [];
if (currentTags.includes(tagId)) {
return movie; // Already has the tag
}
const updatedMovie = {
...movie,
tags: [...currentTags, tagId]
};
return await client.updateMovie(updatedMovie);
}
/**
* Apply search tag to multiple movies
*/
export async function applySearchTagToMovies(
client: RadarrClient,
movies: RadarrMovie[],
tagId: number
): Promise<{ success: number; failed: number; errors: string[] }> {
let success = 0;
let failed = 0;
const errors: string[] = [];
for (const movie of movies) {
try {
await applySearchTag(client, movie, tagId);
success++;
} catch (error) {
failed++;
errors.push(`Failed to tag "${movie.title}": ${error instanceof Error ? error.message : String(error)}`);
}
}
return { success, failed, errors };
}

View File

@@ -0,0 +1,83 @@
/**
* Structured logging for upgrade jobs
* Uses the shared logger with source 'UpgradeJob'
*/
import { logger } from '$logger/logger.ts';
import type { UpgradeJobLog } from './types.ts';
const SOURCE = 'UpgradeJob';
/**
* Log an upgrade run with structured data
* Logs info summary and debug with full details
*/
export async function logUpgradeRun(log: UpgradeJobLog): Promise<void> {
// Build summary message
const statusEmoji = log.status === 'success' ? '✓' : log.status === 'partial' ? '~' : '✗';
const duration = new Date(log.completedAt).getTime() - new Date(log.startedAt).getTime();
const summary = `[${statusEmoji}] ${log.instanceName}: ${log.filter.name} - ${log.selection.actualCount}/${log.selection.requestedCount} items searched (${duration}ms)`;
// Log info with key metrics
await logger.info(summary, {
source: SOURCE,
meta: {
instanceId: log.instanceId,
configId: log.configId,
status: log.status,
matchedCount: log.filter.matchedCount,
afterCooldown: log.filter.afterCooldown,
searchedCount: log.selection.actualCount,
durationMs: duration
}
});
// Log debug with full structured data
await logger.debug('Upgrade job details', {
source: SOURCE,
meta: log
});
}
/**
* Log when an upgrade config is skipped
*/
export async function logUpgradeSkipped(
instanceId: number,
instanceName: string,
reason: string
): Promise<void> {
await logger.info(`Skipped ${instanceName}: ${reason}`, {
source: SOURCE,
meta: { instanceId, reason }
});
}
/**
* Log when upgrade processing starts
*/
export async function logUpgradeStart(
instanceId: number,
instanceName: string,
filterName: string
): Promise<void> {
await logger.debug(`Starting upgrade for ${instanceName} with filter "${filterName}"`, {
source: SOURCE,
meta: { instanceId, filterName }
});
}
/**
* Log errors during upgrade processing
*/
export async function logUpgradeError(
instanceId: number,
instanceName: string,
error: string
): Promise<void> {
await logger.error(`Upgrade failed for ${instanceName}: ${error}`, {
source: SOURCE,
meta: { instanceId, error }
});
}

View File

@@ -0,0 +1,93 @@
/**
* Normalization logic for converting arr library items to UpgradeItem
* Maps raw API responses to the normalized interface used by filter evaluation
*/
import type { RadarrMovie, RadarrMovieFile, RadarrQualityProfile } from '$lib/server/utils/arr/types.ts';
import type { UpgradeItem } from './types.ts';
/**
* Normalize a Radarr movie to an UpgradeItem for filter evaluation
*
* @param movie - The raw movie from Radarr API
* @param movieFile - The movie file (if exists)
* @param profile - The quality profile
* @param cutoffPercent - The cutoff percentage from filter config (0-100)
*/
export function normalizeRadarrItem(
movie: RadarrMovie,
movieFile: RadarrMovieFile | undefined,
profile: RadarrQualityProfile | undefined,
cutoffPercent: number
): UpgradeItem {
// Calculate current score
const currentScore = movieFile?.customFormatScore ?? 0;
// Calculate cutoff threshold based on profile and filter's cutoff percent
const profileCutoff = profile?.cutoffFormatScore ?? 0;
const cutoffThreshold = (profileCutoff * cutoffPercent) / 100;
// Determine if cutoff is met
const cutoffMet = currentScore >= cutoffThreshold;
// Convert size to GB
const sizeOnDiskGB = (movie.sizeOnDisk ?? 0) / (1024 * 1024 * 1024);
// Extract ratings with fallbacks
const tmdbRating = movie.ratings?.tmdb?.value ?? 0;
const imdbRating = movie.ratings?.imdb?.value ?? 0;
const tomatoRating = movie.ratings?.rottenTomatoes?.value ?? 0;
const traktRating = movie.ratings?.trakt?.value ?? 0;
// Date added - use movie's added date
const dateAdded = movie.added ?? new Date().toISOString();
return {
// Core fields (snake_case for filter matching)
id: movie.id,
title: movie.title,
year: movie.year ?? 0,
monitored: movie.monitored ?? false,
cutoff_met: cutoffMet,
minimum_availability: movie.minimumAvailability ?? 'released',
quality_profile: profile?.name ?? 'Unknown',
collection: movie.collection?.title ?? movie.collection?.name ?? '',
studio: movie.studio ?? '',
original_language: movie.originalLanguage?.name ?? '',
genres: movie.genres?.join(', ') ?? '',
keywords: '', // Radarr doesn't expose keywords in basic movie endpoint
release_group: movieFile?.releaseGroup ?? '',
popularity: movie.popularity ?? 0,
runtime: movie.runtime ?? 0,
size_on_disk: sizeOnDiskGB,
tmdb_rating: tmdbRating,
imdb_rating: imdbRating,
tomato_rating: tomatoRating,
trakt_rating: traktRating,
date_added: dateAdded,
// For selectors (camelCase)
dateAdded: dateAdded,
score: currentScore,
// Original data
_raw: movie,
_tags: movie.tags ?? []
};
}
/**
* Normalize a batch of Radarr movies
*/
export function normalizeRadarrItems(
movies: RadarrMovie[],
movieFileMap: Map<number, RadarrMovieFile>,
profileMap: Map<number, RadarrQualityProfile>,
cutoffPercent: number
): UpgradeItem[] {
return movies.map((movie) => {
const movieFile = movieFileMap.get(movie.id);
const profile = profileMap.get(movie.qualityProfileId);
return normalizeRadarrItem(movie, movieFile, profile, cutoffPercent);
});
}

View File

@@ -0,0 +1,252 @@
/**
* Main orchestrator for processing upgrade configs
* Coordinates fetching, filtering, selection, and searching
*/
import { RadarrClient } from '$lib/server/utils/arr/clients/radarr.ts';
import type { ArrInstance } from '$lib/server/db/queries/arrInstances.ts';
import type { UpgradeConfig, FilterConfig } from '$lib/shared/filters.ts';
import { evaluateGroup } from '$lib/shared/filters.ts';
import { getSelector } from '$lib/shared/selectors.ts';
import type { UpgradeItem, UpgradeJobLog } from './types.ts';
import { normalizeRadarrItems } from './normalize.ts';
import { filterByCooldown, getTodayTagLabel, applySearchTagToMovies } from './cooldown.ts';
import { logUpgradeRun, logUpgradeStart, logUpgradeError, logUpgradeSkipped } from './logger.ts';
/**
* Get the next filter to run based on the config's mode
*/
function getNextFilter(config: UpgradeConfig): FilterConfig | null {
const enabledFilters = config.filters.filter((f) => f.enabled);
if (enabledFilters.length === 0) {
return null;
}
if (config.filterMode === 'random') {
const randomIndex = Math.floor(Math.random() * enabledFilters.length);
return enabledFilters[randomIndex];
}
// Round robin
const index = config.currentFilterIndex % enabledFilters.length;
return enabledFilters[index];
}
/**
* Create an empty/skipped job log
*/
function createSkippedLog(
config: UpgradeConfig,
instance: ArrInstance,
reason: string
): UpgradeJobLog {
const now = new Date().toISOString();
return {
id: crypto.randomUUID(),
configId: config.id ?? 0,
instanceId: instance.id,
instanceName: instance.name,
startedAt: now,
completedAt: now,
status: 'skipped',
config: {
schedule: config.schedule,
filterMode: config.filterMode,
selectedFilter: '',
dryRun: config.dryRun
},
library: {
totalItems: 0,
fetchedFromCache: false,
fetchDurationMs: 0
},
filter: {
name: '',
rules: { type: 'group', match: 'all', children: [] },
matchedCount: 0,
afterCooldown: 0
},
selection: {
method: '',
requestedCount: 0,
actualCount: 0,
items: []
},
results: {
searchesTriggered: 0,
successful: 0,
failed: 0,
errors: [reason]
}
};
}
/**
* Process a single upgrade config for an arr instance
*/
export async function processUpgradeConfig(
config: UpgradeConfig,
instance: ArrInstance
): Promise<UpgradeJobLog> {
const startedAt = new Date();
const logId = crypto.randomUUID();
// Get the filter to run
const filter = getNextFilter(config);
if (!filter) {
const log = createSkippedLog(config, instance, 'No enabled filters');
await logUpgradeSkipped(instance.id, instance.name, 'No enabled filters');
return log;
}
await logUpgradeStart(instance.id, instance.name, filter.name);
// Create client
const client = new RadarrClient(instance.url, instance.api_key);
try {
// Step 1: Fetch library data
const fetchStart = Date.now();
const [movies, profiles] = await Promise.all([
client.getMovies(),
client.getQualityProfiles()
]);
// Get movie files for movies with files
const movieIdsWithFiles = movies.filter((m) => m.hasFile).map((m) => m.id);
const movieFiles = await client.getMovieFiles(movieIdsWithFiles);
const fetchDurationMs = Date.now() - fetchStart;
// Create lookup maps
const movieFileMap = new Map(movieFiles.map((mf) => [mf.movieId, mf]));
const profileMap = new Map(profiles.map((p) => [p.id, p]));
// Step 2: Normalize items
const normalizedItems = normalizeRadarrItems(
movies,
movieFileMap,
profileMap,
filter.cutoff
);
// Step 3: Apply filter rules
const matchedItems = normalizedItems.filter((item) =>
evaluateGroup(item as unknown as Record<string, unknown>, filter.group)
);
// Step 4: Filter by cooldown
const tags = await client.getTags();
const afterCooldown = filterByCooldown(matchedItems, tags, filter.searchCooldown);
// Step 5: Apply selector
const selector = getSelector(filter.selector);
const selectedItems: UpgradeItem[] = selector
? selector.select(afterCooldown, filter.count)
: afterCooldown.slice(0, filter.count);
// Build selection info
const selectionItems = selectedItems.map((item) => ({
id: item.id,
title: item.title
}));
// Step 6: Trigger search if we have items (skip if dry run)
let searchesTriggered = 0;
let successful = 0;
let failed = 0;
const errors: string[] = [];
const isDryRun = config.dryRun;
if (selectedItems.length > 0) {
if (isDryRun) {
// Dry run - log what would happen without actually doing it
searchesTriggered = selectedItems.length;
successful = selectedItems.length;
errors.push('[DRY RUN] Search and tagging skipped');
} else {
try {
const movieIds = selectedItems.map((item) => item.id);
await client.searchMovies(movieIds);
searchesTriggered = movieIds.length;
// Step 7: Apply search tag
const tagLabel = getTodayTagLabel();
const searchTag = await client.getOrCreateTag(tagLabel);
const tagResult = await applySearchTagToMovies(
client,
selectedItems.map((item) => item._raw),
searchTag.id
);
successful = tagResult.success;
failed = tagResult.failed;
errors.push(...tagResult.errors);
} catch (error) {
failed = selectedItems.length;
errors.push(`Search failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
// Build the log
const completedAt = new Date();
const log: UpgradeJobLog = {
id: logId,
configId: config.id ?? 0,
instanceId: instance.id,
instanceName: instance.name,
startedAt: startedAt.toISOString(),
completedAt: completedAt.toISOString(),
status: failed > 0 && successful === 0 ? 'failed' : failed > 0 ? 'partial' : 'success',
config: {
schedule: config.schedule,
filterMode: config.filterMode,
selectedFilter: filter.name,
dryRun: isDryRun
},
library: {
totalItems: movies.length,
fetchedFromCache: false, // TODO: implement caching
fetchDurationMs
},
filter: {
name: filter.name,
rules: filter.group,
matchedCount: matchedItems.length,
afterCooldown: afterCooldown.length
},
selection: {
method: filter.selector,
requestedCount: filter.count,
actualCount: selectedItems.length,
items: selectionItems
},
results: {
searchesTriggered,
successful,
failed,
errors
}
};
await logUpgradeRun(log);
return log;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await logUpgradeError(instance.id, instance.name, errorMessage);
const log = createSkippedLog(config, instance, errorMessage);
log.id = logId;
log.startedAt = startedAt.toISOString();
log.completedAt = new Date().toISOString();
log.status = 'failed';
log.config.selectedFilter = filter?.name ?? '';
return log;
} finally {
client.close();
}
}

View File

@@ -0,0 +1,100 @@
/**
* Types for the upgrade processing system
*/
import type { RadarrMovie } from '$lib/server/utils/arr/types.ts';
import type { FilterGroup } from '$lib/shared/filters.ts';
/**
* Normalized item interface that matches filter field names
* Used for evaluating filter rules against library items
*/
export interface UpgradeItem {
// Core fields
id: number;
title: string;
year: number;
monitored: boolean;
cutoff_met: boolean;
minimum_availability: string;
quality_profile: string;
collection: string;
studio: string;
original_language: string;
genres: string;
keywords: string;
release_group: string;
popularity: number;
runtime: number;
size_on_disk: number;
tmdb_rating: number;
imdb_rating: number;
tomato_rating: number;
trakt_rating: number;
date_added: string;
// For selectors (camelCase versions)
dateAdded: string;
score: number;
// Original data for API calls
_raw: RadarrMovie;
_tags: number[];
}
/**
* Structured log for each upgrade run
* Contains all metrics and details about what happened
*/
export interface UpgradeJobLog {
id: string; // UUID
configId: number;
instanceId: number;
instanceName: string;
startedAt: string;
completedAt: string;
status: 'success' | 'partial' | 'failed' | 'skipped';
config: {
schedule: number;
filterMode: string;
selectedFilter: string;
dryRun: boolean;
};
library: {
totalItems: number;
fetchedFromCache: boolean;
fetchDurationMs: number;
};
filter: {
name: string;
rules: FilterGroup;
matchedCount: number;
afterCooldown: number;
};
selection: {
method: string;
requestedCount: number;
actualCount: number;
items: { id: number; title: string }[];
};
results: {
searchesTriggered: number;
successful: number;
failed: number;
errors: string[];
};
}
/**
* Result from processing a single upgrade config
*/
export interface UpgradeProcessResult {
success: boolean;
log: UpgradeJobLog;
error?: string;
}

View File

@@ -6,7 +6,9 @@ import type {
RadarrLibraryItem,
ScoreBreakdownItem,
CustomFormatRef,
QualityProfileFormatItem
QualityProfileFormatItem,
RadarrTag,
RadarrCommand
} from '../types.ts';
/**
@@ -107,6 +109,8 @@ export class RadarrClient extends BaseArrClient {
qualityProfileId: movie.qualityProfileId,
qualityProfileName: profileName,
hasFile: movie.hasFile,
dateAdded: movie.added,
popularity: movie.popularity,
customFormats,
customFormatScore,
qualityName: movieFile?.quality?.quality?.name,
@@ -122,4 +126,62 @@ export class RadarrClient extends BaseArrClient {
return libraryItems;
}
// =========================================================================
// Search Methods
// =========================================================================
/**
* Trigger a search for specific movies
* Uses the MoviesSearch command endpoint
*/
searchMovies(movieIds: number[]): Promise<RadarrCommand> {
return this.post<RadarrCommand>(`/api/${this.apiVersion}/command`, {
name: 'MoviesSearch',
movieIds
});
}
// =========================================================================
// Tag Methods
// =========================================================================
/**
* Get all tags
*/
getTags(): Promise<RadarrTag[]> {
return this.get<RadarrTag[]>(`/api/${this.apiVersion}/tag`);
}
/**
* Create a new tag
*/
createTag(label: string): Promise<RadarrTag> {
return this.post<RadarrTag>(`/api/${this.apiVersion}/tag`, { label });
}
/**
* Get a tag by label, or create it if it doesn't exist
*/
async getOrCreateTag(label: string): Promise<RadarrTag> {
const tags = await this.getTags();
const existing = tags.find((t) => t.label.toLowerCase() === label.toLowerCase());
if (existing) {
return existing;
}
return this.createTag(label);
}
// =========================================================================
// Movie Update Methods
// =========================================================================
/**
* Update a movie (used for adding/removing tags)
*/
updateMovie(movie: RadarrMovie): Promise<RadarrMovie> {
return this.put<RadarrMovie>(`/api/${this.apiVersion}/movie/${movie.id}`, movie);
}
}

View File

@@ -29,6 +29,8 @@ export interface RadarrMovie {
ratings?: {
imdb?: { votes: number; value: number };
tmdb?: { votes: number; value: number };
rottenTomatoes?: { votes: number; value: number };
trakt?: { votes: number; value: number };
};
genres?: string[];
overview?: string;
@@ -38,6 +40,17 @@ export interface RadarrMovie {
rootFolderPath?: string;
sizeOnDisk?: number;
status?: string;
tags?: number[];
collection?: {
title?: string;
name?: string; // Deprecated, use title
tmdbId?: number;
};
popularity?: number;
originalLanguage?: {
id: number;
name: string;
};
}
/**
@@ -117,6 +130,32 @@ export interface RadarrQualityProfile {
}[];
}
/**
* Tag from /api/v3/tag
*/
export interface RadarrTag {
id: number;
label: string;
}
/**
* Command response from /api/v3/command
*/
export interface RadarrCommand {
id: number;
name: string;
commandName: string;
status: 'queued' | 'started' | 'completed' | 'failed' | string;
queued?: string;
started?: string;
ended?: string;
message?: string;
body?: {
movieIds?: number[];
sendUpdatesToClient?: boolean;
};
}
// =============================================================================
// Library View Types (computed/joined data)
// =============================================================================
@@ -141,6 +180,8 @@ export interface RadarrLibraryItem {
qualityProfileId: number;
qualityProfileName: string;
hasFile: boolean;
dateAdded?: string;
popularity?: number;
// From /moviefile (only if hasFile)
customFormats: CustomFormatRef[];

View File

@@ -183,3 +183,72 @@ export function parseLogLine(line: string): LogEntry | null {
return null;
}
}
/**
* Filter options for reading logs
*/
export interface LogFilterOptions {
source?: string;
instanceId?: number;
count?: number;
}
/**
* Read logs filtered by source and/or instanceId
* @param options Filter options
* @returns Array of filtered log entries (newest first)
*/
export async function readFilteredLogs(
options: LogFilterOptions = {}
): Promise<LogEntry[]> {
const { source, instanceId, count = 500 } = options;
try {
const logFiles = await getLogFiles();
const logs: LogEntry[] = [];
// Read from newest files first
for (const filePath of logFiles) {
try {
const content = await Deno.readTextFile(filePath);
const lines = content.split("\n").filter((line) => line.trim());
// Parse each line as JSON
for (const line of lines) {
try {
const entry = JSON.parse(line) as LogEntry;
// Apply filters
if (source && entry.source !== source) {
continue;
}
if (instanceId !== undefined && entry.meta) {
const meta = entry.meta as Record<string, unknown>;
if (meta.instanceId !== instanceId) {
continue;
}
}
logs.push(entry);
} catch {
// Skip invalid JSON lines
}
}
} catch {
// Skip files we can't read
}
}
// Sort by timestamp (newest first)
logs.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// Return first N entries
return logs.slice(0, count);
} catch (_error) {
// If anything fails, return empty array
return [];
}
}

View File

@@ -8,8 +8,10 @@ export interface FilterOperator {
label: string;
}
export type FilterValueType = string | number | boolean | null;
export interface FilterValue {
value: any;
value: FilterValueType;
label: string;
}
@@ -25,7 +27,7 @@ export interface FilterRule {
type: 'rule';
field: string;
operator: string;
value: any;
value: FilterValueType;
}
export interface FilterGroup {
@@ -51,6 +53,7 @@ export interface UpgradeConfig {
id?: number;
arrInstanceId: number;
enabled: boolean;
dryRun: boolean;
schedule: number; // minutes
filterMode: FilterMode;
filters: FilterConfig[];
@@ -309,6 +312,7 @@ export function createEmptyUpgradeConfig(arrInstanceId: number): UpgradeConfig {
return {
arrInstanceId,
enabled: false,
dryRun: false,
schedule: 360, // 6 hours
filterMode: 'round_robin',
filters: [],
@@ -342,3 +346,131 @@ export function isRule(child: FilterRule | FilterGroup): child is FilterRule {
export function isGroup(child: FilterRule | FilterGroup): child is FilterGroup {
return child.type === 'group';
}
/**
* Evaluate a single filter rule against an item
*/
export function evaluateRule(item: Record<string, unknown>, rule: FilterRule): boolean {
const fieldValue = item[rule.field];
const ruleValue = rule.value;
// Handle null/undefined field values
if (fieldValue === null || fieldValue === undefined) {
// For 'is_not' or negation operators, null means "not equal" so return true
if (['is_not', 'neq', 'not_contains'].includes(rule.operator)) {
return true;
}
return false;
}
switch (rule.operator) {
// Boolean operators
case 'is':
return fieldValue === ruleValue;
case 'is_not':
return fieldValue !== ruleValue;
// Number operators
case 'eq':
if (typeof fieldValue === 'string' && typeof ruleValue === 'string') {
return fieldValue.toLowerCase() === ruleValue.toLowerCase();
}
return fieldValue === ruleValue;
case 'neq':
if (typeof fieldValue === 'string' && typeof ruleValue === 'string') {
return fieldValue.toLowerCase() !== ruleValue.toLowerCase();
}
return fieldValue !== ruleValue;
case 'gt':
return typeof fieldValue === 'number' && typeof ruleValue === 'number' && fieldValue > ruleValue;
case 'gte':
return typeof fieldValue === 'number' && typeof ruleValue === 'number' && fieldValue >= ruleValue;
case 'lt':
return typeof fieldValue === 'number' && typeof ruleValue === 'number' && fieldValue < ruleValue;
case 'lte':
return typeof fieldValue === 'number' && typeof ruleValue === 'number' && fieldValue <= ruleValue;
// Text operators (case-insensitive)
case 'contains': {
const strField = String(fieldValue).toLowerCase();
const strRule = String(ruleValue).toLowerCase();
return strField.includes(strRule);
}
case 'not_contains': {
const strField = String(fieldValue).toLowerCase();
const strRule = String(ruleValue).toLowerCase();
return !strField.includes(strRule);
}
case 'starts_with': {
const strField = String(fieldValue).toLowerCase();
const strRule = String(ruleValue).toLowerCase();
return strField.startsWith(strRule);
}
case 'ends_with': {
const strField = String(fieldValue).toLowerCase();
const strRule = String(ruleValue).toLowerCase();
return strField.endsWith(strRule);
}
// Date operators
case 'before': {
const fieldDate = new Date(fieldValue as string);
const ruleDate = new Date(ruleValue as string);
return fieldDate < ruleDate;
}
case 'after': {
const fieldDate = new Date(fieldValue as string);
const ruleDate = new Date(ruleValue as string);
return fieldDate > ruleDate;
}
case 'in_last': {
// ruleValue is number of days/hours depending on context
const fieldDate = new Date(fieldValue as string);
const now = new Date();
const diffMs = now.getTime() - fieldDate.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
return diffDays <= (ruleValue as number);
}
case 'not_in_last': {
const fieldDate = new Date(fieldValue as string);
const now = new Date();
const diffMs = now.getTime() - fieldDate.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
return diffDays > (ruleValue as number);
}
default:
return false;
}
}
/**
* Evaluate a filter group against an item
* Supports nested groups with AND/OR logic
*/
export function evaluateGroup(item: Record<string, unknown>, group: FilterGroup): boolean {
if (group.children.length === 0) {
// Empty group matches everything
return true;
}
if (group.match === 'all') {
// AND logic: all children must match
return group.children.every((child) => {
if (isRule(child)) {
return evaluateRule(item, child);
} else {
return evaluateGroup(item, child);
}
});
} else {
// OR logic: any child must match
return group.children.some((child) => {
if (isRule(child)) {
return evaluateRule(item, child);
} else {
return evaluateGroup(item, child);
}
});
}
}

View File

@@ -111,14 +111,14 @@ export const load: ServerLoad = async ({ params }) => {
};
export const actions: Actions = {
refresh: async ({ params }) => {
refresh: ({ params }) => {
const id = parseInt(params.id || '', 10);
if (!isNaN(id)) {
cache.delete(`library:${id}`);
}
return { success: true };
},
delete: async ({ params }) => {
delete: ({ params }) => {
const id = parseInt(params.id || '', 10);
if (!isNaN(id)) {
arrInstancesQueries.delete(id);

View File

@@ -1,64 +1,162 @@
<script lang="ts">
import { AlertTriangle, Check, Film, ExternalLink, CircleAlert, RefreshCw, HardDrive, CheckCircle, ArrowUpCircle, Pencil, Trash2, FolderSync, Database } from 'lucide-svelte';
import { AlertTriangle, Film } from 'lucide-svelte';
import { alertStore } from '$alerts/store';
import { enhance } from '$app/forms';
import { browser } from '$app/environment';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import type { Column } from '$ui/table/types';
import type { Column, SortState } from '$ui/table/types';
import type { PageData } from './$types';
import type { RadarrLibraryItem } from '$utils/arr/types.ts';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import { createSearchStore } from '$stores/search';
import LibraryHeader from './components/LibraryHeader.svelte';
import LibraryActionBar from './components/LibraryActionBar.svelte';
import MovieRow from './components/MovieRow.svelte';
export let data: PageData;
let refreshing = false;
const searchStore = createSearchStore({ debounceMs: 150 });
// ==========================================================================
// Column Visibility
// ==========================================================================
const STORAGE_KEY = 'profilarr-library-columns';
const TOGGLEABLE_COLUMNS = ['qualityName', 'customFormatScore', 'progress', 'popularity', 'dateAdded'] as const;
type ToggleableColumn = typeof TOGGLEABLE_COLUMNS[number];
function loadColumnVisibility(): Set<ToggleableColumn> {
if (!browser) return new Set(TOGGLEABLE_COLUMNS);
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as ToggleableColumn[];
return new Set(parsed);
}
} catch {}
return new Set(TOGGLEABLE_COLUMNS);
}
function saveColumnVisibility(visible: Set<ToggleableColumn>) {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify([...visible]));
}
let visibleColumns = loadColumnVisibility();
function toggleColumn(key: string) {
const colKey = key as ToggleableColumn;
if (visibleColumns.has(colKey)) {
visibleColumns.delete(colKey);
} else {
visibleColumns.add(colKey);
}
visibleColumns = visibleColumns;
saveColumnVisibility(visibleColumns);
}
const columnLabels: Record<ToggleableColumn, string> = {
qualityName: 'Quality',
customFormatScore: 'Score',
progress: 'Progress',
popularity: 'Popularity',
dateAdded: 'Added'
};
// ==========================================================================
// Filter System
// ==========================================================================
type FilterOperator = 'eq' | 'neq';
type FilterField = 'qualityName' | 'qualityProfileName';
interface ActiveFilter {
field: FilterField;
operator: FilterOperator;
value: string | number | boolean;
label: string;
}
let activeFilters: ActiveFilter[] = [];
$: uniqueQualities = [...new Set(data.library.filter(m => m.qualityName).map(m => m.qualityName!))].sort();
$: uniqueProfiles = [...new Set(data.library.map(m => m.qualityProfileName))].sort();
function toggleFilter(field: FilterField, operator: FilterOperator, value: string | number | boolean, label: string) {
const existingIndex = activeFilters.findIndex(f => f.field === field && f.value === value);
if (existingIndex >= 0) {
activeFilters = activeFilters.filter((_, i) => i !== existingIndex);
} else {
activeFilters = [...activeFilters, { field, operator, value, label }];
}
}
function applyFilters(items: RadarrLibraryItem[]): RadarrLibraryItem[] {
if (activeFilters.length === 0) return items;
const filtersByField = new Map<FilterField, ActiveFilter[]>();
for (const filter of activeFilters) {
const existing = filtersByField.get(filter.field) || [];
existing.push(filter);
filtersByField.set(filter.field, existing);
}
return items.filter(item => {
return [...filtersByField.entries()].every(([field, filters]) => {
const itemValue = item[field];
return filters.some(filter => {
if (filter.operator === 'eq') return itemValue === filter.value;
if (filter.operator === 'neq') return itemValue !== filter.value;
return true;
});
});
});
}
function handleChangeProfile(databaseName: string, profileName: string) {
const count = moviesWithFiles.length;
// TODO: Implement actual profile change functionality
alertStore.add('success', `Changing ${count} movies to "${profileName}" from ${databaseName}`);
}
// Get base URL without trailing slash
// ==========================================================================
// Data & Columns
// ==========================================================================
$: baseUrl = data.instance.url.replace(/\/$/, '');
// Subscribe to debounced query for reactivity
$: debouncedQuery = $searchStore.query;
$: allMoviesWithFiles = data.library.filter((m) => m.hasFile);
// Filter movies by search query
$: moviesWithFiles = data.library
.filter((m) => m.hasFile)
.filter((m) => !debouncedQuery || m.title.toLowerCase().includes(debouncedQuery.toLowerCase()));
$: moviesWithFiles = (() => {
const filters = activeFilters;
let result = allMoviesWithFiles
.filter((m) => !debouncedQuery || m.title.toLowerCase().includes(debouncedQuery.toLowerCase()));
return applyFilters(result);
})();
// Get progress bar color
function getProgressColor(progress: number, cutoffMet: boolean): string {
if (cutoffMet) return 'bg-green-500 dark:bg-green-400';
if (progress >= 0.75) return 'bg-yellow-500 dark:bg-yellow-400';
if (progress >= 0.5) return 'bg-orange-500 dark:bg-orange-400';
return 'bg-red-500 dark:bg-red-400';
}
const columns: Column<RadarrLibraryItem>[] = [
{ key: 'title', header: 'Title', align: 'left' },
{ key: 'qualityProfileName', header: 'Profile', align: 'left', width: 'w-40' },
{ key: 'qualityName', header: 'Quality', align: 'left', width: 'w-32' },
{ key: 'customFormatScore', header: 'Score', align: 'right', width: 'w-32' },
{ key: 'progress', header: 'Progress', align: 'center', width: 'w-48' },
{ key: 'actions', header: 'Actions', align: 'center', width: 'w-20' }
const allColumns: Column<RadarrLibraryItem>[] = [
{ key: 'title', header: 'Title', align: 'left', sortable: true },
{ key: 'qualityProfileName', header: 'Profile', align: 'left', width: 'w-40', sortable: true },
{ key: 'qualityName', header: 'Quality', align: 'left', width: 'w-32', sortable: true },
{ key: 'customFormatScore', header: 'Score', align: 'right', width: 'w-28', sortable: true, defaultSortDirection: 'desc' },
{ key: 'progress', header: 'Progress', align: 'center', width: 'w-40', sortable: true, sortAccessor: (row) => row.progress, defaultSortDirection: 'desc' },
{ key: 'popularity', header: 'Popularity', align: 'right', width: 'w-24', sortable: true, defaultSortDirection: 'desc' },
{ key: 'dateAdded', header: 'Added', align: 'right', width: 'w-28', sortable: true, sortAccessor: (row) => row.dateAdded ? new Date(row.dateAdded).getTime() : 0, defaultSortDirection: 'desc' },
{ key: 'actions', header: '', align: 'center', width: 'w-12' }
];
$: columns = allColumns.filter(col =>
col.key === 'title' ||
col.key === 'qualityProfileName' ||
col.key === 'actions' ||
visibleColumns.has(col.key as ToggleableColumn)
);
const defaultSort: SortState = { key: 'title', direction: 'asc' };
</script>
<svelte:head>
<title>{data.instance.name} - Library - Profilarr</title>
</svelte:head>
<div class="mt-6 space-y-6">
{#if data.libraryError}
<div class="rounded-lg border border-red-200 bg-red-50 p-6 dark:border-red-800 dark:bg-red-950/40">
@@ -82,7 +180,7 @@
</div>
</div>
</div>
{:else if moviesWithFiles.length === 0}
{:else if allMoviesWithFiles.length === 0}
<div class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<div class="flex items-center gap-3">
<Film class="h-5 w-5 text-neutral-400" />
@@ -95,232 +193,41 @@
</div>
</div>
{:else}
<!-- Header with inline stats -->
<div class="flex items-center justify-between rounded-lg border border-neutral-200 bg-white px-4 py-3 dark:border-neutral-800 dark:bg-neutral-900">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">{data.instance.name}</h2>
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800 dark:bg-orange-900/30 dark:text-orange-400 capitalize">
{data.instance.type}
</span>
<div class="hidden sm:flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Total movies in library">
<Film size={12} class="text-blue-500" />
{data.library.length} Total
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Movies with files on disk">
<HardDrive size={12} class="text-purple-500" />
{moviesWithFiles.length} On Disk
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Movies that have met the quality cutoff">
<CheckCircle size={12} class="text-green-500" />
{moviesWithFiles.filter((m) => m.cutoffMet).length} Cutoff Met
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Movies that can still be upgraded">
<ArrowUpCircle size={12} class="text-orange-500" />
{moviesWithFiles.filter((m) => !m.cutoffMet).length} Upgradeable
</span>
</div>
</div>
<code class="text-xs font-mono text-neutral-500 dark:text-neutral-400">{data.instance.url}</code>
</div>
<div class="flex items-center gap-2">
<form
method="POST"
action="?/refresh"
use:enhance={() => {
refreshing = true;
return async ({ update }) => {
await update();
refreshing = false;
};
}}
>
<button
type="submit"
disabled={refreshing}
class="inline-flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<RefreshCw size={14} class={refreshing ? 'animate-spin' : ''} />
Refresh
</button>
</form>
<a
href={baseUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
Open Radarr
<ExternalLink size={14} />
</a>
<a
href="/arr/{data.instance.id}/edit"
class="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-700 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
title="Edit instance"
>
<Pencil size={14} />
</a>
<form
method="POST"
action="?/delete"
use:enhance={() => {
if (!confirm('Are you sure you want to delete this instance?')) {
return ({ cancel }) => cancel();
}
return async ({ update }) => {
await update();
};
}}
>
<button
type="submit"
class="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-red-200 bg-red-50 text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
title="Delete instance"
>
<Trash2 size={14} />
</button>
</form>
</div>
</div>
<LibraryHeader
instance={data.instance}
library={data.library}
{allMoviesWithFiles}
/>
<!-- Actions Bar -->
<ActionsBar>
<SearchAction {searchStore} placeholder="Search movies..." />
<ActionButton icon={FolderSync} hasDropdown={true} dropdownPosition="right" square={false}>
<span class="ml-2 text-sm text-neutral-700 dark:text-neutral-300">Change Profile</span>
<span class="ml-1 text-xs text-neutral-500 dark:text-neutral-400">({moviesWithFiles.length})</span>
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="16rem">
<div class="max-h-80 overflow-y-auto py-1">
{#if data.profilesByDatabase.length === 0}
<div class="px-4 py-3 text-sm text-neutral-500 dark:text-neutral-400">
No databases configured
</div>
{:else}
{#each data.profilesByDatabase as db}
<div class="border-b border-neutral-100 dark:border-neutral-700 last:border-b-0">
<div class="px-3 py-2 bg-neutral-50 dark:bg-neutral-900 flex items-center gap-2">
<Database size={12} class="text-neutral-400" />
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{db.databaseName}
</span>
</div>
{#each db.profiles as profile}
<button
type="button"
on:click={() => handleChangeProfile(db.databaseName, profile)}
class="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
{profile}
</button>
{/each}
</div>
{/each}
{/if}
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
</ActionsBar>
<LibraryActionBar
{searchStore}
visibleColumns={new Set([...visibleColumns])}
toggleableColumns={TOGGLEABLE_COLUMNS}
{columnLabels}
{activeFilters}
{uniqueQualities}
{uniqueProfiles}
profilesByDatabase={data.profilesByDatabase}
filteredCount={moviesWithFiles.length}
onToggleColumn={toggleColumn}
onToggleFilter={toggleFilter}
onChangeProfile={handleChangeProfile}
/>
<!-- Library Table -->
<ExpandableTable {columns} data={moviesWithFiles} getRowId={(row) => row.id} compact={true}>
<ExpandableTable
{columns}
data={moviesWithFiles}
getRowId={(row) => row.id}
compact={true}
{defaultSort}
emptyMessage={activeFilters.length > 0 || debouncedQuery ? 'No movies match the current filters' : 'No movies with files'}
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'title'}
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-50">{row.title}</div>
{#if row.year}
<div class="text-xs text-neutral-500 dark:text-neutral-400">{row.year}</div>
{/if}
</div>
{:else if column.key === 'qualityProfileName'}
<div class="relative group inline-flex">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium {row.isProfilarrProfile ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400'}"
>
{#if !row.isProfilarrProfile}
<CircleAlert size={12} />
{/if}
{row.qualityProfileName}
</span>
{#if !row.isProfilarrProfile}
<div class="absolute left-1/2 -translate-x-1/2 bottom-full mb-1 px-2 py-1 text-xs text-white bg-neutral-800 dark:bg-neutral-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-10">
Not managed by Profilarr
</div>
{/if}
</div>
{:else if column.key === 'qualityName'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{row.qualityName ?? 'N/A'}
</code>
{:else if column.key === 'customFormatScore'}
<div class="text-right">
<span class="font-mono font-medium {row.cutoffMet ? 'text-green-600 dark:text-green-400' : 'text-neutral-900 dark:text-neutral-100'}">
{row.customFormatScore.toLocaleString()}
</span>
<span class="text-xs text-neutral-500 dark:text-neutral-400">
/ {row.cutoffScore.toLocaleString()}
</span>
</div>
{:else if column.key === 'progress'}
<div class="flex items-center gap-2">
<div class="flex-1 h-2 rounded-full bg-neutral-200 dark:bg-neutral-700 overflow-hidden">
<div
class="h-full rounded-full transition-all {getProgressColor(row.progress, row.cutoffMet)}"
style="width: {Math.min(row.progress * 100, 100)}%"
></div>
</div>
{#if row.cutoffMet}
<Check size={16} class="text-green-600 dark:text-green-400 flex-shrink-0" />
{:else}
<span class="text-xs font-mono text-neutral-500 dark:text-neutral-400 w-10 text-right">
{Math.round(row.progress * 100)}%
</span>
{/if}
</div>
{:else if column.key === 'actions'}
<div class="flex items-center justify-center">
{#if row.tmdbId}
<a
href="{baseUrl}/movie/{row.tmdbId}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
title="Open in Radarr"
on:click|stopPropagation
>
<ExternalLink size={14} />
</a>
{/if}
</div>
{/if}
<MovieRow {row} {column} {baseUrl} mode="cell" />
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
<div class="flex flex-col gap-3">
<!-- File Name -->
{#if row.fileName}
<code class="text-xs font-mono text-neutral-600 dark:text-neutral-400 break-all">{row.fileName}</code>
{/if}
<!-- Custom Formats with Scores (sorted by score descending) -->
{#if row.scoreBreakdown.length > 0}
<div class="flex flex-wrap items-center gap-2">
{#each row.scoreBreakdown.toSorted((a, b) => b.score - a.score) as item}
<div class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs {item.score > 0 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : item.score < 0 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' : 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'}">
<span class="font-medium">{item.name}</span>
<span class="font-mono">{item.score >= 0 ? '+' : ''}{item.score.toLocaleString()}</span>
</div>
{/each}
<span class="text-xs text-neutral-500 dark:text-neutral-400">
= <span class="font-mono font-medium">{row.customFormatScore.toLocaleString()}</span>
</span>
</div>
{:else}
<div class="text-xs text-neutral-500 dark:text-neutral-400">No custom formats matched</div>
{/if}
</div>
<MovieRow {row} column={allColumns[0]} {baseUrl} mode="expanded" />
</svelte:fragment>
</ExpandableTable>
{/if}

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import { Check, Columns, Filter, FolderSync, Database } from 'lucide-svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import { type SearchStore } from '$stores/search';
type FilterOperator = 'eq' | 'neq';
type FilterField = 'qualityName' | 'qualityProfileName';
interface ActiveFilter {
field: FilterField;
operator: FilterOperator;
value: string | number | boolean;
label: string;
}
interface DatabaseProfiles {
databaseId: number;
databaseName: string;
profiles: string[];
}
export let searchStore: SearchStore;
export let visibleColumns: Set<string>;
export let toggleableColumns: readonly string[];
export let columnLabels: Record<string, string>;
export let activeFilters: ActiveFilter[];
export let uniqueQualities: string[];
export let uniqueProfiles: string[];
export let profilesByDatabase: DatabaseProfiles[];
export let filteredCount: number;
export let onToggleColumn: (key: string) => void;
export let onToggleFilter: (field: FilterField, operator: FilterOperator, value: string | number | boolean, label: string) => void;
export let onChangeProfile: (databaseName: string, profileName: string) => void;
</script>
<ActionsBar>
<SearchAction {searchStore} placeholder="Search movies..." />
<ActionButton icon={Columns} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="10rem">
<div class="py-1">
{#each toggleableColumns as colKey}
<button
type="button"
on:click={() => onToggleColumn(colKey)}
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {visibleColumns.has(colKey) ? 'bg-neutral-50 dark:bg-neutral-700' : ''}"
>
<span class="text-neutral-700 dark:text-neutral-300">{columnLabels[colKey]}</span>
<IconCheckbox
checked={visibleColumns.has(colKey)}
icon={Check}
color="blue"
shape="circle"
/>
</button>
{/each}
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
<ActionButton icon={Filter} hasDropdown={true} dropdownPosition="right">
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="14rem">
<div class="max-h-96 overflow-y-auto">
<!-- Quality Filter -->
<div class="border-b border-neutral-100 dark:border-neutral-700">
<div class="px-3 py-2 bg-neutral-50 dark:bg-neutral-800">
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Quality</span>
</div>
{#each uniqueQualities as quality}
<button
type="button"
on:click={() => onToggleFilter('qualityName', 'eq', quality, quality)}
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {activeFilters.find(f => f.field === 'qualityName' && f.value === quality) ? 'bg-neutral-50 dark:bg-neutral-700' : ''}"
>
<span class="text-neutral-700 dark:text-neutral-300">{quality}</span>
<IconCheckbox
checked={!!activeFilters.find(f => f.field === 'qualityName' && f.value === quality)}
icon={Check}
color="blue"
shape="circle"
/>
</button>
{/each}
</div>
<!-- Profile Filter -->
<div>
<div class="px-3 py-2 bg-neutral-50 dark:bg-neutral-800">
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Profile</span>
</div>
{#each uniqueProfiles as profile}
<button
type="button"
on:click={() => onToggleFilter('qualityProfileName', 'eq', profile, profile)}
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-sm transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700 {activeFilters.find(f => f.field === 'qualityProfileName' && f.value === profile) ? 'bg-neutral-50 dark:bg-neutral-700' : ''}"
>
<span class="text-neutral-700 dark:text-neutral-300">{profile}</span>
<IconCheckbox
checked={!!activeFilters.find(f => f.field === 'qualityProfileName' && f.value === profile)}
icon={Check}
color="blue"
shape="circle"
/>
</button>
{/each}
</div>
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
<ActionButton icon={FolderSync} hasDropdown={true} dropdownPosition="right" square={false}>
<span class="ml-2 text-sm text-neutral-700 dark:text-neutral-300">Change Profile</span>
<span class="ml-1 text-xs text-neutral-500 dark:text-neutral-400">({filteredCount})</span>
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
<Dropdown position={dropdownPosition} {open} minWidth="16rem">
<div class="max-h-80 overflow-y-auto py-1">
{#if profilesByDatabase.length === 0}
<div class="px-4 py-3 text-sm text-neutral-500 dark:text-neutral-400">
No databases configured
</div>
{:else}
{#each profilesByDatabase as db}
<div class="border-b border-neutral-100 dark:border-neutral-700 last:border-b-0">
<div class="px-3 py-2 bg-neutral-50 dark:bg-neutral-900 flex items-center gap-2">
<Database size={12} class="text-neutral-400" />
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{db.databaseName}
</span>
</div>
{#each db.profiles as profile}
<button
type="button"
on:click={() => onChangeProfile(db.databaseName, profile)}
class="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
{profile}
</button>
{/each}
</div>
{/each}
{/if}
</div>
</Dropdown>
</svelte:fragment>
</ActionButton>
</ActionsBar>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { Film, ExternalLink, RefreshCw, HardDrive, CheckCircle, ArrowUpCircle, Pencil, Trash2 } from 'lucide-svelte';
import { enhance } from '$app/forms';
import type { RadarrLibraryItem } from '$utils/arr/types.ts';
export let instance: { id: number; name: string; type: string; url: string };
export let library: RadarrLibraryItem[];
export let allMoviesWithFiles: RadarrLibraryItem[];
let refreshing = false;
$: baseUrl = instance.url.replace(/\/$/, '');
</script>
<div class="flex items-center justify-between rounded-lg border border-neutral-200 bg-white px-4 py-3 dark:border-neutral-800 dark:bg-neutral-900">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">{instance.name}</h2>
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800 dark:bg-orange-900/30 dark:text-orange-400 capitalize">
{instance.type}
</span>
<div class="hidden sm:flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Total movies in library">
<Film size={12} class="text-blue-500" />
{library.length} Total
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Movies with files on disk">
<HardDrive size={12} class="text-purple-500" />
{allMoviesWithFiles.length} On Disk
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Movies that have met the quality cutoff">
<CheckCircle size={12} class="text-green-500" />
{allMoviesWithFiles.filter((m) => m.cutoffMet).length} Cutoff Met
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300" title="Movies that can still be upgraded">
<ArrowUpCircle size={12} class="text-orange-500" />
{allMoviesWithFiles.filter((m) => !m.cutoffMet).length} Upgradeable
</span>
</div>
</div>
<code class="text-xs font-mono text-neutral-500 dark:text-neutral-400">{instance.url}</code>
</div>
<div class="flex items-center gap-2">
<form
method="POST"
action="?/refresh"
use:enhance={() => {
refreshing = true;
return async ({ update }) => {
await update();
refreshing = false;
};
}}
>
<button
type="submit"
disabled={refreshing}
class="inline-flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<RefreshCw size={14} class={refreshing ? 'animate-spin' : ''} />
Refresh
</button>
</form>
<a
href={baseUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
Open Radarr
<ExternalLink size={14} />
</a>
<a
href="/arr/{instance.id}/edit"
class="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-700 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
title="Edit instance"
>
<Pencil size={14} />
</a>
<form
method="POST"
action="?/delete"
use:enhance={({ cancel }) => {
if (!confirm('Are you sure you want to delete this instance?')) {
cancel();
return;
}
return ({ update }) => update();
}}
>
<button
type="submit"
class="inline-flex items-center justify-center h-8 w-8 rounded-lg border border-red-200 bg-red-50 text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
title="Delete instance"
>
<Trash2 size={14} />
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { Check, ExternalLink, CircleAlert } from 'lucide-svelte';
import type { RadarrLibraryItem } from '$utils/arr/types.ts';
import type { Column } from '$ui/table/types';
export let row: RadarrLibraryItem;
export let column: Column<RadarrLibraryItem>;
export let baseUrl: string;
export let mode: 'cell' | 'expanded' = 'cell';
function getProgressColor(progress: number, cutoffMet: boolean): string {
if (cutoffMet) return 'bg-green-500 dark:bg-green-400';
if (progress >= 0.75) return 'bg-yellow-500 dark:bg-yellow-400';
if (progress >= 0.5) return 'bg-orange-500 dark:bg-orange-400';
return 'bg-red-500 dark:bg-red-400';
}
function formatDate(isoString?: string): string {
if (!isoString) return '-';
const date = new Date(isoString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' });
}
</script>
{#if mode === 'cell'}
{#if column.key === 'title'}
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-50">{row.title}</div>
{#if row.year}
<div class="text-xs text-neutral-500 dark:text-neutral-400">{row.year}</div>
{/if}
</div>
{:else if column.key === 'qualityProfileName'}
<div class="relative group inline-flex">
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium {row.isProfilarrProfile ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400'}"
>
{#if !row.isProfilarrProfile}
<CircleAlert size={12} />
{/if}
{row.qualityProfileName}
</span>
{#if !row.isProfilarrProfile}
<div class="absolute left-1/2 -translate-x-1/2 bottom-full mb-1 px-2 py-1 text-xs text-white bg-neutral-800 dark:bg-neutral-700 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-10">
Not managed by Profilarr
</div>
{/if}
</div>
{:else if column.key === 'qualityName'}
<code class="rounded bg-neutral-100 px-2 py-1 font-mono text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
{row.qualityName ?? 'N/A'}
</code>
{:else if column.key === 'customFormatScore'}
<div class="text-right">
<span class="font-mono font-medium {row.cutoffMet ? 'text-green-600 dark:text-green-400' : 'text-neutral-900 dark:text-neutral-100'}">
{row.customFormatScore.toLocaleString()}
</span>
<span class="text-xs text-neutral-500 dark:text-neutral-400">
/ {row.cutoffScore.toLocaleString()}
</span>
</div>
{:else if column.key === 'progress'}
<div class="flex items-center gap-2">
<div class="flex-1 h-2 rounded-full bg-neutral-200 dark:bg-neutral-700 overflow-hidden">
<div
class="h-full rounded-full transition-all {getProgressColor(row.progress, row.cutoffMet)}"
style="width: {Math.min(row.progress * 100, 100)}%"
></div>
</div>
{#if row.cutoffMet}
<Check size={16} class="text-green-600 dark:text-green-400 flex-shrink-0" />
{:else}
<span class="text-xs font-mono text-neutral-500 dark:text-neutral-400 w-10 text-right">
{Math.round(row.progress * 100)}%
</span>
{/if}
</div>
{:else if column.key === 'popularity'}
<span class="font-mono text-sm text-neutral-600 dark:text-neutral-400">
{row.popularity?.toFixed(1) ?? '-'}
</span>
{:else if column.key === 'dateAdded'}
<span class="text-sm text-neutral-600 dark:text-neutral-400">
{formatDate(row.dateAdded)}
</span>
{:else if column.key === 'actions'}
<div class="flex items-center justify-center">
{#if row.tmdbId}
<a
href="{baseUrl}/movie/{row.tmdbId}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
title="Open in Radarr"
on:click|stopPropagation
>
<ExternalLink size={14} />
</a>
{/if}
</div>
{/if}
{:else}
<!-- Expanded content -->
<div class="flex flex-col gap-3">
<!-- File Name -->
{#if row.fileName}
<code class="text-xs font-mono text-neutral-600 dark:text-neutral-400 break-all">{row.fileName}</code>
{/if}
<!-- Custom Formats with Scores (sorted by score descending) -->
{#if row.scoreBreakdown.length > 0}
<div class="flex flex-wrap items-center gap-2">
{#each [...row.scoreBreakdown].sort((a, b) => b.score - a.score) as item}
<div class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs {item.score > 0 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : item.score < 0 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' : 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'}">
<span class="font-medium">{item.name}</span>
<span class="font-mono">{item.score >= 0 ? '+' : ''}{item.score.toLocaleString()}</span>
</div>
{/each}
<span class="text-xs text-neutral-500 dark:text-neutral-400">
= <span class="font-mono font-medium">{row.customFormatScore.toLocaleString()}</span>
</span>
</div>
{:else}
<div class="text-xs text-neutral-500 dark:text-neutral-400">No custom formats matched</div>
{/if}
</div>
{/if}

View File

@@ -1,8 +1,36 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { readFilteredLogs } from '$logger/reader.ts';
import type { UpgradeJobLog } from '$lib/server/upgrades/types.ts';
export const load: ServerLoad = ({ params }) => {
/**
* Extract UpgradeJobLog from a DEBUG log entry
* DEBUG logs contain the full structured log in the meta field
*/
function extractUpgradeJobLog(meta: unknown): UpgradeJobLog | null {
if (!meta || typeof meta !== 'object') return null;
const log = meta as Record<string, unknown>;
// Check for required UpgradeJobLog fields
if (
typeof log.id === 'string' &&
typeof log.instanceId === 'number' &&
typeof log.status === 'string' &&
log.config &&
log.library &&
log.filter &&
log.selection &&
log.results
) {
return log as unknown as UpgradeJobLog;
}
return null;
}
export const load: ServerLoad = async ({ params }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
@@ -15,7 +43,35 @@ export const load: ServerLoad = ({ params }) => {
error(404, `Instance not found: ${id}`);
}
// Load upgrade job logs for this instance
const logs = await readFilteredLogs({
source: 'UpgradeJob',
instanceId: id,
count: 500
});
// Extract full UpgradeJobLog objects from DEBUG entries
const upgradeRuns: UpgradeJobLog[] = [];
const seenIds = new Set<string>();
for (const log of logs) {
if (log.level === 'DEBUG' && log.meta) {
const upgradeLog = extractUpgradeJobLog(log.meta);
if (upgradeLog && !seenIds.has(upgradeLog.id)) {
seenIds.add(upgradeLog.id);
upgradeRuns.push(upgradeLog);
}
}
}
// Sort by startedAt (newest first)
upgradeRuns.sort(
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
);
return {
instance
instance,
logs,
upgradeRuns
};
};

View File

@@ -1,21 +1,122 @@
<script lang="ts">
import { RefreshCw } from 'lucide-svelte';
import type { PageData } from './$types';
import UpgradeRunCard from './components/UpgradeRunCard.svelte';
export let data: PageData;
// Filter state
type StatusFilter = 'all' | 'success' | 'partial' | 'failed' | 'skipped';
let statusFilter: StatusFilter = 'all';
const statusFilters: { value: StatusFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'success', label: 'Success' },
{ value: 'partial', label: 'Partial' },
{ value: 'failed', label: 'Failed' },
{ value: 'skipped', label: 'Skipped' }
];
// Filtered runs
$: filteredRuns = data.upgradeRuns.filter((run) => {
if (statusFilter === 'all') return true;
return run.status === statusFilter;
});
// Stats
$: stats = {
total: data.upgradeRuns.length,
success: data.upgradeRuns.filter((r) => r.status === 'success').length,
partial: data.upgradeRuns.filter((r) => r.status === 'partial').length,
failed: data.upgradeRuns.filter((r) => r.status === 'failed').length,
skipped: data.upgradeRuns.filter((r) => r.status === 'skipped').length
};
function refreshLogs() {
window.location.reload();
}
</script>
<svelte:head>
<title>{data.instance.name} - Logs - Profilarr</title>
</svelte:head>
<div class="mt-6">
<div class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Logs</h2>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
View sync and activity logs for this {data.instance.type} instance.
</p>
<div class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">
Logs viewer coming soon...
<div class="mt-6 space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-50">Upgrade Logs</h1>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
View upgrade job history for this {data.instance.type} instance.
</p>
</div>
<button
type="button"
on:click={refreshLogs}
class="flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
<!-- Stats & Filters -->
<div class="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<!-- Stats -->
<div class="flex items-center gap-6 text-sm">
<div class="text-neutral-600 dark:text-neutral-400">
<span class="font-medium text-neutral-900 dark:text-neutral-100">{stats.total}</span> total runs
</div>
{#if stats.success > 0}
<div class="text-green-600 dark:text-green-400">
<span class="font-medium">{stats.success}</span> successful
</div>
{/if}
{#if stats.partial > 0}
<div class="text-yellow-600 dark:text-yellow-400">
<span class="font-medium">{stats.partial}</span> partial
</div>
{/if}
{#if stats.failed > 0}
<div class="text-red-600 dark:text-red-400">
<span class="font-medium">{stats.failed}</span> failed
</div>
{/if}
</div>
<!-- Filter Buttons -->
<div class="flex gap-2">
{#each statusFilters as filter}
<button
type="button"
on:click={() => (statusFilter = filter.value)}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {statusFilter ===
filter.value
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'border border-neutral-300 bg-white text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700'}"
>
{filter.label}
</button>
{/each}
</div>
</div>
<!-- Upgrade Runs -->
{#if filteredRuns.length === 0}
<div class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900">
<p class="text-neutral-600 dark:text-neutral-400">
{#if data.upgradeRuns.length === 0}
No upgrade runs yet. Configure upgrades and run a test to see logs here.
{:else}
No runs match the selected filter.
{/if}
</p>
</div>
{:else}
<div class="space-y-3">
{#each filteredRuns as run, index (run.id)}
<UpgradeRunCard {run} runNumber={data.upgradeRuns.length - data.upgradeRuns.indexOf(run)} />
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,216 @@
<script lang="ts">
import { ChevronDown, Check, AlertTriangle, X, Zap } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import type { UpgradeJobLog } from '$lib/server/upgrades/types.ts';
export let run: UpgradeJobLog;
export let runNumber: number;
let expanded = false;
// Status badge styling (card uses neutral bg)
const statusBadges = {
success: {
badge: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400',
icon: Check
},
partial: {
badge: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-400',
icon: AlertTriangle
},
failed: {
badge: 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400',
icon: X
},
skipped: {
badge: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400',
icon: X
}
};
$: badgeStyle = statusBadges[run.status] || statusBadges.failed;
$: StatusIcon = badgeStyle.icon;
// Format schedule
function formatSchedule(minutes: number): string {
if (minutes < 60) return `Every ${minutes} minutes`;
if (minutes === 60) return 'Every hour';
if (minutes < 1440) return `Every ${minutes / 60} hours`;
return 'Every day';
}
// Format date
function formatDate(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function formatTime(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
// Format duration
function formatDuration(startedAt: string, completedAt: string): string {
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}
// Format filter mode
function formatFilterMode(mode: string): string {
return mode
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
// Format selector method
function formatMethod(method: string): string {
return method
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="overflow-hidden rounded-lg border border-neutral-200 bg-white transition-shadow hover:shadow-sm dark:border-neutral-800 dark:bg-neutral-900"
>
<!-- Header -->
<div on:click={() => (expanded = !expanded)} class="flex cursor-pointer items-start justify-between gap-4 p-4">
<div class="flex-1">
<!-- Title row -->
<div class="flex items-center gap-2">
<span class="font-mono text-sm text-neutral-500 dark:text-neutral-500">#{runNumber}</span>
<span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{run.config.selectedFilter || 'Unknown Filter'}
</span>
{#if run.config.dryRun}
<span class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-400">
DRY RUN
</span>
{/if}
</div>
<!-- Subtitle -->
<div class="mt-1 flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<span>{formatDate(run.startedAt)} @ {formatTime(run.startedAt)}</span>
<span class="text-neutral-300 dark:text-neutral-600">|</span>
<span class="font-mono text-xs">{formatDuration(run.startedAt, run.completedAt)}</span>
</div>
</div>
<!-- Status + Chevron -->
<div class="flex items-center gap-3">
<span class="flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium {badgeStyle.badge}">
<svelte:component this={StatusIcon} size={12} />
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
</span>
<ChevronDown
size={18}
class="text-neutral-400 transition-transform {expanded ? 'rotate-180' : ''}"
/>
</div>
</div>
<!-- Expanded Details -->
{#if expanded}
<div transition:slide={{ duration: 200 }} class="border-t border-neutral-200 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-900">
<div class="space-y-3">
<!-- Config -->
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">Config</span>
<span class="text-sm text-neutral-900 dark:text-neutral-100">
Schedule: {formatSchedule(run.config.schedule)} | Mode: {formatFilterMode(run.config.filterMode)}
</span>
</div>
<!-- Library -->
<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">
{run.library.totalItems.toLocaleString()} items
{#if run.library.fetchedFromCache}
<span class="text-neutral-500 dark:text-neutral-400">(cached)</span>
{/if}
<span class="ml-1 font-mono text-xs text-neutral-500 dark:text-neutral-400">
({run.library.fetchDurationMs}ms)
</span>
</span>
</div>
<!-- Filter -->
<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">
"{run.filter.name}"
<span class="mx-1 text-neutral-400"></span>
<span class="font-medium">{run.filter.matchedCount}</span> matched
<span class="mx-1 text-neutral-400"></span>
<span class="font-medium">{run.filter.afterCooldown}</span> after cooldown
</span>
</div>
<!-- Selection -->
<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">
{formatMethod(run.selection.method)}
<span class="font-medium">{run.selection.actualCount}</span> of {run.selection.requestedCount}
</span>
</div>
<!-- Results -->
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">Results</span>
<span class="text-sm text-neutral-900 dark:text-neutral-100">
{run.results.searchesTriggered} searches triggered,
<span class="{run.results.successful > 0 ? 'text-green-600 dark:text-green-400' : ''}">{run.results.successful} successful</span>
{#if run.results.failed > 0}
<span class="text-red-600 dark:text-red-400">, {run.results.failed} failed</span>
{/if}
</span>
</div>
<!-- Errors -->
{#if run.results.errors.length > 0}
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">Notes</span>
<div class="text-sm text-neutral-600 dark:text-neutral-400">
{#each run.results.errors as error}
<div class="italic">{error}</div>
{/each}
</div>
</div>
{/if}
<!-- Items Searched -->
{#if run.selection.items.length > 0}
<div class="mt-4 border-t border-neutral-200 pt-4 dark:border-neutral-700">
<div class="mb-2 flex items-center gap-1.5 text-sm font-medium text-neutral-700 dark:text-neutral-300">
<Zap size={14} />
Items Searched
</div>
<ul class="space-y-1 pl-5 text-sm text-neutral-600 dark:text-neutral-400">
{#each run.selection.items as item}
<li class="list-disc">{item.title}</li>
{/each}
</ul>
</div>
{/if}
</div>
</div>
{/if}
</div>

View File

@@ -3,7 +3,8 @@ import type { Actions, ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { upgradeConfigsQueries } from '$db/queries/upgradeConfigs.ts';
import { logger } from '$logger/logger.ts';
import type { FilterConfig, FilterMode } from '$lib/shared/filters';
import type { FilterConfig, FilterMode } from '$lib/shared/filters.ts';
import { processUpgradeConfig } from '$lib/server/upgrades/processor.ts';
export const load: ServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
@@ -43,6 +44,7 @@ export const actions: Actions = {
try {
const enabled = formData.get('enabled') === 'true';
const dryRun = formData.get('dryRun') === 'true';
const schedule = parseInt(formData.get('schedule') as string, 10) || 360;
const filterMode = (formData.get('filterMode') as FilterMode) || 'round_robin';
const filtersJson = formData.get('filters') as string;
@@ -50,6 +52,7 @@ export const actions: Actions = {
const configData = {
enabled,
dryRun,
schedule,
filterMode,
filters
@@ -67,10 +70,11 @@ export const actions: Actions = {
meta: {
instanceId: id,
enabled,
dryRun,
schedule,
filterMode,
filterCount: filters.length,
filters: filters.map((f) => ({
filters: filters.map((f: FilterConfig) => ({
id: f.id,
name: f.name,
enabled: f.enabled,
@@ -113,6 +117,7 @@ export const actions: Actions = {
try {
const enabled = formData.get('enabled') === 'true';
const dryRun = formData.get('dryRun') === 'true';
const schedule = parseInt(formData.get('schedule') as string, 10) || 360;
const filterMode = (formData.get('filterMode') as FilterMode) || 'round_robin';
const filtersJson = formData.get('filters') as string;
@@ -120,6 +125,7 @@ export const actions: Actions = {
const configData = {
enabled,
dryRun,
schedule,
filterMode,
filters
@@ -137,10 +143,11 @@ export const actions: Actions = {
meta: {
instanceId: id,
enabled,
dryRun,
schedule,
filterMode,
filterCount: filters.length,
filters: filters.map((f) => ({
filters: filters.map((f: FilterConfig) => ({
id: f.id,
name: f.name,
enabled: f.enabled,
@@ -160,5 +167,78 @@ export const actions: Actions = {
});
return fail(500, { error: 'Failed to update configuration' });
}
},
run: async ({ params }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
return fail(400, { error: 'Invalid instance ID' });
}
const instance = arrInstancesQueries.getById(id);
if (!instance) {
return fail(404, { error: 'Instance not found' });
}
const config = upgradeConfigsQueries.getByArrInstanceId(id);
if (!config) {
return fail(404, { error: 'No upgrade configuration found. Save a configuration first.' });
}
if (config.filters.length === 0) {
return fail(400, { error: 'No filters configured. Add at least one filter.' });
}
const enabledFilters = config.filters.filter((f: FilterConfig) => f.enabled);
if (enabledFilters.length === 0) {
return fail(400, { error: 'No enabled filters. Enable at least one filter.' });
}
// Only support Radarr for now
if (instance.type !== 'radarr') {
return fail(400, { error: `Upgrade not yet supported for ${instance.type}` });
}
// Only allow manual runs in dry run mode
if (!config.dryRun) {
return fail(400, { error: 'Manual runs only allowed in Dry Run mode. Enable Dry Run first.' });
}
try {
await logger.info(`Manual upgrade run triggered for "${instance.name}"`, {
source: 'upgrades',
meta: { instanceId: id, dryRun: config.dryRun }
});
const result = await processUpgradeConfig(config, instance);
// Update last run timestamp
upgradeConfigsQueries.updateLastRun(id);
// Update filter index for round-robin mode
if (result.status !== 'failed' && config.filterMode === 'round_robin') {
upgradeConfigsQueries.incrementFilterIndex(id);
}
return {
success: true,
runResult: {
status: result.status,
filterName: result.config.selectedFilter,
dryRun: result.config.dryRun,
matched: result.filter.matchedCount,
afterCooldown: result.filter.afterCooldown,
searched: result.selection.actualCount,
items: result.selection.items
}
};
} catch (err) {
await logger.error('Manual upgrade run failed', {
source: 'upgrades',
meta: { instanceId: id, error: err }
});
return fail(500, { error: 'Upgrade run failed. Check logs for details.' });
}
}
};

View File

@@ -3,16 +3,18 @@
import type { FilterConfig, FilterMode } from '$lib/shared/filters';
import { enhance } from '$app/forms';
import { alertStore } from '$lib/client/alerts/store';
import { Info, Save, Pencil } from 'lucide-svelte';
import { Info, Save, Pencil, Play } from 'lucide-svelte';
import CoreSettings from './components/CoreSettings.svelte';
import FilterSettings from './components/FilterSettings.svelte';
import UpgradesInfoModal from './components/UpgradesInfoModal.svelte';
import CooldownTracker from './components/CooldownTracker.svelte';
export let data: PageData;
export let form: ActionData;
// Initialize from existing config or defaults
let enabled = data.config?.enabled ?? false;
let dryRun = data.config?.dryRun ?? false;
let schedule = String(data.config?.schedule ?? 360);
let filterMode: FilterMode = data.config?.filterMode ?? 'round_robin';
let filters: FilterConfig[] = data.config?.filters ?? [];
@@ -22,11 +24,20 @@
let showInfoModal = false;
let saving = false;
let running = false;
// Handle form response
$: if (form?.success) {
$: if (form?.success && !form?.runResult) {
alertStore.add('success', `Configuration ${isNewConfig ? 'saved' : 'updated'} successfully`);
}
$: if (form?.success && form?.runResult) {
const r = form.runResult;
const dryLabel = r.dryRun ? '[DRY RUN] ' : '';
alertStore.add(
r.status === 'success' ? 'success' : r.status === 'partial' ? 'warning' : 'error',
`${dryLabel}${r.filterName}: ${r.searched}/${r.matched} items searched (${r.afterCooldown} after cooldown)`
);
}
$: if (form?.error) {
alertStore.add('error', form.error);
}
@@ -48,6 +59,7 @@
}}
>
<input type="hidden" name="enabled" value={enabled} />
<input type="hidden" name="dryRun" value={dryRun} />
<input type="hidden" name="schedule" value={schedule} />
<input type="hidden" name="filterMode" value={filterMode} />
<input type="hidden" name="filters" value={JSON.stringify(filters)} />
@@ -73,14 +85,22 @@
</button>
</div>
<CoreSettings bind:enabled bind:schedule bind:filterMode />
{#if !isNewConfig && data.config?.lastRunAt}
<CooldownTracker
enabled={data.config.enabled}
schedule={data.config.schedule}
lastRunAt={data.config.lastRunAt}
/>
{/if}
<CoreSettings bind:enabled bind:dryRun bind:schedule bind:filterMode />
<FilterSettings bind:filters />
<!-- Save/Update Button -->
<div class="flex justify-end">
<!-- Action Buttons -->
<div class="flex justify-end gap-3">
<button
type="submit"
disabled={saving}
disabled={saving || running}
class="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 {isNewConfig
? 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
: 'bg-green-600 text-white hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600'}"
@@ -97,4 +117,28 @@
</div>
</form>
{#if !isNewConfig && data.config?.dryRun}
<form
method="POST"
action="?/run"
class="mt-4 flex justify-end"
use:enhance={() => {
running = true;
return async ({ update }) => {
await update({ reset: false });
running = false;
};
}}
>
<button
type="submit"
disabled={running || saving}
class="flex items-center gap-1.5 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-700 transition-colors hover:bg-amber-100 disabled:opacity-50 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-300 dark:hover:bg-amber-900/50"
>
<Play size={16} />
{running ? 'Running...' : 'Test Run'}
</button>
</form>
{/if}
<UpgradesInfoModal bind:open={showInfoModal} />

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Clock, Timer, CheckCircle, PauseCircle } from 'lucide-svelte';
export let enabled: boolean;
export let schedule: number; // minutes
export let lastRunAt: string | null;
let now = Date.now();
let interval: ReturnType<typeof setInterval>;
onMount(() => {
interval = setInterval(() => {
now = Date.now();
}, 1000);
});
onDestroy(() => {
if (interval) clearInterval(interval);
});
// Calculate times
// Database stores timestamps without timezone - append Z to parse as UTC
$: lastRunTime = lastRunAt ? new Date(lastRunAt.endsWith('Z') ? lastRunAt : lastRunAt + 'Z').getTime() : null;
$: scheduleMs = schedule * 60 * 1000;
$: nextRunTime = lastRunTime ? lastRunTime + scheduleMs : null;
$: timeUntilNext = nextRunTime ? nextRunTime - now : null;
$: isDue = timeUntilNext !== null && timeUntilNext <= 0;
$: progress = lastRunTime && nextRunTime
? Math.min(100, Math.max(0, ((now - lastRunTime) / scheduleMs) * 100))
: 0;
// Format relative time
function formatTimeRemaining(ms: number): string {
if (ms <= 0) return 'Due now';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
if (minutes > 0) {
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
return `${seconds}s`;
}
// Parse timestamp - append Z if needed to treat as UTC
function parseTimestamp(str: string): Date {
return new Date(str.endsWith('Z') ? str : str + 'Z');
}
// Format absolute time
function formatTime(isoString: string): string {
const date = parseTimestamp(isoString);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
function formatDate(isoString: string): string {
const date = parseTimestamp(isoString);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return 'Today';
}
if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday';
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
// Format schedule
function formatSchedule(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
if (minutes === 60) return '1 hour';
if (minutes < 1440) return `${minutes / 60} hours`;
return '24 hours';
}
</script>
{#if lastRunAt}
<div class="rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<div class="flex items-center justify-between gap-4">
<!-- Status -->
<div class="flex items-center gap-3">
{#if !enabled}
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-100 dark:bg-neutral-800">
<PauseCircle size={20} class="text-neutral-400" />
</div>
<div>
<div class="text-sm font-medium text-neutral-500 dark:text-neutral-400">Paused</div>
<div class="text-xs text-neutral-400 dark:text-neutral-500">
Enable to resume scheduled runs
</div>
</div>
{:else if isDue}
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<CheckCircle size={20} class="text-green-600 dark:text-green-400" />
</div>
<div>
<div class="text-sm font-medium text-green-600 dark:text-green-400">Ready to run</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Will run on next job cycle
</div>
</div>
{:else}
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
<Timer size={20} class="text-blue-600 dark:text-blue-400" />
</div>
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Next run in <span class="font-mono text-blue-600 dark:text-blue-400">{formatTimeRemaining(timeUntilNext ?? 0)}</span>
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Every {formatSchedule(schedule)}
</div>
</div>
{/if}
</div>
<!-- Last run info -->
<div class="text-right">
<div class="flex items-center justify-end gap-1.5 text-xs text-neutral-500 dark:text-neutral-400">
<Clock size={12} />
Last run
</div>
<div class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{formatDate(lastRunAt)} @ {formatTime(lastRunAt)}
</div>
</div>
</div>
<!-- Progress bar -->
{#if enabled && !isDue}
<div class="mt-3">
<div class="h-1.5 w-full overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-700">
<div
class="h-full rounded-full bg-blue-500 transition-all duration-1000 ease-linear"
style="width: {progress}%"
></div>
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -2,6 +2,7 @@
import { filterModes, type FilterMode } from '$lib/shared/filters';
export let enabled: boolean = true;
export let dryRun: boolean = false;
export let schedule: string = '360';
export let filterMode: FilterMode = 'round_robin';
@@ -64,35 +65,69 @@
</div>
</div>
<!-- Enabled Toggle -->
<div class="flex items-center justify-between">
<div>
<label
for="enabled"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
<!-- Toggles Row -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Enabled Toggle -->
<div class="flex items-center justify-between rounded-lg border border-neutral-200 p-3 dark:border-neutral-700">
<div>
<label
for="enabled"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Enabled
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Run upgrade jobs on schedule
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="Toggle enabled status"
on:click={() => (enabled = !enabled)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {enabled
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-neutral-200 dark:bg-neutral-700'}"
>
Enabled
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Enable or disable this upgrade configuration
</p>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {enabled
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
<!-- Dry Run Toggle -->
<div class="flex items-center justify-between rounded-lg border border-neutral-200 p-3 dark:border-neutral-700">
<div>
<label
for="dryRun"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Dry Run
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">
Log actions without triggering searches
</p>
</div>
<button
type="button"
role="switch"
aria-checked={dryRun}
aria-label="Toggle dry run mode"
on:click={() => (dryRun = !dryRun)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 {dryRun
? 'bg-amber-500 dark:bg-amber-500'
: 'bg-neutral-200 dark:bg-neutral-700'}"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {dryRun
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label="Toggle enabled status"
on:click={() => (enabled = !enabled)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {enabled
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-neutral-200 dark:bg-neutral-700'}"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {enabled
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
</div>
</div>

View File

@@ -138,13 +138,13 @@
/>
{:else if field?.valueType === 'number'}
<div class="w-32">
<NumberInput name="value-{childIndex}" bind:value={child.value} font="mono" />
<NumberInput name="value-{childIndex}" value={child.value as number} on:change={(e) => child.value = e.detail} font="mono" />
</div>
{:else if field?.valueType === 'date'}
{#if child.operator === 'in_last' || child.operator === 'not_in_last'}
<div class="flex items-center gap-2">
<div class="w-24">
<NumberInput name="value-{childIndex}" bind:value={child.value} min={1} font="mono" />
<NumberInput name="value-{childIndex}" value={child.value as number} on:change={(e) => child.value = e.detail} min={1} font="mono" />
</div>
<span class="text-sm text-neutral-500 dark:text-neutral-400">days</span>
</div>

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { Plus, X, ChevronDown, Pencil, Check, Info, Power, Copy } from 'lucide-svelte';
import { Plus, X, ChevronDown, Pencil, Check, Info, Power, Copy, ClipboardCopy, ClipboardPaste } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import { createEmptyFilterConfig, type FilterConfig } from '$lib/shared/filters';
import { selectors } from '$lib/shared/selectors';
import FilterGroupComponent from './FilterGroup.svelte';
import NumberInput from '$ui/form/NumberInput.svelte';
import FiltersInfoModal from './FiltersInfoModal.svelte';
import { alertStore } from '$alerts/store';
export let filters: FilterConfig[] = [];
@@ -84,6 +85,57 @@
expandedIds = expandedIds;
}
}
async function copyFilter(id: string, event: MouseEvent) {
event.stopPropagation();
const filter = filters.find((f) => f.id === id);
if (!filter) return;
const exportData = {
...structuredClone(filter),
id: undefined
};
try {
await navigator.clipboard.writeText(JSON.stringify(exportData, null, 2));
alertStore.add('success', 'Filter copied to clipboard');
} catch {
alertStore.add('error', 'Failed to copy to clipboard');
}
}
async function pasteFilter(event: MouseEvent) {
event.stopPropagation();
try {
const text = await navigator.clipboard.readText();
const imported = JSON.parse(text);
if (!imported.name || !imported.group) {
alertStore.add('error', 'Invalid filter format');
return;
}
const newFilter: FilterConfig = {
id: crypto.randomUUID(),
name: imported.name,
enabled: imported.enabled ?? true,
group: imported.group,
selector: imported.selector ?? 'random',
count: imported.count ?? 5,
cutoff: imported.cutoff ?? 80,
searchCooldown: imported.searchCooldown ?? 24
};
filters = [...filters, newFilter];
expandedIds.add(newFilter.id);
expandedIds = expandedIds;
alertStore.add('success', `Imported "${newFilter.name}"`);
} catch {
alertStore.add('error', 'Failed to paste filter from clipboard');
}
}
</script>
<div
@@ -100,6 +152,15 @@
<Info size={14} />
Fields
</button>
<button
type="button"
on:click={pasteFilter}
class="flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
title="Paste filter from clipboard"
>
<ClipboardPaste size={14} />
Paste
</button>
<button
type="button"
on:click={addFilter}
@@ -120,17 +181,19 @@
{#each filters as filter (filter.id)}
<div class="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
<!-- Accordion Header -->
<div class="flex cursor-pointer items-center justify-between bg-neutral-50 px-4 py-3 dark:bg-neutral-800/50">
<button
type="button"
on:click={() => toggleExpanded(filter.id)}
class="flex flex-1 cursor-pointer items-center gap-3 text-left"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
on:click={() => toggleExpanded(filter.id)}
class="flex cursor-pointer items-center justify-between bg-neutral-50 px-4 py-3 dark:bg-neutral-800/50"
>
<div class="flex flex-1 items-center gap-3 text-left">
<ChevronDown
size={16}
class="text-neutral-400 transition-transform {expandedIds.has(filter.id) ? 'rotate-180' : ''}"
/>
{#if editingId === filter.id}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={editingName}
@@ -150,7 +213,7 @@
</span>
{/if}
{/if}
</button>
</div>
<div class="flex items-center gap-1">
{#if editingId === filter.id}
<button
@@ -180,6 +243,14 @@
>
<Pencil size={14} />
</button>
<button
type="button"
on:click={(e) => copyFilter(filter.id, e)}
class="inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
title="Copy to clipboard"
>
<ClipboardCopy size={14} />
</button>
<button
type="button"
on:click={(e) => duplicateFilter(filter.id, e)}

View File

@@ -27,7 +27,7 @@ const config = {
$http: './src/lib/server/utils/http',
$utils: './src/lib/server/utils',
$notifications: './src/lib/server/notifications',
$cache: './src/lib/server/cache',
$cache: './src/lib/server/utils/cache',
}
}
};