mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 19:01:02 +01:00
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:
103
src/lib/client/ui/modal/SaveTargetModal.svelte
Normal file
103
src/lib/client/ui/modal/SaveTargetModal.svelte
Normal 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}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
97
src/lib/server/pcd/queries/delayProfiles/create.ts
Normal file
97
src/lib/server/pcd/queries/delayProfiles/create.ts
Normal 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;
|
||||
}
|
||||
69
src/lib/server/pcd/queries/delayProfiles/delete.ts
Normal file
69
src/lib/server/pcd/queries/delayProfiles/delete.ts
Normal 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;
|
||||
}
|
||||
59
src/lib/server/pcd/queries/delayProfiles/get.ts
Normal file
59
src/lib/server/pcd/queries/delayProfiles/get.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
7
src/lib/server/pcd/queries/delayProfiles/mutations.ts
Normal file
7
src/lib/server/pcd/queries/delayProfiles/mutations.ts
Normal 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';
|
||||
134
src/lib/server/pcd/queries/delayProfiles/update.ts
Normal file
134
src/lib/server/pcd/queries/delayProfiles/update.ts
Normal 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;
|
||||
}
|
||||
229
src/lib/server/pcd/writer.ts
Normal file
229
src/lib/server/pcd/writer.ts
Normal 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;
|
||||
}
|
||||
18
src/routes/delay-profiles/+page.server.ts
Normal file
18
src/routes/delay-profiles/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
17
src/routes/delay-profiles/+page.svelte
Normal file
17
src/routes/delay-profiles/+page.svelte
Normal 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}
|
||||
/>
|
||||
44
src/routes/delay-profiles/[databaseId]/+page.server.ts
Normal file
44
src/routes/delay-profiles/[databaseId]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
72
src/routes/delay-profiles/[databaseId]/+page.svelte
Normal file
72
src/routes/delay-profiles/[databaseId]/+page.svelte
Normal 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>
|
||||
169
src/routes/delay-profiles/[databaseId]/[id]/+page.server.ts
Normal file
169
src/routes/delay-profiles/[databaseId]/[id]/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
42
src/routes/delay-profiles/[databaseId]/[id]/+page.svelte
Normal file
42
src/routes/delay-profiles/[databaseId]/[id]/+page.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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}
|
||||
107
src/routes/delay-profiles/[databaseId]/new/+page.server.ts
Normal file
107
src/routes/delay-profiles/[databaseId]/new/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
41
src/routes/delay-profiles/[databaseId]/new/+page.svelte
Normal file
41
src/routes/delay-profiles/[databaseId]/new/+page.svelte
Normal 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}
|
||||
/>
|
||||
110
src/routes/delay-profiles/[databaseId]/views/CardView.svelte
Normal file
110
src/routes/delay-profiles/[databaseId]/views/CardView.svelte
Normal 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>
|
||||
127
src/routes/delay-profiles/[databaseId]/views/TableView.svelte
Normal file
127
src/routes/delay-profiles/[databaseId]/views/TableView.svelte
Normal 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}
|
||||
/>
|
||||
Reference in New Issue
Block a user