feat(upgrades): add upgrade configuration management with CRUD operations

This commit is contained in:
Sam Chau
2025-12-27 06:31:27 +10:30
parent a740937246
commit 3a2d98491c
7 changed files with 548 additions and 32 deletions

View File

@@ -12,6 +12,7 @@ import { migration as migration007 } from './migrations/007_create_notification_
import { migration as migration008 } from './migrations/008_create_database_instances.ts';
import { migration as migration009 } from './migrations/009_add_personal_access_token.ts';
import { migration as migration010 } from './migrations/010_add_is_private.ts';
import { migration as migration011 } from './migrations/011_create_upgrade_configs.ts';
export interface Migration {
version: number;
@@ -239,7 +240,8 @@ export function loadMigrations(): Migration[] {
migration007,
migration008,
migration009,
migration010
migration010,
migration011
];
// Sort by version number

View File

@@ -0,0 +1,58 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 011: Create upgrade_configs table
*
* Creates the table for storing upgrade configuration per arr instance.
* Each arr instance can have one upgrade config that controls automatic
* quality upgrade searching.
*
* Fields:
* - id: Auto-incrementing primary key
* - arr_instance_id: Foreign key to arr_instances (unique - one config per instance)
* - enabled: Whether upgrade searching is enabled
* - schedule: Interval in minutes between upgrade runs
* - filter_mode: How to cycle through filters ('round_robin' | 'random')
* - filters: JSON array of FilterConfig objects
* - current_filter_index: Tracks position for round-robin mode
* - created_at: Timestamp of creation
* - updated_at: Timestamp of last update
*/
export const migration: Migration = {
version: 11,
name: 'Create upgrade_configs table',
up: `
CREATE TABLE upgrade_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Relationship
arr_instance_id INTEGER NOT NULL UNIQUE,
-- Core settings
enabled INTEGER NOT NULL DEFAULT 0,
schedule INTEGER NOT NULL DEFAULT 360,
filter_mode TEXT NOT NULL DEFAULT 'round_robin',
-- Filters (stored as JSON)
filters TEXT NOT NULL DEFAULT '[]',
-- State tracking
current_filter_index INTEGER NOT NULL DEFAULT 0,
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (arr_instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
);
CREATE INDEX idx_upgrade_configs_arr_instance ON upgrade_configs(arr_instance_id);
`,
down: `
DROP INDEX IF EXISTS idx_upgrade_configs_arr_instance;
DROP TABLE IF EXISTS upgrade_configs;
`
};

View File

@@ -0,0 +1,200 @@
import { db } from '../db.ts';
import type { FilterConfig, FilterMode, UpgradeConfig } from '$lib/shared/filters';
/**
* Database row type for upgrade_configs table
*/
interface UpgradeConfigRow {
id: number;
arr_instance_id: number;
enabled: number;
schedule: number;
filter_mode: string;
filters: string;
current_filter_index: number;
created_at: string;
updated_at: string;
}
/**
* Input for creating/updating an upgrade config
*/
export interface UpgradeConfigInput {
enabled?: boolean;
schedule?: number;
filterMode?: FilterMode;
filters?: FilterConfig[];
currentFilterIndex?: number;
}
/**
* Convert database row to UpgradeConfig
*/
function rowToConfig(row: UpgradeConfigRow): UpgradeConfig {
return {
id: row.id,
arrInstanceId: row.arr_instance_id,
enabled: row.enabled === 1,
schedule: row.schedule,
filterMode: row.filter_mode as FilterMode,
filters: JSON.parse(row.filters) as FilterConfig[],
currentFilterIndex: row.current_filter_index,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
/**
* All queries for upgrade_configs table
*/
export const upgradeConfigsQueries = {
/**
* Get upgrade config by arr instance ID
*/
getByArrInstanceId(arrInstanceId: number): UpgradeConfig | undefined {
const row = db.queryFirst<UpgradeConfigRow>(
'SELECT * FROM upgrade_configs WHERE arr_instance_id = ?',
arrInstanceId
);
return row ? rowToConfig(row) : undefined;
},
/**
* Get all upgrade configs
*/
getAll(): UpgradeConfig[] {
const rows = db.query<UpgradeConfigRow>('SELECT * FROM upgrade_configs');
return rows.map(rowToConfig);
},
/**
* Get all enabled upgrade configs
*/
getEnabled(): UpgradeConfig[] {
const rows = db.query<UpgradeConfigRow>(
'SELECT * FROM upgrade_configs WHERE enabled = 1'
);
return rows.map(rowToConfig);
},
/**
* Create or update an upgrade config for an arr instance
* Uses upsert pattern since there's one config per instance
*/
upsert(arrInstanceId: number, input: UpgradeConfigInput): UpgradeConfig {
const existing = this.getByArrInstanceId(arrInstanceId);
if (existing) {
// Update existing
this.update(arrInstanceId, input);
return this.getByArrInstanceId(arrInstanceId)!;
}
// Create new
const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : 0;
const schedule = input.schedule ?? 360;
const filterMode = input.filterMode ?? 'round_robin';
const filters = JSON.stringify(input.filters ?? []);
const currentFilterIndex = input.currentFilterIndex ?? 0;
db.execute(
`INSERT INTO upgrade_configs
(arr_instance_id, enabled, schedule, filter_mode, filters, current_filter_index)
VALUES (?, ?, ?, ?, ?, ?)`,
arrInstanceId,
enabled,
schedule,
filterMode,
filters,
currentFilterIndex
);
return this.getByArrInstanceId(arrInstanceId)!;
},
/**
* Update an upgrade config
*/
update(arrInstanceId: number, input: UpgradeConfigInput): boolean {
const updates: string[] = [];
const params: (string | number)[] = [];
if (input.enabled !== undefined) {
updates.push('enabled = ?');
params.push(input.enabled ? 1 : 0);
}
if (input.schedule !== undefined) {
updates.push('schedule = ?');
params.push(input.schedule);
}
if (input.filterMode !== undefined) {
updates.push('filter_mode = ?');
params.push(input.filterMode);
}
if (input.filters !== undefined) {
updates.push('filters = ?');
params.push(JSON.stringify(input.filters));
}
if (input.currentFilterIndex !== undefined) {
updates.push('current_filter_index = ?');
params.push(input.currentFilterIndex);
}
if (updates.length === 0) {
return false;
}
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(arrInstanceId);
const affected = db.execute(
`UPDATE upgrade_configs SET ${updates.join(', ')} WHERE arr_instance_id = ?`,
...params
);
return affected > 0;
},
/**
* Delete an upgrade config
*/
delete(arrInstanceId: number): boolean {
const affected = db.execute(
'DELETE FROM upgrade_configs WHERE arr_instance_id = ?',
arrInstanceId
);
return affected > 0;
},
/**
* Increment the current filter index (for round-robin mode)
* Wraps around to 0 when reaching the end
*/
incrementFilterIndex(arrInstanceId: number): number {
const config = this.getByArrInstanceId(arrInstanceId);
if (!config) return 0;
const enabledFilters = config.filters.filter((f) => f.enabled);
if (enabledFilters.length === 0) return 0;
const nextIndex = (config.currentFilterIndex + 1) % enabledFilters.length;
db.execute(
'UPDATE upgrade_configs SET current_filter_index = ?, updated_at = CURRENT_TIMESTAMP WHERE arr_instance_id = ?',
nextIndex,
arrInstanceId
);
return nextIndex;
},
/**
* Reset the filter index to 0
*/
resetFilterIndex(arrInstanceId: number): void {
db.execute(
'UPDATE upgrade_configs SET current_filter_index = 0, updated_at = CURRENT_TIMESTAMP WHERE arr_instance_id = ?',
arrInstanceId
);
}
};

View File

@@ -1,7 +1,7 @@
-- Profilarr Database Schema
-- This file documents the current database schema after all migrations
-- DO NOT execute this file directly - use migrations instead
-- Last updated: 2025-11-04
-- Last updated: 2025-12-27
-- ==============================================================================
-- TABLE: migrations
@@ -225,6 +225,36 @@ CREATE TABLE database_instances (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- ==============================================================================
-- TABLE: upgrade_configs
-- Purpose: Store upgrade configuration per arr instance for automated quality upgrades
-- Migration: 011_create_upgrade_configs.ts
-- ==============================================================================
CREATE TABLE upgrade_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Relationship (one config per arr instance)
arr_instance_id INTEGER NOT NULL UNIQUE,
-- Core settings
enabled INTEGER NOT NULL DEFAULT 0, -- Master on/off switch
schedule INTEGER NOT NULL DEFAULT 360, -- Run interval in minutes (default 6 hours)
filter_mode TEXT NOT NULL DEFAULT 'round_robin', -- 'round_robin' or 'random'
-- Filters (stored as JSON array of FilterConfig objects)
filters TEXT NOT NULL DEFAULT '[]',
-- State tracking
current_filter_index INTEGER NOT NULL DEFAULT 0, -- For round-robin mode
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (arr_instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
);
-- ==============================================================================
-- INDEXES
-- Purpose: Improve query performance
@@ -249,3 +279,6 @@ CREATE INDEX idx_notification_history_status ON notification_history(status);
-- Database instances indexes (Migration: 008_create_database_instances.ts)
CREATE INDEX idx_database_instances_uuid ON database_instances(uuid);
-- Upgrade configs indexes (Migration: 011_create_upgrade_configs.ts)
CREATE INDEX idx_upgrade_configs_arr_instance ON upgrade_configs(arr_instance_id);

View File

@@ -47,6 +47,18 @@ export interface FilterConfig {
export type FilterMode = 'round_robin' | 'random';
export interface UpgradeConfig {
id?: number;
arrInstanceId: number;
enabled: boolean;
schedule: number; // minutes
filterMode: FilterMode;
filters: FilterConfig[];
currentFilterIndex: number;
createdAt?: string;
updatedAt?: string;
}
export const filterModes: { id: FilterMode; label: string; description: string }[] = [
{
id: 'round_robin',
@@ -289,6 +301,20 @@ export function createEmptyFilterConfig(name: string = 'New Filter'): FilterConf
};
}
/**
* Create an empty upgrade config for an arr instance
*/
export function createEmptyUpgradeConfig(arrInstanceId: number): UpgradeConfig {
return {
arrInstanceId,
enabled: false,
schedule: 360, // 6 hours
filterMode: 'round_robin',
filters: [],
currentFilterIndex: 0
};
}
/**
* Create an empty filter rule with defaults
*/

View File

@@ -1,6 +1,9 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { error, fail } from '@sveltejs/kit';
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';
export const load: ServerLoad = ({ params }) => {
const id = parseInt(params.id || '', 10);
@@ -15,7 +18,147 @@ export const load: ServerLoad = ({ params }) => {
error(404, `Instance not found: ${id}`);
}
const config = upgradeConfigsQueries.getByArrInstanceId(id);
return {
instance
instance,
config: config ?? null
};
};
export const actions: Actions = {
save: async ({ params, request }) => {
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 formData = await request.formData();
try {
const enabled = formData.get('enabled') === '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;
const filters: FilterConfig[] = filtersJson ? JSON.parse(filtersJson) : [];
const configData = {
enabled,
schedule,
filterMode,
filters
};
upgradeConfigsQueries.upsert(id, configData);
await logger.info(`Upgrade config saved for instance "${instance.name}"`, {
source: 'upgrades',
meta: { instanceId: id, instanceName: instance.name }
});
await logger.debug('Upgrade config details', {
source: 'upgrades',
meta: {
instanceId: id,
enabled,
schedule,
filterMode,
filterCount: filters.length,
filters: filters.map((f) => ({
id: f.id,
name: f.name,
enabled: f.enabled,
selector: f.selector,
count: f.count,
cutoff: f.cutoff,
searchCooldown: f.searchCooldown
}))
}
});
return { success: true };
} catch (err) {
await logger.error('Failed to save upgrade config', {
source: 'upgrades',
meta: { instanceId: id, error: err }
});
return fail(500, { error: 'Failed to save configuration' });
}
},
update: async ({ params, request }) => {
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 existing = upgradeConfigsQueries.getByArrInstanceId(id);
if (!existing) {
return fail(404, { error: 'Configuration not found' });
}
const formData = await request.formData();
try {
const enabled = formData.get('enabled') === '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;
const filters: FilterConfig[] = filtersJson ? JSON.parse(filtersJson) : [];
const configData = {
enabled,
schedule,
filterMode,
filters
};
upgradeConfigsQueries.update(id, configData);
await logger.info(`Upgrade config updated for instance "${instance.name}"`, {
source: 'upgrades',
meta: { instanceId: id, instanceName: instance.name }
});
await logger.debug('Upgrade config details', {
source: 'upgrades',
meta: {
instanceId: id,
enabled,
schedule,
filterMode,
filterCount: filters.length,
filters: filters.map((f) => ({
id: f.id,
name: f.name,
enabled: f.enabled,
selector: f.selector,
count: f.count,
cutoff: f.cutoff,
searchCooldown: f.searchCooldown
}))
}
});
return { success: true };
} catch (err) {
await logger.error('Failed to update upgrade config', {
source: 'upgrades',
meta: { instanceId: id, error: err }
});
return fail(500, { error: 'Failed to update configuration' });
}
}
};

View File

@@ -1,46 +1,100 @@
<script lang="ts">
import type { PageData } from './$types';
import type { PageData, ActionData } from './$types';
import type { FilterConfig, FilterMode } from '$lib/shared/filters';
import { Info } from 'lucide-svelte';
import { enhance } from '$app/forms';
import { alertStore } from '$lib/client/alerts/store';
import { Info, Save, Pencil } from 'lucide-svelte';
import CoreSettings from './components/CoreSettings.svelte';
import FilterSettings from './components/FilterSettings.svelte';
import UpgradesInfoModal from './components/UpgradesInfoModal.svelte';
export let data: PageData;
export let form: ActionData;
let enabled = true;
let schedule = '360'; // 6 hours in minutes
let filterMode: FilterMode = 'round_robin';
let filters: FilterConfig[] = [];
// Initialize from existing config or defaults
let enabled = data.config?.enabled ?? false;
let schedule = String(data.config?.schedule ?? 360);
let filterMode: FilterMode = data.config?.filterMode ?? 'round_robin';
let filters: FilterConfig[] = data.config?.filters ?? [];
// Track if config exists (determines save vs edit)
$: isNewConfig = !data.config;
let showInfoModal = false;
let saving = false;
// Handle form response
$: if (form?.success) {
alertStore.add('success', `Configuration ${isNewConfig ? 'saved' : 'updated'} successfully`);
}
$: if (form?.error) {
alertStore.add('error', form.error);
}
</script>
<svelte:head>
<title>{data.instance.name} - Upgrades - Profilarr</title>
</svelte:head>
<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 Configuration</h1>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Automatically search for better quality releases for your library items.
</p>
</div>
<button
type="button"
on:click={() => (showInfoModal = true)}
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"
>
<Info size={14} />
How it works
</button>
</div>
<form
method="POST"
action={isNewConfig ? '?/save' : '?/update'}
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update({ reset: false });
saving = false;
};
}}
>
<input type="hidden" name="enabled" value={enabled} />
<input type="hidden" name="schedule" value={schedule} />
<input type="hidden" name="filterMode" value={filterMode} />
<input type="hidden" name="filters" value={JSON.stringify(filters)} />
<CoreSettings bind:enabled bind:schedule bind:filterMode />
<FilterSettings bind:filters />
</div>
<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 Configuration
</h1>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Automatically search for better quality releases for your library items.
</p>
</div>
<button
type="button"
on:click={() => (showInfoModal = true)}
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"
>
<Info size={14} />
How it works
</button>
</div>
<CoreSettings bind:enabled bind:schedule bind:filterMode />
<FilterSettings bind:filters />
<!-- Save/Update Button -->
<div class="flex justify-end">
<button
type="submit"
disabled={saving}
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'}"
>
{#if isNewConfig}
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
{:else}
<Pencil size={16} />
{saving ? 'Updating...' : 'Update'}
{/if}
</button>
</div>
</div>
</form>
<UpgradesInfoModal bind:open={showInfoModal} />