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
+85
View File
@@ -0,0 +1,85 @@
/**
* Component: Stream-based File Copy Utility Tests
* Documentation: documentation/phase3/file-organization.md
*/
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { Readable, Writable } from 'stream';
const pipelineMock = vi.hoisted(() => vi.fn());
const createReadStreamMock = vi.hoisted(() => vi.fn());
const createWriteStreamMock = vi.hoisted(() => vi.fn());
vi.mock('stream/promises', () => ({
pipeline: pipelineMock,
}));
vi.mock('fs', () => ({
createReadStream: createReadStreamMock,
createWriteStream: createWriteStreamMock,
}));
import { copyFile } from '@/lib/utils/copy-file';
describe('copyFile', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('pipes source to destination via pipeline', async () => {
const mockReadStream = new Readable({ read() {} });
const mockWriteStream = new Writable({ write(_, __, cb) { cb(); } });
createReadStreamMock.mockReturnValue(mockReadStream);
createWriteStreamMock.mockReturnValue(mockWriteStream);
pipelineMock.mockResolvedValue(undefined);
await copyFile('/source/file.m4b', '/dest/file.m4b');
expect(createReadStreamMock).toHaveBeenCalledWith('/source/file.m4b');
expect(createWriteStreamMock).toHaveBeenCalledWith('/dest/file.m4b');
expect(pipelineMock).toHaveBeenCalledWith(mockReadStream, mockWriteStream);
});
it('propagates read errors', async () => {
const mockReadStream = new Readable({ read() {} });
const mockWriteStream = new Writable({ write(_, __, cb) { cb(); } });
createReadStreamMock.mockReturnValue(mockReadStream);
createWriteStreamMock.mockReturnValue(mockWriteStream);
pipelineMock.mockRejectedValue(
Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' })
);
await expect(copyFile('/missing/file.m4b', '/dest/file.m4b'))
.rejects.toThrow('ENOENT');
});
it('propagates write errors', async () => {
const mockReadStream = new Readable({ read() {} });
const mockWriteStream = new Writable({ write(_, __, cb) { cb(); } });
createReadStreamMock.mockReturnValue(mockReadStream);
createWriteStreamMock.mockReturnValue(mockWriteStream);
pipelineMock.mockRejectedValue(
Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })
);
await expect(copyFile('/source/file.m4b', '/readonly/file.m4b'))
.rejects.toThrow('EACCES');
});
it('propagates EPERM errors (the original bug scenario)', async () => {
const mockReadStream = new Readable({ read() {} });
const mockWriteStream = new Writable({ write(_, __, cb) { cb(); } });
createReadStreamMock.mockReturnValue(mockReadStream);
createWriteStreamMock.mockReturnValue(mockWriteStream);
pipelineMock.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
);
await expect(copyFile('/nfs/source.m4b', '/nfs/dest.m4b'))
.rejects.toThrow('EPERM');
});
});
+29 -24
View File
@@ -11,7 +11,6 @@ const fsMock = vi.hoisted(() => ({
access: vi.fn(),
stat: vi.fn(),
mkdir: vi.fn(),
copyFile: vi.fn(),
chmod: vi.fn(),
unlink: vi.fn(),
writeFile: vi.fn(),
@@ -71,11 +70,17 @@ const ebookMock = vi.hoisted(() => ({
downloadEbook: vi.fn(),
}));
const copyFileMock = vi.hoisted(() => ({
copyFile: vi.fn(),
}));
vi.mock('fs/promises', () => ({
default: fsMock,
...fsMock,
}));
vi.mock('@/lib/utils/copy-file', () => copyFileMock);
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
@@ -109,7 +114,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const organizer = new FileOrganizer('/media', '/tmp');
@@ -174,7 +179,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const organizer = new FileOrganizer('/media', '/tmp');
@@ -216,7 +221,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
@@ -235,7 +240,7 @@ describe('file organizer', () => {
const expectedDir = path.join('/media', 'Author', 'Book');
expect(result.success).toBe(true);
expect(result.targetPath).toBe(expectedDir);
expect(fsMock.copyFile).toHaveBeenCalledWith('/tmp/tagged.m4b', path.join(expectedDir, 'book.m4b'));
expect(copyFileMock.copyFile).toHaveBeenCalledWith('/tmp/tagged.m4b', path.join(expectedDir, 'book.m4b'));
expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/tagged.m4b');
});
@@ -261,7 +266,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
@@ -272,7 +277,7 @@ describe('file organizer', () => {
expect(result.success).toBe(true);
expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available');
expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled();
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
});
it('downloads remote cover art when no local cover exists', async () => {
@@ -294,7 +299,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
@@ -316,7 +321,7 @@ describe('file organizer', () => {
// NOTE: Ebook downloads are now handled as first-class requests through the job queue
// The file organizer no longer downloads ebooks inline
expect(ebookMock.downloadEbook).not.toHaveBeenCalled();
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
expect(result.filesMovedCount).toBe(1);
});
@@ -340,7 +345,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
axiosMock.get.mockRejectedValue(new Error('cover failed'));
@@ -352,7 +357,7 @@ describe('file organizer', () => {
expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Failed to download cover art');
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
});
it('continues when chapter analysis returns no valid chapters', async () => {
@@ -378,7 +383,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
@@ -415,7 +420,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
fsMock.unlink.mockResolvedValue(undefined);
@@ -502,7 +507,7 @@ describe('file organizer', () => {
expect(result.audioFiles).toEqual([]);
expect(result.errors.join(' ')).toContain('Source file not found');
expect(result.errors.join(' ')).toContain('No audio files were successfully copied');
expect(fsMock.copyFile).not.toHaveBeenCalled();
expect(copyFileMock.copyFile).not.toHaveBeenCalled();
});
it('skips copying when target files already exist', async () => {
@@ -535,7 +540,7 @@ describe('file organizer', () => {
expect(result.success).toBe(true);
expect(result.audioFiles).toEqual([targetPath]);
expect(result.filesMovedCount).toBe(0);
expect(fsMock.copyFile).not.toHaveBeenCalled();
expect(copyFileMock.copyFile).not.toHaveBeenCalled();
});
it('continues when metadata tagging throws', async () => {
@@ -557,7 +562,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
const result = await organizer.organize('/downloads/book', {
@@ -567,7 +572,7 @@ describe('file organizer', () => {
expect(result.success).toBe(true);
expect(result.errors.join(' ')).toContain('Metadata tagging failed');
expect(fsMock.copyFile).toHaveBeenCalled();
expect(copyFileMock.copyFile).toHaveBeenCalled();
});
it('validates paths and reports multiple issues', async () => {
@@ -668,7 +673,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockRejectedValue(
copyFileMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
);
@@ -705,7 +710,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockImplementation(async (src: string, dest: string) => {
copyFileMock.copyFile.mockImplementation(async (src: string, dest: string) => {
// Tagged file copy fails with EPERM
if (path.normalize(src) === path.normalize(taggedPath)) {
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
@@ -736,7 +741,7 @@ describe('file organizer', () => {
// Tagged temp file should be cleaned up
expect(fsMock.unlink).toHaveBeenCalledWith(taggedPath);
// Fallback copy should use the original source
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
expect(copyFileMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
// Should record that tagged copy failed
expect(result.errors.join(' ')).toContain('Tagged copy failed');
expect(result.errors.join(' ')).toContain('without metadata tags');
@@ -760,7 +765,7 @@ describe('file organizer', () => {
});
fsMock.mkdir.mockResolvedValue(undefined);
// Both tagged and original copies fail
fsMock.copyFile.mockRejectedValue(
copyFileMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
);
fsMock.unlink.mockResolvedValue(undefined);
@@ -808,7 +813,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockImplementation(async (src: string) => {
copyFileMock.copyFile.mockImplementation(async (src: string) => {
// First file succeeds, second fails
if (path.normalize(src) === path.normalize(source2)) {
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
@@ -847,7 +852,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockResolvedValue(undefined);
copyFileMock.copyFile.mockResolvedValue(undefined);
fsMock.chmod.mockResolvedValue(undefined);
fsMock.writeFile.mockResolvedValue(undefined);
axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') });
@@ -881,7 +886,7 @@ describe('file organizer', () => {
throw new Error('missing');
});
fsMock.mkdir.mockResolvedValue(undefined);
fsMock.copyFile.mockRejectedValue(
copyFileMock.copyFile.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' })
);
fsMock.writeFile.mockResolvedValue(undefined);