diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index e0281085cf..4dd02ec69f 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -76,7 +76,6 @@ describe('/asset', () => { let user2Assets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; let ratingAsset: AssetMediaResponseDto; - let facesAsset: AssetMediaResponseDto; const setupTests = async () => { await utils.resetDatabase(); @@ -236,7 +235,7 @@ describe('/asset', () => { await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); // asset faces - facesAsset = await utils.createAsset(admin.accessToken, { + const facesAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'portrait.jpg', bytes: await readFile(facesAssetFilepath), diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 9446010127..55b9babcb4 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -2,7 +2,6 @@ import { BullModule } from '@nestjs/bullmq'; import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import _ from 'lodash'; @@ -42,7 +41,6 @@ const imports = [ BullModule.registerQueue(...bullQueues), ClsModule.forRoot(clsConfig), ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), TypeOrmModule.forRootAsync({ inject: [ModuleRef], @@ -114,16 +112,3 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { providers: [...common, ...commands, SchedulerRegistry], }) export class ImmichAdminModule {} - -@Module({ - imports: [ - ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), - TypeOrmModule.forRoot(databaseConfig), - TypeOrmModule.forFeature(entities), - OpenTelemetryModule.forRoot(otelConfig), - ], - controllers: [...controllers], - providers: [...common, ...middleware, SchedulerRegistry], -}) -export class AppTestModule {} diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 6bf85d1553..92c3cc1103 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { INestApplication } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -85,7 +84,6 @@ class SqlGenerator { logger: this.sqlLogger, }), TypeOrmModule.forFeature(entities), - EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), ], providers: [...repositories, AuthService, SchedulerRegistry], diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 8ed53344cc..816ab00446 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -4,7 +4,6 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { load as loadYaml } from 'js-yaml'; import * as _ from 'lodash'; -import { Subject } from 'rxjs'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemMetadataKey } from 'src/enum'; @@ -24,8 +23,6 @@ export class SystemConfigCore { private config: SystemConfig | null = null; private lastUpdated: number | null = null; - config$ = new Subject(); - private constructor( private repository: ISystemMetadataRepository, private logger: ILoggerRepository, @@ -42,6 +39,11 @@ export class SystemConfigCore { instance = null; } + invalidateCache() { + this.config = null; + this.lastUpdated = null; + } + async getConfig({ withCache }: { withCache: boolean }): Promise { if (!withCache || !this.config) { const lastUpdated = this.lastUpdated; @@ -74,14 +76,7 @@ export class SystemConfigCore { await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); - const config = await this.getConfig({ withCache: false }); - this.config$.next(config); - return config; - } - - async refreshConfig() { - const newConfig = await this.getConfig({ withCache: false }); - this.config$.next(newConfig); + return this.getConfig({ withCache: false }); } isUsingConfigFile() { diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 9b6910391a..2782368239 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,11 +1,9 @@ import { SetMetadata, applyDecorators } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { MetadataKey } from 'src/enum'; -import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface'; +import { EmitEvent } from 'src/interfaces/event.interface'; import { setUnion } from 'src/utils/set'; // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the @@ -133,15 +131,14 @@ export interface GenerateSqlQueries { /** Decorator to enable versioning/tracking of generated Sql */ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options); -export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) => - OnEvent(event, { suppressErrors: false, ...options }); - -export type EmitConfig = { - event: EmitEvent; +export type EventConfig = { + name: EmitEvent; + /** handle socket.io server events as well */ + server?: boolean; /** lower value has higher priority, defaults to 0 */ priority?: number; }; -export const OnEmit = (config: EmitConfig) => SetMetadata(MetadataKey.ON_EMIT_CONFIG, config); +export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/enum.ts b/server/src/enum.ts index e0c1e27859..757291b118 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -310,7 +310,7 @@ export enum MetadataKey { ADMIN_ROUTE = 'admin_route', SHARED_ROUTE = 'shared_route', API_KEY_SECURITY = 'api_key', - ON_EMIT_CONFIG = 'on_emit_config', + EVENT_CONFIG = 'event_config', } export enum RouteKey { diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index bc5ce90f40..02027d87e6 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -4,13 +4,19 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d export const IEventRepository = 'IEventRepository'; -type EmitEventMap = { +type EventMap = { // app events 'app.bootstrap': ['api' | 'microservices']; 'app.shutdown': []; // config events - 'config.update': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + 'config.update': [ + { + newConfig: SystemConfig; + /** When the server starts, `oldConfig` is `undefined` */ + oldConfig?: SystemConfig; + }, + ]; 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events @@ -43,12 +49,18 @@ type EmitEventMap = { // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; + + // websocket events + 'websocket.connect': [{ userId: string }]; }; -export type EmitEvent = keyof EmitEventMap; +export const serverEvents = ['config.update'] as const; +export type ServerEvents = (typeof serverEvents)[number]; + +export type EmitEvent = keyof EventMap; export type EmitHandler = (...args: ArgsOf) => Promise | void; -export type ArgOf = EmitEventMap[T][0]; -export type ArgsOf = EmitEventMap[T]; +export type ArgOf = EventMap[T][0]; +export type ArgsOf = EventMap[T]; export enum ClientEvent { UPLOAD_SUCCESS = 'on_upload_success', @@ -82,19 +94,15 @@ export interface ClientEventMap { [ClientEvent.SESSION_DELETE]: string; } -export enum ServerEvent { - CONFIG_UPDATE = 'config.update', - WEBSOCKET_CONNECT = 'websocket.connect', -} - -export interface ServerEventMap { - [ServerEvent.CONFIG_UPDATE]: null; - [ServerEvent.WEBSOCKET_CONNECT]: { userId: string }; -} +export type EventItem = { + event: T; + handler: EmitHandler; + server: boolean; +}; export interface IEventRepository { - on(event: T, handler: EmitHandler): void; - emit(event: T, ...args: ArgsOf): Promise; + on(item: EventItem): void; + emit(event: T, ...args: ArgsOf): Promise; /** * Send to connected clients for a specific user @@ -105,7 +113,7 @@ export interface IEventRepository { */ clientBroadcast(event: E, data: ClientEventMap[E]): void; /** - * Notify listeners in this and connected processes. Subscribe to an event with `@OnServerEvent` + * Send to all connected servers */ - serverSend(event: E, data: ServerEventMap[E]): boolean; + serverSend(event: T, ...args: ArgsOf): void; } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 9aa12e15dd..a8b2fa67c3 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { EventEmitter2 } from '@nestjs/event-emitter'; import { OnGatewayConnection, OnGatewayDisconnect, @@ -13,16 +12,17 @@ import { ArgsOf, ClientEventMap, EmitEvent, - EmitHandler, + EventItem, IEventRepository, - ServerEvent, - ServerEventMap, + serverEvents, + ServerEvents, } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; import { Instrumentation } from 'src/utils/instrumentation'; +import { handlePromiseError } from 'src/utils/misc'; -type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler[] }>; +type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; @Instrumentation() @WebSocketGateway({ @@ -39,7 +39,6 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect constructor( private moduleRef: ModuleRef, - private eventEmitter: EventEmitter2, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(EventRepository.name); @@ -48,14 +47,10 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect afterInit(server: Server) { this.logger.log('Initialized websocket server'); - for (const event of Object.values(ServerEvent)) { - if (event === ServerEvent.WEBSOCKET_CONNECT) { - continue; - } - - server.on(event, (data: unknown) => { + for (const event of serverEvents) { + server.on(event, (...args: ArgsOf) => { this.logger.debug(`Server event: ${event} (receive)`); - this.eventEmitter.emit(event, data); + handlePromiseError(this.onEvent({ name: event, args, server: true }), this.logger); }); } } @@ -72,7 +67,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect if (auth.session) { await client.join(auth.session.id); } - this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); + await this.onEvent({ name: 'websocket.connect', args: [{ userId: auth.user.id }], server: false }); } catch (error: Error | any) { this.logger.error(`Websocket connection error: ${error}`, error?.stack); client.emit('error', 'unauthorized'); @@ -85,18 +80,29 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect await client.leave(client.nsp.name); } - on(event: T, handler: EmitHandler): void { + on(item: EventItem): void { + const event = item.event; + if (!this.emitHandlers[event]) { this.emitHandlers[event] = []; } - this.emitHandlers[event].push(handler); + this.emitHandlers[event].push(item); } async emit(event: T, ...args: ArgsOf): Promise { - const handlers = this.emitHandlers[event] || []; - for (const handler of handlers) { - await handler(...args); + return this.onEvent({ name: event, args, server: false }); + } + + private async onEvent(event: { name: T; args: ArgsOf; server: boolean }): Promise { + const handlers = this.emitHandlers[event.name] || []; + for (const { handler, server } of handlers) { + // exclude handlers that ignore server events + if (!server && event.server) { + continue; + } + + await handler(...event.args); } } @@ -108,9 +114,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect this.server?.emit(event, data); } - serverSend(event: E, data: ServerEventMap[E]) { + serverSend(event: T, ...args: ArgsOf): void { this.logger.debug(`Server event: ${event} (send)`); - this.server?.serverSideEmit(event, data); - return this.eventEmitter.emit(event, data); + this.server?.serverSideEmit(event, ...args); } } diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index ee6176115b..9ba190d30a 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import semver from 'semver'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension, @@ -74,7 +74,7 @@ export class DatabaseService { this.logger.setContext(DatabaseService.name); } - @OnEmit({ event: 'app.bootstrap', priority: -200 }) + @OnEvent({ name: 'app.bootstrap', priority: -200 }) async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index c2d7a29b9f..8d7c15073d 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,6 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults } from 'src/config'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { @@ -60,6 +59,19 @@ describe(JobService.name, () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should update concurrency', () => { + sut.onBootstrap('microservices'); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults }); + + expect(jobMock.setConcurrency).toHaveBeenCalledTimes(14); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); + }); + }); + describe('handleNightlyJobs', () => { it('should run the scheduled jobs', async () => { await sut.handleNightlyJobs(); @@ -239,36 +251,6 @@ describe(JobService.name, () => { expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); }); - it('should subscribe to config changes', async () => { - await sut.init(makeMockHandlers(JobStatus.FAILED)); - - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - job: { - [QueueName.BACKGROUND_TASK]: { concurrency: 10 }, - [QueueName.SMART_SEARCH]: { concurrency: 10 }, - [QueueName.METADATA_EXTRACTION]: { concurrency: 10 }, - [QueueName.FACE_DETECTION]: { concurrency: 10 }, - [QueueName.SEARCH]: { concurrency: 10 }, - [QueueName.SIDECAR]: { concurrency: 10 }, - [QueueName.LIBRARY]: { concurrency: 10 }, - [QueueName.MIGRATION]: { concurrency: 10 }, - [QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 }, - [QueueName.VIDEO_CONVERSION]: { concurrency: 10 }, - [QueueName.NOTIFICATION]: { concurrency: 5 }, - }, - } as SystemConfig); - - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.BACKGROUND_TASK, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SMART_SEARCH, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.FACE_DETECTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.MIGRATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10); - }); - const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ { item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } }, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 9c73e71cbf..68da13a8e4 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,11 +1,12 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; import { AssetType, ManualJobName } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { ConcurrentQueueName, IJobRepository, @@ -45,6 +46,7 @@ const asJobItem = (dto: JobCreateDto): JobItem => { @Injectable() export class JobService { private configCore: SystemConfigCore; + private isMicroservices = false; constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -59,6 +61,28 @@ export class JobService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } + @OnEvent({ name: 'app.bootstrap' }) + onBootstrap(app: ArgOf<'app.bootstrap'>) { + this.isMicroservices = app === 'microservices'; + } + + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: config, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.isMicroservices) { + return; + } + + this.logger.debug(`Updating queue concurrency settings`); + for (const queueName of Object.values(QueueName)) { + let concurrency = 1; + if (this.isConcurrentQueue(queueName)) { + concurrency = config.job[queueName].concurrency; + } + this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); + this.jobRepository.setConcurrency(queueName, concurrency); + } + } + async create(dto: JobCreateDto): Promise { await this.jobRepository.queue(asJobItem(dto)); } @@ -209,18 +233,6 @@ export class JobService { } }); } - - this.configCore.config$.subscribe((config) => { - this.logger.debug(`Updating queue concurrency settings`); - for (const queueName of Object.values(QueueName)) { - let concurrency = 1; - if (this.isConcurrentQueue(queueName)) { - concurrency = config.job[queueName].concurrency; - } - this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); - this.jobRepository.setConcurrency(queueName, concurrency); - } - }); } private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 8b14c76cbc..bcf0f1d0b5 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,7 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults, SystemConfig } from 'src/config'; import { mapLibrary } from 'src/dtos/library.dto'; import { UserEntity } from 'src/entities/user.entity'; import { AssetType } from 'src/enum'; @@ -81,22 +80,26 @@ describe(LibraryService.name, () => { }); describe('onBootstrapEvent', () => { - it('should init cron job and subscribe to config changes', async () => { + it('should init cron job and handle config changes', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); await sut.onBootstrap(); - expect(systemMock.get).toHaveBeenCalled(); - expect(jobMock.addCronJob).toHaveBeenCalled(); - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - library: { - scan: { - enabled: true, - cronExpression: '0 1 * * *', + expect(jobMock.addCronJob).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + + await sut.onConfigUpdate({ + oldConfig: defaults, + newConfig: { + library: { + scan: { + enabled: true, + cronExpression: '0 1 * * *', + }, + watch: { enabled: false }, }, - watch: { enabled: true }, - }, - } as SystemConfig); + } as SystemConfig, + }); expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 52b786089c..b8b478531f 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -4,7 +4,7 @@ import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { CreateLibraryDto, LibraryResponseDto, @@ -61,7 +61,7 @@ export class LibraryService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { const config = await this.configCore.getConfig({ withCache: false }); @@ -83,19 +83,24 @@ export class LibraryService { if (this.watchLibraries) { await this.watchAll(); } - - this.configCore.config$.subscribe(({ library }) => { - this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); - - if (library.watch.enabled !== this.watchLibraries) { - // Watch configuration changed, update accordingly - this.watchLibraries = library.watch.enabled; - handlePromiseError(this.watchLibraries ? this.watchAll() : this.unwatchAll(), this.logger); - } - }); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + async onConfigUpdate({ newConfig: { library }, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.watchLock) { + return; + } + + this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); + + if (library.watch.enabled !== this.watchLibraries) { + // Watch configuration changed, update accordingly + this.watchLibraries = library.watch.enabled; + await (this.watchLibraries ? this.watchAll() : this.unwatchAll()); + } + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { @@ -185,7 +190,7 @@ export class LibraryService { } } - @OnEmit({ event: 'app.shutdown' }) + @OnEvent({ name: 'app.shutdown' }) async onShutdown() { await this.unwatchAll(); } diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 224ef03b3b..9499a4bdd9 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -8,7 +8,7 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; @@ -132,7 +132,7 @@ export class MetadataService { ); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; @@ -141,7 +141,12 @@ export class MetadataService { await this.init(config); } - @OnEmit({ event: 'config.update' }) + @OnEvent({ name: 'app.shutdown' }) + async onShutdown() { + await this.repository.teardown(); + } + + @OnEvent({ name: 'config.update' }) async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { await this.init(newConfig); } @@ -164,11 +169,6 @@ export class MetadataService { } } - @OnEmit({ event: 'app.shutdown' }) - async onShutdown() { - await this.repository.teardown(); - } - async handleLivePhotoLinking(job: IEntityJob): Promise { const { id } = job; const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); @@ -333,12 +333,12 @@ export class MetadataService { return this.processSidecar(id, false); } - @OnEmit({ event: 'asset.tag' }) + @OnEvent({ name: 'asset.tag' }) async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) { await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); } - @OnEmit({ event: 'asset.untag' }) + @OnEvent({ name: 'asset.untag' }) async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) { await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 0afefefff3..23604b6ef6 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { ArgOf } from 'src/interfaces/event.interface'; import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; @@ -43,7 +43,7 @@ export class MicroservicesService { private versionService: VersionService, ) {} - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index b3a1e73541..106f0be082 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,7 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; +import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; @@ -100,6 +100,15 @@ describe(NotificationService.name, () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should emit client and server events', () => { + const update = { newConfig: defaults }; + expect(sut.onConfigUpdate(update)).toBeUndefined(); + expect(eventMock.clientBroadcast).toHaveBeenCalledWith(ClientEvent.CONFIG_UPDATE, {}); + expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); + }); + }); + describe('onConfigValidateEvent', () => { it('validates smtp config when enabling smtp', async () => { const oldConfig = configs.smtpDisabled; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index fdb8257ffa..626e536c40 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -43,7 +43,13 @@ export class NotificationService { this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); } - @OnEmit({ event: 'config.validate', priority: -100 }) + @OnEvent({ name: 'config.update' }) + onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); + this.eventRepository.serverSend('config.update', { oldConfig, newConfig }); + } + + @OnEvent({ name: 'config.validate', priority: -100 }) async onConfigValidate({ oldConfig, newConfig }: ArgOf<'config.validate'>) { try { if ( @@ -58,74 +64,74 @@ export class NotificationService { } } - @OnEmit({ event: 'asset.hide' }) + @OnEvent({ name: 'asset.hide' }) onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId); } - @OnEmit({ event: 'asset.show' }) + @OnEvent({ name: 'asset.show' }) async onAssetShow({ assetId }: ArgOf<'asset.show'>) { await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } }); } - @OnEmit({ event: 'asset.trash' }) + @OnEvent({ name: 'asset.trash' }) onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) { this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]); } - @OnEmit({ event: 'asset.delete' }) + @OnEvent({ name: 'asset.delete' }) onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId); } - @OnEmit({ event: 'assets.trash' }) + @OnEvent({ name: 'assets.trash' }) onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) { this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds); } - @OnEmit({ event: 'assets.restore' }) + @OnEvent({ name: 'assets.restore' }) onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds); } - @OnEmit({ event: 'stack.create' }) + @OnEvent({ name: 'stack.create' }) onStackCreate({ userId }: ArgOf<'stack.create'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stack.update' }) + @OnEvent({ name: 'stack.update' }) onStackUpdate({ userId }: ArgOf<'stack.update'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stack.delete' }) + @OnEvent({ name: 'stack.delete' }) onStackDelete({ userId }: ArgOf<'stack.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'stacks.delete' }) + @OnEvent({ name: 'stacks.delete' }) onStacksDelete({ userId }: ArgOf<'stacks.delete'>) { this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []); } - @OnEmit({ event: 'user.signup' }) + @OnEvent({ name: 'user.signup' }) async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - @OnEmit({ event: 'album.update' }) + @OnEvent({ name: 'album.update' }) async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); } - @OnEmit({ event: 'album.invite' }) + @OnEvent({ name: 'album.invite' }) async onAlbumInvite({ id, userId }: ArgOf<'album.invite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } - @OnEmit({ event: 'session.delete' }) + @OnEvent({ name: 'session.delete' }) onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { // after the response is sent setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index a192c2f308..708fe32db5 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -3,7 +3,7 @@ import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { serverVersion } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -42,7 +42,7 @@ export class ServerService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index a75594100f..ef7865d25c 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; @@ -39,7 +39,7 @@ export class SmartInfoService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { if (app !== 'microservices') { return; @@ -49,7 +49,12 @@ export class SmartInfoService { await this.init(config); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + await this.init(newConfig, oldConfig); + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { getCLIPModelInfo(newConfig.machineLearning.clip.modelName); @@ -60,11 +65,6 @@ export class SmartInfoService { } } - @OnEmit({ event: 'config.update' }) - async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { - await this.init(newConfig, oldConfig); - } - private async init(newConfig: SystemConfig, oldConfig?: SystemConfig) { if (!isSmartSearchEnabled(newConfig.machineLearning)) { return; diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index e8e222c7b2..36a50c41bd 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,6 +1,5 @@ import { Stats } from 'node:fs'; import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -74,7 +73,7 @@ describe(StorageTemplateService.name, () => { loggerMock, ); - SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); + sut.onConfigUpdate({ newConfig: defaults }); }); describe('onConfigValidate', () => { @@ -164,13 +163,15 @@ describe(StorageTemplateService.name, () => { originalPath: newMotionPicturePath, }); }); - it('Should use handlebar if condition for album', async () => { + + it('should use handlebar if condition for album', async () => { const asset = assetStub.image; const user = userStub.user1; const album = albumStub.oneAsset; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; - SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); userMock.get.mockResolvedValue(user); assetMock.getByIds.mockResolvedValueOnce([asset]); @@ -185,12 +186,13 @@ describe(StorageTemplateService.name, () => { pathType: AssetPathType.ORIGINAL, }); }); - it('Should use handlebar else condition for album', async () => { + + it('should use handlebar else condition for album', async () => { const asset = assetStub.image; const user = userStub.user1; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; - SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); userMock.get.mockResolvedValue(user); assetMock.getByIds.mockResolvedValueOnce([asset]); @@ -205,6 +207,7 @@ describe(StorageTemplateService.name, () => { pathType: AssetPathType.ORIGINAL, }); }); + it('should migrate previously failed move from original path when it still exists', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; @@ -242,6 +245,7 @@ describe(StorageTemplateService.name, () => { originalPath: newPath, }); }); + it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 30d0eb575f..33b08efc9b 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -3,7 +3,6 @@ import handlebar from 'handlebars'; import { DateTime } from 'luxon'; import path from 'node:path'; import sanitize from 'sanitize-filename'; -import { SystemConfig } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -15,7 +14,7 @@ import { } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -76,7 +75,6 @@ export class StorageTemplateService { ) { this.logger.setContext(StorageTemplateService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.configCore.config$.subscribe((config) => this.onConfig(config)); this.storageCore = StorageCore.create( assetRepository, cryptoRepository, @@ -88,7 +86,16 @@ export class StorageTemplateService { ); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { + const template = newConfig.storageTemplate.template; + if (!this._template || template !== this.template.raw) { + this.logger.debug(`Compiling new storage template: ${template}`); + this._template = this.compile(template); + } + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); @@ -282,14 +289,6 @@ export class StorageTemplateService { } } - private onConfig(config: SystemConfig) { - const template = config.storageTemplate.template; - if (!this._template || template !== this.template.raw) { - this.logger.debug(`Compiling new storage template: ${template}`); - this._template = this.compile(template); - } - } - private compile(template: string) { return { raw: template, diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 6d15f097d3..b32e48ea49 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; @@ -21,7 +21,7 @@ export class StorageService { this.logger.setContext(StorageService.name); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 514d8aa0f8..ac517bb3ff 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -6,14 +6,13 @@ import { CQMode, ImageFormat, LogLevel, - SystemMetadataKey, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, } from 'src/enum'; -import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -381,14 +380,13 @@ describe(SystemConfigService.name, () => { }); describe('updateConfig', () => { - it('should update the config and emit client and server events', async () => { + it('should update the config and emit an event', async () => { systemMock.get.mockResolvedValue(partialConfig); - await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); - - expect(eventMock.clientBroadcast).toHaveBeenCalled(); - expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + expect(eventMock.emit).toHaveBeenCalledWith( + 'config.update', + expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), + ); }); it('should throw an error if a config file is in use', async () => { diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 8a7f9123e0..100ab6f47c 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; -import { SystemConfig, defaults } from 'src/config'; +import { defaults } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -13,10 +13,10 @@ import { supportedYearTokens, } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit, OnServerEvent } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; import { LogLevel } from 'src/enum'; -import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; +import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { toPlainObject } from 'src/utils/object'; @@ -32,13 +32,12 @@ export class SystemConfigService { ) { this.logger.setContext(SystemConfigService.name); this.core = SystemConfigCore.create(repository, this.logger); - this.core.config$.subscribe((config) => this.setLogLevel(config)); } - @OnEmit({ event: 'app.bootstrap', priority: -100 }) + @OnEvent({ name: 'app.bootstrap', priority: -100 }) async onBootstrap() { const config = await this.core.getConfig({ withCache: false }); - this.core.config$.next(config); + await this.eventRepository.emit('config.update', { newConfig: config }); } async getConfig(): Promise { @@ -50,7 +49,18 @@ export class SystemConfigService { return mapConfig(defaults); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: { logging } }: ArgOf<'config.update'>) { + const envLevel = this.getEnvLogLevel(); + const configLevel = logging.enabled ? logging.level : false; + const level = envLevel ?? configLevel; + this.logger.setLogLevel(level); + this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); + // TODO only do this if the event is a socket.io event + this.core.invalidateCache(); + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); @@ -73,9 +83,6 @@ export class SystemConfigService { const newConfig = await this.core.updateConfig(dto); - // TODO probably move web socket emits to a separate service - this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); - this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); await this.eventRepository.emit('config.update', { newConfig, oldConfig }); return mapConfig(newConfig); @@ -101,19 +108,6 @@ export class SystemConfigService { return theme.customCss; } - @OnServerEvent(ServerEvent.CONFIG_UPDATE) - async onConfigUpdateEvent() { - await this.core.refreshConfig(); - } - - private setLogLevel({ logging }: SystemConfig) { - const envLevel = this.getEnvLogLevel(); - const configLevel = logging.enabled ? logging.level : false; - const level = envLevel ?? configLevel; - this.logger.setLogLevel(level); - this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); - } - private getEnvLogLevel() { return process.env.IMMICH_LOG_LEVEL as LogLevel; } diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index 88340f7d7c..51771d38a2 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,5 +1,5 @@ import { Inject } from '@nestjs/common'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TrashResponseDto } from 'src/dtos/trash.dto'; @@ -54,7 +54,7 @@ export class TrashService { return { count }; } - @OnEmit({ event: 'assets.delete' }) + @OnEvent({ name: 'assets.delete' }) async onAssetsDelete() { await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); } diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 468e8c9bdd..0c7ae52cac 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -3,11 +3,11 @@ import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { isDev, serverVersion } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit, OnServerEvent } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { SystemMetadataKey } from 'src/enum'; -import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -37,7 +37,7 @@ export class VersionService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { await this.handleVersionCheck(); } @@ -90,8 +90,8 @@ export class VersionService { return JobStatus.SUCCESS; } - @OnServerEvent(ServerEvent.WEBSOCKET_CONNECT) - async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) { + @OnEvent({ name: 'websocket.connect' }) + async onWebsocketConnection({ userId }: ArgOf<'websocket.connect'>) { this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion); const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); if (metadata) { diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts index f5b079dea4..fbac554578 100644 --- a/server/src/utils/events.ts +++ b/server/src/utils/events.ts @@ -1,6 +1,6 @@ import { ModuleRef, Reflector } from '@nestjs/core'; import _ from 'lodash'; -import { EmitConfig } from 'src/decorators'; +import { EventConfig } from 'src/decorators'; import { MetadataKey } from 'src/enum'; import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface'; import { services } from 'src/services'; @@ -9,6 +9,7 @@ type Item = { event: T; handler: EmitHandler; priority: number; + server: boolean; label: string; }; @@ -35,14 +36,15 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => { continue; } - const options = reflector.get(MetadataKey.ON_EMIT_CONFIG, handler); - if (!options) { + const event = reflector.get(MetadataKey.EVENT_CONFIG, handler); + if (!event) { continue; } items.push({ - event: options.event, - priority: options.priority || 0, + event: event.name, + priority: event.priority || 0, + server: event.server ?? false, handler: handler.bind(instance), label: `${Service.name}.${handler.name}`, }); @@ -52,8 +54,8 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => { const handlers = _.orderBy(items, ['priority'], ['asc']); // register by priority - for (const { event, handler } of handlers) { - repository.on(event as EmitEvent, handler); + for (const handler of handlers) { + repository.on(handler); } return handlers; diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts index a9af627599..78c62e95f2 100644 --- a/server/test/repositories/event.repository.mock.ts +++ b/server/test/repositories/event.repository.mock.ts @@ -3,7 +3,7 @@ import { Mocked, vitest } from 'vitest'; export const newEventRepositoryMock = (): Mocked => { return { - on: vitest.fn(), + on: vitest.fn() as any, emit: vitest.fn() as any, clientSend: vitest.fn(), clientBroadcast: vitest.fn(),