From 3a2d98491c31881799d5792e123c26eef320f69d Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Sat, 27 Dec 2025 06:31:27 +1030 Subject: [PATCH] feat(upgrades): add upgrade configuration management with CRUD operations --- src/lib/server/db/migrations.ts | 4 +- .../migrations/011_create_upgrade_configs.ts | 58 +++++ src/lib/server/db/queries/upgradeConfigs.ts | 200 ++++++++++++++++++ src/lib/server/db/schema.sql | 35 ++- src/lib/shared/filters.ts | 26 +++ src/routes/arr/[id]/upgrades/+page.server.ts | 149 ++++++++++++- src/routes/arr/[id]/upgrades/+page.svelte | 108 +++++++--- 7 files changed, 548 insertions(+), 32 deletions(-) create mode 100644 src/lib/server/db/migrations/011_create_upgrade_configs.ts create mode 100644 src/lib/server/db/queries/upgradeConfigs.ts diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index be418bd..24acfcd 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -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 diff --git a/src/lib/server/db/migrations/011_create_upgrade_configs.ts b/src/lib/server/db/migrations/011_create_upgrade_configs.ts new file mode 100644 index 0000000..a70e1b0 --- /dev/null +++ b/src/lib/server/db/migrations/011_create_upgrade_configs.ts @@ -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; + ` +}; diff --git a/src/lib/server/db/queries/upgradeConfigs.ts b/src/lib/server/db/queries/upgradeConfigs.ts new file mode 100644 index 0000000..082f7ec --- /dev/null +++ b/src/lib/server/db/queries/upgradeConfigs.ts @@ -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( + 'SELECT * FROM upgrade_configs WHERE arr_instance_id = ?', + arrInstanceId + ); + return row ? rowToConfig(row) : undefined; + }, + + /** + * Get all upgrade configs + */ + getAll(): UpgradeConfig[] { + const rows = db.query('SELECT * FROM upgrade_configs'); + return rows.map(rowToConfig); + }, + + /** + * Get all enabled upgrade configs + */ + getEnabled(): UpgradeConfig[] { + const rows = db.query( + '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 + ); + } +}; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 8eaf853..1577f61 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -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); diff --git a/src/lib/shared/filters.ts b/src/lib/shared/filters.ts index c7c005d..be290b1 100644 --- a/src/lib/shared/filters.ts +++ b/src/lib/shared/filters.ts @@ -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 */ diff --git a/src/routes/arr/[id]/upgrades/+page.server.ts b/src/routes/arr/[id]/upgrades/+page.server.ts index 8c8ae41..58a3942 100644 --- a/src/routes/arr/[id]/upgrades/+page.server.ts +++ b/src/routes/arr/[id]/upgrades/+page.server.ts @@ -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' }); + } + } +}; diff --git a/src/routes/arr/[id]/upgrades/+page.svelte b/src/routes/arr/[id]/upgrades/+page.svelte index 4ca5482..287f507 100644 --- a/src/routes/arr/[id]/upgrades/+page.svelte +++ b/src/routes/arr/[id]/upgrades/+page.svelte @@ -1,46 +1,100 @@ {data.instance.name} - Upgrades - Profilarr -
- -
-
-

Upgrade Configuration

-

- Automatically search for better quality releases for your library items. -

-
- -
+
{ + saving = true; + return async ({ update }) => { + await update({ reset: false }); + saving = false; + }; + }} +> + + + + - - -
+
+ +
+
+

+ Upgrade Configuration +

+

+ Automatically search for better quality releases for your library items. +

+
+ +
+ + + + + +
+ +
+
+