From e24410f6f3ccc65144a7458355cd3bf588740455 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Mon, 20 Oct 2025 02:13:09 +1030 Subject: [PATCH] stack(arrConfig): implemented arr config handling - database module + migrations handler - http client class + arr client child (with connection pooling, retries, backoff) - toast alerts - add new arr configs --- .npmrc | 1 + deno.json | 7 +- deno.lock | 65 ++++ src/app.d.ts | 5 + src/components/form/TagInput.svelte | 59 ++++ .../navigation/pageNav/pageNav.svelte | 6 +- src/components/toast/Toast.svelte | 56 ++++ src/components/toast/ToastContainer.svelte | 12 + src/db/db.ts | 162 ++++++++++ src/db/migrations.ts | 301 ++++++++++++++++++ src/db/migrations/001_create_arr_instances.ts | 52 +++ src/db/migrations/002_remove_sync_profile.ts | 20 ++ src/db/migrations/_template.ts | 70 ++++ src/db/queries/arrInstances.ts | 169 ++++++++++ src/db/schema.sql | 42 +++ src/deno.d.ts | 23 ++ src/hooks.server.ts | 8 + src/routes/+layout.svelte | 2 + src/routes/arr/[type]/+page.server.ts | 18 ++ src/routes/arr/[type]/+page.svelte | 32 ++ src/routes/arr/new/+page.server.ts | 102 ++++++ src/routes/arr/new/+page.svelte | 229 +++++++++++++ src/routes/arr/test/+server.ts | 40 +++ src/routes/settings/about/+page.server.ts | 111 +++++++ src/routes/settings/about/+page.svelte | 200 ++++++++++++ .../settings/about/components/InfoRow.svelte | 33 ++ .../about/components/InfoTable.svelte | 23 ++ .../about/components/VersionBadge.svelte | 26 ++ src/stores/toast.ts | 41 +++ src/utils/api/request.ts | 88 +++++ src/utils/arr/README.md | 105 ++++++ src/utils/arr/base.ts | 49 +++ src/utils/arr/clients/chaptarr.ts | 9 + src/utils/arr/clients/lidarr.ts | 9 + src/utils/arr/clients/radarr.ts | 9 + src/utils/arr/clients/sonarr.ts | 9 + src/utils/arr/factory.ts | 32 ++ src/utils/arr/types.ts | 43 +++ src/utils/config/config.ts | 15 +- src/utils/http/client.ts | 180 +++++++++++ src/utils/http/types.ts | 30 ++ svelte.config.js | 7 +- 42 files changed, 2494 insertions(+), 6 deletions(-) create mode 100644 src/components/form/TagInput.svelte create mode 100644 src/components/toast/Toast.svelte create mode 100644 src/components/toast/ToastContainer.svelte create mode 100644 src/db/db.ts create mode 100644 src/db/migrations.ts create mode 100644 src/db/migrations/001_create_arr_instances.ts create mode 100644 src/db/migrations/002_remove_sync_profile.ts create mode 100644 src/db/migrations/_template.ts create mode 100644 src/db/queries/arrInstances.ts create mode 100644 src/db/schema.sql create mode 100644 src/routes/arr/[type]/+page.server.ts create mode 100644 src/routes/arr/[type]/+page.svelte create mode 100644 src/routes/arr/new/+page.server.ts create mode 100644 src/routes/arr/new/+page.svelte create mode 100644 src/routes/arr/test/+server.ts create mode 100644 src/routes/settings/about/+page.server.ts create mode 100644 src/routes/settings/about/+page.svelte create mode 100644 src/routes/settings/about/components/InfoRow.svelte create mode 100644 src/routes/settings/about/components/InfoTable.svelte create mode 100644 src/routes/settings/about/components/VersionBadge.svelte create mode 100644 src/stores/toast.ts create mode 100644 src/utils/api/request.ts create mode 100644 src/utils/arr/README.md create mode 100644 src/utils/arr/base.ts create mode 100644 src/utils/arr/clients/chaptarr.ts create mode 100644 src/utils/arr/clients/lidarr.ts create mode 100644 src/utils/arr/clients/radarr.ts create mode 100644 src/utils/arr/clients/sonarr.ts create mode 100644 src/utils/arr/factory.ts create mode 100644 src/utils/arr/types.ts create mode 100644 src/utils/http/client.ts create mode 100644 src/utils/http/types.ts diff --git a/.npmrc b/.npmrc index b6f27f1..3f0f34a 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ engine-strict=true +@jsr:registry=https://npm.jsr.io diff --git a/deno.json b/deno.json index 2eaaa68..bd4e42f 100644 --- a/deno.json +++ b/deno.json @@ -3,7 +3,12 @@ "$config": "./src/utils/config/config.ts", "$stores": "./src/stores", "$components": "./src/components", - "$static": "./src/static" + "$static": "./src/static", + "$db/": "./src/db/", + "$logger": "./src/utils/logger/logger.ts", + "$arr/": "./src/utils/arr/", + "$http/": "./src/utils/http/", + "$api": "./src/utils/api/request.ts" }, "tasks": { "dev": "APP_BASE_PATH=./temp vite dev", diff --git a/deno.lock b/deno.lock index ff44b35..726e181 100644 --- a/deno.lock +++ b/deno.lock @@ -3,6 +3,7 @@ "specifiers": { "npm:@eslint/compat@^1.4.0": "1.4.0_eslint@9.37.0", "npm:@eslint/js@^9.36.0": "9.37.0", + "npm:@jsr/db__sqlite@0.12": "0.12.0", "npm:@sveltejs/kit@^2.43.2": "2.47.1_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.2___acorn@8.15.0__vite@7.1.10___@types+node@22.18.11___picomatch@4.0.3__@types+node@22.18.11_svelte@5.40.2__acorn@8.15.0_vite@7.1.10__@types+node@22.18.11__picomatch@4.0.3_acorn@8.15.0_@types+node@22.18.11", "npm:@sveltejs/vite-plugin-svelte@^6.2.0": "6.2.1_svelte@5.40.2__acorn@8.15.0_vite@7.1.10__@types+node@22.18.11__picomatch@4.0.3_@types+node@22.18.11", "npm:@tailwindcss/forms@~0.5.10": "0.5.10_tailwindcss@4.1.14", @@ -396,6 +397,69 @@ "@jridgewell/sourcemap-codec" ] }, + "@jsr/db__sqlite@0.12.0": { + "integrity": "sha512-nTMYOzEl8oFhtPS90tAdMbpYTec7/brHtlVLt8afAsNLX+z1FkEIWljxqJS8UDzN23wCTsNXL96NArqBaJRy9A==", + "dependencies": [ + "@jsr/denosaurs__plug", + "@jsr/std__path@0.217.0" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/db__sqlite/0.12.0.tgz" + }, + "@jsr/denosaurs__plug@1.1.0": { + "integrity": "sha512-GNRMr8XcYWbv8C1B5OjDa5u8q3p2lz7YVWQLhH5HAy0pkpb0+Y3npSxzjM49v5ajTFIzUCwIKv1gQukPm9q7qw==", + "dependencies": [ + "@jsr/std__encoding", + "@jsr/std__fmt@1.0.8", + "@jsr/std__fs", + "@jsr/std__path@1.1.2" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/denosaurs__plug/1.1.0.tgz" + }, + "@jsr/std__assert@0.217.0": { + "integrity": "sha512-kCGfcXX8lMcZHWrCgFhbdpNloB50MkLwHdRZvZKjZK424F9g+M742jkTDLMOJmwkDoEqFKyNVrGhPtspS4+NvQ==", + "dependencies": [ + "@jsr/std__fmt@0.217.0" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/0.217.0.tgz" + }, + "@jsr/std__encoding@1.0.10": { + "integrity": "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz" + }, + "@jsr/std__fmt@0.217.0": { + "integrity": "sha512-AM33Cr/V3St3Cj5O4QQe4aMKGyzL9eYz+mOC58BmqxgeZwFbvSC06DzM2DS3ixcsAnwH2kYMXHpCBax0sT9q8Q==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__fmt/0.217.0.tgz" + }, + "@jsr/std__fmt@1.0.8": { + "integrity": "sha512-miZHzj9OgjuajrcMKzpqNVwFb9O71UHZzV/FHVq0E0Uwmv/1JqXgmXAoBNPrn+MP0fHT3mMgaZ6XvQO7dam67Q==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.8.tgz" + }, + "@jsr/std__fs@1.0.19": { + "integrity": "sha512-TEjyE8g+46jPlu7dJHLrwc8NMGl8zfG+JjWxyNQyDbxP0RtqZ4JmYZfR9vy4RWYWJQbLpw6Kbt2n+K/2zAO/JA==", + "dependencies": [ + "@jsr/std__internal", + "@jsr/std__path@1.1.2" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.19.tgz" + }, + "@jsr/std__internal@1.0.12": { + "integrity": "sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz" + }, + "@jsr/std__path@0.217.0": { + "integrity": "sha512-KoqEpZX9CE8zyyr4+X6AROOGYv95AysnJni2E5g9pqG+IGUUuNjOC3yRTvHnsB5tJ6uQs6DwET5chIdUPcylIQ==", + "dependencies": [ + "@jsr/std__assert" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/0.217.0.tgz" + }, + "@jsr/std__path@1.1.2": { + "integrity": "sha512-5hkOR1s5M7am02Bn9KS+SNMNwUSivz7t7/w2HBhFIfO7Eh8+mWilaZ+1tdanV9aaSHr4c99Zo4Da+cCSuzUOdA==", + "dependencies": [ + "@jsr/std__internal" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.2.tgz" + }, "@nodelib/fs.scandir@2.1.5": { "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dependencies": [ @@ -1815,6 +1879,7 @@ "dependencies": [ "npm:@eslint/compat@^1.4.0", "npm:@eslint/js@^9.36.0", + "npm:@jsr/db__sqlite@0.12", "npm:@sveltejs/kit@^2.43.2", "npm:@sveltejs/vite-plugin-svelte@^6.2.0", "npm:@tailwindcss/forms@~0.5.10", diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..d6e8c08 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -8,6 +8,11 @@ declare global { // interface PageState {} // interface Platform {} } + + // Extend RequestInit to support Deno HttpClient + interface RequestInit { + client?: Deno.HttpClient; + } } export {}; diff --git a/src/components/form/TagInput.svelte b/src/components/form/TagInput.svelte new file mode 100644 index 0000000..801c210 --- /dev/null +++ b/src/components/form/TagInput.svelte @@ -0,0 +1,59 @@ + + +
+ {#each tags as tag, index (tag)} +
+ {tag} + +
+ {/each} + + +
diff --git a/src/components/navigation/pageNav/pageNav.svelte b/src/components/navigation/pageNav/pageNav.svelte index bdc8938..c61ea14 100644 --- a/src/components/navigation/pageNav/pageNav.svelte +++ b/src/components/navigation/pageNav/pageNav.svelte @@ -10,9 +10,9 @@
- - - + + + diff --git a/src/components/toast/Toast.svelte b/src/components/toast/Toast.svelte new file mode 100644 index 0000000..acacd41 --- /dev/null +++ b/src/components/toast/Toast.svelte @@ -0,0 +1,56 @@ + + +
+ +

{message}

+ +
diff --git a/src/components/toast/ToastContainer.svelte b/src/components/toast/ToastContainer.svelte new file mode 100644 index 0000000..3a44ee7 --- /dev/null +++ b/src/components/toast/ToastContainer.svelte @@ -0,0 +1,12 @@ + + +
+ {#each $toastStore as toast (toast.id)} +
+ +
+ {/each} +
diff --git a/src/db/db.ts b/src/db/db.ts new file mode 100644 index 0000000..49443aa --- /dev/null +++ b/src/db/db.ts @@ -0,0 +1,162 @@ +import { Database } from '@jsr/db__sqlite'; +import type { RestBindParameters } from '@jsr/db__sqlite'; +import { config } from '$config'; +import { logger } from '$logger'; + +/** + * Database singleton for SQLite + */ +class DatabaseManager { + private db: Database | null = null; + private initialized = false; + + /** + * Initialize the database connection + */ + async initialize(): Promise { + if (this.initialized) { + return; + } + + try { + // Ensure data directory exists + await Deno.mkdir(config.paths.data, { recursive: true }); + + // Open database connection + this.db = new Database(config.paths.database); + + // Enable foreign keys + this.db.exec('PRAGMA foreign_keys = ON'); + + // Set journal mode to WAL for better concurrency + this.db.exec('PRAGMA journal_mode = WAL'); + + // Set synchronous to NORMAL for better performance + this.db.exec('PRAGMA synchronous = NORMAL'); + + this.initialized = true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await logger.error(`Failed to initialize database: ${message}`, { + source: 'DatabaseManager', + meta: error + }); + throw new Error(`Failed to initialize database: ${message}`); + } + } + + /** + * Get the database instance + */ + getDatabase(): Database { + if (!this.db) { + throw new Error('Database not initialized. Call initialize() first.'); + } + return this.db; + } + + /** + * Execute a SQL statement + */ + exec(sql: string): void { + const db = this.getDatabase(); + db.exec(sql); + } + + /** + * Prepare a SQL statement + */ + prepare(sql: string) { + const db = this.getDatabase(); + return db.prepare(sql); + } + + /** + * Run a query and return all results + */ + query(sql: string, ...params: RestBindParameters): T[] { + const stmt = this.prepare(sql); + if (params.length > 0) { + return stmt.all(...params) as T[]; + } + return stmt.all() as T[]; + } + + /** + * Run a query and return the first result + */ + queryFirst(sql: string, ...params: RestBindParameters): T | undefined { + const stmt = this.prepare(sql); + if (params.length > 0) { + return stmt.get(...params) as T | undefined; + } + return stmt.get() as T | undefined; + } + + /** + * Execute a statement and return the number of affected rows + */ + execute(sql: string, ...params: RestBindParameters): number { + const stmt = this.prepare(sql); + if (params.length > 0) { + stmt.run(...params); + } else { + stmt.run(); + } + return this.getDatabase().changes; + } + + /** + * Begin a transaction + */ + beginTransaction(): void { + this.exec('BEGIN TRANSACTION'); + } + + /** + * Commit a transaction + */ + commit(): void { + this.exec('COMMIT'); + } + + /** + * Rollback a transaction + */ + rollback(): void { + this.exec('ROLLBACK'); + } + + /** + * Run a function within a transaction + */ + async transaction(fn: () => T | Promise): Promise { + this.beginTransaction(); + try { + const result = await fn(); + this.commit(); + return result; + } catch (error) { + this.rollback(); + await logger.error('Transaction rolled back due to error', { + source: 'DatabaseManager', + meta: error + }); + throw error; + } + } + + /** + * Close the database connection + */ + close(): void { + if (this.db) { + this.db.close(); + this.db = null; + this.initialized = false; + } + } +} + +// Export singleton instance +export const db = new DatabaseManager(); diff --git a/src/db/migrations.ts b/src/db/migrations.ts new file mode 100644 index 0000000..610b5eb --- /dev/null +++ b/src/db/migrations.ts @@ -0,0 +1,301 @@ +import { db } from "./db.ts"; +import { logger } from "$logger"; + +export interface Migration { + version: number; + name: string; + up: string; + down?: string; +} + +/** + * Migration runner for database schema management + */ +class MigrationRunner { + private migrationsTable = "migrations"; + + /** + * Initialize the migrations table + */ + initialize(): void { + const sql = ` + CREATE TABLE IF NOT EXISTS ${this.migrationsTable} ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `; + db.exec(sql); + } + + /** + * Get the current migration version + */ + getCurrentVersion(): number { + const result = db.queryFirst<{ version: number }>( + `SELECT MAX(version) as version FROM ${this.migrationsTable}`, + ); + return result?.version ?? 0; + } + + /** + * Check if a migration has been applied + */ + isApplied(version: number): boolean { + const result = db.queryFirst<{ count: number }>( + `SELECT COUNT(*) as count FROM ${this.migrationsTable} WHERE version = ?`, + version, + ); + return (result?.count ?? 0) > 0; + } + + /** + * Apply a single migration + */ + private async applyMigration(migration: Migration): Promise { + try { + await db.transaction(async () => { + // Execute the migration + db.exec(migration.up); + + // Record the migration + db.execute( + `INSERT INTO ${this.migrationsTable} (version, name) VALUES (?, ?)`, + migration.version, + migration.name, + ); + + await logger.info( + `✓ Applied migration ${migration.version}: ${migration.name}`, + { + source: "MigrationRunner", + }, + ); + }); + } catch (error) { + await logger.error( + `✗ Failed to apply migration ${migration.version}: ${migration.name}`, + { + source: "MigrationRunner", + meta: error, + }, + ); + throw error; + } + } + + /** + * Rollback a single migration + */ + private async rollbackMigration(migration: Migration): Promise { + if (!migration.down) { + throw new Error( + `Migration ${migration.version} does not support rollback`, + ); + } + + try { + await db.transaction(async () => { + // Execute the rollback + db.exec(migration.down!); + + // Remove the migration record + db.execute( + `DELETE FROM ${this.migrationsTable} WHERE version = ?`, + migration.version, + ); + + await logger.info( + `✓ Rolled back migration ${migration.version}: ${migration.name}`, + { + source: "MigrationRunner", + }, + ); + }); + } catch (error) { + await logger.error( + `✗ Failed to rollback migration ${migration.version}: ${migration.name}`, + { + source: "MigrationRunner", + meta: error, + }, + ); + throw error; + } + } + + /** + * Run all pending migrations + */ + async up(migrations: Migration[]): Promise { + this.initialize(); + + // Sort migrations by version + const sortedMigrations = [...migrations].sort((a, b) => + a.version - b.version + ); + + let applied = 0; + for (const migration of sortedMigrations) { + if (this.isApplied(migration.version)) { + continue; + } + + await this.applyMigration(migration); + applied++; + } + + if (applied === 0) { + await logger.info("✓ Database is up to date", { + source: "MigrationRunner", + }); + } + } + + /** + * Rollback to a specific version + */ + async down(migrations: Migration[], targetVersion = 0): Promise { + this.initialize(); + + const currentVersion = this.getCurrentVersion(); + if (currentVersion <= targetVersion) { + await logger.info("✓ Already at target version or below", { + source: "MigrationRunner", + }); + return; + } + + // Sort migrations by version in descending order + const sortedMigrations = [...migrations] + .filter((m) => m.version > targetVersion && m.version <= currentVersion) + .sort((a, b) => b.version - a.version); + + let rolledBack = 0; + for (const migration of sortedMigrations) { + if (!this.isApplied(migration.version)) { + continue; + } + + await this.rollbackMigration(migration); + rolledBack++; + } + + await logger.info(`✓ Rolled back ${rolledBack} migration(s)`, { + source: "MigrationRunner", + }); + } + + /** + * Get list of applied migrations + */ + getAppliedMigrations(): Array< + { version: number; name: string; applied_at: string } + > { + return db.query( + `SELECT version, name, applied_at FROM ${this.migrationsTable} ORDER BY version`, + ); + } + + /** + * Get list of pending migrations + */ + getPendingMigrations(migrations: Migration[]): Migration[] { + const pending: Migration[] = []; + for (const migration of migrations) { + if (!this.isApplied(migration.version)) { + pending.push(migration); + } + } + return pending.sort((a, b) => a.version - b.version); + } + + /** + * Reset the database (rollback all migrations) + */ + async reset(migrations: Migration[]): Promise { + await this.down(migrations, 0); + } + + /** + * Fresh migration (reset and reapply all) + */ + async fresh(migrations: Migration[]): Promise { + await logger.warn("⚠ Resetting database...", { source: "MigrationRunner" }); + await this.reset(migrations); + await logger.info("↻ Reapplying all migrations...", { + source: "MigrationRunner", + }); + await this.up(migrations); + } +} + +// Export singleton instance +export const migrationRunner = new MigrationRunner(); + +/** + * Helper function to load migrations from files + * Loads all migration files from src/db/migrations/ directory + */ +export async function loadMigrations(): Promise { + const migrations: Migration[] = []; + const migrationsDir = new URL("./migrations/", import.meta.url).pathname; + + try { + // Read all files in migrations directory + for await (const entry of Deno.readDir(migrationsDir)) { + // Skip template files (starting with _) + if (entry.name.startsWith("_")) { + continue; + } + + // Only process TypeScript/JavaScript files + if ( + entry.isFile && + (entry.name.endsWith(".ts") || entry.name.endsWith(".js")) + ) { + try { + // Dynamically import the migration file + const migrationModule = await import(/* @vite-ignore */ `./migrations/${entry.name}`); + + // Get the migration object (could be default export or named export) + const migration = migrationModule.default || + migrationModule.migration; + + if (migration && typeof migration.version === "number") { + migrations.push(migration); + } else { + await logger.warn( + `Migration file ${entry.name} does not export a valid migration`, + { + source: "MigrationRunner", + }, + ); + } + } catch (error) { + await logger.error(`Failed to load migration file ${entry.name}`, { + source: "MigrationRunner", + meta: error, + }); + } + } + } + } catch (_error) { + // If directory doesn't exist or can't be read, return empty array + await logger.info("No migrations directory found or empty", { + source: "MigrationRunner", + }); + return []; + } + + // Sort by version number + return migrations.sort((a, b) => a.version - b.version); +} + +/** + * Run migrations + */ +export async function runMigrations(migrations?: Migration[]): Promise { + const migrationsToRun = migrations ?? await loadMigrations(); + await migrationRunner.up(migrationsToRun); +} diff --git a/src/db/migrations/001_create_arr_instances.ts b/src/db/migrations/001_create_arr_instances.ts new file mode 100644 index 0000000..cf9dbcf --- /dev/null +++ b/src/db/migrations/001_create_arr_instances.ts @@ -0,0 +1,52 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 001: Create arr_instances table + * + * Creates the initial table for storing *arr application instance configurations. + * This includes Radarr, Sonarr, Readarr, Lidarr, Prowlarr, etc. + * + * Fields: + * - id: Auto-incrementing primary key + * - name: User-friendly name (unique) + * - type: Instance type (radarr, sonarr, etc.) + * - url: Base URL for the instance + * - api_key: API key for authentication + * - tags: JSON array of tags + * - sync_profile: Optional sync profile identifier + * - enabled: Boolean flag (1=enabled, 0=disabled) + * - created_at: Timestamp of creation + * - updated_at: Timestamp of last update + */ + +export const migration: Migration = { + version: 1, + name: 'Create arr_instances table', + + up: ` + CREATE TABLE arr_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Instance identification + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + + -- Connection details + url TEXT NOT NULL, + api_key TEXT NOT NULL, + + -- Configuration + tags TEXT, + sync_profile TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `, + + down: ` + DROP TABLE IF EXISTS arr_instances; + ` +}; diff --git a/src/db/migrations/002_remove_sync_profile.ts b/src/db/migrations/002_remove_sync_profile.ts new file mode 100644 index 0000000..73651f0 --- /dev/null +++ b/src/db/migrations/002_remove_sync_profile.ts @@ -0,0 +1,20 @@ +import type { Migration } from '../migrations.ts'; + +/** + * Migration 002: Remove sync_profile column + * + * Drops the sync_profile column from arr_instances table as it's not needed. + */ + +export const migration: Migration = { + version: 2, + name: 'Remove sync_profile column from arr_instances', + + up: ` + ALTER TABLE arr_instances DROP COLUMN sync_profile; + `, + + down: ` + ALTER TABLE arr_instances ADD COLUMN sync_profile TEXT; + ` +}; diff --git a/src/db/migrations/_template.ts b/src/db/migrations/_template.ts new file mode 100644 index 0000000..bdc9b2c --- /dev/null +++ b/src/db/migrations/_template.ts @@ -0,0 +1,70 @@ +import type { Migration } from '../migrations.ts'; + +/** + * MIGRATION TEMPLATE + * + * Copy this file to create a new migration: + * 1. Copy _template.ts to a new file (e.g., 001_initial_schema.ts) + * 2. Update the version number (must be unique and sequential) + * 3. Update the name (describe what this migration does) + * 4. Write your SQL in the `up` section + * 5. Write rollback SQL in the `down` section (optional but recommended) + * 6. Delete these comments + * + * NAMING CONVENTION: + * - Use format: NNN_description.ts (e.g., 001_initial_schema.ts, 002_add_users.ts) + * - Numbers should be sequential (001, 002, 003, ...) + * - Use underscores for spaces in description + * - Keep descriptions short but descriptive + * + * BEST PRACTICES: + * - Always test migrations in development first + * - Keep migrations small and focused + * - Use transactions (handled automatically) + * - Always provide a `down` migration for rollback capability + * - Use IF NOT EXISTS for tables/indexes when appropriate + * - Comment complex SQL + * + * VERSION NUMBERS: + * - Must be unique integers + * - Should be sequential (1, 2, 3, ...) + * - Once applied to production, NEVER change a migration + * - If you need to fix a migration, create a new one + */ + +export const migration: Migration = { + // REQUIRED: Unique version number (increment from last migration) + version: 999, // CHANGE THIS + + // REQUIRED: Human-readable description + name: 'Template migration - CHANGE THIS', + + // REQUIRED: SQL to apply the migration (forward migration) + up: ` + -- Example: Create a new table + CREATE TABLE IF NOT EXISTS example_table ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + -- Example: Create an index + CREATE INDEX IF NOT EXISTS idx_example_email ON example_table(email); + + -- Example: Insert initial data + INSERT INTO example_table (name, email) VALUES ('Example', 'example@example.com'); + `, + + // OPTIONAL: SQL to rollback the migration (reverse migration) + // If not provided, migration cannot be rolled back + down: ` + -- Rollback in reverse order of 'up' + DROP INDEX IF EXISTS idx_example_email; + DROP TABLE IF EXISTS example_table; + ` +}; + +// Alternative: You can also use default export +// export default migration; diff --git a/src/db/queries/arrInstances.ts b/src/db/queries/arrInstances.ts new file mode 100644 index 0000000..b55e041 --- /dev/null +++ b/src/db/queries/arrInstances.ts @@ -0,0 +1,169 @@ +import { db } from '../db.ts'; + +/** + * Types for arr_instances table + */ +export interface ArrInstance { + id: number; + name: string; + type: string; + url: string; + api_key: string; + tags: string | null; + enabled: number; + created_at: string; + updated_at: string; +} + +export interface CreateArrInstanceInput { + name: string; + type: string; + url: string; + apiKey: string; + tags?: string[]; + enabled?: boolean; +} + +export interface UpdateArrInstanceInput { + name?: string; + type?: string; + url?: string; + apiKey?: string; + tags?: string[]; + enabled?: boolean; +} + +/** + * All queries for arr_instances table + */ +export const arrInstancesQueries = { + /** + * Create a new arr instance + */ + create(input: CreateArrInstanceInput): number { + const tagsJson = input.tags && input.tags.length > 0 ? JSON.stringify(input.tags) : null; + const enabled = input.enabled !== false ? 1 : 0; + + db.execute( + `INSERT INTO arr_instances (name, type, url, api_key, tags, enabled) + VALUES (?, ?, ?, ?, ?, ?)`, + input.name, + input.type, + input.url, + input.apiKey, + tagsJson, + enabled + ); + + // Get the last inserted ID + const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id'); + return result?.id ?? 0; + }, + + /** + * Get an arr instance by ID + */ + getById(id: number): ArrInstance | undefined { + return db.queryFirst('SELECT * FROM arr_instances WHERE id = ?', id); + }, + + /** + * Get all arr instances + */ + getAll(): ArrInstance[] { + return db.query('SELECT * FROM arr_instances ORDER BY name'); + }, + + /** + * Get arr instances by type + */ + getByType(type: string): ArrInstance[] { + return db.query( + 'SELECT * FROM arr_instances WHERE type = ? ORDER BY name', + type + ); + }, + + /** + * Get enabled arr instances + */ + getEnabled(): ArrInstance[] { + return db.query('SELECT * FROM arr_instances WHERE enabled = 1 ORDER BY name'); + }, + + /** + * Update an arr instance + */ + update(id: number, input: UpdateArrInstanceInput): boolean { + const updates: string[] = []; + const params: (string | number | null)[] = []; + + if (input.name !== undefined) { + updates.push('name = ?'); + params.push(input.name); + } + if (input.type !== undefined) { + updates.push('type = ?'); + params.push(input.type); + } + if (input.url !== undefined) { + updates.push('url = ?'); + params.push(input.url); + } + if (input.apiKey !== undefined) { + updates.push('api_key = ?'); + params.push(input.apiKey); + } + if (input.tags !== undefined) { + updates.push('tags = ?'); + params.push(input.tags.length > 0 ? JSON.stringify(input.tags) : null); + } + if (input.enabled !== undefined) { + updates.push('enabled = ?'); + params.push(input.enabled ? 1 : 0); + } + + if (updates.length === 0) { + return false; + } + + // Add updated_at + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(id); + + const affected = db.execute( + `UPDATE arr_instances SET ${updates.join(', ')} WHERE id = ?`, + ...params + ); + + return affected > 0; + }, + + /** + * Delete an arr instance + */ + delete(id: number): boolean { + const affected = db.execute('DELETE FROM arr_instances WHERE id = ?', id); + return affected > 0; + }, + + /** + * Check if an instance name already exists + */ + nameExists(name: string, excludeId?: number): boolean { + if (excludeId !== undefined) { + const result = db.queryFirst<{ count: number }>( + 'SELECT COUNT(*) as count FROM arr_instances WHERE name = ? AND id != ?', + name, + excludeId + ); + return (result?.count ?? 0) > 0; + } + + const result = db.queryFirst<{ count: number }>( + 'SELECT COUNT(*) as count FROM arr_instances WHERE name = ?', + name + ); + return (result?.count ?? 0) > 0; + } +}; diff --git a/src/db/schema.sql b/src/db/schema.sql new file mode 100644 index 0000000..e36a32c --- /dev/null +++ b/src/db/schema.sql @@ -0,0 +1,42 @@ +-- 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-10-20 + +-- ============================================================================== +-- TABLE: migrations +-- Purpose: Track applied database migrations +-- Managed by: MigrationRunner (src/db/migrations.ts) +-- ============================================================================== + +CREATE TABLE IF NOT EXISTS migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================== +-- TABLE: arr_instances +-- Purpose: Store configuration for *arr application instances (Radarr, Sonarr, etc.) +-- Migration: 001_create_arr_instances.ts +-- ============================================================================== + +CREATE TABLE arr_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- Instance identification + name TEXT NOT NULL UNIQUE, -- User-friendly name (e.g., "Main Radarr", "4K Sonarr") + type TEXT NOT NULL, -- Instance type: radarr, sonarr, readarr, lidarr, prowlarr + + -- Connection details + url TEXT NOT NULL, -- Base URL (e.g., "http://localhost:7878") + api_key TEXT NOT NULL, -- API key for authentication + + -- Configuration + tags TEXT, -- JSON array of tags (e.g., '["movies","4k"]') + enabled INTEGER NOT NULL DEFAULT 1, -- 1=enabled, 0=disabled + + -- Metadata + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/deno.d.ts b/src/deno.d.ts index 01d41d6..fbbdd4e 100644 --- a/src/deno.d.ts +++ b/src/deno.d.ts @@ -2,6 +2,9 @@ * Deno global type declarations for SvelteKit project */ +/** App version injected at build time */ +declare const __APP_VERSION__: string; + declare namespace Deno { export const env: { get(key: string): string | undefined; @@ -14,4 +17,24 @@ declare namespace Deno { data: string, options?: { append?: boolean } ): Promise; + + export interface HttpClient { + close(): void; + } + + export interface CreateHttpClientOptions { + poolMaxIdlePerHost?: number; + poolIdleTimeout?: number; + } + + export function createHttpClient(options?: CreateHttpClientOptions): HttpClient; + + export interface DirEntry { + name: string; + isFile: boolean; + isDirectory: boolean; + isSymlink: boolean; + } + + export function readDir(path: string): AsyncIterable; } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index bf86cfd..e009acf 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,8 +1,16 @@ import { config } from '$config'; import { logStartup } from './utils/logger/startup.ts'; +import { db } from '$db/db.ts'; +import { runMigrations } from '$db/migrations.ts'; // Initialize configuration on server startup await config.init(); // Log startup banner await logStartup(); + +// Initialize database +await db.initialize(); + +// Run database migrations +await runMigrations(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9c6db4e..d2225a4 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import logo from '$static/logo.svg'; import Navbar from '$components/navigation/navbar/navbar.svelte'; import PageNav from '$components/navigation/pageNav/pageNav.svelte'; + import ToastContainer from '$components/toast/ToastContainer.svelte'; @@ -12,6 +13,7 @@ +
diff --git a/src/routes/arr/[type]/+page.server.ts b/src/routes/arr/[type]/+page.server.ts new file mode 100644 index 0000000..9957cfb --- /dev/null +++ b/src/routes/arr/[type]/+page.server.ts @@ -0,0 +1,18 @@ +import { error } from '@sveltejs/kit'; +import type { ServerLoad } from '@sveltejs/kit'; + +// Valid arr types +const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr']; + +export const load: ServerLoad = ({ params }) => { + const type = params.type; + + // Validate type + if (!type || !VALID_TYPES.includes(type)) { + error(404, `Invalid arr type: ${type}`); + } + + return { + type + }; +}; diff --git a/src/routes/arr/[type]/+page.svelte b/src/routes/arr/[type]/+page.svelte new file mode 100644 index 0000000..8da5051 --- /dev/null +++ b/src/routes/arr/[type]/+page.svelte @@ -0,0 +1,32 @@ + + +
+
+
+

{displayName}

+

+ Manage your {displayName} instances +

+
+ + + Add Instance + +
+ + +
+

No instances configured yet. Click "Add Instance" to get started.

+
+
diff --git a/src/routes/arr/new/+page.server.ts b/src/routes/arr/new/+page.server.ts new file mode 100644 index 0000000..32d2d9d --- /dev/null +++ b/src/routes/arr/new/+page.server.ts @@ -0,0 +1,102 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from '@sveltejs/kit'; +import { arrInstancesQueries } from '$db/queries/arrInstances.ts'; +import { logger } from '$logger'; + +const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr']; + +export const actions = { + default: async ({ request }) => { + const formData = await request.formData(); + + const name = formData.get('name')?.toString().trim(); + const type = formData.get('type')?.toString().trim(); + const url = formData.get('url')?.toString().trim(); + const apiKey = formData.get('api_key')?.toString().trim(); + const tagsJson = formData.get('tags')?.toString().trim(); + + // Validation + if (!name || !type || !url || !apiKey) { + await logger.warn('Attempted to create instance with missing required fields', { + source: 'arr/new', + meta: { name, type, url, hasApiKey: !!apiKey } + }); + + return fail(400, { + error: 'Name, type, URL, and API key are required', + values: { name, type, url } + }); + } + + if (!VALID_TYPES.includes(type)) { + await logger.warn('Attempted to create instance with invalid type', { + source: 'arr/new', + meta: { name, type, url } + }); + + return fail(400, { + error: 'Invalid arr type', + values: { name, type, url } + }); + } + + // Check if name already exists + if (arrInstancesQueries.nameExists(name)) { + await logger.warn('Attempted to create instance with duplicate name', { + source: 'arr/new', + meta: { name, type } + }); + + return fail(400, { + error: 'An instance with this name already exists', + values: { name, type, url } + }); + } + + // Parse tags + let tags: string[] = []; + if (tagsJson) { + try { + const parsed = JSON.parse(tagsJson); + if (Array.isArray(parsed)) { + tags = parsed; + } + } catch (error) { + await logger.warn('Failed to parse tags JSON', { + source: 'arr/new', + meta: { tagsJson, error } + }); + } + } + + try { + // Create the instance + const id = arrInstancesQueries.create({ + name, + type, + url, + apiKey, + tags + }); + + await logger.info(`Created new ${type} instance: ${name}`, { + source: 'arr/new', + meta: { id, name, type, url } + }); + + } catch (error) { + await logger.error('Failed to create arr instance', { + source: 'arr/new', + meta: error + }); + + return fail(500, { + error: 'Failed to create instance', + values: { name, type, url } + }); + } + + // Redirect to the type page (outside try-catch since redirect throws) + redirect(303, `/arr/${type}`); + } +} satisfies Actions; diff --git a/src/routes/arr/new/+page.svelte b/src/routes/arr/new/+page.svelte new file mode 100644 index 0000000..9041115 --- /dev/null +++ b/src/routes/arr/new/+page.svelte @@ -0,0 +1,229 @@ + + +
+
+

Add Arr Instance

+

+ Configure a new Radarr, Sonarr, Lidarr, or Chaptarr instance +

+
+ +
+
{ + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + // Show error toast + toastStore.add('error', (result.data as { error?: string }).error || 'Failed to save instance'); + } else if (result.type === 'redirect') { + // Show success toast before redirect + toastStore.add('success', 'Instance created successfully!'); + } + await update(); + }; + }} + > + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ +
+

+ Optional. Press Enter to add a tag, Backspace to remove. +

+ + 0 ? JSON.stringify(tags) : ''} /> +
+ + +
+
+ + + +
+ + + {#if connectionStatus === 'success'} +

+ Connection test passed! You can now save this instance. +

+ {/if} + {#if connectionStatus === 'error'} +

+ {connectionError} +

+ {/if} + {#if !canSubmit && connectionStatus !== 'success'} +

+ Please test the connection before saving +

+ {/if} +
+
+
+
diff --git a/src/routes/arr/test/+server.ts b/src/routes/arr/test/+server.ts new file mode 100644 index 0000000..45efc56 --- /dev/null +++ b/src/routes/arr/test/+server.ts @@ -0,0 +1,40 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { createArrClient } from '$arr/factory.ts'; +import type { ArrType } from '$arr/types.ts'; + +const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr']; + +export const POST: RequestHandler = async ({ request }) => { + try { + const { type, url, apiKey } = await request.json(); + + // Validation + if (!type || !url || !apiKey) { + return json({ success: false, error: 'Missing required fields' }, { status: 400 }); + } + + if (!VALID_TYPES.includes(type)) { + return json({ success: false, error: 'Invalid arr type' }, { status: 400 }); + } + + // Create client and test connection + const client = createArrClient(type as ArrType, url, apiKey); + const isConnected = await client.testConnection(); + client.close(); + + if (isConnected) { + return json({ success: true }); + } else { + return json({ success: false, error: 'Connection test failed' }); + } + } catch (error) { + return json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +}; diff --git a/src/routes/settings/about/+page.server.ts b/src/routes/settings/about/+page.server.ts new file mode 100644 index 0000000..e10521a --- /dev/null +++ b/src/routes/settings/about/+page.server.ts @@ -0,0 +1,111 @@ +import { migrationRunner } from '$db/migrations.ts'; +import { config } from '$config'; +import packageJson from '../../../../package.json' with { type: 'json' }; + +type GitHubRelease = { + tag_name: string; + name: string; + published_at: string; + html_url: string; + prerelease: boolean; +}; + +type VersionStatus = 'up-to-date' | 'out-of-date' | 'dev-build'; + +async function fetchGitHubReleases(): Promise { + try { + const response = await fetch( + 'https://api.github.com/repos/Dictionarry-Hub/profilarr/releases', + { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Profilarr' + } + } + ); + + if (!response.ok) { + return []; + } + + return await response.json(); + } catch { + return []; + } +} + +function compareVersions(v1: string, v2: string): number { + const parts1 = v1.split('.').map(Number); + const parts2 = v2.split('.').map(Number); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const num1 = parts1[i] || 0; + const num2 = parts2[i] || 0; + + if (num1 > num2) return 1; + if (num1 < num2) return -1; + } + + return 0; +} + +function getVersionStatus( + currentVersion: string, + latestVersion: string | undefined +): VersionStatus { + if (!latestVersion) { + return 'dev-build'; + } + + // Remove 'v' prefix if present + const current = currentVersion.replace(/^v/, ''); + const latest = latestVersion.replace(/^v/, ''); + + // Check if it's a dev build (e.g., has -dev, -alpha, -beta suffix) + if (current.includes('-') || current.includes('dev')) { + return 'dev-build'; + } + + // Compare versions semantically + const comparison = compareVersions(current, latest); + + if (comparison > 0) { + // Current version is greater than latest release - must be a dev build + return 'dev-build'; + } else if (comparison === 0) { + // Versions are equal + return 'up-to-date'; + } else { + // Current version is less than latest release + return 'out-of-date'; + } +} + +export const load = async () => { + const currentMigrationVersion = migrationRunner.getCurrentVersion(); + const appliedMigrations = migrationRunner.getAppliedMigrations(); + + // Fetch GitHub releases + const releases = await fetchGitHubReleases(); + const latestRelease = releases.find((r) => !r.prerelease); + + // Determine version status + const versionStatus = getVersionStatus(packageJson.version, latestRelease?.tag_name); + + return { + version: packageJson.version, + versionStatus, + timezone: config.timezone, + paths: { + base: config.paths.base, + data: config.paths.data, + logs: config.paths.logs, + database: config.paths.database + }, + migration: { + current: currentMigrationVersion, + applied: appliedMigrations + }, + releases: releases.slice(0, 10) // Return latest 10 releases + }; +}; diff --git a/src/routes/settings/about/+page.svelte b/src/routes/settings/about/+page.svelte new file mode 100644 index 0000000..a9e780c --- /dev/null +++ b/src/routes/settings/about/+page.svelte @@ -0,0 +1,200 @@ + + +
+

About Profilarr

+ +
+ + + + + Version + + +
+ + v{data.version} + + +
+ + + +
+ + {#each sections as section (section.title)} + + {#each section.rows as row (row.label)} + + {/each} + + {/each} + + + {#if data.migration.applied.length > 0} + + + + Migrations + + +
+ {#each data.migration.applied as migration (migration.version)} +
+
+ + v{migration.version} + + + {migration.name} + +
+ + {new Date(migration.applied_at).toLocaleDateString()} + +
+ {/each} +
+ + +
+ {/if} + + + {#if data.releases.length > 0} + + + +
+ {#each data.releases as release, index (release.tag_name)} +
+
+ + {release.tag_name} + + {#if index === 0} + + Latest + + {/if} + {#if release.prerelease} + + Pre-release + + {/if} +
+ + {new Date(release.published_at).toLocaleDateString()} + +
+ {/each} +
+ + +
+ {/if} +
+
diff --git a/src/routes/settings/about/components/InfoRow.svelte b/src/routes/settings/about/components/InfoRow.svelte new file mode 100644 index 0000000..8b8b87c --- /dev/null +++ b/src/routes/settings/about/components/InfoRow.svelte @@ -0,0 +1,33 @@ + + + + + {label} + + + {#if type === 'code'} + + {value} + + {:else if type === 'link' && href} + + {value} + + {:else} + {value} + {/if} + + diff --git a/src/routes/settings/about/components/InfoTable.svelte b/src/routes/settings/about/components/InfoTable.svelte new file mode 100644 index 0000000..83260a6 --- /dev/null +++ b/src/routes/settings/about/components/InfoTable.svelte @@ -0,0 +1,23 @@ + + +
+
+
+ {#if icon} + + {/if} +

{title}

+
+
+ + + + +
+
diff --git a/src/routes/settings/about/components/VersionBadge.svelte b/src/routes/settings/about/components/VersionBadge.svelte new file mode 100644 index 0000000..b51dc3a --- /dev/null +++ b/src/routes/settings/about/components/VersionBadge.svelte @@ -0,0 +1,26 @@ + + + + {config.label} + diff --git a/src/stores/toast.ts b/src/stores/toast.ts new file mode 100644 index 0000000..4a48988 --- /dev/null +++ b/src/stores/toast.ts @@ -0,0 +1,41 @@ +import { writable } from 'svelte/store'; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface Toast { + id: string; + type: ToastType; + message: string; + duration?: number; // Auto-dismiss duration in ms (default: 5000) +} + +function createToastStore() { + const { subscribe, update } = writable([]); + + return { + subscribe, + add: (type: ToastType, message: string, duration = 5000) => { + const id = crypto.randomUUID(); + const toast: Toast = { id, type, message, duration }; + + update((toasts) => [...toasts, toast]); + + // Auto-dismiss after duration + if (duration > 0) { + setTimeout(() => { + update((toasts) => toasts.filter((t) => t.id !== id)); + }, duration); + } + + return id; + }, + remove: (id: string) => { + update((toasts) => toasts.filter((t) => t.id !== id)); + }, + clear: () => { + update(() => []); + } + }; +} + +export const toastStore = createToastStore(); diff --git a/src/utils/api/request.ts b/src/utils/api/request.ts new file mode 100644 index 0000000..7678d5b --- /dev/null +++ b/src/utils/api/request.ts @@ -0,0 +1,88 @@ +import { toastStore } from '$stores/toast'; + +export interface ApiRequestOptions extends RequestInit { + showSuccessToast?: boolean; // Show toast on success (default: false) + showErrorToast?: boolean; // Show toast on error (default: true) + successMessage?: string; // Custom success message + errorMessage?: string; // Custom error message (overrides server error) +} + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + public data?: unknown + ) { + super(message); + this.name = 'ApiError'; + } +} + +/** + * Wrapper for fetch API with automatic toast notifications + * @param url - Request URL + * @param options - Request options with toast configuration + * @returns Response data (automatically parsed as JSON) + */ +export async function apiRequest( + url: string, + options: ApiRequestOptions = {} +): Promise { + const { + showSuccessToast = false, + showErrorToast = true, + successMessage, + errorMessage, + headers, + ...fetchOptions + } = options; + + try { + // Set default headers + const defaultHeaders: HeadersInit = { + 'Content-Type': 'application/json' + }; + + const response = await fetch(url, { + ...fetchOptions, + headers: { + ...defaultHeaders, + ...headers + } + }); + + // Parse response + const data = await response.json(); + + // Handle HTTP errors + if (!response.ok) { + const message = errorMessage || data.error || data.message || `HTTP ${response.status}`; + + if (showErrorToast) { + toastStore.add('error', message); + } + + throw new ApiError(message, response.status, data); + } + + // Show success toast if requested + if (showSuccessToast && successMessage) { + toastStore.add('success', successMessage); + } + + return data as T; + } catch (error) { + // Handle network errors or other exceptions + if (error instanceof ApiError) { + throw error; + } + + const message = errorMessage || (error instanceof Error ? error.message : 'Network error'); + + if (showErrorToast) { + toastStore.add('error', message); + } + + throw new ApiError(message, 0); + } +} diff --git a/src/utils/arr/README.md b/src/utils/arr/README.md new file mode 100644 index 0000000..e39f782 --- /dev/null +++ b/src/utils/arr/README.md @@ -0,0 +1,105 @@ +# Arr HTTP Client Utilities + +Object-oriented HTTP client architecture for communicating with *arr +applications (Radarr, Sonarr, Lidarr, Chaptarr). + +## Architecture + +### Class Hierarchy + +``` +BaseHttpClient (generic HTTP operations) + ↓ +BaseArrClient (arr-specific features: API key auth, common patterns) + ↓ +RadarrClient, SonarrClient, LidarrClient, ChaptarrClient (specific API methods) +``` + +### File Structure + +``` +src/utils/http/ +├── client.ts # BaseHttpClient - connection pooling, basic HTTP methods +└── types.ts # TypeScript types for HTTP requests/responses + +src/utils/arr/ +├── base.ts # BaseArrClient - arr-specific auth, common patterns +├── radarr.ts # RadarrClient - Radarr API methods +├── sonarr.ts # SonarrClient - Sonarr API methods +├── lidarr.ts # LidarrClient - Lidarr API methods +├── chaptarr.ts # ChaptarrClient - Chaptarr API methods +├── factory.ts # Factory function to create client by type +├── types.ts # Arr-specific types +└── README.md # This file +``` + +## Class Responsibilities + +### BaseHttpClient (`src/utils/http/client.ts`) + +Base HTTP client with generic request capabilities. + +**Features:** + +- Connection pooling (using Deno's built-in `fetch` with keep-alive) +- Basic HTTP methods: `get()`, `post()`, `put()`, `delete()`, `patch()` +- Request/response handling +- Error handling +- Configurable timeouts +- Base URL management + +**Constructor:** + +```typescript +new BaseHttpClient(baseUrl: string, options?: HttpClientOptions) +``` + +**Methods:** + +- `get(path: string, options?: RequestOptions): Promise` +- `post(path: string, body?: unknown, options?: RequestOptions): Promise` +- `put(path: string, body?: unknown, options?: RequestOptions): Promise` +- `delete(path: string, options?: RequestOptions): Promise` +- `patch(path: string, body?: unknown, options?: RequestOptions): Promise` + +### BaseArrClient (`src/utils/arr/base.ts`) + +Base client for all *arr applications. Extends `BaseHttpClient`. + +**Features:** + +- Automatically adds `X-Api-Key` header to all requests +- Common arr API patterns (pagination, filtering) +- Connection testing via `/api/v3/system/status` + +**Constructor:** + +```typescript +new BaseArrClient(url: string, apiKey: string) +``` + +**Methods:** + +- `testConnection(): Promise` - Test connection to arr instance +- All methods from `BaseHttpClient` + +### Future Usage (when specific methods are implemented) + +```typescript +import { createArrClient } from "$utils/arr/factory.ts"; + +const radarr = createArrClient("radarr", "http://localhost:7878", "api-key"); + +// Get movies +const movies = await radarr.getMovies(); + +// Get quality profiles +const profiles = await radarr.getQualityProfiles(); + +// Add movie +await radarr.addMovie({ + title: "Inception", + tmdbId: 27205, + qualityProfileId: 1, +}); +``` diff --git a/src/utils/arr/base.ts b/src/utils/arr/base.ts new file mode 100644 index 0000000..5006ea0 --- /dev/null +++ b/src/utils/arr/base.ts @@ -0,0 +1,49 @@ +import { BaseHttpClient } from '../http/client.ts'; +import type { ArrSystemStatus } from './types.ts'; +import { logger } from '$logger'; + +/** + * Base client for all *arr applications + * Extends BaseHttpClient with arr-specific features + */ +export class BaseArrClient extends BaseHttpClient { + private apiKey: string; + + constructor(url: string, apiKey: string) { + super(url, { + headers: { + 'X-Api-Key': apiKey + } + }); + this.apiKey = apiKey; + } + + /** + * Test connection to the arr instance + * Calls /api/v3/system/status endpoint + * @returns true if connection successful, false otherwise + */ + async testConnection(): Promise { + try { + const status = await this.get('/api/v3/system/status'); + + await logger.info(`Connection successful to ${this.baseUrl}`, { + source: 'BaseArrClient', + meta: { + appName: status.appName, + version: status.version, + osName: status.osName + } + }); + + return true; + } catch (error) { + await logger.error(`Connection failed to ${this.baseUrl}`, { + source: 'BaseArrClient', + meta: error + }); + + return false; + } + } +} diff --git a/src/utils/arr/clients/chaptarr.ts b/src/utils/arr/clients/chaptarr.ts new file mode 100644 index 0000000..723734c --- /dev/null +++ b/src/utils/arr/clients/chaptarr.ts @@ -0,0 +1,9 @@ +import { BaseArrClient } from '../base.ts'; + +/** + * Chaptarr API client + * Extends BaseArrClient with Chaptarr-specific API methods + */ +export class ChaptarrClient extends BaseArrClient { + // Specific API methods will be implemented here as needed +} diff --git a/src/utils/arr/clients/lidarr.ts b/src/utils/arr/clients/lidarr.ts new file mode 100644 index 0000000..b9b61a7 --- /dev/null +++ b/src/utils/arr/clients/lidarr.ts @@ -0,0 +1,9 @@ +import { BaseArrClient } from '../base.ts'; + +/** + * Lidarr API client + * Extends BaseArrClient with Lidarr-specific API methods + */ +export class LidarrClient extends BaseArrClient { + // Specific API methods will be implemented here as needed +} diff --git a/src/utils/arr/clients/radarr.ts b/src/utils/arr/clients/radarr.ts new file mode 100644 index 0000000..41c2b7c --- /dev/null +++ b/src/utils/arr/clients/radarr.ts @@ -0,0 +1,9 @@ +import { BaseArrClient } from '../base.ts'; + +/** + * Radarr API client + * Extends BaseArrClient with Radarr-specific API methods + */ +export class RadarrClient extends BaseArrClient { + // Specific API methods will be implemented here as needed +} diff --git a/src/utils/arr/clients/sonarr.ts b/src/utils/arr/clients/sonarr.ts new file mode 100644 index 0000000..620a164 --- /dev/null +++ b/src/utils/arr/clients/sonarr.ts @@ -0,0 +1,9 @@ +import { BaseArrClient } from '../base.ts'; + +/** + * Sonarr API client + * Extends BaseArrClient with Sonarr-specific API methods + */ +export class SonarrClient extends BaseArrClient { + // Specific API methods will be implemented here as needed +} diff --git a/src/utils/arr/factory.ts b/src/utils/arr/factory.ts new file mode 100644 index 0000000..8348a0d --- /dev/null +++ b/src/utils/arr/factory.ts @@ -0,0 +1,32 @@ +import type { ArrType } from './types.ts'; +import { BaseArrClient } from './base.ts'; +import { RadarrClient } from './clients/radarr.ts'; +import { SonarrClient } from './clients/sonarr.ts'; +import { LidarrClient } from './clients/lidarr.ts'; +import { ChaptarrClient } from './clients/chaptarr.ts'; + +/** + * Factory function to create an arr client instance + * @param type - The arr application type (radarr, sonarr, lidarr, chaptarr) + * @param url - Base URL of the arr instance + * @param apiKey - API key for authentication + * @returns Arr client instance + */ +export function createArrClient( + type: ArrType, + url: string, + apiKey: string +): BaseArrClient { + switch (type) { + case 'radarr': + return new RadarrClient(url, apiKey); + case 'sonarr': + return new SonarrClient(url, apiKey); + case 'lidarr': + return new LidarrClient(url, apiKey); + case 'chaptarr': + return new ChaptarrClient(url, apiKey); + default: + throw new Error(`Unknown arr type: ${type}`); + } +} diff --git a/src/utils/arr/types.ts b/src/utils/arr/types.ts new file mode 100644 index 0000000..85c78db --- /dev/null +++ b/src/utils/arr/types.ts @@ -0,0 +1,43 @@ +/** + * Arr Client Types + */ + +export type ArrType = 'radarr' | 'sonarr' | 'lidarr' | 'chaptarr'; + +/** + * System status response from /api/v3/system/status + * Based on actual Radarr API response + */ +export interface ArrSystemStatus { + appName: string; + instanceName: string; + version: string; + buildTime: string; + isDebug: boolean; + isProduction: boolean; + isAdmin: boolean; + isUserInteractive: boolean; + startupPath: string; + appData: string; + osName: string; + osVersion: string; + isNetCore: boolean; + isLinux: boolean; + isOsx: boolean; + isWindows: boolean; + isDocker: boolean; + mode: 'console' | string; + branch: string; + databaseType: 'sqLite' | string; + databaseVersion: string; + authentication: 'none' | 'basic' | 'forms' | string; + migrationVersion: number; + urlBase: string; + runtimeVersion: string; + runtimeName: string; + startTime: string; + packageVersion: string; + packageAuthor: string; + packageUpdateMechanism: 'builtIn' | string; + packageUpdateMechanismMessage: string; +} diff --git a/src/utils/config/config.ts b/src/utils/config/config.ts index 03bb605..8ddccf0 100644 --- a/src/utils/config/config.ts +++ b/src/utils/config/config.ts @@ -4,12 +4,18 @@ class Config { private basePath: string; + public readonly timezone: string; constructor() { // Default base path logic: // 1. Check environment variable // 2. Fall back to /app (Docker default) this.basePath = Deno.env.get('APP_BASE_PATH') || '/app'; + + // Timezone configuration: + // 1. Check TZ environment variable + // 2. Fall back to system timezone + this.timezone = Deno.env.get('TZ') || Intl.DateTimeFormat().resolvedOptions().timeZone; } /** @@ -18,6 +24,7 @@ class Config { */ async init(): Promise { await Deno.mkdir(this.paths.logs, { recursive: true }); + await Deno.mkdir(this.paths.data, { recursive: true }); } /** @@ -39,7 +46,13 @@ class Config { }, get logFile(): string { return `${config.basePath}/logs/app.log`; - } + }, + get data(): string { + return `${config.basePath}/data`; + }, + get database(): string { + return `${config.basePath}/data/profilarr.db`; + }, }; } diff --git a/src/utils/http/client.ts b/src/utils/http/client.ts new file mode 100644 index 0000000..c5895ce --- /dev/null +++ b/src/utils/http/client.ts @@ -0,0 +1,180 @@ +import type { HttpClientOptions, RequestOptions } from './types.ts'; +import { HttpError } from './types.ts'; + +/** + * Base HTTP client with connection pooling and generic request capabilities + */ +export class BaseHttpClient { + protected baseUrl: string; + protected defaultHeaders: Record; + protected timeout: number; + protected retries: number; + protected retryDelay: number; + protected retryStatusCodes: number[]; + protected httpClient: Deno.HttpClient; + + constructor(baseUrl: string, options?: HttpClientOptions) { + // Ensure baseUrl doesn't have trailing slash + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.timeout = options?.timeout ?? 30000; + this.retries = options?.retries ?? 3; + this.retryDelay = options?.retryDelay ?? 500; + this.retryStatusCodes = options?.retryStatusCodes ?? [500, 502, 503, 504]; + this.defaultHeaders = { + 'Content-Type': 'application/json', + ...options?.headers + }; + + // Create HTTP client with connection pooling + this.httpClient = Deno.createHttpClient({ + poolMaxIdlePerHost: options?.poolMaxIdlePerHost ?? 5, + poolIdleTimeout: options?.poolIdleTimeout ?? 30000 + }); + } + + /** + * Sleep for specified milliseconds + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Make an HTTP request with retry logic + */ + protected async request( + method: string, + path: string, + options?: RequestOptions & { body?: unknown } + ): Promise { + let lastError: HttpError | undefined; + + // Retry loop + for (let attempt = 0; attempt <= this.retries; attempt++) { + try { + const url = `${this.baseUrl}${path}`; + const timeout = options?.timeout ?? this.timeout; + + // Create abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const headers = { + ...this.defaultHeaders, + ...options?.headers + }; + + const response = await fetch(url, { + method, + headers, + body: options?.body ? JSON.stringify(options.body) : undefined, + signal: options?.signal ?? controller.signal, + client: this.httpClient + }); + + clearTimeout(timeoutId); + + // Parse response + const data = await response.json(); + + // Check for HTTP errors + if (!response.ok) { + const error = new HttpError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + data + ); + + // Retry on specific status codes + if (this.retryStatusCodes.includes(response.status) && attempt < this.retries) { + lastError = error; + // Exponential backoff: delay * (2 ^ attempt) + const delay = this.retryDelay * Math.pow(2, attempt); + await this.sleep(delay); + continue; + } + + throw error; + } + + return data as T; + } catch (error) { + clearTimeout(timeoutId); + + // Handle abort/timeout + if (error instanceof Error && error.name === 'AbortError') { + throw new HttpError('Request timeout', 408); + } + + // Re-throw HttpError (might be retryable) + if (error instanceof HttpError) { + throw error; + } + + // Wrap other errors + throw new HttpError( + error instanceof Error ? error.message : 'Unknown error', + 0 + ); + } + } catch (error) { + // If it's not an HttpError or not retryable, throw immediately + if (error instanceof HttpError) { + if (this.retryStatusCodes.includes(error.status) && attempt < this.retries) { + lastError = error; + const delay = this.retryDelay * Math.pow(2, attempt); + await this.sleep(delay); + continue; + } + } + throw error; + } + } + + // If we exhausted retries, throw the last error + throw lastError ?? new HttpError('Request failed after retries', 0); + } + + /** + * GET request + */ + get(path: string, options?: RequestOptions): Promise { + return this.request('GET', path, options); + } + + /** + * POST request + */ + post(path: string, body?: unknown, options?: RequestOptions): Promise { + return this.request('POST', path, { ...options, body }); + } + + /** + * PUT request + */ + put(path: string, body?: unknown, options?: RequestOptions): Promise { + return this.request('PUT', path, { ...options, body }); + } + + /** + * DELETE request + */ + delete(path: string, options?: RequestOptions): Promise { + return this.request('DELETE', path, options); + } + + /** + * PATCH request + */ + patch(path: string, body?: unknown, options?: RequestOptions): Promise { + return this.request('PATCH', path, { ...options, body }); + } + + /** + * Close the HTTP client and cleanup resources + */ + close(): void { + this.httpClient.close(); + } +} diff --git a/src/utils/http/types.ts b/src/utils/http/types.ts new file mode 100644 index 0000000..5dadd67 --- /dev/null +++ b/src/utils/http/types.ts @@ -0,0 +1,30 @@ +/** + * HTTP Client Types + */ + +export interface HttpClientOptions { + timeout?: number; // Request timeout in milliseconds (default: 30000) + headers?: Record; // Default headers to include in all requests + retries?: number; // Number of retries (default: 3) + retryDelay?: number; // Initial retry delay in ms (default: 500) + retryStatusCodes?: number[]; // Status codes to retry on (default: [500, 502, 503, 504]) + poolMaxIdlePerHost?: number; // Max idle connections per host (default: 5) + poolIdleTimeout?: number; // Idle connection timeout in ms (default: 30000) +} + +export interface RequestOptions { + headers?: Record; // Additional headers for this request + timeout?: number; // Override timeout for this request + signal?: AbortSignal; // Abort signal for cancellation +} + +export class HttpError extends Error { + constructor( + message: string, + public status: number, + public response?: unknown + ) { + super(message); + this.name = 'HttpError'; + } +} diff --git a/svelte.config.js b/svelte.config.js index 58d5148..b2da855 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -11,10 +11,15 @@ const config = { }), alias: { $config: './src/utils/config/config.ts', + $logger: './src/utils/logger/logger.ts', $stores: './src/stores', $components: './src/components', $static: './src/static', - $server: './src/server' + $server: './src/server', + $db: './src/db', + $arr: './src/utils/arr', + $http: './src/utils/http', + $api: './src/utils/api/request.ts' } } };