From 8d3e20d3c36f17fffa23999959339fef8cfbeea0 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Mon, 19 Jan 2026 02:26:12 +1030 Subject: [PATCH] refactor: use base http client for notifications, parser, autocomp --- .../notifications/base/BaseHttpNotifier.ts | 43 +---- .../notifications/base/webhookClient.ts | 42 +++++ .../notifiers/discord/DiscordNotifier.ts | 23 +-- src/lib/server/utils/ai/client.ts | 178 +++++++++++------- src/lib/server/utils/arr/parser/client.ts | 126 +++++++------ 5 files changed, 232 insertions(+), 180 deletions(-) create mode 100644 src/lib/server/notifications/base/webhookClient.ts diff --git a/src/lib/server/notifications/base/BaseHttpNotifier.ts b/src/lib/server/notifications/base/BaseHttpNotifier.ts index 8d1a94c..b46ba90 100644 --- a/src/lib/server/notifications/base/BaseHttpNotifier.ts +++ b/src/lib/server/notifications/base/BaseHttpNotifier.ts @@ -1,6 +1,7 @@ import { logger } from '$logger/logger.ts'; import type { Notification } from '../types.ts'; import type { Notifier } from './Notifier.ts'; +import { getWebhookClient } from './webhookClient.ts'; /** * Base class for HTTP-based notification services (webhooks) @@ -9,7 +10,6 @@ import type { Notifier } from './Notifier.ts'; export abstract class BaseHttpNotifier implements Notifier { private lastSentAt: Date | null = null; private readonly minInterval: number = 1000; // 1 second between notifications - private readonly timeout: number = 10000; // 10 second timeout /** * Get the webhook URL for this service @@ -47,43 +47,14 @@ export abstract class BaseHttpNotifier implements Notifier { const payload = this.formatPayload(notification); const url = this.getWebhookUrl(); - // Create abort controller for timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); + await getWebhookClient().sendWebhook(url, payload); - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload), - signal: controller.signal - }); + await logger.debug(`Notification sent`, { + source: this.getName(), + meta: { type: notification.type } + }); - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text().catch(() => 'Unknown error'); - throw new Error(`HTTP ${response.status}: ${errorText}`); - } - - await logger.debug(`Notification sent`, { - source: this.getName(), - meta: { type: notification.type } - }); - - this.lastSentAt = new Date(); - } catch (error) { - clearTimeout(timeoutId); - - // Handle timeout - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('Request timeout'); - } - - throw error; - } + this.lastSentAt = new Date(); } catch (error) { // Log error but don't throw (fire-and-forget) await logger.error(`Failed to send notification`, { diff --git a/src/lib/server/notifications/base/webhookClient.ts b/src/lib/server/notifications/base/webhookClient.ts new file mode 100644 index 0000000..420317d --- /dev/null +++ b/src/lib/server/notifications/base/webhookClient.ts @@ -0,0 +1,42 @@ +/** + * Shared HTTP client for webhook-based notifications + * Uses BaseHttpClient for connection pooling + */ + +import { BaseHttpClient } from '../../utils/http/client.ts'; + +/** + * Webhook HTTP client + * Extends BaseHttpClient with webhook-specific settings: + * - No retries (webhooks should either work or not) + * - 10 second timeout + */ +class WebhookClient extends BaseHttpClient { + constructor() { + // Empty base URL - we pass full webhook URLs as paths + super('', { + timeout: 10000, + retries: 0 + }); + } + + /** + * POST to a webhook URL + */ + sendWebhook(url: string, payload: unknown): Promise { + return this.post(url, payload); + } +} + +// Singleton instance - lazy initialized +let webhookClient: WebhookClient | null = null; + +/** + * Get the shared webhook client + */ +export function getWebhookClient(): WebhookClient { + if (!webhookClient) { + webhookClient = new WebhookClient(); + } + return webhookClient; +} diff --git a/src/lib/server/notifications/notifiers/discord/DiscordNotifier.ts b/src/lib/server/notifications/notifiers/discord/DiscordNotifier.ts index 074414c..54697bf 100644 --- a/src/lib/server/notifications/notifiers/discord/DiscordNotifier.ts +++ b/src/lib/server/notifications/notifiers/discord/DiscordNotifier.ts @@ -1,6 +1,7 @@ import { logger } from '$logger/logger.ts'; import type { DiscordConfig, Notification } from '../../types.ts'; import { Colors, type DiscordEmbed } from './embed.ts'; +import { getWebhookClient } from '../../base/webhookClient.ts'; const RATE_LIMIT_DELAY = 1000; // 1 second between messages @@ -104,30 +105,10 @@ export class DiscordNotifier { */ private async sendWebhook(payload: unknown): Promise { const payloadObj = payload as { embeds?: unknown[] }; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); try { - const response = await fetch(this.config.webhook_url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text().catch(() => 'Unknown error'); - throw new Error(`HTTP ${response.status}: ${errorText}`); - } + await getWebhookClient().sendWebhook(this.config.webhook_url, payload); } catch (error) { - clearTimeout(timeoutId); - - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('Request timeout'); - } - const embedCharCounts = payloadObj.embeds?.map((e, i) => `${i}:${getEmbedCharCount(e as DiscordEmbed)}`).join(', ') || 'none'; await logger.error('Failed to send notification', { source: this.getName(), diff --git a/src/lib/server/utils/ai/client.ts b/src/lib/server/utils/ai/client.ts index c331c24..2ab932d 100644 --- a/src/lib/server/utils/ai/client.ts +++ b/src/lib/server/utils/ai/client.ts @@ -4,6 +4,7 @@ import { aiSettingsQueries } from '$db/queries/aiSettings.ts'; import { logger } from '$logger/logger.ts'; +import { BaseHttpClient } from '../http/client.ts'; interface ChatMessage { role: 'system' | 'user' | 'assistant'; @@ -25,6 +26,72 @@ interface ChatCompletionResponse { }>; } +/** + * AI API HTTP client + * Extends BaseHttpClient for OpenAI-compatible APIs + */ +class AIClient extends BaseHttpClient { + constructor(baseUrl: string, apiKey?: string) { + super(baseUrl, { + timeout: 60000, // AI requests can be slow + retries: 2, + retryDelay: 1000, + headers: { + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) + } + }); + } + + /** + * Chat completions API (GPT-4, etc.) + */ + chatCompletions(model: string, messages: ChatMessage[], maxTokens = 100, temperature = 0.3): Promise { + return this.post('/chat/completions', { + model, + messages, + max_tokens: maxTokens, + temperature + }); + } + + /** + * Responses API (GPT-5) + */ + responses(model: string, instructions: string, input: string): Promise { + return this.post('/responses', { + model, + instructions, + input, + text: { verbosity: 'low' } + }); + } +} + +// Cached client instance (recreated if settings change) +let cachedClient: AIClient | null = null; +let cachedApiUrl: string | null = null; +let cachedApiKey: string | null = null; + +function getClient(): AIClient | null { + const settings = aiSettingsQueries.get(); + + if (!settings || settings.enabled !== 1 || !settings.api_url) { + return null; + } + + // Recreate client if settings changed + if (!cachedClient || cachedApiUrl !== settings.api_url || cachedApiKey !== settings.api_key) { + if (cachedClient) { + cachedClient.close(); + } + cachedClient = new AIClient(settings.api_url, settings.api_key || undefined); + cachedApiUrl = settings.api_url; + cachedApiKey = settings.api_key; + } + + return cachedClient; +} + /** * Check if AI is enabled and configured */ @@ -43,6 +110,11 @@ export async function generateCommitMessage(diff: string): Promise { throw new Error('AI is not enabled'); } + const client = getClient(); + if (!client) { + throw new Error('AI client not available'); + } + const systemPrompt = `Generate a git commit message for database operation files. File format: "N.operation-entity-name.sql" where operation is create/update/delete. @@ -65,83 +137,51 @@ For multiple files, combine: "create(custom-format): HDR, DV" or list operations Output only the commit message, max 72 chars.`; - const userPrompt = diff; + try { + let data: ChatCompletionResponse; - // Use Responses API for GPT-5 models, Chat Completions for others - const isGpt5 = settings.model.startsWith('gpt-5'); + // Use Responses API for GPT-5 models, Chat Completions for others + const isGpt5 = settings.model.startsWith('gpt-5'); - let response: Response; + if (isGpt5) { + data = await client.responses(settings.model, systemPrompt, diff); + } else { + const messages: ChatMessage[] = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: diff } + ]; + data = await client.chatCompletions(settings.model, messages); + } - if (isGpt5) { - // Responses API (recommended for GPT-5) - response = await fetch(`${settings.api_url}/responses`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(settings.api_key ? { 'Authorization': `Bearer ${settings.api_key}` } : {}) - }, - body: JSON.stringify({ - model: settings.model, - instructions: systemPrompt, - input: userPrompt, - text: { verbosity: 'low' } - }) + await logger.debug('AI response received', { + source: 'ai/client', + meta: { response: JSON.stringify(data) } }); - } else { - // Chat Completions API (for other models) - const messages: ChatMessage[] = [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ]; - response = await fetch(`${settings.api_url}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(settings.api_key ? { 'Authorization': `Bearer ${settings.api_key}` } : {}) - }, - body: JSON.stringify({ - model: settings.model, - messages, - max_tokens: 100, - temperature: 0.3 - }) + // Handle Responses API format + if (data.output) { + const textOutput = data.output.find(o => o.type === 'message'); + const textContent = textOutput?.content?.find(c => c.type === 'output_text'); + if (textContent?.text) { + return textContent.text.trim(); + } + } + + // Handle Chat Completions API format + if (data.choices?.[0]?.message?.content) { + return data.choices[0].message.content.trim(); + } + + await logger.error('Invalid AI response structure', { + source: 'ai/client', + meta: { response: JSON.stringify(data) } }); - } - - if (!response.ok) { - const text = await response.text(); + throw new Error('Invalid response from AI'); + } catch (error) { await logger.error('AI request failed', { source: 'ai/client', - meta: { status: response.status, error: text } + meta: { error: error instanceof Error ? error.message : String(error) } }); - throw new Error(`AI request failed: ${response.status} ${text}`); + throw error; } - - const data = await response.json() as ChatCompletionResponse; - - await logger.debug('AI response received', { - source: 'ai/client', - meta: { response: JSON.stringify(data) } - }); - - // Handle Responses API format - if (data.output) { - const textOutput = data.output.find(o => o.type === 'message'); - const textContent = textOutput?.content?.find(c => c.type === 'output_text'); - if (textContent?.text) { - return textContent.text.trim(); - } - } - - // Handle Chat Completions API format - if (data.choices?.[0]?.message?.content) { - return data.choices[0].message.content.trim(); - } - - await logger.error('Invalid AI response structure', { - source: 'ai/client', - meta: { response: JSON.stringify(data) } - }); - throw new Error('Invalid response from AI'); } diff --git a/src/lib/server/utils/arr/parser/client.ts b/src/lib/server/utils/arr/parser/client.ts index fd65c8b..c5e47a2 100644 --- a/src/lib/server/utils/arr/parser/client.ts +++ b/src/lib/server/utils/arr/parser/client.ts @@ -5,6 +5,7 @@ import { config } from '$config'; import { logger } from '$logger/logger.ts'; +import { BaseHttpClient } from '../../http/client.ts'; import { parsedReleaseCacheQueries } from '$db/queries/parsedReleaseCache.ts'; import { patternMatchCacheQueries } from '$db/queries/patternMatchCache.ts'; import { @@ -14,7 +15,6 @@ import { ReleaseType, type QualityInfo, type ParseResult, - type EpisodeInfo, type Resolution, type MediaType } from './types.ts'; @@ -59,23 +59,78 @@ interface ParseResponse { episode: EpisodeResponse | null; } +interface HealthResponse { + status: string; + version: string; +} + +interface MatchResponse { + results: Record; +} + +interface BatchMatchResponse { + results: Record>; +} + +/** + * Parser service HTTP client + * Extends BaseHttpClient with parser-specific methods + */ +class ParserClient extends BaseHttpClient { + constructor(baseUrl: string) { + super(baseUrl, { + timeout: 30000, + retries: 2, + retryDelay: 500 + }); + } + + /** + * Parse a release title + */ + async parse(title: string, type: MediaType): Promise { + return this.post('/parse', { title, type }); + } + + /** + * Check health and get version + */ + async health(): Promise { + return this.get('/health'); + } + + /** + * Match patterns against text + */ + async match(text: string, patterns: string[]): Promise { + return this.post('/match', { text, patterns }); + } + + /** + * Match patterns against multiple texts (batch) + */ + async matchBatch(texts: string[], patterns: string[]): Promise { + return this.post('/match/batch', { texts, patterns }); + } +} + +// Singleton client instance - lazy initialized +let parserClient: ParserClient | null = null; + +function getClient(): ParserClient { + if (!parserClient) { + parserClient = new ParserClient(config.parserUrl); + } + return parserClient; +} + /** * Parse a release title - returns quality, resolution, modifier, revision, and languages * @param title - The release title to parse * @param type - The media type: 'movie' or 'series' */ export async function parse(title: string, type: MediaType): Promise { - const res = await fetch(`${config.parserUrl}/parse`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, type }) - }); - - if (!res.ok) { - throw new Error(`Parser error: ${res.status}`); - } - - const data: ParseResponse = await res.json(); + const data = await getClient().parse(title, type); return { title: data.title, @@ -134,8 +189,8 @@ export async function parseQuality(title: string, type: MediaType): Promise { try { - const res = await fetch(`${config.parserUrl}/health`); - return res.ok; + await getClient().health(); + return true; } catch { return false; } @@ -151,16 +206,7 @@ export async function getParserVersion(): Promise { } try { - const res = await fetch(`${config.parserUrl}/health`); - if (!res.ok) { - await logger.warn('Parser health check failed', { - source: 'ParserClient', - meta: { status: res.status } - }); - return null; - } - - const data: { status: string; version: string } = await res.json(); + const data = await getClient().health(); cachedParserVersion = data.version; await logger.debug(`Parser version: ${data.version}`, { source: 'ParserClient' }); return cachedParserVersion; @@ -333,21 +379,7 @@ export async function matchPatterns( } try { - const res = await fetch(`${config.parserUrl}/match`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, patterns }) - }); - - if (!res.ok) { - await logger.warn('Pattern match request failed', { - source: 'ParserClient', - meta: { status: res.status } - }); - return null; - } - - const data: { results: Record } = await res.json(); + const data = await getClient().match(text, patterns); return new Map(Object.entries(data.results)); } catch (err) { await logger.warn('Failed to connect to parser for pattern matching', { @@ -378,21 +410,7 @@ async function fetchPatternMatches( patterns: string[] ): Promise> | null> { try { - const res = await fetch(`${config.parserUrl}/match/batch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ texts, patterns }) - }); - - if (!res.ok) { - await logger.warn('Batch pattern match request failed', { - source: 'ParserClient', - meta: { status: res.status } - }); - return null; - } - - const data: { results: Record> } = await res.json(); + const data = await getClient().matchBatch(texts, patterns); const result = new Map>(); for (const [text, patternResults] of Object.entries(data.results)) {