diff --git a/src/lib/client/ui/modal/SaveTargetModal.svelte b/src/lib/client/ui/modal/SaveTargetModal.svelte new file mode 100644 index 0000000..c400b21 --- /dev/null +++ b/src/lib/client/ui/modal/SaveTargetModal.svelte @@ -0,0 +1,103 @@ + + +{#if open} + + + + +{/if} diff --git a/src/lib/server/pcd/cache.ts b/src/lib/server/pcd/cache.ts index 8f7934c..6d3c8d9 100644 --- a/src/lib/server/pcd/cache.ts +++ b/src/lib/server/pcd/cache.ts @@ -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', diff --git a/src/lib/server/pcd/ops.ts b/src/lib/server/pcd/ops.ts index 32c40e4..7a50737 100644 --- a/src/lib/server/pcd/ops.ts +++ b/src/lib/server/pcd/ops.ts @@ -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 { const allOperations: Operation[] = []; @@ -102,14 +102,28 @@ export async function loadAllOperations(pcdPath: string): Promise { 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 diff --git a/src/lib/server/pcd/queries/delayProfiles/create.ts b/src/lib/server/pcd/queries/delayProfiles/create.ts new file mode 100644 index 0000000..e6d1a45 --- /dev/null +++ b/src/lib/server/pcd/queries/delayProfiles/create.ts @@ -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; +} diff --git a/src/lib/server/pcd/queries/delayProfiles/delete.ts b/src/lib/server/pcd/queries/delayProfiles/delete.ts new file mode 100644 index 0000000..d4cb932 --- /dev/null +++ b/src/lib/server/pcd/queries/delayProfiles/delete.ts @@ -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; +} diff --git a/src/lib/server/pcd/queries/delayProfiles/get.ts b/src/lib/server/pcd/queries/delayProfiles/get.ts new file mode 100644 index 0000000..9643b88 --- /dev/null +++ b/src/lib/server/pcd/queries/delayProfiles/get.ts @@ -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 { + 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 + }; +} diff --git a/src/lib/server/pcd/queries/delayProfiles/index.ts b/src/lib/server/pcd/queries/delayProfiles/index.ts index 8652e99..fc04490 100644 --- a/src/lib/server/pcd/queries/delayProfiles/index.ts +++ b/src/lib/server/pcd/queries/delayProfiles/index.ts @@ -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'; diff --git a/src/lib/server/pcd/queries/delayProfiles/mutations.ts b/src/lib/server/pcd/queries/delayProfiles/mutations.ts new file mode 100644 index 0000000..76307dd --- /dev/null +++ b/src/lib/server/pcd/queries/delayProfiles/mutations.ts @@ -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'; diff --git a/src/lib/server/pcd/queries/delayProfiles/update.ts b/src/lib/server/pcd/queries/delayProfiles/update.ts new file mode 100644 index 0000000..67fd6d0 --- /dev/null +++ b/src/lib/server/pcd/queries/delayProfiles/update.ts @@ -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; +} diff --git a/src/lib/server/pcd/writer.ts b/src/lib/server/pcd/writer.ts new file mode 100644 index 0000000..8e33e9d --- /dev/null +++ b/src/lib/server/pcd/writer.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/src/routes/delay-profiles/+page.server.ts b/src/routes/delay-profiles/+page.server.ts new file mode 100644 index 0000000..57fab78 --- /dev/null +++ b/src/routes/delay-profiles/+page.server.ts @@ -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 + }; +}; diff --git a/src/routes/delay-profiles/+page.svelte b/src/routes/delay-profiles/+page.svelte new file mode 100644 index 0000000..e62c145 --- /dev/null +++ b/src/routes/delay-profiles/+page.svelte @@ -0,0 +1,17 @@ + + + + Delay Profiles - Profilarr + + + diff --git a/src/routes/delay-profiles/[databaseId]/+page.server.ts b/src/routes/delay-profiles/[databaseId]/+page.server.ts new file mode 100644 index 0000000..fec38d8 --- /dev/null +++ b/src/routes/delay-profiles/[databaseId]/+page.server.ts @@ -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 + }; +}; diff --git a/src/routes/delay-profiles/[databaseId]/+page.svelte b/src/routes/delay-profiles/[databaseId]/+page.svelte new file mode 100644 index 0000000..57629a2 --- /dev/null +++ b/src/routes/delay-profiles/[databaseId]/+page.svelte @@ -0,0 +1,72 @@ + + + + Delay Profiles - {data.currentDatabase.name} - Profilarr + + +
+ + + + + + + + goto(`/delay-profiles/${data.currentDatabase.id}/new`)} /> + + + +
+ {#if data.delayProfiles.length === 0} +
+

+ No delay profiles found for {data.currentDatabase.name} +

+
+ {:else if $filtered.length === 0} +
+

+ No delay profiles match your search +

+
+ {:else if $view === 'table'} + + {:else} + + {/if} +
+
diff --git a/src/routes/delay-profiles/[databaseId]/[id]/+page.server.ts b/src/routes/delay-profiles/[databaseId]/[id]/+page.server.ts new file mode 100644 index 0000000..a0e3842 --- /dev/null +++ b/src/routes/delay-profiles/[databaseId]/[id]/+page.server.ts @@ -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}`); + } +}; diff --git a/src/routes/delay-profiles/[databaseId]/[id]/+page.svelte b/src/routes/delay-profiles/[databaseId]/[id]/+page.svelte new file mode 100644 index 0000000..a41e3c5 --- /dev/null +++ b/src/routes/delay-profiles/[databaseId]/[id]/+page.svelte @@ -0,0 +1,42 @@ + + + + {data.delayProfile.name} - Delay Profiles - Profilarr + + + diff --git a/src/routes/delay-profiles/[databaseId]/components/DelayProfileForm.svelte b/src/routes/delay-profiles/[databaseId]/components/DelayProfileForm.svelte new file mode 100644 index 0000000..16fe216 --- /dev/null +++ b/src/routes/delay-profiles/[databaseId]/components/DelayProfileForm.svelte @@ -0,0 +1,379 @@ + + +
+ +
+

{title}

+

+ {description} +

+
+ +
{ + 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; + }; + }} + > + + + + + + + + + + + +
+

+ Basic Info +

+ +
+ +
+ + +
+ + +
+ +

+ Delay profiles apply to items with matching tags +

+
+ +
+
+
+
+ + +
+

+ Protocol Preference +

+ +
+ {#each protocolOptions as option} + + {/each} +
+
+ + +
+

+ Delays +

+

+ Time to wait before downloading from each source. Set to 0 for no delay. +

+ +
+ {#if showUsenetDelay} +
+ +
+ +
+
+ {/if} + + {#if showTorrentDelay} +
+ +
+ +
+
+ {/if} +
+
+ + +
+

+ Bypass Conditions +

+

+ Skip the delay when these conditions are met. +

+ +
+ + + + +
+ + + {#if bypassIfAboveCfScore} +
+ +
+ +
+
+ {/if} +
+
+
+ + +
+ +
+ {#if mode === 'edit'} + + {/if} +
+ + +
+ + +
+
+
+ + + {#if mode === 'edit'} + + {/if} +
+ + +{#if mode === 'edit'} + { + showDeleteModal = false; + deleteFormElement?.requestSubmit(); + }} + on:cancel={() => (showDeleteModal = false)} + /> +{/if} + + +{#if canWriteToBase} + (showSaveTargetModal = false)} + /> +{/if} diff --git a/src/routes/delay-profiles/[databaseId]/new/+page.server.ts b/src/routes/delay-profiles/[databaseId]/new/+page.server.ts new file mode 100644 index 0000000..3b16435 --- /dev/null +++ b/src/routes/delay-profiles/[databaseId]/new/+page.server.ts @@ -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}`); + } +}; diff --git a/src/routes/delay-profiles/[databaseId]/new/+page.svelte b/src/routes/delay-profiles/[databaseId]/new/+page.svelte new file mode 100644 index 0000000..618f758 --- /dev/null +++ b/src/routes/delay-profiles/[databaseId]/new/+page.svelte @@ -0,0 +1,41 @@ + + + + New Delay Profile - {data.currentDatabase.name} - Profilarr + + + diff --git a/src/routes/delay-profiles/[databaseId]/views/CardView.svelte b/src/routes/delay-profiles/[databaseId]/views/CardView.svelte new file mode 100644 index 0000000..c72db68 --- /dev/null +++ b/src/routes/delay-profiles/[databaseId]/views/CardView.svelte @@ -0,0 +1,110 @@ + + +
+ {#each profiles as profile} + + {/each} +
diff --git a/src/routes/delay-profiles/[databaseId]/views/TableView.svelte b/src/routes/delay-profiles/[databaseId]/views/TableView.svelte new file mode 100644 index 0000000..c8a35fd --- /dev/null +++ b/src/routes/delay-profiles/[databaseId]/views/TableView.svelte @@ -0,0 +1,127 @@ + + +