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
This commit is contained in:
Sam Chau
2025-10-20 02:13:09 +10:30
parent 24e5571a66
commit e24410f6f3
42 changed files with 2494 additions and 6 deletions

1
.npmrc
View File

@@ -1 +1,2 @@
engine-strict=true engine-strict=true
@jsr:registry=https://npm.jsr.io

View File

@@ -3,7 +3,12 @@
"$config": "./src/utils/config/config.ts", "$config": "./src/utils/config/config.ts",
"$stores": "./src/stores", "$stores": "./src/stores",
"$components": "./src/components", "$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": { "tasks": {
"dev": "APP_BASE_PATH=./temp vite dev", "dev": "APP_BASE_PATH=./temp vite dev",

65
deno.lock generated
View File

@@ -3,6 +3,7 @@
"specifiers": { "specifiers": {
"npm:@eslint/compat@^1.4.0": "1.4.0_eslint@9.37.0", "npm:@eslint/compat@^1.4.0": "1.4.0_eslint@9.37.0",
"npm:@eslint/js@^9.36.0": "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/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:@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", "npm:@tailwindcss/forms@~0.5.10": "0.5.10_tailwindcss@4.1.14",
@@ -396,6 +397,69 @@
"@jridgewell/sourcemap-codec" "@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": { "@nodelib/fs.scandir@2.1.5": {
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dependencies": [ "dependencies": [
@@ -1815,6 +1879,7 @@
"dependencies": [ "dependencies": [
"npm:@eslint/compat@^1.4.0", "npm:@eslint/compat@^1.4.0",
"npm:@eslint/js@^9.36.0", "npm:@eslint/js@^9.36.0",
"npm:@jsr/db__sqlite@0.12",
"npm:@sveltejs/kit@^2.43.2", "npm:@sveltejs/kit@^2.43.2",
"npm:@sveltejs/vite-plugin-svelte@^6.2.0", "npm:@sveltejs/vite-plugin-svelte@^6.2.0",
"npm:@tailwindcss/forms@~0.5.10", "npm:@tailwindcss/forms@~0.5.10",

5
src/app.d.ts vendored
View File

@@ -8,6 +8,11 @@ declare global {
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
// Extend RequestInit to support Deno HttpClient
interface RequestInit {
client?: Deno.HttpClient;
}
} }
export {}; export {};

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { X } from 'lucide-svelte';
export let tags: string[] = [];
export let placeholder = 'Type and press Enter to add tags';
let inputValue = '';
function addTag() {
const trimmed = inputValue.trim();
if (trimmed && !tags.includes(trimmed)) {
tags = [...tags, trimmed];
inputValue = '';
}
}
function removeTag(index: number) {
tags = tags.filter((_, i) => i !== index);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
addTag();
} else if (event.key === 'Backspace' && inputValue === '' && tags.length > 0) {
// Remove last tag when backspace is pressed on empty input
removeTag(tags.length - 1);
}
}
</script>
<div
class="flex min-h-[2.5rem] flex-wrap gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-800"
>
{#each tags as tag, index (tag)}
<div
class="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
>
<span>{tag}</span>
<button
type="button"
on:click={() => removeTag(index)}
class="hover:text-blue-900 dark:hover:text-blue-100"
aria-label="Remove tag"
>
<X size={14} />
</button>
</div>
{/each}
<input
type="text"
id="tags-input"
bind:value={inputValue}
on:keydown={handleKeydown}
{placeholder}
class="min-w-[120px] flex-1 border-0 bg-transparent text-neutral-900 outline-none placeholder:text-neutral-400 focus:ring-0 dark:text-neutral-50 dark:placeholder:text-neutral-500"
/>
</div>

View File

@@ -10,9 +10,9 @@
<div class="flex-1 overflow-y-auto p-4"> <div class="flex-1 overflow-y-auto p-4">
<Group label="🏠 Home" href="/" hasItems={true}> <Group label="🏠 Home" href="/" hasItems={true}>
<GroupItem label="Databases" href="/databases" /> <GroupItem label="Databases" href="/databases" />
<GroupItem label="Radarr" href="/radarr" /> <GroupItem label="Radarr" href="/arr/radarr" />
<GroupItem label="Sonarr" href="/sonarr" /> <GroupItem label="Sonarr" href="/arr/sonarr" />
<GroupItem label="Lidarr" href="/lidarr" /> <GroupItem label="Lidarr" href="/arr/lidarr" />
</Group> </Group>
<Group label="⚡ Quality Profiles" href="/quality-profiles" initialOpen={false} /> <Group label="⚡ Quality Profiles" href="/quality-profiles" initialOpen={false} />

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-svelte';
import type { ToastType } from '$stores/toast';
import { toastStore } from '$stores/toast';
import { fade, fly } from 'svelte/transition';
export let id: string;
export let type: ToastType;
export let message: string;
// Icon mapping
const icons = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info
};
// Style mapping
const styles = {
success: 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200',
error: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200',
info: 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-200'
};
// Icon color mapping
const iconColors = {
success: 'text-green-600 dark:text-green-400',
error: 'text-red-600 dark:text-red-400',
warning: 'text-yellow-600 dark:text-yellow-400',
info: 'text-blue-600 dark:text-blue-400'
};
const Icon = icons[type];
function dismiss() {
toastStore.remove(id);
}
</script>
<div
in:fly={{ y: -20, duration: 300 }}
out:fade={{ duration: 200 }}
class="flex items-start gap-3 rounded-lg border px-4 py-3 shadow-lg {styles[type]}"
>
<Icon size={20} class="mt-0.5 flex-shrink-0 {iconColors[type]}" />
<p class="flex-1 text-sm font-medium">{message}</p>
<button
on:click={dismiss}
class="flex-shrink-0 opacity-50 transition-opacity hover:opacity-100"
aria-label="Dismiss"
>
<X size={16} />
</button>
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { toastStore } from '$stores/toast';
import Toast from './Toast.svelte';
</script>
<div class="pointer-events-none fixed right-4 top-20 z-50 flex flex-col gap-3">
{#each $toastStore as toast (toast.id)}
<div class="pointer-events-auto">
<Toast id={toast.id} type={toast.type} message={toast.message} />
</div>
{/each}
</div>

162
src/db/db.ts Normal file
View File

@@ -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<void> {
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<T = unknown>(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<T = unknown>(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<T>(fn: () => T | Promise<T>): Promise<T> {
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();

301
src/db/migrations.ts Normal file
View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.down(migrations, 0);
}
/**
* Fresh migration (reset and reapply all)
*/
async fresh(migrations: Migration[]): Promise<void> {
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<Migration[]> {
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<void> {
const migrationsToRun = migrations ?? await loadMigrations();
await migrationRunner.up(migrationsToRun);
}

View File

@@ -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;
`
};

View File

@@ -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;
`
};

View File

@@ -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;

View File

@@ -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<ArrInstance>('SELECT * FROM arr_instances WHERE id = ?', id);
},
/**
* Get all arr instances
*/
getAll(): ArrInstance[] {
return db.query<ArrInstance>('SELECT * FROM arr_instances ORDER BY name');
},
/**
* Get arr instances by type
*/
getByType(type: string): ArrInstance[] {
return db.query<ArrInstance>(
'SELECT * FROM arr_instances WHERE type = ? ORDER BY name',
type
);
},
/**
* Get enabled arr instances
*/
getEnabled(): ArrInstance[] {
return db.query<ArrInstance>('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;
}
};

42
src/db/schema.sql Normal file
View File

@@ -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
);

23
src/deno.d.ts vendored
View File

@@ -2,6 +2,9 @@
* Deno global type declarations for SvelteKit project * Deno global type declarations for SvelteKit project
*/ */
/** App version injected at build time */
declare const __APP_VERSION__: string;
declare namespace Deno { declare namespace Deno {
export const env: { export const env: {
get(key: string): string | undefined; get(key: string): string | undefined;
@@ -14,4 +17,24 @@ declare namespace Deno {
data: string, data: string,
options?: { append?: boolean } options?: { append?: boolean }
): Promise<void>; ): Promise<void>;
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<DirEntry>;
} }

View File

@@ -1,8 +1,16 @@
import { config } from '$config'; import { config } from '$config';
import { logStartup } from './utils/logger/startup.ts'; import { logStartup } from './utils/logger/startup.ts';
import { db } from '$db/db.ts';
import { runMigrations } from '$db/migrations.ts';
// Initialize configuration on server startup // Initialize configuration on server startup
await config.init(); await config.init();
// Log startup banner // Log startup banner
await logStartup(); await logStartup();
// Initialize database
await db.initialize();
// Run database migrations
await runMigrations();

View File

@@ -3,6 +3,7 @@
import logo from '$static/logo.svg'; import logo from '$static/logo.svg';
import Navbar from '$components/navigation/navbar/navbar.svelte'; import Navbar from '$components/navigation/navbar/navbar.svelte';
import PageNav from '$components/navigation/pageNav/pageNav.svelte'; import PageNav from '$components/navigation/pageNav/pageNav.svelte';
import ToastContainer from '$components/toast/ToastContainer.svelte';
</script> </script>
<svelte:head> <svelte:head>
@@ -12,6 +13,7 @@
<Navbar /> <Navbar />
<PageNav /> <PageNav />
<ToastContainer />
<main class="pt-16 pl-72"> <main class="pt-16 pl-72">
<slot /> <slot />

View File

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

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import type { PageData } from './$types';
import { Plus } from 'lucide-svelte';
export let data: PageData;
// Capitalize first letter for display (reactive statement)
$: displayName = data.type ? data.type.charAt(0).toUpperCase() + data.type.slice(1) : '';
</script>
<div class="p-8">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">{displayName}</h1>
<p class="mt-3 text-lg text-neutral-600 dark:text-neutral-400">
Manage your {displayName} instances
</p>
</div>
<a
href="/arr/new?type={data.type}"
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<Plus size={16} />
Add Instance
</a>
</div>
<!-- Instance list will go here later -->
<div class="text-neutral-600 dark:text-neutral-400">
<p>No instances configured yet. Click "Add Instance" to get started.</p>
</div>
</div>

View File

@@ -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;

View File

@@ -0,0 +1,229 @@
<script lang="ts">
import { page } from '$app/stores';
import { Check, X, Loader2, Save, Wifi } from 'lucide-svelte';
import { apiRequest } from '$api';
import TagInput from '$components/form/TagInput.svelte';
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import type { ActionData } from './$types';
export let form: ActionData;
// Get type from URL params if provided
const typeFromUrl = $page.url.searchParams.get('type') || '';
// Form values (restore from form action if there was an error)
let name = form?.values?.name ?? '';
let type = form?.values?.type ?? typeFromUrl;
let url = form?.values?.url ?? '';
let apiKey = '';
let tags: string[] = [];
// Connection test state
type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error';
let connectionStatus: ConnectionStatus = 'idle';
let connectionError = '';
// Test connection function
async function testConnection() {
// Validation
if (!type || !url || !apiKey) {
connectionError = 'Please fill in Type, URL, and API Key';
connectionStatus = 'error';
return;
}
connectionStatus = 'testing';
connectionError = '';
try {
const data = await apiRequest<{ success: boolean; error?: string }>('/arr/test', {
method: 'POST',
body: JSON.stringify({ type, url, apiKey }),
showSuccessToast: true,
successMessage: 'Connection successful!'
});
connectionStatus = 'success';
} catch (error) {
connectionStatus = 'error';
connectionError = error instanceof Error ? error.message : 'Connection test failed';
}
}
// Reset connection status when form fields change
function resetConnectionStatus() {
if (connectionStatus !== 'idle') {
connectionStatus = 'idle';
connectionError = '';
}
}
$: canSubmit = connectionStatus === 'success';
</script>
<div class="p-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Add Arr Instance</h1>
<p class="mt-3 text-lg text-neutral-600 dark:text-neutral-400">
Configure a new Radarr, Sonarr, Lidarr, or Chaptarr instance
</p>
</div>
<div class="max-w-2xl">
<form
method="POST"
class="space-y-6"
use:enhance={() => {
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();
};
}}
>
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={name}
required
placeholder="e.g., Main Radarr, 4K Sonarr"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
/>
</div>
<!-- Type -->
<div>
<label for="type" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
Type <span class="text-red-500">*</span>
</label>
<select
id="type"
name="type"
bind:value={type}
on:change={resetConnectionStatus}
required
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50"
>
<option value="">Select type...</option>
<option value="radarr">Radarr</option>
<option value="sonarr">Sonarr</option>
<option value="lidarr">Lidarr</option>
<option value="chaptarr">Chaptarr</option>
</select>
</div>
<!-- URL -->
<div>
<label for="url" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
URL <span class="text-red-500">*</span>
</label>
<input
type="url"
id="url"
name="url"
bind:value={url}
on:input={resetConnectionStatus}
required
placeholder="http://localhost:7878"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
/>
</div>
<!-- API Key -->
<div>
<label for="api_key" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
API Key <span class="text-red-500">*</span>
</label>
<input
type="text"
id="api_key"
name="api_key"
bind:value={apiKey}
on:input={resetConnectionStatus}
required
placeholder="Enter API key"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
/>
</div>
<!-- Tags (optional) -->
<div>
<label for="tags-input" class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
Tags
</label>
<div class="mt-1">
<TagInput bind:tags />
</div>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Optional. Press Enter to add a tag, Backspace to remove.
</p>
<!-- Hidden input to submit tags as JSON -->
<input type="hidden" name="tags" value={tags.length > 0 ? JSON.stringify(tags) : ''} />
</div>
<!-- Buttons -->
<div class="space-y-3">
<div class="flex justify-between gap-3">
<button
type="button"
on:click={testConnection}
disabled={connectionStatus === 'testing'}
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
{#if connectionStatus === 'testing'}
<Loader2 size={14} class="animate-spin" />
Testing...
{:else if connectionStatus === 'success'}
<Check size={14} class="text-green-600 dark:text-green-400" />
Connected
{:else if connectionStatus === 'error'}
<X size={14} class="text-red-600 dark:text-red-400" />
Test
{:else}
<Wifi size={14} />
Test
{/if}
</button>
<button
type="submit"
disabled={!canSubmit}
class="flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<Save size={14} />
Save
</button>
</div>
<!-- Connection Status Messages -->
{#if connectionStatus === 'success'}
<p class="text-sm text-green-600 dark:text-green-400">
Connection test passed! You can now save this instance.
</p>
{/if}
{#if connectionStatus === 'error'}
<p class="text-sm text-red-600 dark:text-red-400">
{connectionError}
</p>
{/if}
{#if !canSubmit && connectionStatus !== 'success'}
<p class="text-sm text-neutral-500 dark:text-neutral-400">
Please test the connection before saving
</p>
{/if}
</div>
</form>
</div>
</div>

View File

@@ -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 }
);
}
};

View File

@@ -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<GitHubRelease[]> {
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
};
};

View File

@@ -0,0 +1,200 @@
<script lang="ts">
import type { PageData } from './$types';
import {
Info,
FolderOpen,
Database,
HelpCircle,
Heart,
ListChecks,
Package
} from 'lucide-svelte';
import InfoTable from './components/InfoTable.svelte';
import InfoRow from './components/InfoRow.svelte';
import VersionBadge from './components/VersionBadge.svelte';
export let data: PageData;
type InfoRowData = {
label: string;
value: string;
type: 'code' | 'link' | 'text';
href?: string;
};
type Section = {
title: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: any;
rows: InfoRowData[];
};
const sections: Section[] = [
{
title: 'Paths',
icon: FolderOpen,
rows: [
{ label: 'Base Path', value: data.paths.base, type: 'code' },
{ label: 'Data Directory', value: data.paths.data, type: 'code' },
{ label: 'Logs Directory', value: data.paths.logs, type: 'code' },
{ label: 'Database Path', value: data.paths.database, type: 'code' }
]
},
{
title: 'Getting Support',
icon: HelpCircle,
rows: [
{
label: 'Documentation',
value: 'https://dictionarry.dev/',
type: 'link',
href: 'https://dictionarry.dev/'
},
{
label: 'GitHub',
value: 'https://github.com/Dictionarry-Hub',
type: 'link',
href: 'https://github.com/Dictionarry-Hub'
},
{
label: 'Discord',
value: 'https://discord.gg/XGdTJP5G8a',
type: 'link',
href: 'https://discord.gg/XGdTJP5G8a'
}
]
},
{
title: 'Support',
icon: Heart,
rows: [
{
label: 'GitHub Sponsors',
value: 'https://github.com/sponsors/Dictionarry-Hub',
type: 'link',
href: 'https://github.com/sponsors/Dictionarry-Hub'
},
{
label: 'Buy Me a Coffee',
value: 'https://www.buymeacoffee.com/santiagosayshey',
type: 'link',
href: 'https://www.buymeacoffee.com/santiagosayshey'
}
]
}
];
</script>
<div class="p-8">
<h1 class="mb-6 text-3xl font-bold text-neutral-900 dark:text-neutral-50">About Profilarr</h1>
<div class="space-y-6">
<!-- Application (special case with version badge) -->
<InfoTable title="Application" icon={Info}>
<tr class="bg-white dark:bg-neutral-900">
<td class="w-1/3 px-6 py-4 text-sm font-medium text-neutral-900 dark:text-neutral-50">
Version
</td>
<td class="px-6 py-4 text-sm">
<div class="flex items-center gap-2">
<code
class="rounded bg-neutral-100 px-2 py-1 font-mono text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
v{data.version}
</code>
<VersionBadge status={data.versionStatus} />
</div>
</td>
</tr>
<InfoRow label="Timezone" value={data.timezone} type="code" />
</InfoTable>
{#each sections as section (section.title)}
<InfoTable title={section.title} icon={section.icon}>
{#each section.rows as row (row.label)}
<InfoRow label={row.label} value={row.value} type={row.type} href={row.href} />
{/each}
</InfoTable>
{/each}
<!-- Database (special case with custom content) -->
{#if data.migration.applied.length > 0}
<InfoTable title="Database" icon={Database}>
<tr class="bg-white dark:bg-neutral-900">
<td
class="w-1/3 px-6 py-4 align-top text-sm font-medium text-neutral-900 dark:text-neutral-50"
>
Migrations
</td>
<td class="px-6 py-4">
<div class="space-y-2">
{#each data.migration.applied as migration (migration.version)}
<div class="flex items-center justify-between">
<div class="text-sm">
<code
class="rounded bg-neutral-100 px-2 py-1 font-mono text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
v{migration.version}
</code>
<span class="ml-2 text-neutral-600 dark:text-neutral-400">
{migration.name}
</span>
</div>
<span class="text-xs text-neutral-500">
{new Date(migration.applied_at).toLocaleDateString()}
</span>
</div>
{/each}
</div>
</td>
</tr>
</InfoTable>
{/if}
<!-- Releases Section -->
{#if data.releases.length > 0}
<InfoTable title="Releases" icon={Package}>
<tr class="bg-white dark:bg-neutral-900">
<td colspan="2" class="px-6 py-4">
<div class="space-y-3">
{#each data.releases as release, index (release.tag_name)}
<div
class="flex items-center justify-between border-b border-neutral-200 pb-3 last:border-0 last:pb-0 dark:border-neutral-800"
>
<div class="flex items-center gap-3">
<a
href={release.html_url}
target="_blank"
rel="noopener noreferrer"
data-sveltekit-reload
class="font-mono text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{release.tag_name}
</a>
{#if index === 0}
<span
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
>
Latest
</span>
{/if}
{#if release.prerelease}
<span
class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
>
Pre-release
</span>
{/if}
</div>
<span class="text-xs text-neutral-500 dark:text-neutral-500">
{new Date(release.published_at).toLocaleDateString()}
</span>
</div>
{/each}
</div>
</td>
</tr>
</InfoTable>
{/if}
</div>
</div>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
export let label: string;
export let value: string;
export let type: 'text' | 'code' | 'link' = 'text';
export let href: string | undefined = undefined;
</script>
<tr class="bg-white dark:bg-neutral-900">
<td class="w-1/3 px-6 py-4 text-sm font-medium text-neutral-900 dark:text-neutral-50">
{label}
</td>
<td class="px-6 py-4 text-sm">
{#if type === 'code'}
<code
class="rounded bg-neutral-100 px-2 py-1 font-mono text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{value}
</code>
{:else if type === 'link' && href}
<a
{href}
target="_blank"
rel="noopener noreferrer"
data-sveltekit-reload
class="rounded bg-neutral-100 px-2 py-1 font-mono text-blue-600 hover:text-blue-700 dark:bg-neutral-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{value}
</a>
{:else}
{value}
{/if}
</td>
</tr>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
export let title: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let icon: any = undefined;
</script>
<div class="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
<div
class="border-b border-neutral-200 bg-neutral-50 px-6 py-3 dark:border-neutral-800 dark:bg-neutral-800"
>
<div class="flex items-center gap-2">
{#if icon}
<svelte:component this={icon} class="h-4 w-4 text-neutral-600 dark:text-neutral-400" />
{/if}
<h2 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">{title}</h2>
</div>
</div>
<table class="w-full">
<tbody class="divide-y divide-neutral-200 dark:divide-neutral-800">
<slot />
</tbody>
</table>
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
export let status: 'up-to-date' | 'out-of-date' | 'dev-build';
const statusConfig = {
'up-to-date': {
label: 'Up to date',
class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
},
'out-of-date': {
label: 'Out of date',
class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
},
'dev-build': {
label: 'Dev build',
class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
}
};
const config = statusConfig[status];
</script>
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {config.class}"
>
{config.label}
</span>

41
src/stores/toast.ts Normal file
View File

@@ -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<Toast[]>([]);
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();

88
src/utils/api/request.ts Normal file
View File

@@ -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<T = unknown>(
url: string,
options: ApiRequestOptions = {}
): Promise<T> {
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);
}
}

105
src/utils/arr/README.md Normal file
View File

@@ -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<T>(path: string, options?: RequestOptions): Promise<T>`
- `post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>`
- `put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>`
- `delete<T>(path: string, options?: RequestOptions): Promise<T>`
- `patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>`
### 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<boolean>` - 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,
});
```

49
src/utils/arr/base.ts Normal file
View File

@@ -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<boolean> {
try {
const status = await this.get<ArrSystemStatus>('/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;
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

32
src/utils/arr/factory.ts Normal file
View File

@@ -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}`);
}
}

43
src/utils/arr/types.ts Normal file
View File

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

View File

@@ -4,12 +4,18 @@
class Config { class Config {
private basePath: string; private basePath: string;
public readonly timezone: string;
constructor() { constructor() {
// Default base path logic: // Default base path logic:
// 1. Check environment variable // 1. Check environment variable
// 2. Fall back to /app (Docker default) // 2. Fall back to /app (Docker default)
this.basePath = Deno.env.get('APP_BASE_PATH') || '/app'; 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<void> { async init(): Promise<void> {
await Deno.mkdir(this.paths.logs, { recursive: true }); 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 { get logFile(): string {
return `${config.basePath}/logs/app.log`; return `${config.basePath}/logs/app.log`;
} },
get data(): string {
return `${config.basePath}/data`;
},
get database(): string {
return `${config.basePath}/data/profilarr.db`;
},
}; };
} }

180
src/utils/http/client.ts Normal file
View File

@@ -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<string, string>;
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Make an HTTP request with retry logic
*/
protected async request<T>(
method: string,
path: string,
options?: RequestOptions & { body?: unknown }
): Promise<T> {
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<T>(path: string, options?: RequestOptions): Promise<T> {
return this.request<T>('GET', path, options);
}
/**
* POST request
*/
post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>('POST', path, { ...options, body });
}
/**
* PUT request
*/
put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>('PUT', path, { ...options, body });
}
/**
* DELETE request
*/
delete<T>(path: string, options?: RequestOptions): Promise<T> {
return this.request<T>('DELETE', path, options);
}
/**
* PATCH request
*/
patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>('PATCH', path, { ...options, body });
}
/**
* Close the HTTP client and cleanup resources
*/
close(): void {
this.httpClient.close();
}
}

30
src/utils/http/types.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* HTTP Client Types
*/
export interface HttpClientOptions {
timeout?: number; // Request timeout in milliseconds (default: 30000)
headers?: Record<string, string>; // 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<string, string>; // 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';
}
}

View File

@@ -11,10 +11,15 @@ const config = {
}), }),
alias: { alias: {
$config: './src/utils/config/config.ts', $config: './src/utils/config/config.ts',
$logger: './src/utils/logger/logger.ts',
$stores: './src/stores', $stores: './src/stores',
$components: './src/components', $components: './src/components',
$static: './src/static', $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'
} }
} }
}; };