mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(library): run scans on the stored auto-scan schedule
The auto-scan cron expression was validated, persisted and shown in the UI, but nothing ever consumed it, so a library configured with a schedule never scanned and no scan job was ever recorded with the schedule trigger. Register a cron job per configured library on boot and keep it in step as libraries are created, updated and deleted. An occurrence that lands while a scan is already running is skipped rather than retried, and a failing occurrence leaves the job registered so later ones still fire. Schedules run in the server timezone, matching the cleanup, audit and statistics jobs, so a deployment that sets TZ gets the wall-clock time its users expect. Move cron validation into a single shared grammar used by both the API and the schedule editor. The two sides previously disagreed in both directions: the UI accepted step-zero and reversed ranges that the API rejected, and rejected day names the API accepted. The grammar is a strict subset of what the scheduler and the human-readable preview can each parse, so an expression the editor marks valid can always be scheduled. Closes #985
This commit is contained in:
@@ -76,6 +76,7 @@
|
||||
"chokidar": "^4.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cron": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fast-xml-parser": "^5.10.1",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
@@ -19,8 +18,6 @@ import { Transform } from 'class-transformer';
|
||||
import { ICON_VALUE_MAX_LENGTH, type CoverAspectRatio, type OrganizationMode } from '@bookorbit/types';
|
||||
|
||||
import {
|
||||
LIBRARY_AUTO_SCAN_CRON_EXPRESSION_ERROR,
|
||||
LIBRARY_AUTO_SCAN_CRON_EXPRESSION_REGEX,
|
||||
LIBRARY_COVER_ASPECT_RATIOS,
|
||||
LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX,
|
||||
LIBRARY_FILE_WRITE_MAX_SIZE_MB_MIN,
|
||||
@@ -30,6 +27,7 @@ import {
|
||||
LIBRARY_READING_THRESHOLD_MAX,
|
||||
LIBRARY_READING_THRESHOLD_MIN,
|
||||
} from '../library.constants';
|
||||
import { IsLibraryAutoScanCronExpression } from '../library-cron.validator';
|
||||
|
||||
function trimString(value: unknown): unknown {
|
||||
return typeof value === 'string' ? value.trim() : value;
|
||||
@@ -70,9 +68,7 @@ export class CreateLibraryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ValidateIf((o: { autoScanCronExpression?: unknown }) => o.autoScanCronExpression !== null)
|
||||
@Matches(LIBRARY_AUTO_SCAN_CRON_EXPRESSION_REGEX, {
|
||||
message: LIBRARY_AUTO_SCAN_CRON_EXPRESSION_ERROR,
|
||||
})
|
||||
@IsLibraryAutoScanCronExpression()
|
||||
autoScanCronExpression?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
@@ -18,8 +17,6 @@ import { Transform } from 'class-transformer';
|
||||
import { ICON_VALUE_MAX_LENGTH, type CoverAspectRatio, type OrganizationMode } from '@bookorbit/types';
|
||||
|
||||
import {
|
||||
LIBRARY_AUTO_SCAN_CRON_EXPRESSION_ERROR,
|
||||
LIBRARY_AUTO_SCAN_CRON_EXPRESSION_REGEX,
|
||||
LIBRARY_COVER_ASPECT_RATIOS,
|
||||
LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX,
|
||||
LIBRARY_FILE_WRITE_MAX_SIZE_MB_MIN,
|
||||
@@ -29,6 +26,7 @@ import {
|
||||
LIBRARY_READING_THRESHOLD_MAX,
|
||||
LIBRARY_READING_THRESHOLD_MIN,
|
||||
} from '../library.constants';
|
||||
import { IsLibraryAutoScanCronExpression } from '../library-cron.validator';
|
||||
|
||||
function trimString(value: unknown): unknown {
|
||||
return typeof value === 'string' ? value.trim() : value;
|
||||
@@ -71,9 +69,7 @@ export class UpdateLibraryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ValidateIf((o: { autoScanCronExpression?: unknown }) => o.autoScanCronExpression !== null)
|
||||
@Matches(LIBRARY_AUTO_SCAN_CRON_EXPRESSION_REGEX, {
|
||||
message: LIBRARY_AUTO_SCAN_CRON_EXPRESSION_ERROR,
|
||||
})
|
||||
@IsLibraryAutoScanCronExpression()
|
||||
autoScanCronExpression?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { CronTime } from 'cron';
|
||||
import { isFiveFieldCronExpression } from '@bookorbit/types';
|
||||
|
||||
import { isValidLibraryAutoScanCronExpression } from './library-cron.validator';
|
||||
|
||||
const ACCEPTED = [
|
||||
'0 4 * * *',
|
||||
'*/30 * * * *',
|
||||
'0 0 * * 1',
|
||||
'0 4 * * MON',
|
||||
'0 4 * * mon-fri',
|
||||
'0 4 * jan-mar *',
|
||||
'1-5/2 * * * *',
|
||||
'5/10 * * * *',
|
||||
'0,30 4 1,15 * *',
|
||||
'0 4 * * 0-7',
|
||||
'05 04 * * *',
|
||||
' 0 4 * * * ',
|
||||
'0 4 * * *',
|
||||
];
|
||||
|
||||
const REJECTED = [
|
||||
'not a cron',
|
||||
'0 99 * * *',
|
||||
'* * * * * *',
|
||||
'@daily',
|
||||
'*/0 * * * *',
|
||||
'5-1 * * * *',
|
||||
'0 4 */0 * *',
|
||||
'0 4 * * FRI-MON',
|
||||
'0 4 L * *',
|
||||
'0 4 * * mon#2',
|
||||
'0 4 * * ?',
|
||||
'0,,30 4 * * *',
|
||||
'0 4 * * jan',
|
||||
'0 4 * * 8',
|
||||
'',
|
||||
];
|
||||
|
||||
describe('isValidLibraryAutoScanCronExpression', () => {
|
||||
it.each(ACCEPTED)('accepts %s', (expression) => {
|
||||
expect(isValidLibraryAutoScanCronExpression(expression)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(REJECTED)('rejects %s', (expression) => {
|
||||
expect(isValidLibraryAutoScanCronExpression(expression)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([null, undefined, 42, {}])('rejects the non-string value %s', (value) => {
|
||||
expect(isValidLibraryAutoScanCronExpression(value)).toBe(false);
|
||||
});
|
||||
|
||||
it('never accepts an expression the scheduler cannot build a job from', () => {
|
||||
const unschedulable = [...ACCEPTED, ...REJECTED].filter((expression) => {
|
||||
if (!isFiveFieldCronExpression(expression)) return false;
|
||||
try {
|
||||
new CronTime(expression);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
expect(unschedulable).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ValidateBy, type ValidationOptions } from 'class-validator';
|
||||
import { CronTime } from 'cron';
|
||||
import { isFiveFieldCronExpression } from '@bookorbit/types';
|
||||
|
||||
import { LIBRARY_AUTO_SCAN_CRON_EXPRESSION_ERROR } from './library.constants';
|
||||
|
||||
export function isValidLibraryAutoScanCronExpression(value: unknown): boolean {
|
||||
if (!isFiveFieldCronExpression(value)) return false;
|
||||
|
||||
try {
|
||||
new CronTime(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function IsLibraryAutoScanCronExpression(validationOptions?: ValidationOptions): PropertyDecorator {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'isLibraryAutoScanCronExpression',
|
||||
validator: {
|
||||
validate: isValidLibraryAutoScanCronExpression,
|
||||
defaultMessage: () => LIBRARY_AUTO_SCAN_CRON_EXPRESSION_ERROR,
|
||||
},
|
||||
},
|
||||
validationOptions,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ConflictException, Logger } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
|
||||
import { LibraryScanSchedulerService } from './library-scan-scheduler.service';
|
||||
|
||||
describe('LibraryScanSchedulerService', () => {
|
||||
const libraryRepo = {
|
||||
findAutoScanSchedules: vi.fn(),
|
||||
};
|
||||
const scannerService = {
|
||||
startScan: vi.fn(),
|
||||
};
|
||||
|
||||
let schedulerRegistry: SchedulerRegistry;
|
||||
let service: LibraryScanSchedulerService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
schedulerRegistry = new SchedulerRegistry();
|
||||
service = new LibraryScanSchedulerService(libraryRepo as never, scannerService as never, schedulerRegistry);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.onModuleDestroy();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers persisted schedules on bootstrap while isolating invalid expressions', async () => {
|
||||
libraryRepo.findAutoScanSchedules.mockResolvedValue([
|
||||
{ id: 4, autoScanCronExpression: '0 4 * * *' },
|
||||
{ id: 5, autoScanCronExpression: 'not a cron' },
|
||||
]);
|
||||
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(schedulerRegistry.doesExist('cron', 'library-auto-scan:4')).toBe(true);
|
||||
expect(schedulerRegistry.doesExist('cron', 'library-auto-scan:5')).toBe(false);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('scheduleCount=2 registeredCount=1 failedCount=1'));
|
||||
});
|
||||
|
||||
it('starts a scan with the schedule trigger when a registered job fires', async () => {
|
||||
scannerService.startScan.mockResolvedValue({ jobId: 91 });
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
|
||||
expect(service.syncSchedule(7, '0 4 * * *')).toBe(true);
|
||||
const job = schedulerRegistry.getCronJob('library-auto-scan:7');
|
||||
expect(job.nextDate().zoneName).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
await job.fireOnTick();
|
||||
|
||||
expect(scannerService.startScan).toHaveBeenCalledWith(7, 'schedule');
|
||||
});
|
||||
|
||||
it('replaces an existing job and removes it when the schedule is disabled', () => {
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
|
||||
service.syncSchedule(8, '0 4 * * *');
|
||||
const originalJob = schedulerRegistry.getCronJob('library-auto-scan:8');
|
||||
|
||||
service.syncSchedule(8, '0 6 * * *');
|
||||
|
||||
expect(originalJob.isActive).toBe(false);
|
||||
expect(schedulerRegistry.getCronJob('library-auto-scan:8')).not.toBe(originalJob);
|
||||
|
||||
service.syncSchedule(8, null);
|
||||
|
||||
expect(schedulerRegistry.doesExist('cron', 'library-auto-scan:8')).toBe(false);
|
||||
});
|
||||
|
||||
it('skips an occurrence when another scan is already running', async () => {
|
||||
scannerService.startScan.mockRejectedValue(new ConflictException('scan running'));
|
||||
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
|
||||
service.syncSchedule(9, '0 4 * * *');
|
||||
await schedulerRegistry.getCronJob('library-auto-scan:9').fireOnTick();
|
||||
|
||||
expect(scannerService.startScan).toHaveBeenCalledWith(9, 'schedule');
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('outcome=skipped reason=scan_in_progress'));
|
||||
});
|
||||
|
||||
it('logs and contains scan failures so future occurrences remain registered', async () => {
|
||||
scannerService.startScan.mockRejectedValue(new Error('database\nunavailable'));
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
service.syncSchedule(10, '0 4 * * *');
|
||||
await schedulerRegistry.getCronJob('library-auto-scan:10').fireOnTick();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('error="database unavailable"'));
|
||||
expect(schedulerRegistry.doesExist('cron', 'library-auto-scan:10')).toBe(true);
|
||||
});
|
||||
|
||||
it('fails bootstrap when schedules cannot be loaded', async () => {
|
||||
libraryRepo.findAutoScanSchedules.mockRejectedValue(new Error('database unavailable'));
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await expect(service.onApplicationBootstrap()).rejects.toThrow('database unavailable');
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('[library.auto_scan.bootstrap] [fail]'));
|
||||
});
|
||||
|
||||
it('unregisters jobs when the module is destroyed', () => {
|
||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
service.syncSchedule(11, '0 4 * * *');
|
||||
service.syncSchedule(12, '0 6 * * *');
|
||||
|
||||
service.onModuleDestroy();
|
||||
|
||||
expect(schedulerRegistry.doesExist('cron', 'library-auto-scan:11')).toBe(false);
|
||||
expect(schedulerRegistry.doesExist('cron', 'library-auto-scan:12')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ConflictException, Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { CronJob } from 'cron';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { ScannerService } from '../scanner/scanner.service';
|
||||
import { LibraryRepository } from './library.repository';
|
||||
|
||||
const LIBRARY_SCAN_JOB_PREFIX = 'library-auto-scan:';
|
||||
|
||||
@Injectable()
|
||||
export class LibraryScanSchedulerService implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private readonly logger = new Logger(LibraryScanSchedulerService.name);
|
||||
private readonly registeredJobNames = new Set<string>();
|
||||
private readonly timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
constructor(
|
||||
private readonly libraryRepo: LibraryRepository,
|
||||
private readonly scannerService: ScannerService,
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const event = 'library.auto_scan.bootstrap';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] - scheduled scan registration started`);
|
||||
|
||||
try {
|
||||
const schedules = await this.libraryRepo.findAutoScanSchedules();
|
||||
let registeredCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const schedule of schedules) {
|
||||
if (this.syncSchedule(schedule.id, schedule.autoScanCronExpression)) registeredCount++;
|
||||
else failedCount++;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] durationMs=${Date.now() - startedAt} scheduleCount=${schedules.length} registeredCount=${registeredCount} failedCount=${failedCount} - scheduled scan registration completed`,
|
||||
);
|
||||
} catch (err) {
|
||||
const errorClass = err instanceof Error ? err.name : 'Error';
|
||||
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||
this.logger.error(
|
||||
`[${event}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - scheduled scan registration failed`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
for (const jobName of this.registeredJobNames) {
|
||||
if (this.schedulerRegistry.doesExist('cron', jobName)) this.schedulerRegistry.deleteCronJob(jobName);
|
||||
}
|
||||
this.registeredJobNames.clear();
|
||||
}
|
||||
|
||||
syncSchedule(libraryId: number, expression: string | null): boolean {
|
||||
const event = 'library.auto_scan.schedule';
|
||||
const startedAt = Date.now();
|
||||
const jobName = this.getJobName(libraryId);
|
||||
|
||||
try {
|
||||
if (expression === null) {
|
||||
if (this.deleteJob(jobName)) {
|
||||
this.logger.log(`[${event}] [end] libraryId=${libraryId} durationMs=${Date.now() - startedAt} enabled=false - scheduled scan disabled`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const job = CronJob.from({
|
||||
cronTime: expression,
|
||||
onTick: () => this.runScheduledScan(libraryId),
|
||||
start: false,
|
||||
});
|
||||
|
||||
this.deleteJob(jobName);
|
||||
this.schedulerRegistry.addCronJob(jobName, job);
|
||||
this.registeredJobNames.add(jobName);
|
||||
job.start();
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] libraryId=${libraryId} durationMs=${Date.now() - startedAt} enabled=true timeZone=${this.timeZone} nextRunAt=${job.nextDate().toISO()} - scheduled scan registered`,
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.deleteJob(jobName);
|
||||
const errorClass = err instanceof Error ? err.name : 'Error';
|
||||
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||
this.logger.error(
|
||||
`[${event}] [fail] libraryId=${libraryId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - scheduled scan registration failed`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
removeSchedule(libraryId: number): void {
|
||||
this.deleteJob(this.getJobName(libraryId));
|
||||
}
|
||||
|
||||
private async runScheduledScan(libraryId: number): Promise<void> {
|
||||
const event = 'library.auto_scan.run';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] libraryId=${libraryId} - scheduled scan started`);
|
||||
|
||||
try {
|
||||
const { jobId } = await this.scannerService.startScan(libraryId, 'schedule');
|
||||
this.logger.log(
|
||||
`[${event}] [end] libraryId=${libraryId} durationMs=${Date.now() - startedAt} outcome=accepted jobId=${jobId} - scheduled scan accepted`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ConflictException) {
|
||||
this.logger.log(
|
||||
`[${event}] [end] libraryId=${libraryId} durationMs=${Date.now() - startedAt} outcome=skipped reason=scan_in_progress - scheduled scan skipped`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorClass = err instanceof Error ? err.name : 'Error';
|
||||
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||
this.logger.error(
|
||||
`[${event}] [fail] libraryId=${libraryId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - scheduled scan failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteJob(jobName: string): boolean {
|
||||
const exists = this.schedulerRegistry.doesExist('cron', jobName);
|
||||
if (exists) this.schedulerRegistry.deleteCronJob(jobName);
|
||||
const tracked = this.registeredJobNames.delete(jobName);
|
||||
return exists || tracked;
|
||||
}
|
||||
|
||||
private getJobName(libraryId: number): string {
|
||||
return `${LIBRARY_SCAN_JOB_PREFIX}${libraryId}`;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export const LIBRARY_ORGANIZATION_MODES = ['book_per_file', 'book_per_folder'] a
|
||||
export const DEFAULT_LIBRARY_ORGANIZATION_MODE: OrganizationMode = 'book_per_folder';
|
||||
export const DEFAULT_LIBRARY_COVER_ASPECT_RATIO: CoverAspectRatio = '2/3';
|
||||
|
||||
export const LIBRARY_AUTO_SCAN_CRON_EXPRESSION_REGEX = /^((\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)? ){4}(\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?$/;
|
||||
export const LIBRARY_AUTO_SCAN_CRON_EXPRESSION_ERROR = 'autoScanCronExpression must be a valid 5-field cron expression';
|
||||
|
||||
export const LIBRARY_READING_THRESHOLD_MIN = 0.05;
|
||||
|
||||
@@ -41,6 +41,16 @@ describe('Library DTO validation', () => {
|
||||
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { icon: 'BookOpen' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('validates five-field scan cron expressions semantically', async () => {
|
||||
const base = { name: 'Sci-Fi', icon: 'BookOpen', folders: ['/books/scifi'] };
|
||||
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, autoScanCronExpression: '0 4 * * *' }))).toBe(false);
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, autoScanCronExpression: '0 99 * * *' }))).toBe(true);
|
||||
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, autoScanCronExpression: '* * * * * *' }))).toBe(true);
|
||||
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { autoScanCronExpression: null }))).toBe(false);
|
||||
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { autoScanCronExpression: 'invalid' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('UpdateLibraryDto allows explicit null fileNamingPattern while validating string values', async () => {
|
||||
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { fileNamingPattern: null }))).toBe(false);
|
||||
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { fileNamingPattern: 123 }))).toBe(true);
|
||||
|
||||
@@ -10,13 +10,19 @@ vi.mock('../notification/notification.module', () => ({ NotificationModule: clas
|
||||
import { LibraryController } from './library.controller';
|
||||
import { LibraryModule } from './library.module';
|
||||
import { LibraryRepository } from './library.repository';
|
||||
import { LibraryScanSchedulerService } from './library-scan-scheduler.service';
|
||||
import { LibraryService } from './library.service';
|
||||
import { BulkRenameService } from './bulk-rename.service';
|
||||
|
||||
describe('LibraryModule', () => {
|
||||
it('registers expected controller/providers/exports', () => {
|
||||
expect(Reflect.getMetadata('controllers', LibraryModule)).toEqual([LibraryController]);
|
||||
expect(Reflect.getMetadata('providers', LibraryModule)).toEqual([LibraryService, LibraryRepository, BulkRenameService]);
|
||||
expect(Reflect.getMetadata('providers', LibraryModule)).toEqual([
|
||||
LibraryService,
|
||||
LibraryRepository,
|
||||
LibraryScanSchedulerService,
|
||||
BulkRenameService,
|
||||
]);
|
||||
expect(Reflect.getMetadata('exports', LibraryModule)).toEqual([LibraryService, LibraryRepository]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ScannerModule } from '../scanner/scanner.module';
|
||||
import { BulkRenameService } from './bulk-rename.service';
|
||||
import { LibraryController } from './library.controller';
|
||||
import { LibraryRepository } from './library.repository';
|
||||
import { LibraryScanSchedulerService } from './library-scan-scheduler.service';
|
||||
import { LibraryService } from './library.service';
|
||||
|
||||
@Module({
|
||||
@@ -23,7 +24,7 @@ import { LibraryService } from './library.service';
|
||||
PathModule,
|
||||
],
|
||||
controllers: [LibraryController],
|
||||
providers: [LibraryService, LibraryRepository, BulkRenameService],
|
||||
providers: [LibraryService, LibraryRepository, LibraryScanSchedulerService, BulkRenameService],
|
||||
exports: [LibraryService, LibraryRepository],
|
||||
})
|
||||
export class LibraryModule {}
|
||||
|
||||
@@ -3,6 +3,7 @@ vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
|
||||
getTableColumns: vi.fn(() => ({})),
|
||||
inArray: vi.fn((left: unknown, right: unknown[]) => ({ op: 'inArray', left, right })),
|
||||
isNotNull: vi.fn((value: unknown) => ({ op: 'isNotNull', value })),
|
||||
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', text: strings.join(''), values })),
|
||||
}));
|
||||
|
||||
@@ -96,6 +97,22 @@ describe('LibraryRepository', () => {
|
||||
expect(orderBy).toHaveBeenCalledWith(libraries.displayOrder, libraries.name);
|
||||
});
|
||||
|
||||
it('findAutoScanSchedules selects only libraries with a configured expression', async () => {
|
||||
const orderBy = vi.fn().mockResolvedValue([{ id: 4, autoScanCronExpression: '0 4 * * *' }]);
|
||||
const where = vi.fn().mockReturnValue({ orderBy });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
db.select.mockReturnValue({ from });
|
||||
|
||||
await expect(repo.findAutoScanSchedules()).resolves.toEqual([{ id: 4, autoScanCronExpression: '0 4 * * *' }]);
|
||||
|
||||
expect(db.select).toHaveBeenCalledWith({
|
||||
id: libraries.id,
|
||||
autoScanCronExpression: libraries.autoScanCronExpression,
|
||||
});
|
||||
expect(where).toHaveBeenCalledWith({ op: 'isNotNull', value: libraries.autoScanCronExpression });
|
||||
expect(orderBy).toHaveBeenCalledWith(libraries.id);
|
||||
});
|
||||
|
||||
it('getStats aggregates counts, sizes, and format map', async () => {
|
||||
const countChain = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq, getTableColumns, inArray, sql } from 'drizzle-orm';
|
||||
import { and, eq, getTableColumns, inArray, isNotNull, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import type { AccessLevel, ContentFilterRules } from '@bookorbit/types';
|
||||
|
||||
@@ -55,6 +55,14 @@ export class LibraryRepository {
|
||||
return this.db.select({ id: libraries.id }).from(libraries).orderBy(libraries.displayOrder, libraries.name);
|
||||
}
|
||||
|
||||
findAutoScanSchedules() {
|
||||
return this.db
|
||||
.select({ id: libraries.id, autoScanCronExpression: libraries.autoScanCronExpression })
|
||||
.from(libraries)
|
||||
.where(isNotNull(libraries.autoScanCronExpression))
|
||||
.orderBy(libraries.id);
|
||||
}
|
||||
|
||||
findAccessibleIdsForUser(userId: number) {
|
||||
return this.db
|
||||
.select({ id: libraries.id })
|
||||
|
||||
@@ -70,6 +70,10 @@ describe('LibraryService', () => {
|
||||
assertWithinBrowseRoot: vi.fn((path: string) => Promise.resolve(path)),
|
||||
resolveBrowsePath: vi.fn((path: string) => Promise.resolve(path)),
|
||||
};
|
||||
const scanScheduler = {
|
||||
syncSchedule: vi.fn(),
|
||||
removeSchedule: vi.fn(),
|
||||
};
|
||||
|
||||
let service: LibraryService;
|
||||
|
||||
@@ -84,6 +88,7 @@ describe('LibraryService', () => {
|
||||
fileWriteService as any,
|
||||
achievementEvents as any,
|
||||
pathPolicy as any,
|
||||
scanScheduler as any,
|
||||
);
|
||||
|
||||
mockStat.mockResolvedValue({ isDirectory: () => true } as Awaited<ReturnType<typeof stat>>);
|
||||
@@ -222,6 +227,21 @@ describe('LibraryService', () => {
|
||||
expect(scannerService.startScanAsync).toHaveBeenCalledWith(6);
|
||||
});
|
||||
|
||||
it('create registers the configured scan schedule', async () => {
|
||||
libraryRepo.findByName.mockResolvedValue([]);
|
||||
libraryRepo.insert.mockResolvedValue([{ id: 6, name: 'Scheduled', icon: 'BookOpen', watch: false }]);
|
||||
libraryRepo.insertFolder.mockResolvedValueOnce([{ id: 21, path: '/scheduled' }]);
|
||||
|
||||
await service.create({
|
||||
name: 'Scheduled',
|
||||
icon: 'BookOpen',
|
||||
folders: ['/scheduled'],
|
||||
autoScanCronExpression: '0 4 * * *',
|
||||
} as any);
|
||||
|
||||
expect(scanScheduler.syncSchedule).toHaveBeenCalledWith(6, '0 4 * * *');
|
||||
});
|
||||
|
||||
it('create rejects duplicate library names', async () => {
|
||||
libraryRepo.findByName.mockResolvedValue([{ id: 9 }]);
|
||||
|
||||
@@ -327,6 +347,18 @@ describe('LibraryService', () => {
|
||||
expect(scannerService.startScanAsync).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('update replaces or disables the configured scan schedule', async () => {
|
||||
libraryRepo.findById.mockResolvedValue([{ id: 10, name: 'Current', icon: 'BookOpen', watch: false }]);
|
||||
libraryRepo.update.mockResolvedValue([{ id: 10, name: 'Current', icon: 'BookOpen', watch: false }]);
|
||||
libraryRepo.findFoldersByLibrary.mockResolvedValue([{ id: 1, path: '/books' }]);
|
||||
|
||||
await service.update(10, { autoScanCronExpression: '0 6 * * *' } as any);
|
||||
await service.update(10, { autoScanCronExpression: null } as any);
|
||||
|
||||
expect(scanScheduler.syncSchedule).toHaveBeenNthCalledWith(1, 10, '0 6 * * *');
|
||||
expect(scanScheduler.syncSchedule).toHaveBeenNthCalledWith(2, 10, null);
|
||||
});
|
||||
|
||||
it('update rejects organization mode changes after creation', async () => {
|
||||
libraryRepo.findById.mockResolvedValue([{ id: 10, name: 'Current', icon: 'BookOpen', watch: false, organizationMode: 'book_per_folder' }]);
|
||||
|
||||
@@ -417,6 +449,7 @@ describe('LibraryService', () => {
|
||||
|
||||
expect(fileWatcherService.stopWatcher).toHaveBeenCalledWith(4);
|
||||
expect(libraryRepo.delete).toHaveBeenCalledWith(4);
|
||||
expect(scanScheduler.removeSchedule).toHaveBeenCalledWith(4);
|
||||
expect(mockRm).toHaveBeenCalledWith('/books/covers/101', { recursive: true, force: true });
|
||||
expect(mockRm).toHaveBeenCalledWith('/books/covers/102', { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ReorderLibrariesDto } from './dto/reorder-libraries.dto';
|
||||
import { UpdateLibraryDto } from './dto/update-library.dto';
|
||||
import { DEFAULT_LIBRARY_COVER_ASPECT_RATIO, DEFAULT_LIBRARY_ORGANIZATION_MODE, LIBRARY_METADATA_PRECEDENCE_DEFAULT } from './library.constants';
|
||||
import { LibraryRepository } from './library.repository';
|
||||
import { LibraryScanSchedulerService } from './library-scan-scheduler.service';
|
||||
|
||||
interface LibraryMetadataWriteStreamOptions {
|
||||
onProgress?: (event: LibraryFileSyncProgressEvent) => void;
|
||||
@@ -56,6 +57,7 @@ export class LibraryService {
|
||||
private readonly fileWriteService: FileWriteService,
|
||||
private readonly achievementEvents: AchievementEventsService,
|
||||
private readonly pathPolicy: PathPolicyService,
|
||||
private readonly scanScheduler: LibraryScanSchedulerService,
|
||||
) {
|
||||
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
|
||||
}
|
||||
@@ -151,6 +153,7 @@ export class LibraryService {
|
||||
);
|
||||
}
|
||||
|
||||
this.scanScheduler.syncSchedule(library.id, dto.autoScanCronExpression ?? null);
|
||||
this.scannerService.startScanAsync(library.id);
|
||||
|
||||
return { ...normalizeLibraryOrganizationMode(library), folders: folders.map(([f]) => f) };
|
||||
@@ -182,6 +185,7 @@ export class LibraryService {
|
||||
}
|
||||
|
||||
const [updated] = await this.libraryRepo.update(id, fields);
|
||||
if (dto.autoScanCronExpression !== undefined) this.scanScheduler.syncSchedule(id, dto.autoScanCronExpression);
|
||||
|
||||
if (folderPaths !== undefined) {
|
||||
const existingFolders = await this.libraryRepo.findFoldersByLibrary(id);
|
||||
@@ -230,6 +234,7 @@ export class LibraryService {
|
||||
|
||||
const bookRows = await this.libraryRepo.findBookIdsByLibrary(id);
|
||||
await this.libraryRepo.delete(id);
|
||||
this.scanScheduler.removeSchedule(id);
|
||||
await this.cleanupCoverDirectories(bookRows.map(({ id: bookId }) => bookId));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user