Migrated git root from W:/programming/Projects/ to W:/programming/Projects/Tubearr/. Previous history preserved in Tubearr-full-backup.bundle at parent directory. Completed milestones: M001 through M005 Active: M006/S02 (Add Channel UX)
192 lines
5.1 KiB
TypeScript
192 lines
5.1 KiB
TypeScript
import type { LibSQLDatabase } from 'drizzle-orm/libsql';
|
|
import type * as schema from '../db/schema/index';
|
|
import { getEnabledNotificationSettings } from '../db/repositories/notification-repository';
|
|
|
|
// ── Types ──
|
|
|
|
type Db = LibSQLDatabase<typeof schema>;
|
|
|
|
/** Payload shape for notification callbacks from QueueService. */
|
|
export interface NotificationEvent {
|
|
contentTitle: string;
|
|
channelName: string;
|
|
platform: string;
|
|
url: string;
|
|
filePath?: string;
|
|
error?: string;
|
|
attempt?: number;
|
|
maxAttempts?: number;
|
|
}
|
|
|
|
/** Discord embed field shape. */
|
|
interface DiscordEmbedField {
|
|
name: string;
|
|
value: string;
|
|
inline?: boolean;
|
|
}
|
|
|
|
/** Discord embed shape sent in webhook payload. */
|
|
interface DiscordEmbed {
|
|
title: string;
|
|
description: string;
|
|
color: number;
|
|
fields: DiscordEmbedField[];
|
|
timestamp: string;
|
|
}
|
|
|
|
// ── Colors ──
|
|
|
|
const COLOR_GRAB = 0x3498db; // blue
|
|
const COLOR_DOWNLOAD = 0x2ecc71; // green
|
|
const COLOR_FAILURE = 0xe74c3c; // red
|
|
|
|
// ── NotificationService ──
|
|
|
|
/**
|
|
* Dispatches notification embeds to configured Discord webhooks.
|
|
* All dispatch is fire-and-forget — errors are logged but never thrown.
|
|
*/
|
|
export class NotificationService {
|
|
constructor(private readonly db: Db) {}
|
|
|
|
/**
|
|
* Notify about a content grab (added to queue).
|
|
*/
|
|
async notifyGrab(
|
|
contentTitle: string,
|
|
channelName: string,
|
|
platform: string,
|
|
url: string
|
|
): Promise<void> {
|
|
const embed: DiscordEmbed = {
|
|
title: '📥 Content Grabbed',
|
|
description: `**${contentTitle}**`,
|
|
color: COLOR_GRAB,
|
|
fields: [
|
|
{ name: 'Channel', value: channelName, inline: true },
|
|
{ name: 'Platform', value: platform, inline: true },
|
|
{ name: 'URL', value: url },
|
|
],
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
await this.dispatch('onGrab', embed);
|
|
}
|
|
|
|
/**
|
|
* Notify about a successful download.
|
|
*/
|
|
async notifyDownload(
|
|
contentTitle: string,
|
|
channelName: string,
|
|
platform: string,
|
|
url: string,
|
|
filePath?: string
|
|
): Promise<void> {
|
|
const fields: DiscordEmbedField[] = [
|
|
{ name: 'Channel', value: channelName, inline: true },
|
|
{ name: 'Platform', value: platform, inline: true },
|
|
{ name: 'URL', value: url },
|
|
];
|
|
|
|
if (filePath) {
|
|
fields.push({ name: 'File', value: filePath });
|
|
}
|
|
|
|
const embed: DiscordEmbed = {
|
|
title: '✅ Download Complete',
|
|
description: `**${contentTitle}**`,
|
|
color: COLOR_DOWNLOAD,
|
|
fields,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
await this.dispatch('onDownload', embed);
|
|
}
|
|
|
|
/**
|
|
* Notify about a download failure.
|
|
*/
|
|
async notifyFailure(
|
|
contentTitle: string,
|
|
channelName: string,
|
|
platform: string,
|
|
error: string,
|
|
attempt: number,
|
|
maxAttempts: number
|
|
): Promise<void> {
|
|
const embed: DiscordEmbed = {
|
|
title: '❌ Download Failed',
|
|
description: `**${contentTitle}**`,
|
|
color: COLOR_FAILURE,
|
|
fields: [
|
|
{ name: 'Channel', value: channelName, inline: true },
|
|
{ name: 'Platform', value: platform, inline: true },
|
|
{ name: 'Error', value: error.slice(0, 1024) },
|
|
{ name: 'Attempt', value: `${attempt} / ${maxAttempts}`, inline: true },
|
|
],
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
await this.dispatch('onFailure', embed);
|
|
}
|
|
|
|
// ── Private ──
|
|
|
|
/**
|
|
* Load enabled notification settings with the matching event toggle,
|
|
* and POST the embed to each Discord webhook. Errors are logged with
|
|
* channel name but never thrown — dispatch is fire-and-forget.
|
|
*/
|
|
private async dispatch(
|
|
eventType: 'onGrab' | 'onDownload' | 'onFailure',
|
|
embed: DiscordEmbed
|
|
): Promise<void> {
|
|
let settings;
|
|
try {
|
|
settings = await getEnabledNotificationSettings(this.db);
|
|
} catch (err) {
|
|
console.log(
|
|
`[notification] failed to load settings: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Filter to settings that have the matching event toggle enabled
|
|
const matching = settings.filter((s) => s[eventType] === true);
|
|
|
|
for (const setting of matching) {
|
|
const config = setting.config as { webhookUrl?: string };
|
|
const webhookUrl = config?.webhookUrl;
|
|
|
|
if (!webhookUrl) {
|
|
console.log(
|
|
`[notification] skip channel="${setting.name}" — no webhook URL configured`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(webhookUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ embeds: [embed] }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.log(
|
|
`[notification] dispatch failed channel="${setting.name}" event=${eventType} httpStatus=${response.status}`
|
|
);
|
|
} else {
|
|
console.log(
|
|
`[notification] dispatch success channel="${setting.name}" event=${eventType}`
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.log(
|
|
`[notification] dispatch error channel="${setting.name}" event=${eventType} error="${err instanceof Error ? err.message : String(err)}"`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|