mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 19:01:02 +01:00
refactor: use base http client for notifications, parser, autocomp
This commit is contained in:
@@ -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`, {
|
||||
|
||||
42
src/lib/server/notifications/base/webhookClient.ts
Normal file
42
src/lib/server/notifications/base/webhookClient.ts
Normal file
@@ -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<T = void>(url: string, payload: unknown): Promise<T> {
|
||||
return this.post<T>(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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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(),
|
||||
|
||||
@@ -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<ChatCompletionResponse> {
|
||||
return this.post<ChatCompletionResponse>('/chat/completions', {
|
||||
model,
|
||||
messages,
|
||||
max_tokens: maxTokens,
|
||||
temperature
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Responses API (GPT-5)
|
||||
*/
|
||||
responses(model: string, instructions: string, input: string): Promise<ChatCompletionResponse> {
|
||||
return this.post<ChatCompletionResponse>('/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<string> {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
}
|
||||
|
||||
interface BatchMatchResponse {
|
||||
results: Record<string, Record<string, boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ParseResponse> {
|
||||
return this.post<ParseResponse>('/parse', { title, type });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check health and get version
|
||||
*/
|
||||
async health(): Promise<HealthResponse> {
|
||||
return this.get<HealthResponse>('/health');
|
||||
}
|
||||
|
||||
/**
|
||||
* Match patterns against text
|
||||
*/
|
||||
async match(text: string, patterns: string[]): Promise<MatchResponse> {
|
||||
return this.post<MatchResponse>('/match', { text, patterns });
|
||||
}
|
||||
|
||||
/**
|
||||
* Match patterns against multiple texts (batch)
|
||||
*/
|
||||
async matchBatch(texts: string[], patterns: string[]): Promise<BatchMatchResponse> {
|
||||
return this.post<BatchMatchResponse>('/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<ParseResult> {
|
||||
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<Qual
|
||||
*/
|
||||
export async function isParserHealthy(): Promise<boolean> {
|
||||
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<string | null> {
|
||||
}
|
||||
|
||||
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<string, boolean> } = 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<Map<string, Map<string, boolean>> | 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<string, Record<string, boolean>> } = await res.json();
|
||||
const data = await getClient().matchBatch(texts, patterns);
|
||||
|
||||
const result = new Map<string, Map<string, boolean>>();
|
||||
for (const [text, patternResults] of Object.entries(data.results)) {
|
||||
|
||||
Reference in New Issue
Block a user