tubearr/src/services/notification.ts
John Lightner 4606dce553 feat: Tubearr — full project state through M006/S01
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)
2026-03-24 20:20:10 -05:00

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)}"`
);
}
}
}
}