mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 22:51:59 +00:00
refactor(server): events (#13003)
* refactor(server): events * chore: better type --------- Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
parent
95c67949f7
commit
a2d457b01d
28 changed files with 260 additions and 259 deletions
|
@ -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),
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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<SystemConfig>();
|
||||
|
||||
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<SystemConfig> {
|
||||
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() {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<T extends EmitEvent> = (...args: ArgsOf<T>) => Promise<void> | void;
|
||||
export type ArgOf<T extends EmitEvent> = EmitEventMap[T][0];
|
||||
export type ArgsOf<T extends EmitEvent> = EmitEventMap[T];
|
||||
export type ArgOf<T extends EmitEvent> = EventMap[T][0];
|
||||
export type ArgsOf<T extends EmitEvent> = 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<T extends EmitEvent> = {
|
||||
event: T;
|
||||
handler: EmitHandler<T>;
|
||||
server: boolean;
|
||||
};
|
||||
|
||||
export interface IEventRepository {
|
||||
on<T extends keyof EmitEventMap>(event: T, handler: EmitHandler<T>): void;
|
||||
emit<T extends keyof EmitEventMap>(event: T, ...args: ArgsOf<T>): Promise<void>;
|
||||
on<T extends keyof EventMap>(item: EventItem<T>): void;
|
||||
emit<T extends keyof EventMap>(event: T, ...args: ArgsOf<T>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send to connected clients for a specific user
|
||||
|
@ -105,7 +113,7 @@ export interface IEventRepository {
|
|||
*/
|
||||
clientBroadcast<E extends keyof ClientEventMap>(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<E extends keyof ServerEventMap>(event: E, data: ServerEventMap[E]): boolean;
|
||||
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void;
|
||||
}
|
||||
|
|
|
@ -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<T>[] }>;
|
||||
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
|
||||
|
||||
@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<any>) => {
|
||||
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<T extends EmitEvent>(event: T, handler: EmitHandler<T>): void {
|
||||
on<T extends EmitEvent>(item: EventItem<T>): void {
|
||||
const event = item.event;
|
||||
|
||||
if (!this.emitHandlers[event]) {
|
||||
this.emitHandlers[event] = [];
|
||||
}
|
||||
|
||||
this.emitHandlers[event].push(handler);
|
||||
this.emitHandlers[event].push(item);
|
||||
}
|
||||
|
||||
async emit<T extends EmitEvent>(event: T, ...args: ArgsOf<T>): Promise<void> {
|
||||
const handlers = this.emitHandlers[event] || [];
|
||||
for (const handler of handlers) {
|
||||
await handler(...args);
|
||||
return this.onEvent({ name: event, args, server: false });
|
||||
}
|
||||
|
||||
private async onEvent<T extends EmitEvent>(event: { name: T; args: ArgsOf<T>; server: boolean }): Promise<void> {
|
||||
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<E extends keyof ServerEventMap>(event: E, data: ServerEventMap[E]) {
|
||||
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
|
||||
this.logger.debug(`Server event: ${event} (send)`);
|
||||
this.server?.serverSideEmit(event, data);
|
||||
return this.eventEmitter.emit(event, data);
|
||||
this.server?.serverSideEmit(event, ...args);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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' } },
|
||||
|
|
|
@ -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<void> {
|
||||
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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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<JobStatus> {
|
||||
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 } });
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<void> {
|
||||
const featureFlags = await this.getFeatures();
|
||||
if (featureFlags.configFile) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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<SystemConfigDto> {
|
||||
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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: {} });
|
||||
}
|
||||
|
|
|
@ -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<void> {
|
||||
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) {
|
||||
|
|
|
@ -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<T extends EmitEvent> = {
|
|||
event: T;
|
||||
handler: EmitHandler<T>;
|
||||
priority: number;
|
||||
server: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
|
@ -35,14 +36,15 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => {
|
|||
continue;
|
||||
}
|
||||
|
||||
const options = reflector.get<EmitConfig>(MetadataKey.ON_EMIT_CONFIG, handler);
|
||||
if (!options) {
|
||||
const event = reflector.get<EventConfig>(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;
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Mocked, vitest } from 'vitest';
|
|||
|
||||
export const newEventRepositoryMock = (): Mocked<IEventRepository> => {
|
||||
return {
|
||||
on: vitest.fn(),
|
||||
on: vitest.fn() as any,
|
||||
emit: vitest.fn() as any,
|
||||
clientSend: vitest.fn(),
|
||||
clientBroadcast: vitest.fn(),
|
||||
|
|
Loading…
Reference in a new issue