refactor(arr): create shared instance component for new / edit instance

- add ability to edit existing instances
- add ability to delete existing instances
This commit is contained in:
Sam Chau
2025-10-20 04:02:56 +10:30
parent ea9a01c0d6
commit c7f0698f2d
19 changed files with 1103 additions and 671 deletions

View File

@@ -0,0 +1,357 @@
<script lang="ts">
import { Check, X, Loader2, Save, Wifi, Trash2 } from 'lucide-svelte';
import { apiRequest } from '$api';
import TagInput from '$components/form/TagInput.svelte';
import Modal from '$components/modal/Modal.svelte';
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import type { ArrInstance } from '$db/queries/arrInstances.ts';
// Props
export let mode: 'create' | 'edit';
export let instance: ArrInstance | undefined = undefined;
export let initialType: string = '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export let form: any = undefined;
// Parse tags from JSON string
const parseTags = (tagsJson: string | null): string[] => {
if (!tagsJson) return [];
try {
return JSON.parse(tagsJson);
} catch {
return [];
}
};
// Form values
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let name = (form as any)?.values?.name ?? (mode === 'edit' ? instance?.name : '') ?? '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let type = mode === 'edit' ? instance?.type : ((form as any)?.values?.type ?? initialType ?? '');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let url = (form as any)?.values?.url ?? (mode === 'edit' ? instance?.url : '') ?? '';
let apiKey = ''; // Never pre-populate API key for security
let tags: string[] = mode === 'edit' && instance ? parseTags(instance.tags) : [];
// Connection test state
type ConnectionStatus = 'idle' | 'testing' | 'success' | 'error';
let connectionStatus: ConnectionStatus = 'idle';
let connectionError = '';
// Delete modal state
let showDeleteModal = false;
let deleteFormElement: HTMLFormElement;
// 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 {
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';
// Display text based on mode
$: title = mode === 'create' ? 'Add Arr Instance' : 'Edit Instance';
$: description =
mode === 'create'
? 'Configure a new Radarr, Sonarr, Lidarr, or Chaptarr instance'
: `Update the configuration for ${instance?.name || 'this instance'}`;
$: submitButtonText = mode === 'create' ? 'Save' : 'Save';
$: successMessage =
mode === 'create' ? 'Instance created successfully!' : 'Instance updated successfully!';
$: errorMessage = mode === 'create' ? 'Failed to save instance' : 'Failed to update instance';
</script>
<div class="p-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">{title}</h1>
<p class="mt-3 text-lg text-neutral-600 dark:text-neutral-400">
{description}
</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 || errorMessage);
} else if (result.type === 'redirect') {
// Show success toast before redirect
toastStore.add('success', successMessage);
}
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:ring-1 focus:ring-blue-500 focus:outline-none 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>
{#if mode === 'edit'}
<!-- Disabled type field for edit mode -->
<input
type="text"
id="type"
name="type"
value={type.charAt(0).toUpperCase() + type.slice(1)}
disabled
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-neutral-100 px-3 py-2 text-neutral-500 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400"
/>
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Type cannot be changed after creation
</p>
<input type="hidden" name="type" value={type} />
{:else}
<!-- Editable type dropdown for create mode -->
<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:ring-1 focus:ring-blue-500 focus:outline-none 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>
{/if}
</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:ring-1 focus:ring-blue-500 focus:outline-none 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={mode === 'edit' ? 'Enter API key to test connection' : '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:ring-1 focus:ring-blue-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
/>
{#if mode === 'edit'}
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Re-enter API key to update or test connection
</p>
{/if}
</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">
<div class="flex 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>
{#if mode === 'edit'}
<a
href="/arr/{type}/{instance?.id}"
data-sveltekit-reload
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 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Cancel
</a>
{/if}
</div>
<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} />
{submitButtonText}
</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>
<!-- Delete Section (Edit Mode Only) -->
{#if mode === 'edit'}
<div class="mt-8 border-t border-neutral-200 pt-8 dark:border-neutral-800">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Danger Zone</h2>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Once you delete this instance, there is no going back. Please be certain.
</p>
<button
type="button"
on:click={() => (showDeleteModal = true)}
class="mt-4 flex items-center gap-2 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-700 dark:bg-neutral-900 dark:text-red-400 dark:hover:bg-red-950"
>
<Trash2 size={14} />
Delete Instance
</button>
<!-- Hidden delete form -->
<form
bind:this={deleteFormElement}
method="POST"
action="?/delete"
class="hidden"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to delete instance'
);
} else if (result.type === 'redirect') {
toastStore.add('success', 'Instance deleted successfully');
}
await update();
};
}}
>
<!-- Empty form, just for submission -->
</form>
</div>
{/if}
</div>
</div>
<!-- Delete Confirmation Modal -->
{#if mode === 'edit'}
<Modal
open={showDeleteModal}
header="Delete Instance"
bodyMessage={`Are you sure you want to delete "${instance?.name}"? This action cannot be undone and all data will be permanently lost.`}
confirmText="Delete"
cancelText="Cancel"
confirmDanger={true}
on:confirm={() => {
showDeleteModal = false;
deleteFormElement?.requestSubmit();
}}
on:cancel={() => (showDeleteModal = false)}
/>
{/if}

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { X, Check } from 'lucide-svelte';
// Props
export let open = false;
export let header = 'Confirm';
export let bodyMessage = 'Are you sure?';
export let confirmText = 'Confirm';
export let cancelText = 'Cancel';
export let confirmDanger = false; // If true, confirm button is styled as danger (red)
const dispatch = createEventDispatcher();
function handleConfirm() {
dispatch('confirm');
}
function handleCancel() {
dispatch('cancel');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
handleCancel();
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleCancel();
}
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('keydown', handleKeydown);
};
});
</script>
{#if open}
<!-- Backdrop -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
on:click={handleBackdropClick}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<!-- Modal -->
<div
class="relative w-full max-w-md rounded-lg border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900"
>
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">{header}</h2>
</div>
<!-- Body -->
<div class="px-6 py-4">
<p class="text-sm text-neutral-600 dark:text-neutral-400">{bodyMessage}</p>
</div>
<!-- Footer -->
<div
class="flex justify-between border-t border-neutral-200 px-6 py-4 dark:border-neutral-800"
>
<button
type="button"
on:click={handleCancel}
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<X size={16} />
{cancelText}
</button>
<button
type="button"
on:click={handleConfirm}
class={confirmDanger
? 'flex items-center gap-2 rounded-lg border border-red-600 bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 dark:border-red-500 dark:bg-red-500 dark:hover:bg-red-600'
: 'flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'}
>
<Check size={16} />
{confirmText}
</button>
</div>
</div>
</div>
{/if}

View File

@@ -18,9 +18,12 @@
// 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',
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'
};

View File

@@ -3,7 +3,7 @@
import Toast from './Toast.svelte';
</script>
<div class="pointer-events-none fixed right-4 top-20 z-50 flex flex-col gap-3">
<div class="pointer-events-none fixed top-20 right-4 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} />

View File

@@ -1,233 +1,212 @@
import { db } from "./db.ts";
import { logger } from "$logger";
import { db } from './db.ts';
import { logger } from '$logger';
export interface Migration {
version: number;
name: string;
up: string;
down?: string;
version: number;
name: string;
up: string;
down?: string;
}
/**
* Migration runner for database schema management
*/
class MigrationRunner {
private migrationsTable = "migrations";
private migrationsTable = 'migrations';
/**
* Initialize the migrations table
*/
initialize(): void {
const sql = `
/**
* 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);
}
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;
}
/**
* 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;
}
/**
* 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);
/**
* 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,
);
// 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;
}
}
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`,
);
}
/**
* 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!);
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,
);
// 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;
}
}
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();
/**
* 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
);
// 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;
}
let applied = 0;
for (const migration of sortedMigrations) {
if (this.isApplied(migration.version)) {
continue;
}
await this.applyMigration(migration);
applied++;
}
await this.applyMigration(migration);
applied++;
}
if (applied === 0) {
await logger.info("✓ Database is up to date", {
source: "MigrationRunner",
});
}
}
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();
/**
* 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;
}
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);
// 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;
}
let rolledBack = 0;
for (const migration of sortedMigrations) {
if (!this.isApplied(migration.version)) {
continue;
}
await this.rollbackMigration(migration);
rolledBack++;
}
await this.rollbackMigration(migration);
rolledBack++;
}
await logger.info(`✓ Rolled back ${rolledBack} migration(s)`, {
source: "MigrationRunner",
});
}
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 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);
}
/**
* 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);
}
/**
* 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);
}
/**
* 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
@@ -238,64 +217,57 @@ export const migrationRunner = new MigrationRunner();
* 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;
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;
}
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}`);
// 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;
// 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 [];
}
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);
// 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);
const migrationsToRun = migrations ?? (await loadMigrations());
await migrationRunner.up(migrationsToRun);
}

View File

@@ -78,10 +78,7 @@ export const arrInstancesQueries = {
* Get arr instances by type
*/
getByType(type: string): ArrInstance[] {
return db.query<ArrInstance>(
'SELECT * FROM arr_instances WHERE type = ? ORDER BY name',
type
);
return db.query<ArrInstance>('SELECT * FROM arr_instances WHERE type = ? ORDER BY name', type);
},
/**

View File

@@ -1,5 +1,6 @@
import { error } from '@sveltejs/kit';
import { error, redirect } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
// Valid arr types
const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr'];
@@ -12,7 +13,17 @@ export const load: ServerLoad = ({ params }) => {
error(404, `Invalid arr type: ${type}`);
}
// Fetch instances for this type
const instances = arrInstancesQueries.getByType(type);
// If instances exist, redirect to the first one
if (instances.length > 0) {
redirect(302, `/arr/${type}/${instances[0].id}`);
}
// If no instances, continue to show the page
return {
type
type,
instances
};
};

View File

@@ -1,32 +1,39 @@
<script lang="ts">
import type { PageData } from './$types';
import { Plus } from 'lucide-svelte';
import { Plus, Inbox } from 'lucide-svelte';
export let data: PageData;
// Capitalize first letter for display (reactive statement)
// Capitalize first letter for display
$: 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 class="flex min-h-[calc(100vh-4rem)] items-center justify-center p-8">
<div class="w-full max-w-md text-center">
<!-- Icon -->
<div class="mb-6 flex justify-center">
<div class="rounded-full bg-neutral-100 p-6 dark:bg-neutral-800">
<Inbox class="h-12 w-12 text-neutral-400 dark:text-neutral-500" />
</div>
</div>
<!-- Title -->
<h1 class="mb-3 text-2xl font-bold text-neutral-900 dark:text-neutral-50">
No {displayName} instances yet
</h1>
<!-- Description -->
<p class="mb-8 text-neutral-600 dark:text-neutral-400">
Get started by adding your first {displayName} instance to begin managing your media library.
</p>
<!-- Action Button -->
<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"
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<Plus size={16} />
Add Instance
<Plus size={18} />
Add {displayName} 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,42 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
// Valid arr types
const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr'];
export const load: ServerLoad = ({ params }) => {
const type = params.type;
const id = parseInt(params.id || '', 10);
// Validate type
if (!type || !VALID_TYPES.includes(type)) {
error(404, `Invalid arr type: ${type}`);
}
// Validate ID
if (isNaN(id)) {
error(404, `Invalid instance ID: ${params.id}`);
}
// Fetch the specific instance
const instance = arrInstancesQueries.getById(id);
if (!instance) {
error(404, `Instance not found: ${id}`);
}
// Verify instance type matches route type
if (instance.type !== type) {
error(404, `Instance ${id} is not a ${type} instance`);
}
// Fetch all instances of this type for the tabs
const allInstances = arrInstancesQueries.getByType(type);
return {
type,
instance,
allInstances
};
};

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import type { PageData } from './$types';
import { Plus, Pencil } from 'lucide-svelte';
export let data: PageData;
</script>
<div class="p-8">
<!-- Tabs Section -->
<div class="mb-8">
<div class="border-b border-neutral-200 dark:border-neutral-800">
<nav class="-mb-px flex gap-2" aria-label="Tabs">
<!-- Instance Tabs -->
{#each data.allInstances as instance (instance.id)}
<a
href="/arr/{data.type}/{instance.id}"
class="border-b-2 px-4 py-3 text-sm font-medium transition-colors {data.instance.id ===
instance.id
? 'border-blue-600 text-blue-600 dark:border-blue-500 dark:text-blue-500'
: 'border-transparent text-neutral-600 hover:border-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-50'}"
>
{instance.name}
</a>
{/each}
<!-- Add Instance Tab (always visible) -->
<a
href="/arr/new?type={data.type}"
class="flex items-center gap-2 border-b-2 border-transparent px-4 py-3 text-sm font-medium text-neutral-600 transition-colors hover:border-neutral-300 hover:text-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:text-neutral-50"
>
<Plus size={16} />
Add Instance
</a>
</nav>
</div>
</div>
<!-- Content Area -->
<div class="text-neutral-600 dark:text-neutral-400">
<p>Instance content for: {data.instance.name} (ID: {data.instance.id})</p>
<p class="mt-2">URL: {data.instance.url}</p>
<p class="mt-2">Type: {data.instance.type}</p>
</div>
</div>
<!-- Floating Edit Button -->
<a
href="/arr/{data.type}/{data.instance.id}/edit"
class="group fixed right-8 bottom-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-700 shadow-md transition-all hover:scale-110 hover:border-neutral-300 hover:shadow-lg dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:border-neutral-600"
aria-label="Edit instance"
>
<Pencil size={18} class="transition-transform duration-300 group-hover:rotate-12" />
</a>

View File

@@ -0,0 +1,103 @@
import { error, redirect, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { logger } from '$logger';
// Valid arr types
const VALID_TYPES = ['radarr', 'sonarr', 'lidarr', 'chaptarr'];
export const load: ServerLoad = ({ params }) => {
const type = params.type;
const id = parseInt(params.id || '', 10);
// Validate type
if (!type || !VALID_TYPES.includes(type)) {
error(404, `Invalid arr type: ${type}`);
}
// Validate ID
if (isNaN(id)) {
error(404, `Invalid instance ID: ${params.id}`);
}
// Fetch the specific instance
const instance = arrInstancesQueries.getById(id);
if (!instance) {
error(404, `Instance not found: ${id}`);
}
// Verify instance type matches route type
if (instance.type !== type) {
error(404, `Instance ${id} is not a ${type} instance`);
}
return {
type,
instance
};
};
export const actions: Actions = {
delete: async ({ params }) => {
const type = params.type;
const id = parseInt(params.id || '', 10);
// Validate type
if (!type || !VALID_TYPES.includes(type)) {
await logger.warn('Delete failed: Invalid arr type', {
source: 'arr/[type]/[id]/edit',
meta: { type }
});
return fail(400, { error: 'Invalid arr type' });
}
// Validate ID
if (isNaN(id)) {
await logger.warn('Delete failed: Invalid instance ID', {
source: 'arr/[type]/[id]/edit',
meta: { id: params.id }
});
return fail(400, { error: 'Invalid instance ID' });
}
// Fetch the instance to verify it exists
const instance = arrInstancesQueries.getById(id);
if (!instance) {
await logger.warn('Delete failed: Instance not found', {
source: 'arr/[type]/[id]/edit',
meta: { id, type }
});
return fail(404, { error: 'Instance not found' });
}
// Verify instance type matches route type
if (instance.type !== type) {
await logger.warn('Delete failed: Instance type mismatch', {
source: 'arr/[type]/[id]/edit',
meta: { id, expectedType: type, actualType: instance.type }
});
return fail(400, { error: 'Instance type mismatch' });
}
// Delete the instance
const deleted = arrInstancesQueries.delete(id);
if (!deleted) {
await logger.error('Failed to delete instance', {
source: 'arr/[type]/[id]/edit',
meta: { id, name: instance.name, type: instance.type }
});
return fail(500, { error: 'Failed to delete instance' });
}
await logger.info(`Deleted ${type} instance: ${instance.name}`, {
source: 'arr/[type]/[id]/edit',
meta: { id, name: instance.name, type: instance.type, url: instance.url }
});
// Redirect to the arr type page
redirect(303, `/arr/${type}`);
}
};

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import ArrInstanceForm from '$components/arr/ArrInstanceForm.svelte';
import type { ActionData, PageData } from './$types';
export let form: ActionData;
export let data: PageData;
</script>
<ArrInstanceForm mode="edit" {form} instance={data.instance} />

View File

@@ -83,7 +83,6 @@ export const actions = {
source: 'arr/new',
meta: { id, name, type, url }
});
} catch (error) {
await logger.error('Failed to create arr instance', {
source: 'arr/new',

View File

@@ -1,229 +1,12 @@
<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 ArrInstanceForm from '$components/arr/ArrInstanceForm.svelte';
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>
<ArrInstanceForm mode="create" {form} initialType={typeFromUrl} />

View File

@@ -121,184 +121,194 @@
</div>
{:else}
<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"
<!-- 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"
>
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="flex items-center gap-2 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="text-neutral-600 dark:text-neutral-400">
{migration.name}
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="flex items-center gap-2 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="text-neutral-600 dark:text-neutral-400">
{migration.name}
</span>
{#if migration.latest}
<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}
</div>
<span class="text-xs text-neutral-500">
{new Date(migration.applied_at).toLocaleDateString()}
</span>
{#if migration.latest}
<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}
</div>
<span class="text-xs text-neutral-500">
{new Date(migration.applied_at).toLocaleDateString()}
</span>
</div>
{/each}
</div>
</td>
</tr>
</InfoTable>
{/if}
{/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"
<!-- 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"
>
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}
{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>
<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}
{/each}
</div>
</td>
</tr>
</InfoTable>
{/if}
<!-- Dev Team Section -->
<div class="space-y-3">
<!-- Section Title -->
<div class="flex items-center gap-2">
<Users class="h-5 w-5 text-neutral-600 dark:text-neutral-400" />
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Dev Team</h2>
</div>
<!-- Dev Team Section -->
<div class="space-y-3">
<!-- Section Title -->
<div class="flex items-center gap-2">
<Users class="h-5 w-5 text-neutral-600 dark:text-neutral-400" />
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Dev Team</h2>
</div>
<!-- Table -->
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50">
Name
</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50">
Remark
</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50">
Tags
</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 dark:divide-neutral-800">
{#each devTeam as member (member.name)}
<tr class="bg-white dark:bg-neutral-900">
<td class="px-6 py-4 text-sm font-medium text-neutral-900 dark:text-neutral-50">
{member.name}
</td>
<td class="px-6 py-4 text-sm text-neutral-600 dark:text-neutral-400">
{#if member.remark}
{member.remark}
{:else}
<span class="italic text-neutral-400 dark:text-neutral-500">Remark pending - someone should probably ask them</span>
{/if}
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-2">
{#each member.tags as tag}
<span
class="rounded-full bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
>
{tag}
</span>
{/each}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Table -->
<div
class="overflow-hidden rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr>
<th
class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50"
>
Name
</th>
<th
class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50"
>
Remark
</th>
<th
class="px-6 py-3 text-left text-sm font-semibold text-neutral-900 dark:text-neutral-50"
>
Tags
</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-200 dark:divide-neutral-800">
{#each devTeam as member (member.name)}
<tr class="bg-white dark:bg-neutral-900">
<td class="px-6 py-4 text-sm font-medium text-neutral-900 dark:text-neutral-50">
{member.name}
</td>
<td class="px-6 py-4 text-sm text-neutral-600 dark:text-neutral-400">
{#if member.remark}
{member.remark}
{:else}
<span class="text-neutral-400 italic dark:text-neutral-500"
>Remark pending - someone should probably ask them</span
>
{/if}
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-2">
{#each member.tags as tag}
<span
class="rounded-full bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
>
{tag}
</span>
{/each}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Dedication -->
<div class="mt-8 text-center">
<p class="text-sm italic text-neutral-500 dark:text-neutral-400">
This project is dedicated to Faiza, for helping me find my heart.
</p>
</div>
<!-- Dedication -->
<div class="mt-8 text-center">
<p class="text-sm text-neutral-500 italic dark:text-neutral-400">
This project is dedicated to Faiza, for helping me find my heart.
</p>
</div>
</div>
{/if}
</div>

View File

@@ -1,6 +1,6 @@
# Arr HTTP Client Utilities
Object-oriented HTTP client architecture for communicating with *arr
Object-oriented HTTP client architecture for communicating with \*arr
applications (Radarr, Sonarr, Lidarr, Chaptarr).
## Architecture
@@ -64,7 +64,7 @@ new BaseHttpClient(baseUrl: string, options?: HttpClientOptions)
### BaseArrClient (`src/utils/arr/base.ts`)
Base client for all *arr applications. Extends `BaseHttpClient`.
Base client for all \*arr applications. Extends `BaseHttpClient`.
**Features:**
@@ -86,9 +86,9 @@ new BaseArrClient(url: string, apiKey: string)
### Future Usage (when specific methods are implemented)
```typescript
import { createArrClient } from "$utils/arr/factory.ts";
import { createArrClient } from '$utils/arr/factory.ts';
const radarr = createArrClient("radarr", "http://localhost:7878", "api-key");
const radarr = createArrClient('radarr', 'http://localhost:7878', 'api-key');
// Get movies
const movies = await radarr.getMovies();
@@ -98,8 +98,8 @@ const profiles = await radarr.getQualityProfiles();
// Add movie
await radarr.addMovie({
title: "Inception",
tmdbId: 27205,
qualityProfileId: 1,
title: 'Inception',
tmdbId: 27205,
qualityProfileId: 1
});
```

View File

@@ -12,11 +12,7 @@ import { ChaptarrClient } from './clients/chaptarr.ts';
* @param apiKey - API key for authentication
* @returns Arr client instance
*/
export function createArrClient(
type: ArrType,
url: string,
apiKey: string
): BaseArrClient {
export function createArrClient(type: ArrType, url: string, apiKey: string): BaseArrClient {
switch (type) {
case 'radarr':
return new RadarrClient(url, apiKey);

View File

@@ -52,7 +52,7 @@ class Config {
},
get database(): string {
return `${config.basePath}/data/profilarr.db`;
},
}
};
}

View File

@@ -113,10 +113,7 @@ export class BaseHttpClient {
}
// Wrap other errors
throw new HttpError(
error instanceof Error ? error.message : 'Unknown error',
0
);
throw new HttpError(error instanceof Error ? error.message : 'Unknown error', 0);
}
} catch (error) {
// If it's not an HttpError or not retryable, throw immediately