Add extensible notification providers + UI/API

Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly.

UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions.

APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes.

Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
This commit is contained in:
kikootwo
2026-02-10 15:06:20 -05:00
parent 4a38dd3da8
commit af0eaceb98
73 changed files with 3421 additions and 866 deletions
+120 -5
View File
@@ -100,8 +100,8 @@ describe('substituteTemplate', () => {
expect(result).toBe('Author/Title/Narrator');
});
it('should handle mixed forward and backward slashes', () => {
const template = '{author}\\{title}/{narrator}';
it('should resolve escaped braces to literal brace characters', () => {
const template = '{author}/\\{{narrator}\\}/{title}';
const variables: TemplateVariables = {
author: 'Author',
title: 'Title',
@@ -109,7 +109,7 @@ describe('substituteTemplate', () => {
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Author/Title/Narrator');
expect(result).toBe('Author/{Narrator}/Title');
});
it('should trim dots from path components', () => {
@@ -145,6 +145,74 @@ describe('substituteTemplate', () => {
const result = substituteTemplate(template, variables);
expect(result).toBe('Audiobooks/Author/Books/Title');
});
it('should resolve escaped left brace only', () => {
const template = '{author}/\\{prefix {title}';
const variables: TemplateVariables = {
author: 'Author',
title: 'Title'
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Author/{prefix Title');
});
it('should resolve escaped right brace only', () => {
const template = '{author}/{title} suffix\\}';
const variables: TemplateVariables = {
author: 'Author',
title: 'Title'
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Author/Title suffix}');
});
it('should resolve multiple escaped brace pairs', () => {
const template = '\\{{author}\\}/\\{{title}\\}';
const variables: TemplateVariables = {
author: 'Author',
title: 'Title'
};
const result = substituteTemplate(template, variables);
expect(result).toBe('{Author}/{Title}');
});
it('should handle escaped braces with missing optional variable', () => {
const template = '{author}/\\{{narrator}\\}/{title}';
const variables: TemplateVariables = {
author: 'Author',
title: 'Title'
// narrator is missing
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Author/{}/Title');
});
it('should handle escaped braces adjacent to path separators', () => {
const template = '{author}/\\{{narrator}\\}/{title}';
const variables: TemplateVariables = {
author: 'Author',
title: 'Title',
narrator: 'Michael Kramer'
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Author/{Michael Kramer}/Title');
});
it('should handle escaped braces around static text', () => {
const template = '{author}/\\{narrated\\}/{title}';
const variables: TemplateVariables = {
author: 'Author',
title: 'Title'
};
const result = substituteTemplate(template, variables);
expect(result).toBe('Author/{narrated}/Title');
});
});
describe('validateTemplate', () => {
@@ -205,8 +273,8 @@ describe('validateTemplate', () => {
});
});
it('should reject backslashes in template', () => {
const result = validateTemplate('{author}\\{title}');
it('should reject backslashes that are not brace escapes', () => {
const result = validateTemplate('{author}\\n{title}');
expect(result.valid).toBe(false);
expect(result.error).toContain('forward slashes');
});
@@ -230,6 +298,42 @@ describe('validateTemplate', () => {
expect(result.error).toContain('{narrator}');
expect(result.error).toContain('{asin}');
});
it('should accept escaped braces around a variable', () => {
const result = validateTemplate('{author}/\\{{narrator}\\}/{title}');
expect(result.valid).toBe(true);
});
it('should accept escaped braces around static text', () => {
const result = validateTemplate('{author}/\\{custom\\}/{title}');
expect(result.valid).toBe(true);
});
it('should accept escaped left brace only', () => {
const result = validateTemplate('{author}/\\{prefix {title}');
expect(result.valid).toBe(true);
});
it('should accept escaped right brace only', () => {
const result = validateTemplate('{author}/{title} suffix\\}');
expect(result.valid).toBe(true);
});
it('should accept multiple escaped brace pairs', () => {
const result = validateTemplate('\\{{author}\\}/\\{{title}\\}');
expect(result.valid).toBe(true);
});
it('should accept backslash before brace but reject backslash before other characters', () => {
const result = validateTemplate('{author}\\n/\\{{title}\\}');
expect(result.valid).toBe(false);
expect(result.error).toContain('forward slashes');
});
it('should accept a template that is only escaped braces', () => {
const result = validateTemplate('\\{\\}');
expect(result.valid).toBe(true);
});
});
describe('generateMockPreviews', () => {
@@ -305,6 +409,17 @@ describe('generateMockPreviews', () => {
expect(preview).toContain(' - B');
});
});
it('should resolve escaped braces in previews', () => {
const template = '{author}/\\{{narrator}\\}/{title}';
const previews = generateMockPreviews(template);
// First two mock entries have narrators
expect(previews[0]).toContain('{Michael Kramer}');
expect(previews[1]).toContain('{Stephen Fry}');
// Third mock entry has no narrator - escaped braces remain empty
expect(previews[2]).toContain('{}');
});
});
describe('getValidVariables', () => {