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:
neon
2026-08-14 04:46:29 +00:00
parent 34e09fa655
commit 53be54752d
22 changed files with 545 additions and 23 deletions
+1
View File
@@ -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]);
});
});
+2 -1
View File
@@ -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));
}