feat: add summary notifs for rename jobs, rename original to rich notifs

This commit is contained in:
Sam Chau
2026-01-18 02:24:18 +10:30
parent de39481d4d
commit 0ab315d4a9
11 changed files with 179 additions and 20 deletions

View File

@@ -26,6 +26,7 @@ import { migration as migration021 } from './migrations/021_create_parsed_releas
import { migration as migration022 } from './migrations/022_add_next_run_at.ts';
import { migration as migration023 } from './migrations/023_create_pattern_match_cache.ts';
import { migration as migration024 } from './migrations/024_create_arr_rename_settings.ts';
import { migration as migration025 } from './migrations/025_add_rename_notification_mode.ts';
export interface Migration {
version: number;
@@ -264,7 +265,8 @@ export function loadMigrations(): Migration[] {
migration021,
migration022,
migration023,
migration024
migration024,
migration025
];
// Sort by version number

View File

@@ -0,0 +1,24 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 025: Add summary_notifications to arr_rename_settings
*
* Adds a boolean column to control whether rename notifications are sent as
* summaries (default) or rich notifications with all file details.
*
* - 1 (default): Summary notification with total count and one sample item
* - 0: Rich notification with all renamed files listed
*/
export const migration: Migration = {
version: 25,
name: 'Add summary_notifications to arr_rename_settings',
up: `
ALTER TABLE arr_rename_settings ADD COLUMN summary_notifications INTEGER NOT NULL DEFAULT 1;
`,
down: `
ALTER TABLE arr_rename_settings DROP COLUMN summary_notifications;
`
};

View File

@@ -9,6 +9,7 @@ interface RenameSettingsRow {
dry_run: number;
rename_folders: number;
ignore_tag: string | null;
summary_notifications: number;
enabled: number;
schedule: number;
last_run_at: string | null;
@@ -25,6 +26,7 @@ export interface RenameSettings {
dryRun: boolean;
renameFolders: boolean;
ignoreTag: string | null;
summaryNotifications: boolean;
enabled: boolean;
schedule: number;
lastRunAt: string | null;
@@ -39,6 +41,7 @@ export interface RenameSettingsInput {
dryRun?: boolean;
renameFolders?: boolean;
ignoreTag?: string | null;
summaryNotifications?: boolean;
enabled?: boolean;
schedule?: number;
}
@@ -53,6 +56,7 @@ function rowToSettings(row: RenameSettingsRow): RenameSettings {
dryRun: row.dry_run === 1,
renameFolders: row.rename_folders === 1,
ignoreTag: row.ignore_tag,
summaryNotifications: row.summary_notifications === 1,
enabled: row.enabled === 1,
schedule: row.schedule,
lastRunAt: row.last_run_at,
@@ -110,17 +114,19 @@ export const arrRenameSettingsQueries = {
const dryRun = input.dryRun !== undefined ? (input.dryRun ? 1 : 0) : 1;
const renameFolders = input.renameFolders !== undefined ? (input.renameFolders ? 1 : 0) : 0;
const ignoreTag = input.ignoreTag ?? null;
const summaryNotifications = input.summaryNotifications !== undefined ? (input.summaryNotifications ? 1 : 0) : 1;
const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : 0;
const schedule = input.schedule ?? 1440;
db.execute(
`INSERT INTO arr_rename_settings
(arr_instance_id, dry_run, rename_folders, ignore_tag, enabled, schedule)
VALUES (?, ?, ?, ?, ?, ?)`,
(arr_instance_id, dry_run, rename_folders, ignore_tag, summary_notifications, enabled, schedule)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
arrInstanceId,
dryRun,
renameFolders,
ignoreTag,
summaryNotifications,
enabled,
schedule
);
@@ -147,6 +153,10 @@ export const arrRenameSettingsQueries = {
updates.push('ignore_tag = ?');
params.push(input.ignoreTag);
}
if (input.summaryNotifications !== undefined) {
updates.push('summary_notifications = ?');
params.push(input.summaryNotifications ? 1 : 0);
}
if (input.enabled !== undefined) {
updates.push('enabled = ?');
params.push(input.enabled ? 1 : 0);

View File

@@ -465,7 +465,7 @@ CREATE INDEX idx_pattern_match_cache_created_at ON pattern_match_cache(created_a
-- ==============================================================================
-- TABLE: arr_rename_settings
-- Purpose: Store rename configuration per arr instance for bulk file/folder renaming
-- Migration: 024_create_arr_rename_settings.ts
-- Migration: 024_create_arr_rename_settings.ts, 025_add_rename_notification_mode.ts
-- ==============================================================================
CREATE TABLE arr_rename_settings (
@@ -478,6 +478,7 @@ CREATE TABLE arr_rename_settings (
dry_run INTEGER NOT NULL DEFAULT 1, -- 1=preview only, 0=make changes
rename_folders INTEGER NOT NULL DEFAULT 0, -- 1=rename folders too, 0=files only
ignore_tag TEXT, -- Tag name to skip (items with tag won't be renamed)
summary_notifications INTEGER NOT NULL DEFAULT 1, -- 1=summary, 0=rich (Migration 025)
-- Job scheduling
enabled INTEGER NOT NULL DEFAULT 0, -- Master on/off switch for scheduled job

View File

@@ -8,6 +8,7 @@ import type { RenameJobLog } from '$lib/server/rename/types.ts';
interface RenameNotificationParams {
log: RenameJobLog;
config: { username?: string; avatar_url?: string };
summaryNotifications?: boolean;
}
// Discord limits (https://birdie0.github.io/discord-webhooks-guide/other/field_limits.html)
@@ -231,9 +232,62 @@ function startNewEmbed(
}
/**
* Notification for rename job completion
* Build a summary notification (compact, single embed)
*/
export const rename = ({ log, config }: RenameNotificationParams) => {
function buildSummaryNotification(
log: RenameJobLog,
config: { username?: string; avatar_url?: string }
) {
const embed = createEmbed()
.author(config.username || 'Profilarr', config.avatar_url)
.title(`${getTitle(log)} - ${log.instanceName}`)
.color(Colors.INFO)
.timestamp()
.footer(`Type: rename.${log.status}`);
// Stats fields
if (log.config.dryRun) {
embed.field('Mode', 'Dry Run', true);
embed.field('Files', String(log.results.filesNeedingRename), true);
} else {
embed.field('Files', `${log.results.filesRenamed}/${log.results.filesNeedingRename}`, true);
if (log.config.renameFolders) {
embed.field('Folders', String(log.results.foldersRenamed), true);
}
}
// Add sample if there are renamed items
if (log.renamedItems.length > 0) {
const sample = log.renamedItems[0];
const sampleFile = sample.files[0];
const othersCount = log.results.filesNeedingRename - 1;
const othersText = othersCount > 0 ? ` + ${othersCount} other${othersCount === 1 ? '' : 's'}` : '';
embed.field(
`Sample: ${truncateFieldName(sample.title)}${othersText}`,
'```\n' + formatFileEntry(sampleFile) + '\n```',
false
);
}
const genericMessage =
log.status === 'failed'
? `Rename failed for ${log.instanceName}`
: `Renamed ${log.results.filesRenamed} files for ${log.instanceName}`;
return notify(`rename.${log.status}`)
.generic(getTitle(log), genericMessage)
.discord((d) => d.embed(embed));
}
/**
* Build a rich notification (detailed, multiple embeds if needed)
*/
function buildRichNotification(
log: RenameJobLog,
config: { username?: string; avatar_url?: string }
) {
const embeds: EmbedBuilder[] = [];
// If no files to rename, single embed with just stats
@@ -292,4 +346,31 @@ export const rename = ({ log, config }: RenameNotificationParams) => {
for (const e of embeds) d.embed(e);
return d;
});
}
/**
* Notification for rename job completion
*/
export const rename = ({ log, config, summaryNotifications = true }: RenameNotificationParams) => {
// If no files to rename, always use a simple notification
if (log.renamedItems.length === 0) {
const embed = createEmbed()
.author(config.username || 'Profilarr', config.avatar_url)
.title(`${getTitle(log)} - ${log.instanceName}`)
.color(Colors.INFO)
.field('Status', 'No files needed renaming', false)
.timestamp()
.footer(`Type: rename.${log.status}`);
return notify(`rename.${log.status}`)
.generic(getTitle(log), `No files needed renaming for ${log.instanceName}`)
.discord((d) => d.embed(embed));
}
// Use summary or rich notification based on setting
if (summaryNotifications) {
return buildSummaryNotification(log, config);
}
return buildRichNotification(log, config);
};

View File

@@ -2,6 +2,8 @@
* Core notification types and interfaces
*/
import type { DiscordEmbed } from './notifiers/discord/embed.ts';
/**
* Type-safe notification type constants
*/
@@ -40,7 +42,7 @@ export interface GenericNotification {
* Discord-specific notification content
*/
export interface DiscordNotification {
embeds: unknown[];
embeds: DiscordEmbed[];
}
/**

View File

@@ -60,7 +60,7 @@ function createSkippedLog(
/**
* Send rename notification
*/
async function sendRenameNotification(log: RenameJobLog): Promise<void> {
async function sendRenameNotification(log: RenameJobLog, summaryNotifications: boolean): Promise<void> {
// Only notify if there were files to rename
if (log.results.filesNeedingRename > 0) {
const { DiscordNotifier } = await import('$lib/server/notifications/notifiers/discord/index.ts');
@@ -80,7 +80,7 @@ async function sendRenameNotification(log: RenameJobLog): Promise<void> {
if (service.service_type === 'discord') {
const notifier = new DiscordNotifier(config);
const notification = notifications.rename({ log, config }).build();
const notification = notifications.rename({ log, config, summaryNotifications }).build();
await notifier.notify(notification);
}
} catch {
@@ -179,7 +179,7 @@ async function processRadarrRename(
};
await logRenameRun(log);
sendRenameNotification(log);
sendRenameNotification(log, settings.summaryNotifications);
return log;
}
@@ -280,7 +280,7 @@ async function processRadarrRename(
};
await logRenameRun(log);
sendRenameNotification(log);
sendRenameNotification(log, settings.summaryNotifications);
return log;
}
@@ -375,7 +375,7 @@ async function processSonarrRename(
};
await logRenameRun(log);
sendRenameNotification(log);
sendRenameNotification(log, settings.summaryNotifications);
return log;
}
@@ -490,7 +490,7 @@ async function processSonarrRename(
};
await logRenameRun(log);
sendRenameNotification(log);
sendRenameNotification(log, settings.summaryNotifications);
return log;
}

View File

@@ -28,7 +28,7 @@ export class RadarrClient extends BaseArrClient {
/**
* Get all quality profiles
*/
getQualityProfiles(): Promise<RadarrQualityProfile[]> {
override getQualityProfiles(): Promise<RadarrQualityProfile[]> {
return this.get<RadarrQualityProfile[]>(`/api/${this.apiVersion}/qualityprofile`);
}

View File

@@ -47,13 +47,15 @@ export const actions: Actions = {
const renameFolders = formData.get('renameFolders') === 'true';
const ignoreTag = (formData.get('ignoreTag') as string) || null;
const schedule = parseInt(formData.get('schedule') as string, 10) || 1440;
const summaryNotifications = formData.get('summaryNotifications') === 'true';
const settingsData = {
enabled,
dryRun,
renameFolders,
ignoreTag,
schedule
schedule,
summaryNotifications
};
arrRenameSettingsQueries.upsert(id, settingsData);
@@ -106,13 +108,15 @@ export const actions: Actions = {
const renameFolders = formData.get('renameFolders') === 'true';
const ignoreTag = (formData.get('ignoreTag') as string) || null;
const schedule = parseInt(formData.get('schedule') as string, 10) || 1440;
const summaryNotifications = formData.get('summaryNotifications') === 'true';
const settingsData = {
enabled,
dryRun,
renameFolders,
ignoreTag,
schedule
schedule,
summaryNotifications
};
arrRenameSettingsQueries.update(id, settingsData);

View File

@@ -19,6 +19,7 @@
let renameFolders = data.settings?.renameFolders ?? false;
let ignoreTag = data.settings?.ignoreTag ?? '';
let schedule = String(data.settings?.schedule ?? 1440);
let summaryNotifications = data.settings?.summaryNotifications ?? true;
// Track if settings exist (determines save vs edit)
$: isNewConfig = !data.settings;
@@ -29,7 +30,7 @@
// Initialize dirty tracking
onMount(() => {
const formData = { enabled, dryRun, renameFolders, ignoreTag, schedule };
const formData = { enabled, dryRun, renameFolders, ignoreTag, schedule, summaryNotifications };
if (isNewConfig) {
initCreate(formData);
} else {
@@ -44,6 +45,7 @@
$: update('renameFolders', renameFolders);
$: update('ignoreTag', ignoreTag);
$: update('schedule', schedule);
$: update('summaryNotifications', summaryNotifications);
// Handle form response - use a processed flag to avoid re-running on field changes
let lastFormId: unknown = null;
@@ -51,7 +53,7 @@
lastFormId = form;
if (form.success && !form.runResult) {
alertStore.add('success', 'Configuration saved successfully');
initEdit({ enabled, dryRun, renameFolders, ignoreTag, schedule });
initEdit({ enabled, dryRun, renameFolders, ignoreTag, schedule, summaryNotifications });
}
if (form.success && form.runResult) {
const r = form.runResult;
@@ -89,6 +91,7 @@
<input type="hidden" name="renameFolders" value={renameFolders} />
<input type="hidden" name="ignoreTag" value={ignoreTag} />
<input type="hidden" name="schedule" value={schedule} />
<input type="hidden" name="summaryNotifications" value={summaryNotifications} />
<div class="mt-6 space-y-6">
<!-- Header -->
@@ -119,7 +122,7 @@
/>
{/if}
<RenameSettings bind:enabled bind:dryRun bind:renameFolders bind:ignoreTag bind:schedule />
<RenameSettings bind:enabled bind:dryRun bind:renameFolders bind:ignoreTag bind:schedule bind:summaryNotifications />
<!-- Action Buttons -->
<div class="flex justify-end gap-3">

View File

@@ -4,6 +4,7 @@
export let renameFolders: boolean = false;
export let ignoreTag: string = '';
export let schedule: string = '1440';
export let summaryNotifications: boolean = true;
const scheduleOptions = [
{ value: '60', label: 'Every hour' },
@@ -67,7 +68,7 @@
</div>
<!-- Toggles -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Enabled Toggle -->
<div
class="flex items-center justify-between rounded-lg border border-neutral-200 p-3 dark:border-neutral-700"
@@ -160,6 +161,37 @@
></span>
</button>
</div>
<!-- Summary Notifications Toggle -->
<div
class="flex items-center justify-between rounded-lg border border-neutral-200 p-3 dark:border-neutral-700"
>
<div>
<label
for="summaryNotifications"
class="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
Summary Notifications
</label>
<p class="text-xs text-neutral-500 dark:text-neutral-400">Compact notifications</p>
</div>
<button
type="button"
role="switch"
aria-checked={summaryNotifications}
aria-label="Toggle summary notifications"
on:click={() => (summaryNotifications = !summaryNotifications)}
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 {summaryNotifications
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-neutral-200 dark:bg-neutral-700'}"
>
<span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out {summaryNotifications
? 'translate-x-5'
: 'translate-x-0'}"
></span>
</button>
</div>
</div>
</div>
</div>