feat(delay-profiles): add delay profiles management functionality

- Create a new page for displaying delay profiles with an empty state when no databases are linked.
- Implement server-side loading for delay profiles based on the selected database.
- Add a detailed view for editing and deleting delay profiles, including form validation and error handling.
- Introduce a form component for creating and editing delay profiles with appropriate fields and validation.
- Implement table and card views for displaying delay profiles, allowing users to navigate to detailed views.
- Add functionality for creating new delay profiles with validation and error handling.
This commit is contained in:
Sam Chau
2025-12-28 21:28:17 +10:30
parent 3d27fbf411
commit 7c07f87d7c
21 changed files with 1890 additions and 8 deletions

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { X, User, GitBranch } from 'lucide-svelte';
export let open = false;
const dispatch = createEventDispatcher<{
select: 'user' | 'base';
cancel: void;
}>();
function handleSelect(layer: 'user' | 'base') {
dispatch('select', layer);
}
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="flex items-center justify-between 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">Where to save?</h2>
<button
type="button"
on:click={handleCancel}
class="rounded-lg p-1 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
>
<X size={20} />
</button>
</div>
<!-- Body -->
<div class="space-y-3 p-6">
<button
type="button"
on:click={() => handleSelect('user')}
class="flex w-full items-start gap-4 rounded-lg border border-neutral-200 bg-white p-4 text-left transition-colors hover:border-blue-300 hover:bg-blue-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-blue-600 dark:hover:bg-blue-950"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300">
<User size={20} />
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Personal Override</div>
<div class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Save locally only. Changes won't sync upstream and stay on this machine.
</div>
</div>
</button>
<button
type="button"
on:click={() => handleSelect('base')}
class="flex w-full items-start gap-4 rounded-lg border border-neutral-200 bg-white p-4 text-left transition-colors hover:border-blue-300 hover:bg-blue-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-blue-600 dark:hover:bg-blue-950"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300">
<GitBranch size={20} />
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Contribute to Database</div>
<div class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Add to base operations. You'll need to commit and push manually.
</div>
</div>
</button>
</div>
</div>
</div>
{/if}

View File

@@ -4,7 +4,7 @@
import { Database } from '@jsr/db__sqlite';
import { Kysely } from 'kysely';
import { DenoSqlite3Dialect } from 'jsr:@soapbox/kysely-deno-sqlite';
import { DenoSqlite3Dialect } from '@soapbox/kysely-deno-sqlite';
import { logger } from '$logger/logger.ts';
import { loadAllOperations, validateOperations } from './ops.ts';
import { disableDatabaseInstance } from '$db/queries/databaseInstances.ts';
@@ -97,7 +97,7 @@ export class PCDCache {
}
/**
* Register SQL helper functions (qp, cf)
* Register SQL helper functions (qp, cf, dp, tag)
*/
private registerHelperFunctions(): void {
if (!this.db) return;
@@ -123,6 +123,28 @@ export class PCDCache {
}
return result.id;
});
// dp(name) - Delay profile lookup by name
this.db.function('dp', (name: string) => {
const result = this.db!.prepare('SELECT id FROM delay_profiles WHERE name = ?').get(
name
) as { id: number } | undefined;
if (!result) {
throw new Error(`Delay profile not found: ${name}`);
}
return result.id;
});
// tag(name) - Tag lookup by name (creates if not exists)
this.db.function('tag', (name: string) => {
const result = this.db!.prepare('SELECT id FROM tags WHERE name = ?').get(
name
) as { id: number } | undefined;
if (!result) {
throw new Error(`Tag not found: ${name}`);
}
return result.id;
});
}
/**
@@ -308,6 +330,20 @@ export async function startWatch(pcdPath: string, databaseInstanceId: number): P
// tweaks directory doesn't exist, that's ok
}
// Watch user_ops directory (create if doesn't exist)
const userOpsPath = `${pcdPath}/user_ops`;
try {
await Deno.mkdir(userOpsPath, { recursive: true });
} catch (error) {
if (!(error instanceof Deno.errors.AlreadyExists)) {
await logger.warn('Failed to create user_ops directory', {
source: 'PCDCache',
meta: { error: String(error), pcdPath }
});
}
}
pathsToWatch.push(userOpsPath);
if (pathsToWatch.length === 0) {
await logger.warn('No directories to watch for PCD', {
source: 'PCDCache',

View File

@@ -82,7 +82,7 @@ function extractOrderFromFilename(filename: string): number {
* 1. Schema layer (from dependency)
* 2. Base layer (from PCD)
* 3. Tweaks layer (from PCD, optional)
* 4. User ops layer (TODO: future implementation)
* 4. User ops layer (local user modifications)
*/
export async function loadAllOperations(pcdPath: string): Promise<Operation[]> {
const allOperations: Operation[] = [];
@@ -102,14 +102,28 @@ export async function loadAllOperations(pcdPath: string): Promise<Operation[]> {
const tweakOps = await loadOperationsFromDir(tweaksPath, 'tweaks');
allOperations.push(...tweakOps);
// 4. User ops layer (TODO: implement in future)
// const userOpsPath = `${pcdPath}/user_ops`;
// const userOps = await loadOperationsFromDir(userOpsPath, 'user');
// allOperations.push(...userOps);
// 4. User ops layer (local user modifications)
const userOpsPath = `${pcdPath}/user_ops`;
const userOps = await loadOperationsFromDir(userOpsPath, 'user');
allOperations.push(...userOps);
return allOperations;
}
/**
* Get the user ops directory path for a PCD
*/
export function getUserOpsPath(pcdPath: string): string {
return `${pcdPath}/user_ops`;
}
/**
* Get the base ops directory path for a PCD
*/
export function getBaseOpsPath(pcdPath: string): string {
return `${pcdPath}/ops`;
}
/**
* Validate that operations can be executed
* - Check for empty SQL

View File

@@ -0,0 +1,97 @@
/**
* Create a delay profile operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
import type { PreferredProtocol } from './types.ts';
export interface CreateDelayProfileInput {
name: string;
tags: string[];
preferredProtocol: PreferredProtocol;
usenetDelay: number;
torrentDelay: number;
bypassIfHighestQuality: boolean;
bypassIfAboveCfScore: boolean;
minimumCfScore: number;
}
export interface CreateDelayProfileOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
input: CreateDelayProfileInput;
}
/**
* Create a delay profile by writing an operation to the specified layer
*/
export async function create(options: CreateDelayProfileOptions) {
const { databaseId, cache, layer, input } = options;
const db = cache.kb;
const queries = [];
// Determine delay values based on protocol (schema has CHECK constraints)
// only_torrent -> usenet_delay must be NULL
// only_usenet -> torrent_delay must be NULL
const usenetDelay = input.preferredProtocol === 'only_torrent' ? null : input.usenetDelay;
const torrentDelay = input.preferredProtocol === 'only_usenet' ? null : input.torrentDelay;
// minimum_custom_format_score must be NULL if bypass_if_above_custom_format_score is false
const minimumCfScore = input.bypassIfAboveCfScore ? input.minimumCfScore : null;
// 1. Insert the delay profile
const insertProfile = db
.insertInto('delay_profiles')
.values({
name: input.name,
preferred_protocol: input.preferredProtocol,
usenet_delay: usenetDelay,
torrent_delay: torrentDelay,
bypass_if_highest_quality: input.bypassIfHighestQuality ? 1 : 0,
bypass_if_above_custom_format_score: input.bypassIfAboveCfScore ? 1 : 0,
minimum_custom_format_score: minimumCfScore
})
.compile();
queries.push(insertProfile);
// 2. Insert tags (create if not exist, then link)
for (const tagName of input.tags) {
// Insert tag if not exists
const insertTag = db
.insertInto('tags')
.values({ name: tagName })
.onConflict((oc) => oc.column('name').doNothing())
.compile();
queries.push(insertTag);
// Link tag to delay profile using helper functions
// We use raw SQL here since we need the dp() and tag() helper functions
const linkTag = {
sql: `INSERT INTO delay_profile_tags (delay_profile_id, tag_id) VALUES (dp('${input.name.replace(/'/g, "''")}'), tag('${tagName.replace(/'/g, "''")}'))`,
parameters: [],
query: {} as never
};
queries.push(linkTag);
}
// Write the operation
const result = await writeOperation({
databaseId,
layer,
description: `create-delay-profile-${input.name}`,
queries,
metadata: {
operation: 'create',
entity: 'delay_profile',
name: input.name
}
});
return result;
}

View File

@@ -0,0 +1,69 @@
/**
* Delete a delay profile operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
import type { DelayProfileTableRow } from './types.ts';
export interface DeleteDelayProfileOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
/** The current profile data (for value guards) */
current: DelayProfileTableRow;
}
/**
* Escape a string for SQL
*/
function esc(value: string): string {
return value.replace(/'/g, "''");
}
/**
* Delete a delay profile by writing an operation to the specified layer
* Uses value guards to detect conflicts with upstream changes
*/
export async function remove(options: DeleteDelayProfileOptions) {
const { databaseId, cache, layer, current } = options;
const db = cache.kb;
const queries = [];
// 1. Delete tag links first (foreign key constraint)
for (const tag of current.tags) {
const removeTagLink = {
sql: `DELETE FROM delay_profile_tags WHERE delay_profile_id = dp('${esc(current.name)}') AND tag_id = tag('${esc(tag.name)}')`,
parameters: [],
query: {} as never
};
queries.push(removeTagLink);
}
// 2. Delete the delay profile with value guards
const deleteProfile = db
.deleteFrom('delay_profiles')
.where('id', '=', current.id)
// Value guards - ensure this is the profile we expect
.where('name', '=', current.name)
.where('preferred_protocol', '=', current.preferred_protocol)
.compile();
queries.push(deleteProfile);
// Write the operation
const result = await writeOperation({
databaseId,
layer,
description: `delete-delay-profile-${current.name}`,
queries,
metadata: {
operation: 'delete',
entity: 'delay_profile',
name: current.name
}
});
return result;
}

View File

@@ -0,0 +1,59 @@
/**
* Get a single delay profile by ID
*/
import type { PCDCache } from '../../cache.ts';
import type { Tag } from '../../types.ts';
import type { DelayProfileTableRow, PreferredProtocol } from './types.ts';
/**
* Get a single delay profile by ID with all data
*/
export async function get(cache: PCDCache, id: number): Promise<DelayProfileTableRow | null> {
const db = cache.kb;
// Get the delay profile
const profile = await db
.selectFrom('delay_profiles')
.select([
'id',
'name',
'preferred_protocol',
'usenet_delay',
'torrent_delay',
'bypass_if_highest_quality',
'bypass_if_above_custom_format_score',
'minimum_custom_format_score'
])
.where('id', '=', id)
.executeTakeFirst();
if (!profile) return null;
// Get tags for this profile
const tags = await db
.selectFrom('delay_profile_tags as dpt')
.innerJoin('tags as t', 't.id', 'dpt.tag_id')
.select(['t.id as tag_id', 't.name as tag_name', 't.created_at as tag_created_at'])
.where('dpt.delay_profile_id', '=', id)
.orderBy('t.name')
.execute();
const tagList: Tag[] = tags.map((t) => ({
id: t.tag_id,
name: t.tag_name,
created_at: t.tag_created_at
}));
return {
id: profile.id,
name: profile.name,
preferred_protocol: profile.preferred_protocol as PreferredProtocol,
usenet_delay: profile.usenet_delay,
torrent_delay: profile.torrent_delay,
bypass_if_highest_quality: profile.bypass_if_highest_quality === 1,
bypass_if_above_custom_format_score: profile.bypass_if_above_custom_format_score === 1,
minimum_custom_format_score: profile.minimum_custom_format_score,
tags: tagList
};
}

View File

@@ -1,9 +1,17 @@
/**
* Delay Profile queries
* Delay Profile queries and mutations
*/
// Export all types
export type { DelayProfileTableRow, PreferredProtocol } from './types.ts';
export type { CreateDelayProfileInput } from './create.ts';
export type { UpdateDelayProfileInput } from './update.ts';
// Export query functions
export { list } from './list.ts';
export { get } from './get.ts';
// Export mutation functions
export { create } from './create.ts';
export { update } from './update.ts';
export { remove } from './delete.ts';

View File

@@ -0,0 +1,7 @@
/**
* Delay Profile mutations - re-exports for cleaner imports
*/
export { create, type CreateDelayProfileInput, type CreateDelayProfileOptions } from './create.ts';
export { update, type UpdateDelayProfileInput, type UpdateDelayProfileOptions } from './update.ts';
export { remove, type DeleteDelayProfileOptions } from './delete.ts';

View File

@@ -0,0 +1,134 @@
/**
* Update a delay profile operation
*/
import type { PCDCache } from '../../cache.ts';
import { writeOperation, type OperationLayer } from '../../writer.ts';
import type { PreferredProtocol, DelayProfileTableRow } from './types.ts';
export interface UpdateDelayProfileInput {
name: string;
tags: string[];
preferredProtocol: PreferredProtocol;
usenetDelay: number;
torrentDelay: number;
bypassIfHighestQuality: boolean;
bypassIfAboveCfScore: boolean;
minimumCfScore: number;
}
export interface UpdateDelayProfileOptions {
databaseId: number;
cache: PCDCache;
layer: OperationLayer;
/** The current profile data (for value guards) */
current: DelayProfileTableRow;
/** The new values */
input: UpdateDelayProfileInput;
}
/**
* Escape a string for SQL
*/
function esc(value: string): string {
return value.replace(/'/g, "''");
}
/**
* Update a delay profile by writing an operation to the specified layer
* Uses value guards to detect conflicts with upstream changes
*/
export async function update(options: UpdateDelayProfileOptions) {
const { databaseId, cache, layer, current, input } = options;
const db = cache.kb;
const queries = [];
// Determine delay values based on protocol (schema has CHECK constraints)
// only_torrent -> usenet_delay must be NULL
// only_usenet -> torrent_delay must be NULL
const usenetDelay = input.preferredProtocol === 'only_torrent' ? null : input.usenetDelay;
const torrentDelay = input.preferredProtocol === 'only_usenet' ? null : input.torrentDelay;
// minimum_custom_format_score must be NULL if bypass_if_above_custom_format_score is false
const minimumCfScore = input.bypassIfAboveCfScore ? input.minimumCfScore : null;
// 1. Update the delay profile with value guards
// We build the WHERE clause to include current values as guards
const updateProfile = db
.updateTable('delay_profiles')
.set({
name: input.name,
preferred_protocol: input.preferredProtocol,
usenet_delay: usenetDelay,
torrent_delay: torrentDelay,
bypass_if_highest_quality: input.bypassIfHighestQuality ? 1 : 0,
bypass_if_above_custom_format_score: input.bypassIfAboveCfScore ? 1 : 0,
minimum_custom_format_score: minimumCfScore
})
.where('id', '=', current.id)
// Value guards - ensure current values match what we expect
.where('name', '=', current.name)
.where('preferred_protocol', '=', current.preferred_protocol)
.compile();
queries.push(updateProfile);
// 2. Handle tag changes
const currentTagNames = current.tags.map(t => t.name);
const newTagNames = input.tags;
// Tags to remove
const tagsToRemove = currentTagNames.filter(t => !newTagNames.includes(t));
for (const tagName of tagsToRemove) {
const removeTag = {
sql: `DELETE FROM delay_profile_tags WHERE delay_profile_id = dp('${esc(current.name)}') AND tag_id = tag('${esc(tagName)}')`,
parameters: [],
query: {} as never
};
queries.push(removeTag);
}
// Tags to add
const tagsToAdd = newTagNames.filter(t => !currentTagNames.includes(t));
for (const tagName of tagsToAdd) {
// Insert tag if not exists
const insertTag = db
.insertInto('tags')
.values({ name: tagName })
.onConflict((oc) => oc.column('name').doNothing())
.compile();
queries.push(insertTag);
// Link tag to delay profile
// Use current.name for lookup since the profile might have been renamed
const profileName = input.name !== current.name ? input.name : current.name;
const linkTag = {
sql: `INSERT INTO delay_profile_tags (delay_profile_id, tag_id) VALUES (dp('${esc(profileName)}'), tag('${esc(tagName)}'))`,
parameters: [],
query: {} as never
};
queries.push(linkTag);
}
// Write the operation with metadata
// Include previousName if this is a rename
const isRename = input.name !== current.name;
const result = await writeOperation({
databaseId,
layer,
description: `update-delay-profile-${input.name}`,
queries,
metadata: {
operation: 'update',
entity: 'delay_profile',
name: input.name,
...(isRename && { previousName: current.name })
}
});
return result;
}

View File

@@ -0,0 +1,229 @@
/**
* PCD Operation Writer - Write operations to PCD layers using Kysely
*/
import type { CompiledQuery } from 'kysely';
import { getBaseOpsPath, getUserOpsPath } from './ops.ts';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { logger } from '$logger/logger.ts';
import { compile } from './cache.ts';
export type OperationLayer = 'base' | 'user';
export type OperationType = 'create' | 'update' | 'delete';
/**
* Metadata for an operation - used for optimization and tracking
*/
export interface OperationMetadata {
/** The type of operation */
operation: OperationType;
/** The entity type (e.g., 'delay_profile', 'quality_profile') */
entity: string;
/** The entity name (current name for create/update, name being deleted for delete) */
name: string;
/** Previous name if this is a rename operation */
previousName?: string;
}
export interface WriteOptions {
/** The database instance ID */
databaseId: number;
/** Which layer to write to */
layer: OperationLayer;
/** Description for the operation (used in filename) */
description: string;
/** The compiled Kysely queries to write */
queries: CompiledQuery[];
/** Metadata for optimization and tracking */
metadata?: OperationMetadata;
}
export interface WriteResult {
success: boolean;
filepath?: string;
error?: string;
}
/**
* Convert a compiled Kysely query to executable SQL
* Replaces ? placeholders with actual values
*/
function compiledQueryToSql(compiled: CompiledQuery): string {
let sql = compiled.sql;
const params = compiled.parameters as unknown[];
// Replace each ? placeholder with the actual value
for (const param of params) {
const replacement = formatValue(param);
sql = sql.replace('?', replacement);
}
return sql;
}
/**
* Format a value for SQL insertion
*/
function formatValue(value: unknown): string {
if (value === null || value === undefined) {
return 'NULL';
}
if (typeof value === 'number') {
return String(value);
}
if (typeof value === 'boolean') {
return value ? '1' : '0';
}
if (typeof value === 'string') {
// Escape single quotes by doubling them
return `'${value.replace(/'/g, "''")}'`;
}
// For other types, convert to string and quote
return `'${String(value).replace(/'/g, "''")}'`;
}
/**
* Get the next available operation number for a directory
*/
async function getNextOperationNumber(dirPath: string): Promise<number> {
try {
let maxNumber = 0;
for await (const entry of Deno.readDir(dirPath)) {
if (!entry.isFile || !entry.name.endsWith('.sql')) continue;
const match = entry.name.match(/^(\d+)\./);
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNumber) maxNumber = num;
}
}
return maxNumber + 1;
} catch {
// Directory doesn't exist yet
return 1;
}
}
/**
* Ensure a directory exists
*/
async function ensureDir(path: string): Promise<void> {
try {
await Deno.mkdir(path, { recursive: true });
} catch (error) {
if (!(error instanceof Deno.errors.AlreadyExists)) {
throw error;
}
}
}
/**
* Slugify a description for use in filename
*/
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 50);
}
/**
* Generate a metadata header for the SQL file
*/
function generateMetadataHeader(metadata: OperationMetadata): string {
const lines = [
`-- @operation: ${metadata.operation}`,
`-- @entity: ${metadata.entity}`,
`-- @name: ${metadata.name}`
];
if (metadata.previousName) {
lines.push(`-- @previous_name: ${metadata.previousName}`);
}
return lines.join('\n') + '\n\n';
}
/**
* Write operations to a PCD layer
*
* For base layer: writes to ops/, user must manually commit/push
* For user layer: writes to user_ops/, stays local
*/
export async function writeOperation(options: WriteOptions): Promise<WriteResult> {
const { databaseId, layer, description, queries, metadata } = options;
try {
// Get the database instance
const instance = databaseInstancesQueries.getById(databaseId);
if (!instance) {
return { success: false, error: 'Database instance not found' };
}
// Check if base layer is allowed (requires PAT)
if (layer === 'base' && !instance.personal_access_token) {
return { success: false, error: 'Base layer requires a personal access token' };
}
// Get the target directory
const targetDir = layer === 'base'
? getBaseOpsPath(instance.local_path)
: getUserOpsPath(instance.local_path);
// Ensure directory exists
await ensureDir(targetDir);
// Get next operation number
const opNumber = await getNextOperationNumber(targetDir);
// Generate filename
const slug = slugify(description);
const filename = `${opNumber}.${slug}.sql`;
const filepath = `${targetDir}/${filename}`;
// Convert queries to SQL
const sqlStatements = queries.map(compiledQueryToSql);
const sqlContent = sqlStatements.join(';\n\n') + ';\n';
// Build final content with optional metadata header
const content = metadata
? generateMetadataHeader(metadata) + sqlContent
: sqlContent;
// Write the file
await Deno.writeTextFile(filepath, content);
await logger.info(`Wrote operation to ${layer} layer`, {
source: 'PCDWriter',
meta: { databaseId, filepath, layer, description }
});
// Recompile the cache immediately so the new operation is available
// This avoids waiting for the file watcher's debounce delay
await compile(instance.local_path, instance.id);
await logger.info('Cache recompiled after write', {
source: 'PCDWriter',
meta: { databaseId }
});
return { success: true, filepath };
} catch (error) {
await logger.error('Failed to write operation', {
source: 'PCDWriter',
meta: { error: String(error), databaseId, layer, description }
});
return { success: false, error: String(error) };
}
}
/**
* Check if a database instance can write to the base layer
*/
export function canWriteToBase(databaseId: number): boolean {
const instance = databaseInstancesQueries.getById(databaseId);
return !!instance?.personal_access_token;
}

View File

@@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
export const load: ServerLoad = () => {
// Get all databases
const databases = pcdManager.getAll();
// If there are databases, redirect to the first one
if (databases.length > 0) {
throw redirect(303, `/delay-profiles/${databases[0].id}`);
}
// If no databases, return empty array (page will show empty state)
return {
databases
};
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Database, Plus } from 'lucide-svelte';
import EmptyState from '$ui/state/EmptyState.svelte';
</script>
<svelte:head>
<title>Delay Profiles - Profilarr</title>
</svelte:head>
<EmptyState
icon={Database}
title="No Databases Linked"
description="Link a Profilarr Compliant Database to manage delay profiles."
buttonText="Link Database"
buttonHref="/databases/new"
buttonIcon={Plus}
/>

View File

@@ -0,0 +1,44 @@
import { error } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId } = params;
// Validate params exist
if (!databaseId) {
throw error(400, 'Missing database ID');
}
// Get all databases for tabs
const databases = pcdManager.getAll();
// Parse and validate the database ID
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
throw error(400, 'Invalid database ID');
}
// Get the current database instance
const currentDatabase = databases.find((db) => db.id === currentDatabaseId);
if (!currentDatabase) {
throw error(404, 'Database not found');
}
// Get the cache for the database
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
throw error(500, 'Database cache not available');
}
// Load delay profiles for the current database
const delayProfiles = await delayProfileQueries.list(cache);
return {
databases,
currentDatabase,
delayProfiles
};
};

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import ViewToggle from '$ui/actions/ViewToggle.svelte';
import TableView from './views/TableView.svelte';
import CardView from './views/CardView.svelte';
import { createDataPageStore } from '$lib/client/stores/dataPage';
import { goto } from '$app/navigation';
import { Plus } from 'lucide-svelte';
import type { PageData } from './$types';
export let data: PageData;
// Initialize data page store
const { search, view, filtered, setItems } = createDataPageStore(data.delayProfiles, {
storageKey: 'delayProfilesView',
searchKeys: ['name']
});
// Update items when data changes (e.g., switching databases)
$: setItems(data.delayProfiles);
// Map databases to tabs
$: tabs = data.databases.map((db) => ({
label: db.name,
href: `/delay-profiles/${db.id}`,
active: db.id === data.currentDatabase.id
}));
</script>
<svelte:head>
<title>Delay Profiles - {data.currentDatabase.name} - Profilarr</title>
</svelte:head>
<div class="space-y-6 p-8">
<!-- Tabs -->
<Tabs {tabs} />
<!-- Actions Bar -->
<ActionsBar>
<SearchAction searchStore={search} placeholder="Search delay profiles..." />
<ViewToggle bind:value={$view} />
<ActionButton icon={Plus} on:click={() => goto(`/delay-profiles/${data.currentDatabase.id}/new`)} />
</ActionsBar>
<!-- Delay Profiles Content -->
<div class="mt-6">
{#if data.delayProfiles.length === 0}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
<p class="text-neutral-600 dark:text-neutral-400">
No delay profiles found for {data.currentDatabase.name}
</p>
</div>
{:else if $filtered.length === 0}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
<p class="text-neutral-600 dark:text-neutral-400">
No delay profiles match your search
</p>
</div>
{:else if $view === 'table'}
<TableView profiles={$filtered} />
{:else}
<CardView profiles={$filtered} />
{/if}
</div>
</div>

View File

@@ -0,0 +1,169 @@
import { error, redirect, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { canWriteToBase } from '$pcd/writer.ts';
import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts';
import type { OperationLayer } from '$pcd/writer.ts';
import type { PreferredProtocol } from '$pcd/queries/delayProfiles/index.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId, id } = params;
if (!databaseId || !id) {
throw error(400, 'Missing parameters');
}
const currentDatabaseId = parseInt(databaseId, 10);
const profileId = parseInt(id, 10);
if (isNaN(currentDatabaseId) || isNaN(profileId)) {
throw error(400, 'Invalid parameters');
}
const currentDatabase = pcdManager.getById(currentDatabaseId);
if (!currentDatabase) {
throw error(404, 'Database not found');
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
throw error(500, 'Database cache not available');
}
const delayProfile = await delayProfileQueries.get(cache, profileId);
if (!delayProfile) {
throw error(404, 'Delay profile not found');
}
return {
currentDatabase,
delayProfile,
canWriteToBase: canWriteToBase(currentDatabaseId)
};
};
export const actions: Actions = {
update: async ({ request, params }) => {
const { databaseId, id } = params;
if (!databaseId || !id) {
return fail(400, { error: 'Missing parameters' });
}
const currentDatabaseId = parseInt(databaseId, 10);
const profileId = parseInt(id, 10);
if (isNaN(currentDatabaseId) || isNaN(profileId)) {
return fail(400, { error: 'Invalid parameters' });
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
return fail(500, { error: 'Database cache not available' });
}
// Get current profile for value guards
const current = await delayProfileQueries.get(cache, profileId);
if (!current) {
return fail(404, { error: 'Delay profile not found' });
}
const formData = await request.formData();
// Parse form data
const name = formData.get('name') as string;
const tagsJson = formData.get('tags') as string;
const preferredProtocol = formData.get('preferredProtocol') as PreferredProtocol;
const usenetDelay = parseInt(formData.get('usenetDelay') as string, 10) || 0;
const torrentDelay = parseInt(formData.get('torrentDelay') as string, 10) || 0;
const bypassIfHighestQuality = formData.get('bypassIfHighestQuality') === 'true';
const bypassIfAboveCfScore = formData.get('bypassIfAboveCfScore') === 'true';
const minimumCfScore = parseInt(formData.get('minimumCfScore') as string, 10) || 0;
const layer = (formData.get('layer') as OperationLayer) || 'user';
// Validate
if (!name?.trim()) {
return fail(400, { error: 'Name is required' });
}
let tags: string[] = [];
try {
tags = JSON.parse(tagsJson || '[]');
} catch {
return fail(400, { error: 'Invalid tags format' });
}
if (tags.length === 0) {
return fail(400, { error: 'At least one tag is required' });
}
// Check layer permission
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
return fail(403, { error: 'Cannot write to base layer without personal access token' });
}
// Update the delay profile
const result = await delayProfileQueries.update({
databaseId: currentDatabaseId,
cache,
layer,
current,
input: {
name: name.trim(),
tags,
preferredProtocol,
usenetDelay,
torrentDelay,
bypassIfHighestQuality,
bypassIfAboveCfScore,
minimumCfScore
}
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to update delay profile' });
}
throw redirect(303, `/delay-profiles/${databaseId}`);
},
delete: async ({ params }) => {
const { databaseId, id } = params;
if (!databaseId || !id) {
return fail(400, { error: 'Missing parameters' });
}
const currentDatabaseId = parseInt(databaseId, 10);
const profileId = parseInt(id, 10);
if (isNaN(currentDatabaseId) || isNaN(profileId)) {
return fail(400, { error: 'Invalid parameters' });
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
return fail(500, { error: 'Database cache not available' });
}
// Get current profile for value guards
const current = await delayProfileQueries.get(cache, profileId);
if (!current) {
return fail(404, { error: 'Delay profile not found' });
}
// Delete always goes to user layer (can't remove base ops)
const result = await delayProfileQueries.remove({
databaseId: currentDatabaseId,
cache,
layer: 'user',
current
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to delete delay profile' });
}
throw redirect(303, `/delay-profiles/${databaseId}`);
}
};

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { goto } from '$app/navigation';
import DelayProfileForm from '../components/DelayProfileForm.svelte';
import type { PageData } from './$types';
import type { PreferredProtocol } from '$pcd/queries/delayProfiles';
export let data: PageData;
// Form state initialized from data
let name = data.delayProfile.name;
let tags = data.delayProfile.tags.map((t) => t.name);
let preferredProtocol: PreferredProtocol = data.delayProfile.preferred_protocol;
let usenetDelay = data.delayProfile.usenet_delay ?? 0;
let torrentDelay = data.delayProfile.torrent_delay ?? 0;
let bypassIfHighestQuality = data.delayProfile.bypass_if_highest_quality;
let bypassIfAboveCfScore = data.delayProfile.bypass_if_above_custom_format_score;
let minimumCfScore = data.delayProfile.minimum_custom_format_score ?? 0;
function handleCancel() {
goto(`/delay-profiles/${data.currentDatabase.id}`);
}
</script>
<svelte:head>
<title>{data.delayProfile.name} - Delay Profiles - Profilarr</title>
</svelte:head>
<DelayProfileForm
mode="edit"
databaseName={data.currentDatabase.name}
canWriteToBase={data.canWriteToBase}
actionUrl="?/update"
bind:name
bind:tags
bind:preferredProtocol
bind:usenetDelay
bind:torrentDelay
bind:bypassIfHighestQuality
bind:bypassIfAboveCfScore
bind:minimumCfScore
onCancel={handleCancel}
/>

View File

@@ -0,0 +1,379 @@
<script lang="ts">
import { enhance } from '$app/forms';
import NumberInput from '$ui/form/NumberInput.svelte';
import TagInput from '$ui/form/TagInput.svelte';
import Modal from '$ui/modal/Modal.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import { alertStore } from '$alerts/store';
import { Check, Save, Trash2, Loader2 } from 'lucide-svelte';
import type { PreferredProtocol } from '$pcd/queries/delayProfiles';
// Props
export let mode: 'create' | 'edit';
export let databaseName: string;
export let canWriteToBase: boolean = false;
export let actionUrl: string = '';
// Form data
export let name: string = '';
export let tags: string[] = [];
export let preferredProtocol: PreferredProtocol = 'prefer_usenet';
export let usenetDelay: number = 0;
export let torrentDelay: number = 0;
export let bypassIfHighestQuality: boolean = false;
export let bypassIfAboveCfScore: boolean = false;
export let minimumCfScore: number = 0;
// Event handlers
export let onCancel: () => void;
// Loading states
let saving = false;
let deleting = false;
// Layer selection
let selectedLayer: 'user' | 'base' = 'user';
// Modal states
let showSaveTargetModal = false;
let showDeleteModal = false;
let mainFormElement: HTMLFormElement;
let deleteFormElement: HTMLFormElement;
// Display text based on mode
$: title = mode === 'create' ? 'New Delay Profile' : 'Edit Delay Profile';
$: description =
mode === 'create'
? `Create a new delay profile for ${databaseName}`
: `Update delay profile settings`;
$: submitButtonText = mode === 'create' ? 'Create Profile' : 'Save Changes';
// Computed states based on protocol
$: showUsenetDelay = preferredProtocol !== 'only_torrent';
$: showTorrentDelay = preferredProtocol !== 'only_usenet';
const protocolOptions: { value: PreferredProtocol; label: string; description: string }[] = [
{ value: 'prefer_usenet', label: 'Prefer Usenet', description: 'Try Usenet first, fall back to Torrent' },
{ value: 'prefer_torrent', label: 'Prefer Torrent', description: 'Try Torrent first, fall back to Usenet' },
{ value: 'only_usenet', label: 'Only Usenet', description: 'Never use Torrent' },
{ value: 'only_torrent', label: 'Only Torrent', description: 'Never use Usenet' }
];
$: isValid = name.trim() !== '' && tags.length > 0;
function handleSaveClick() {
if (canWriteToBase) {
// Show modal to ask where to save
showSaveTargetModal = true;
} else {
// No choice, just submit with 'user' layer
selectedLayer = 'user';
mainFormElement?.requestSubmit();
}
}
function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
selectedLayer = event.detail;
showSaveTargetModal = false;
mainFormElement?.requestSubmit();
}
</script>
<div class="space-y-8 p-8">
<!-- Header -->
<div class="space-y-3">
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">{title}</h1>
<p class="text-lg text-neutral-600 dark:text-neutral-400">
{description}
</p>
</div>
<form
bind:this={mainFormElement}
method="POST"
action={actionUrl}
use:enhance={() => {
saving = true;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || 'Operation failed');
} else if (result.type === 'redirect') {
alertStore.add('success', mode === 'create' ? 'Delay profile created!' : 'Delay profile updated!');
}
await update();
saving = false;
};
}}
>
<!-- Hidden fields for form data -->
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
<input type="hidden" name="preferredProtocol" value={preferredProtocol} />
<input type="hidden" name="usenetDelay" value={usenetDelay} />
<input type="hidden" name="torrentDelay" value={torrentDelay} />
<input type="hidden" name="bypassIfHighestQuality" value={bypassIfHighestQuality} />
<input type="hidden" name="bypassIfAboveCfScore" value={bypassIfAboveCfScore} />
<input type="hidden" name="minimumCfScore" value={minimumCfScore} />
<input type="hidden" name="layer" value={selectedLayer} />
<!-- Basic Info Section -->
<div class="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Basic Info
</h2>
<div class="space-y-4">
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={name}
placeholder="e.g., Standard Delay"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm 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-100 dark:placeholder-neutral-500"
/>
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Tags <span class="text-red-500">*</span>
</label>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Delay profiles apply to items with matching tags
</p>
<div class="mt-2">
<TagInput bind:tags placeholder="Add tags..." />
</div>
</div>
</div>
</div>
<!-- Protocol Preference Section -->
<div class="mt-8 rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Protocol Preference
</h2>
<div class="grid gap-2">
{#each protocolOptions as option}
<button
type="button"
on:click={() => (preferredProtocol = option.value)}
class="flex items-center gap-3 rounded-lg border p-3 text-left transition-colors {preferredProtocol === option.value
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950'
: 'border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600'}"
>
<div class="flex h-5 w-5 items-center justify-center rounded-full border-2 {preferredProtocol === option.value
? 'border-blue-500 bg-blue-500 dark:border-blue-400 dark:bg-blue-400'
: 'border-neutral-300 dark:border-neutral-600'}">
{#if preferredProtocol === option.value}
<Check size={12} class="text-white" />
{/if}
</div>
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">{option.label}</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">{option.description}</div>
</div>
</button>
{/each}
</div>
</div>
<!-- Delays Section -->
<div class="mt-8 rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Delays
</h2>
<p class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Time to wait before downloading from each source. Set to 0 for no delay.
</p>
<div class="grid gap-4 sm:grid-cols-2">
{#if showUsenetDelay}
<div>
<label for="usenet-delay" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Usenet Delay (minutes)
</label>
<div class="mt-1">
<NumberInput
name="usenet-delay"
id="usenet-delay"
bind:value={usenetDelay}
min={0}
font="mono"
/>
</div>
</div>
{/if}
{#if showTorrentDelay}
<div>
<label for="torrent-delay" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Torrent Delay (minutes)
</label>
<div class="mt-1">
<NumberInput
name="torrent-delay"
id="torrent-delay"
bind:value={torrentDelay}
min={0}
font="mono"
/>
</div>
</div>
{/if}
</div>
</div>
<!-- Bypass Conditions Section -->
<div class="mt-8 rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
<h2 class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Bypass Conditions
</h2>
<p class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
Skip the delay when these conditions are met.
</p>
<div class="space-y-3">
<!-- Bypass if highest quality -->
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-neutral-200 bg-white p-3 transition-colors hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600">
<input
type="checkbox"
bind:checked={bypassIfHighestQuality}
class="h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-700"
/>
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Bypass if Highest Quality</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">Skip delay when release is already the highest quality in profile</div>
</div>
</label>
<!-- Bypass if above CF score -->
<div class="rounded-lg border border-neutral-200 bg-white p-3 dark:border-neutral-700 dark:bg-neutral-800">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={bypassIfAboveCfScore}
class="h-4 w-4 rounded border-neutral-300 text-blue-600 focus:ring-blue-500 dark:border-neutral-600 dark:bg-neutral-700"
/>
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Bypass if Above Custom Format Score</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">Skip delay when release exceeds minimum score</div>
</div>
</label>
{#if bypassIfAboveCfScore}
<div class="mt-3 pl-7">
<label for="min-cf-score" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Minimum Score
</label>
<div class="mt-1 w-32">
<NumberInput
name="min-cf-score"
id="min-cf-score"
bind:value={minimumCfScore}
font="mono"
/>
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Actions -->
<div class="mt-8 flex flex-wrap items-center justify-between gap-3">
<!-- Left side: Delete (only in edit mode) -->
<div>
{#if mode === 'edit'}
<button
type="button"
on:click={() => (showDeleteModal = true)}
class="flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-700 dark:bg-neutral-900 dark:text-red-300 dark:hover:bg-red-900"
>
<Trash2 size={14} />
Delete
</button>
{/if}
</div>
<!-- Right side: Cancel and Save -->
<div class="flex gap-3">
<button
type="button"
on:click={onCancel}
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"
>
Cancel
</button>
<button
type="button"
disabled={saving || !isValid}
on:click={handleSaveClick}
class="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 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
>
{#if saving}
<Loader2 size={14} class="animate-spin" />
{mode === 'create' ? 'Creating...' : 'Saving...'}
{:else}
<Save size={14} />
{submitButtonText}
{/if}
</button>
</div>
</div>
</form>
<!-- Hidden delete form -->
{#if mode === 'edit'}
<form
bind:this={deleteFormElement}
method="POST"
action="?/delete"
class="hidden"
use:enhance={() => {
deleting = true;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add('error', (result.data as { error?: string }).error || 'Failed to delete');
} else if (result.type === 'redirect') {
alertStore.add('success', 'Delay profile deleted');
}
await update();
deleting = false;
};
}}
>
</form>
{/if}
</div>
<!-- Delete Confirmation Modal -->
{#if mode === 'edit'}
<Modal
open={showDeleteModal}
header="Delete Delay Profile"
bodyMessage={`Are you sure you want to delete "${name}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
confirmDanger={true}
on:confirm={() => {
showDeleteModal = false;
deleteFormElement?.requestSubmit();
}}
on:cancel={() => (showDeleteModal = false)}
/>
{/if}
<!-- Save Target Modal -->
{#if canWriteToBase}
<SaveTargetModal
open={showSaveTargetModal}
on:select={handleLayerSelect}
on:cancel={() => (showSaveTargetModal = false)}
/>
{/if}

View File

@@ -0,0 +1,107 @@
import { error, redirect, fail } from '@sveltejs/kit';
import type { ServerLoad, Actions } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { canWriteToBase } from '$pcd/writer.ts';
import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts';
import type { OperationLayer } from '$pcd/writer.ts';
import type { PreferredProtocol } from '$pcd/queries/delayProfiles/index.ts';
export const load: ServerLoad = ({ params }) => {
const { databaseId } = params;
if (!databaseId) {
throw error(400, 'Missing database ID');
}
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
throw error(400, 'Invalid database ID');
}
const currentDatabase = pcdManager.getById(currentDatabaseId);
if (!currentDatabase) {
throw error(404, 'Database not found');
}
return {
currentDatabase,
canWriteToBase: canWriteToBase(currentDatabaseId)
};
};
export const actions: Actions = {
default: async ({ request, params }) => {
const { databaseId } = params;
if (!databaseId) {
return fail(400, { error: 'Missing database ID' });
}
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
return fail(400, { error: 'Invalid database ID' });
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
return fail(500, { error: 'Database cache not available' });
}
const formData = await request.formData();
// Parse form data
const name = formData.get('name') as string;
const tagsJson = formData.get('tags') as string;
const preferredProtocol = formData.get('preferredProtocol') as PreferredProtocol;
const usenetDelay = parseInt(formData.get('usenetDelay') as string, 10) || 0;
const torrentDelay = parseInt(formData.get('torrentDelay') as string, 10) || 0;
const bypassIfHighestQuality = formData.get('bypassIfHighestQuality') === 'true';
const bypassIfAboveCfScore = formData.get('bypassIfAboveCfScore') === 'true';
const minimumCfScore = parseInt(formData.get('minimumCfScore') as string, 10) || 0;
const layer = (formData.get('layer') as OperationLayer) || 'user';
// Validate
if (!name?.trim()) {
return fail(400, { error: 'Name is required' });
}
let tags: string[] = [];
try {
tags = JSON.parse(tagsJson || '[]');
} catch {
return fail(400, { error: 'Invalid tags format' });
}
if (tags.length === 0) {
return fail(400, { error: 'At least one tag is required' });
}
// Check layer permission
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
return fail(403, { error: 'Cannot write to base layer without personal access token' });
}
// Create the delay profile
const result = await delayProfileQueries.create({
databaseId: currentDatabaseId,
cache,
layer,
input: {
name: name.trim(),
tags,
preferredProtocol,
usenetDelay,
torrentDelay,
bypassIfHighestQuality,
bypassIfAboveCfScore,
minimumCfScore
}
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to create delay profile' });
}
throw redirect(303, `/delay-profiles/${databaseId}`);
}
};

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { goto } from '$app/navigation';
import DelayProfileForm from '../components/DelayProfileForm.svelte';
import type { PageData } from './$types';
import type { PreferredProtocol } from '$pcd/queries/delayProfiles';
export let data: PageData;
// Form state
let name = '';
let tags: string[] = [];
let preferredProtocol: PreferredProtocol = 'prefer_usenet';
let usenetDelay = 0;
let torrentDelay = 0;
let bypassIfHighestQuality = false;
let bypassIfAboveCfScore = false;
let minimumCfScore = 0;
function handleCancel() {
goto(`/delay-profiles/${data.currentDatabase.id}`);
}
</script>
<svelte:head>
<title>New Delay Profile - {data.currentDatabase.name} - Profilarr</title>
</svelte:head>
<DelayProfileForm
mode="create"
databaseName={data.currentDatabase.name}
canWriteToBase={data.canWriteToBase}
bind:name
bind:tags
bind:preferredProtocol
bind:usenetDelay
bind:torrentDelay
bind:bypassIfHighestQuality
bind:bypassIfAboveCfScore
bind:minimumCfScore
onCancel={handleCancel}
/>

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import type { DelayProfileTableRow } from '$pcd/queries/delayProfiles';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Clock, Zap, Shield } from 'lucide-svelte';
export let profiles: DelayProfileTableRow[];
function handleCardClick(profile: DelayProfileTableRow) {
const databaseId = $page.params.databaseId;
goto(`/delay-profiles/${databaseId}/${profile.id}`);
}
function formatProtocol(protocol: string): string {
switch (protocol) {
case 'prefer_usenet':
return 'Prefer Usenet';
case 'prefer_torrent':
return 'Prefer Torrent';
case 'only_usenet':
return 'Only Usenet';
case 'only_torrent':
return 'Only Torrent';
default:
return protocol;
}
}
function formatDelay(minutes: number | null): string {
if (minutes === null) return '-';
if (minutes === 0) return 'No delay';
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
function getProtocolColor(protocol: string): string {
if (protocol.includes('usenet')) {
return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200';
}
return 'border-purple-200 bg-purple-50 text-purple-800 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-200';
}
</script>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each profiles as profile}
<button
on:click={() => handleCardClick(profile)}
class="group relative flex flex-col gap-3 rounded-lg border border-neutral-200 bg-white p-4 text-left transition-all hover:border-neutral-300 hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-neutral-700 cursor-pointer"
>
<!-- Header with name and tags -->
<div>
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{profile.name}</h3>
{#if profile.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each profile.tags as tag}
<span class="inline-flex items-center px-1.5 py-0.5 rounded font-mono text-[10px] bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
<!-- Protocol badge -->
<div>
<span class="inline-flex items-center gap-1 px-2 py-1 rounded border text-xs font-medium {getProtocolColor(profile.preferred_protocol)}">
<Zap size={12} />
{formatProtocol(profile.preferred_protocol)}
</span>
</div>
<!-- Delays -->
<div class="flex flex-wrap items-center gap-3 text-xs text-neutral-600 dark:text-neutral-400">
{#if profile.usenet_delay !== null}
<div class="flex items-center gap-1">
<Clock size={12} />
<span>Usenet: <span class="font-mono bg-neutral-100 dark:bg-neutral-800 px-1 rounded text-neutral-900 dark:text-neutral-100">{formatDelay(profile.usenet_delay)}</span></span>
</div>
{/if}
{#if profile.torrent_delay !== null}
<div class="flex items-center gap-1">
<Clock size={12} />
<span>Torrent: <span class="font-mono bg-neutral-100 dark:bg-neutral-800 px-1 rounded text-neutral-900 dark:text-neutral-100">{formatDelay(profile.torrent_delay)}</span></span>
</div>
{/if}
</div>
<!-- Bypass conditions -->
{#if profile.bypass_if_highest_quality || profile.bypass_if_above_custom_format_score}
<div class="flex items-center gap-2 border-t border-neutral-100 pt-3 text-xs dark:border-neutral-800">
<Shield size={12} class="text-neutral-400" />
<div class="flex flex-wrap gap-1">
{#if profile.bypass_if_highest_quality}
<span class="font-mono text-[10px] bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-1.5 py-0.5 rounded">
Highest Quality
</span>
{/if}
{#if profile.bypass_if_above_custom_format_score && profile.minimum_custom_format_score !== null}
<span class="font-mono text-[10px] bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-1.5 py-0.5 rounded">
CF ≥ {profile.minimum_custom_format_score}
</span>
{/if}
</div>
</div>
{/if}
</button>
{/each}
</div>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import Table from '$ui/table/Table.svelte';
import type { Column } from '$ui/table/types';
import type { DelayProfileTableRow } from '$pcd/queries/delayProfiles';
import { Tag, Clock, Zap, Shield } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
export let profiles: DelayProfileTableRow[];
function handleRowClick(row: DelayProfileTableRow) {
const databaseId = $page.params.databaseId;
goto(`/delay-profiles/${databaseId}/${row.id}`);
}
function formatProtocol(protocol: string): string {
switch (protocol) {
case 'prefer_usenet':
return 'Prefer Usenet';
case 'prefer_torrent':
return 'Prefer Torrent';
case 'only_usenet':
return 'Only Usenet';
case 'only_torrent':
return 'Only Torrent';
default:
return protocol;
}
}
function formatDelay(minutes: number | null): string {
if (minutes === null) return '-';
if (minutes === 0) return 'No delay';
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
const columns: Column<DelayProfileTableRow>[] = [
{
key: 'name',
header: 'Name',
headerIcon: Tag,
align: 'left',
sortable: true,
cell: (row: DelayProfileTableRow) => ({
html: `
<div>
<div class="font-medium">${row.name}</div>
${row.tags.length > 0 ? `
<div class="mt-1 flex flex-wrap gap-1">
${row.tags.map(tag => `
<span class="inline-flex items-center px-2 py-0.5 rounded font-mono text-[10px] bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
${tag.name}
</span>
`).join('')}
</div>
` : ''}
</div>
`
})
},
{
key: 'preferred_protocol',
header: 'Protocol',
headerIcon: Zap,
align: 'left',
width: 'w-40',
cell: (row: DelayProfileTableRow) => ({
html: `<span class="font-mono text-xs bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 rounded">${formatProtocol(row.preferred_protocol)}</span>`
})
},
{
key: 'delays',
header: 'Delays',
headerIcon: Clock,
align: 'left',
width: 'w-48',
cell: (row: DelayProfileTableRow) => ({
html: `
<div class="text-xs space-y-0.5">
${row.usenet_delay !== null ? `<div>Usenet: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${formatDelay(row.usenet_delay)}</span></div>` : ''}
${row.torrent_delay !== null ? `<div>Torrent: <span class="font-mono text-[10px] bg-neutral-100 dark:bg-neutral-800 px-1 rounded">${formatDelay(row.torrent_delay)}</span></div>` : ''}
</div>
`
})
},
{
key: 'bypass',
header: 'Bypass',
headerIcon: Shield,
align: 'left',
width: 'w-56',
cell: (row: DelayProfileTableRow) => {
const bypasses: string[] = [];
if (row.bypass_if_highest_quality) {
bypasses.push('Highest Quality');
}
if (row.bypass_if_above_custom_format_score && row.minimum_custom_format_score !== null) {
bypasses.push(`CF Score ≥ ${row.minimum_custom_format_score}`);
}
if (bypasses.length === 0) {
return { html: '<span class="text-neutral-400">None</span>' };
}
return {
html: `
<div class="text-xs space-y-0.5">
${bypasses.map(b => `<div class="font-mono text-[10px] bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-1.5 py-0.5 rounded inline-block">${b}</div>`).join('')}
</div>
`
};
}
}
];
</script>
<Table
data={profiles}
{columns}
emptyMessage="No delay profiles found"
hoverable={true}
compact={false}
onRowClick={handleRowClick}
/>