mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
@@ -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
57
deno.lock
generated
@@ -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": {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
47
src/lib/server/db/migrations/013_add_upgrade_dry_run.ts
Normal file
47
src/lib/server/db/migrations/013_add_upgrade_dry_run.ts
Normal 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);
|
||||
`
|
||||
};
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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++;
|
||||
|
||||
145
src/lib/server/upgrades/cooldown.ts
Normal file
145
src/lib/server/upgrades/cooldown.ts
Normal 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 };
|
||||
}
|
||||
83
src/lib/server/upgrades/logger.ts
Normal file
83
src/lib/server/upgrades/logger.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
93
src/lib/server/upgrades/normalize.ts
Normal file
93
src/lib/server/upgrades/normalize.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
252
src/lib/server/upgrades/processor.ts
Normal file
252
src/lib/server/upgrades/processor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
100
src/lib/server/upgrades/types.ts
Normal file
100
src/lib/server/upgrades/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
152
src/routes/arr/[id]/library/components/LibraryActionBar.svelte
Normal file
152
src/routes/arr/[id]/library/components/LibraryActionBar.svelte
Normal 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>
|
||||
100
src/routes/arr/[id]/library/components/LibraryHeader.svelte
Normal file
100
src/routes/arr/[id]/library/components/LibraryHeader.svelte
Normal 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>
|
||||
127
src/routes/arr/[id]/library/components/MovieRow.svelte
Normal file
127
src/routes/arr/[id]/library/components/MovieRow.svelte
Normal 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}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
216
src/routes/arr/[id]/logs/components/UpgradeRunCard.svelte
Normal file
216
src/routes/arr/[id]/logs/components/UpgradeRunCard.svelte
Normal 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>
|
||||
@@ -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.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
|
||||
155
src/routes/arr/[id]/upgrades/components/CooldownTracker.svelte
Normal file
155
src/routes/arr/[id]/upgrades/components/CooldownTracker.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user