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 @@
+
+
+
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 @@
+
+
+
+
+
+
+
+
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
+
+
+
+
+
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'
}
}
};