mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
@@ -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",
|
||||
|
||||
65
deno.lock
generated
65
deno.lock
generated
@@ -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",
|
||||
|
||||
5
src/app.d.ts
vendored
5
src/app.d.ts
vendored
@@ -8,6 +8,11 @@ declare global {
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
// Extend RequestInit to support Deno HttpClient
|
||||
interface RequestInit {
|
||||
client?: Deno.HttpClient;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
59
src/components/form/TagInput.svelte
Normal file
59
src/components/form/TagInput.svelte
Normal 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>
|
||||
@@ -10,9 +10,9 @@
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<Group label="🏠 Home" href="/" hasItems={true}>
|
||||
<GroupItem label="Databases" href="/databases" />
|
||||
<GroupItem label="Radarr" href="/radarr" />
|
||||
<GroupItem label="Sonarr" href="/sonarr" />
|
||||
<GroupItem label="Lidarr" href="/lidarr" />
|
||||
<GroupItem label="Radarr" href="/arr/radarr" />
|
||||
<GroupItem label="Sonarr" href="/arr/sonarr" />
|
||||
<GroupItem label="Lidarr" href="/arr/lidarr" />
|
||||
</Group>
|
||||
|
||||
<Group label="⚡ Quality Profiles" href="/quality-profiles" initialOpen={false} />
|
||||
|
||||
56
src/components/toast/Toast.svelte
Normal file
56
src/components/toast/Toast.svelte
Normal 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>
|
||||
12
src/components/toast/ToastContainer.svelte
Normal file
12
src/components/toast/ToastContainer.svelte
Normal 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
162
src/db/db.ts
Normal 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
301
src/db/migrations.ts
Normal 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);
|
||||
}
|
||||
52
src/db/migrations/001_create_arr_instances.ts
Normal file
52
src/db/migrations/001_create_arr_instances.ts
Normal 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;
|
||||
`
|
||||
};
|
||||
20
src/db/migrations/002_remove_sync_profile.ts
Normal file
20
src/db/migrations/002_remove_sync_profile.ts
Normal 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;
|
||||
`
|
||||
};
|
||||
70
src/db/migrations/_template.ts
Normal file
70
src/db/migrations/_template.ts
Normal 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;
|
||||
169
src/db/queries/arrInstances.ts
Normal file
169
src/db/queries/arrInstances.ts
Normal 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
42
src/db/schema.sql
Normal 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
23
src/deno.d.ts
vendored
@@ -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<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>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -12,6 +13,7 @@
|
||||
|
||||
<Navbar />
|
||||
<PageNav />
|
||||
<ToastContainer />
|
||||
|
||||
<main class="pt-16 pl-72">
|
||||
<slot />
|
||||
|
||||
18
src/routes/arr/[type]/+page.server.ts
Normal file
18
src/routes/arr/[type]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
32
src/routes/arr/[type]/+page.svelte
Normal file
32
src/routes/arr/[type]/+page.svelte
Normal 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>
|
||||
102
src/routes/arr/new/+page.server.ts
Normal file
102
src/routes/arr/new/+page.server.ts
Normal 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;
|
||||
229
src/routes/arr/new/+page.svelte
Normal file
229
src/routes/arr/new/+page.svelte
Normal 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>
|
||||
40
src/routes/arr/test/+server.ts
Normal file
40
src/routes/arr/test/+server.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
111
src/routes/settings/about/+page.server.ts
Normal file
111
src/routes/settings/about/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
200
src/routes/settings/about/+page.svelte
Normal file
200
src/routes/settings/about/+page.svelte
Normal 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>
|
||||
33
src/routes/settings/about/components/InfoRow.svelte
Normal file
33
src/routes/settings/about/components/InfoRow.svelte
Normal 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>
|
||||
23
src/routes/settings/about/components/InfoTable.svelte
Normal file
23
src/routes/settings/about/components/InfoTable.svelte
Normal 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>
|
||||
26
src/routes/settings/about/components/VersionBadge.svelte
Normal file
26
src/routes/settings/about/components/VersionBadge.svelte
Normal 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
41
src/stores/toast.ts
Normal 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
88
src/utils/api/request.ts
Normal 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
105
src/utils/arr/README.md
Normal 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
49
src/utils/arr/base.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/utils/arr/clients/chaptarr.ts
Normal file
9
src/utils/arr/clients/chaptarr.ts
Normal 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
|
||||
}
|
||||
9
src/utils/arr/clients/lidarr.ts
Normal file
9
src/utils/arr/clients/lidarr.ts
Normal 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
|
||||
}
|
||||
9
src/utils/arr/clients/radarr.ts
Normal file
9
src/utils/arr/clients/radarr.ts
Normal 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
|
||||
}
|
||||
9
src/utils/arr/clients/sonarr.ts
Normal file
9
src/utils/arr/clients/sonarr.ts
Normal 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
32
src/utils/arr/factory.ts
Normal 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
43
src/utils/arr/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
180
src/utils/http/client.ts
Normal file
180
src/utils/http/client.ts
Normal 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
30
src/utils/http/types.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user