mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +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 user2Assets: AssetMediaResponseDto[];
|
||||||
let locationAsset: AssetMediaResponseDto;
|
let locationAsset: AssetMediaResponseDto;
|
||||||
let ratingAsset: AssetMediaResponseDto;
|
let ratingAsset: AssetMediaResponseDto;
|
||||||
let facesAsset: AssetMediaResponseDto;
|
|
||||||
|
|
||||||
const setupTests = async () => {
|
const setupTests = async () => {
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
|
@ -236,7 +235,7 @@ describe('/asset', () => {
|
||||||
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
|
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
|
||||||
|
|
||||||
// asset faces
|
// asset faces
|
||||||
facesAsset = await utils.createAsset(admin.accessToken, {
|
const facesAsset = await utils.createAsset(admin.accessToken, {
|
||||||
assetData: {
|
assetData: {
|
||||||
filename: 'portrait.jpg',
|
filename: 'portrait.jpg',
|
||||||
bytes: await readFile(facesAssetFilepath),
|
bytes: await readFile(facesAssetFilepath),
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { BullModule } from '@nestjs/bullmq';
|
||||||
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
|
import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core';
|
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 { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
@ -42,7 +41,6 @@ const imports = [
|
||||||
BullModule.registerQueue(...bullQueues),
|
BullModule.registerQueue(...bullQueues),
|
||||||
ClsModule.forRoot(clsConfig),
|
ClsModule.forRoot(clsConfig),
|
||||||
ConfigModule.forRoot(immichAppConfig),
|
ConfigModule.forRoot(immichAppConfig),
|
||||||
EventEmitterModule.forRoot(),
|
|
||||||
OpenTelemetryModule.forRoot(otelConfig),
|
OpenTelemetryModule.forRoot(otelConfig),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ModuleRef],
|
inject: [ModuleRef],
|
||||||
|
@ -114,16 +112,3 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy {
|
||||||
providers: [...common, ...commands, SchedulerRegistry],
|
providers: [...common, ...commands, SchedulerRegistry],
|
||||||
})
|
})
|
||||||
export class ImmichAdminModule {}
|
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
|
#!/usr/bin/env node
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
@ -85,7 +84,6 @@ class SqlGenerator {
|
||||||
logger: this.sqlLogger,
|
logger: this.sqlLogger,
|
||||||
}),
|
}),
|
||||||
TypeOrmModule.forFeature(entities),
|
TypeOrmModule.forFeature(entities),
|
||||||
EventEmitterModule.forRoot(),
|
|
||||||
OpenTelemetryModule.forRoot(otelConfig),
|
OpenTelemetryModule.forRoot(otelConfig),
|
||||||
],
|
],
|
||||||
providers: [...repositories, AuthService, SchedulerRegistry],
|
providers: [...repositories, AuthService, SchedulerRegistry],
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { plainToInstance } from 'class-transformer';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
import { load as loadYaml } from 'js-yaml';
|
import { load as loadYaml } from 'js-yaml';
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import { Subject } from 'rxjs';
|
|
||||||
import { SystemConfig, defaults } from 'src/config';
|
import { SystemConfig, defaults } from 'src/config';
|
||||||
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { SystemMetadataKey } from 'src/enum';
|
||||||
|
@ -24,8 +23,6 @@ export class SystemConfigCore {
|
||||||
private config: SystemConfig | null = null;
|
private config: SystemConfig | null = null;
|
||||||
private lastUpdated: number | null = null;
|
private lastUpdated: number | null = null;
|
||||||
|
|
||||||
config$ = new Subject<SystemConfig>();
|
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
private repository: ISystemMetadataRepository,
|
private repository: ISystemMetadataRepository,
|
||||||
private logger: ILoggerRepository,
|
private logger: ILoggerRepository,
|
||||||
|
@ -42,6 +39,11 @@ export class SystemConfigCore {
|
||||||
instance = null;
|
instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateCache() {
|
||||||
|
this.config = null;
|
||||||
|
this.lastUpdated = null;
|
||||||
|
}
|
||||||
|
|
||||||
async getConfig({ withCache }: { withCache: boolean }): Promise<SystemConfig> {
|
async getConfig({ withCache }: { withCache: boolean }): Promise<SystemConfig> {
|
||||||
if (!withCache || !this.config) {
|
if (!withCache || !this.config) {
|
||||||
const lastUpdated = this.lastUpdated;
|
const lastUpdated = this.lastUpdated;
|
||||||
|
@ -74,14 +76,7 @@ export class SystemConfigCore {
|
||||||
|
|
||||||
await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig);
|
await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig);
|
||||||
|
|
||||||
const config = await this.getConfig({ withCache: false });
|
return this.getConfig({ withCache: false });
|
||||||
this.config$.next(config);
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshConfig() {
|
|
||||||
const newConfig = await this.getConfig({ withCache: false });
|
|
||||||
this.config$.next(newConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isUsingConfigFile() {
|
isUsingConfigFile() {
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { SetMetadata, applyDecorators } from '@nestjs/common';
|
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 { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
|
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
|
||||||
import { MetadataKey } from 'src/enum';
|
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';
|
import { setUnion } from 'src/utils/set';
|
||||||
|
|
||||||
// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
|
// 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 */
|
/** Decorator to enable versioning/tracking of generated Sql */
|
||||||
export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options);
|
export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options);
|
||||||
|
|
||||||
export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) =>
|
export type EventConfig = {
|
||||||
OnEvent(event, { suppressErrors: false, ...options });
|
name: EmitEvent;
|
||||||
|
/** handle socket.io server events as well */
|
||||||
export type EmitConfig = {
|
server?: boolean;
|
||||||
event: EmitEvent;
|
|
||||||
/** lower value has higher priority, defaults to 0 */
|
/** lower value has higher priority, defaults to 0 */
|
||||||
priority?: number;
|
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 LifecycleRelease = 'NEXT_RELEASE' | string;
|
||||||
type LifecycleMetadata = {
|
type LifecycleMetadata = {
|
||||||
|
|
|
@ -310,7 +310,7 @@ export enum MetadataKey {
|
||||||
ADMIN_ROUTE = 'admin_route',
|
ADMIN_ROUTE = 'admin_route',
|
||||||
SHARED_ROUTE = 'shared_route',
|
SHARED_ROUTE = 'shared_route',
|
||||||
API_KEY_SECURITY = 'api_key',
|
API_KEY_SECURITY = 'api_key',
|
||||||
ON_EMIT_CONFIG = 'on_emit_config',
|
EVENT_CONFIG = 'event_config',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum RouteKey {
|
export enum RouteKey {
|
||||||
|
|
|
@ -4,13 +4,19 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d
|
||||||
|
|
||||||
export const IEventRepository = 'IEventRepository';
|
export const IEventRepository = 'IEventRepository';
|
||||||
|
|
||||||
type EmitEventMap = {
|
type EventMap = {
|
||||||
// app events
|
// app events
|
||||||
'app.bootstrap': ['api' | 'microservices'];
|
'app.bootstrap': ['api' | 'microservices'];
|
||||||
'app.shutdown': [];
|
'app.shutdown': [];
|
||||||
|
|
||||||
// config events
|
// 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 }];
|
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
||||||
|
|
||||||
// album events
|
// album events
|
||||||
|
@ -43,12 +49,18 @@ type EmitEventMap = {
|
||||||
|
|
||||||
// user events
|
// user events
|
||||||
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
|
'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 EmitHandler<T extends EmitEvent> = (...args: ArgsOf<T>) => Promise<void> | void;
|
||||||
export type ArgOf<T extends EmitEvent> = EmitEventMap[T][0];
|
export type ArgOf<T extends EmitEvent> = EventMap[T][0];
|
||||||
export type ArgsOf<T extends EmitEvent> = EmitEventMap[T];
|
export type ArgsOf<T extends EmitEvent> = EventMap[T];
|
||||||
|
|
||||||
export enum ClientEvent {
|
export enum ClientEvent {
|
||||||
UPLOAD_SUCCESS = 'on_upload_success',
|
UPLOAD_SUCCESS = 'on_upload_success',
|
||||||
|
@ -82,19 +94,15 @@ export interface ClientEventMap {
|
||||||
[ClientEvent.SESSION_DELETE]: string;
|
[ClientEvent.SESSION_DELETE]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ServerEvent {
|
export type EventItem<T extends EmitEvent> = {
|
||||||
CONFIG_UPDATE = 'config.update',
|
event: T;
|
||||||
WEBSOCKET_CONNECT = 'websocket.connect',
|
handler: EmitHandler<T>;
|
||||||
}
|
server: boolean;
|
||||||
|
};
|
||||||
export interface ServerEventMap {
|
|
||||||
[ServerEvent.CONFIG_UPDATE]: null;
|
|
||||||
[ServerEvent.WEBSOCKET_CONNECT]: { userId: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IEventRepository {
|
export interface IEventRepository {
|
||||||
on<T extends keyof EmitEventMap>(event: T, handler: EmitHandler<T>): void;
|
on<T extends keyof EventMap>(item: EventItem<T>): void;
|
||||||
emit<T extends keyof EmitEventMap>(event: T, ...args: ArgsOf<T>): Promise<void>;
|
emit<T extends keyof EventMap>(event: T, ...args: ArgsOf<T>): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send to connected clients for a specific user
|
* 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;
|
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 { Inject, Injectable } from '@nestjs/common';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import {
|
import {
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
OnGatewayDisconnect,
|
OnGatewayDisconnect,
|
||||||
|
@ -13,16 +12,17 @@ import {
|
||||||
ArgsOf,
|
ArgsOf,
|
||||||
ClientEventMap,
|
ClientEventMap,
|
||||||
EmitEvent,
|
EmitEvent,
|
||||||
EmitHandler,
|
EventItem,
|
||||||
IEventRepository,
|
IEventRepository,
|
||||||
ServerEvent,
|
serverEvents,
|
||||||
ServerEventMap,
|
ServerEvents,
|
||||||
} from 'src/interfaces/event.interface';
|
} from 'src/interfaces/event.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { AuthService } from 'src/services/auth.service';
|
import { AuthService } from 'src/services/auth.service';
|
||||||
import { Instrumentation } from 'src/utils/instrumentation';
|
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()
|
@Instrumentation()
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
|
@ -39,7 +39,6 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private moduleRef: ModuleRef,
|
private moduleRef: ModuleRef,
|
||||||
private eventEmitter: EventEmitter2,
|
|
||||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(EventRepository.name);
|
this.logger.setContext(EventRepository.name);
|
||||||
|
@ -48,14 +47,10 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
||||||
afterInit(server: Server) {
|
afterInit(server: Server) {
|
||||||
this.logger.log('Initialized websocket server');
|
this.logger.log('Initialized websocket server');
|
||||||
|
|
||||||
for (const event of Object.values(ServerEvent)) {
|
for (const event of serverEvents) {
|
||||||
if (event === ServerEvent.WEBSOCKET_CONNECT) {
|
server.on(event, (...args: ArgsOf<any>) => {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
server.on(event, (data: unknown) => {
|
|
||||||
this.logger.debug(`Server event: ${event} (receive)`);
|
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) {
|
if (auth.session) {
|
||||||
await client.join(auth.session.id);
|
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) {
|
} catch (error: Error | any) {
|
||||||
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
|
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
|
||||||
client.emit('error', 'unauthorized');
|
client.emit('error', 'unauthorized');
|
||||||
|
@ -85,18 +80,29 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
||||||
await client.leave(client.nsp.name);
|
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]) {
|
if (!this.emitHandlers[event]) {
|
||||||
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> {
|
async emit<T extends EmitEvent>(event: T, ...args: ArgsOf<T>): Promise<void> {
|
||||||
const handlers = this.emitHandlers[event] || [];
|
return this.onEvent({ name: event, args, server: false });
|
||||||
for (const handler of handlers) {
|
}
|
||||||
await handler(...args);
|
|
||||||
|
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);
|
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.logger.debug(`Server event: ${event} (send)`);
|
||||||
this.server?.serverSideEmit(event, data);
|
this.server?.serverSideEmit(event, ...args);
|
||||||
return this.eventEmitter.emit(event, data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
import { OnEmit } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||||
import {
|
import {
|
||||||
DatabaseExtension,
|
DatabaseExtension,
|
||||||
|
@ -74,7 +74,7 @@ export class DatabaseService {
|
||||||
this.logger.setContext(DatabaseService.name);
|
this.logger.setContext(DatabaseService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.bootstrap', priority: -200 })
|
@OnEvent({ name: 'app.bootstrap', priority: -200 })
|
||||||
async onBootstrap() {
|
async onBootstrap() {
|
||||||
const version = await this.databaseRepository.getPostgresVersion();
|
const version = await this.databaseRepository.getPostgresVersion();
|
||||||
const current = semver.coerce(version);
|
const current = semver.coerce(version);
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { SystemConfig } from 'src/config';
|
import { defaults } from 'src/config';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import {
|
import {
|
||||||
|
@ -60,6 +59,19 @@ describe(JobService.name, () => {
|
||||||
expect(sut).toBeDefined();
|
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', () => {
|
describe('handleNightlyJobs', () => {
|
||||||
it('should run the scheduled jobs', async () => {
|
it('should run the scheduled jobs', async () => {
|
||||||
await sut.handleNightlyJobs();
|
await sut.handleNightlyJobs();
|
||||||
|
@ -239,36 +251,6 @@ describe(JobService.name, () => {
|
||||||
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
|
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[] }> = [
|
const tests: Array<{ item: JobItem; jobs: JobName[] }> = [
|
||||||
{
|
{
|
||||||
item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } },
|
item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } },
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { snakeCase } from 'lodash';
|
import { snakeCase } from 'lodash';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||||
|
import { OnEvent } from 'src/decorators';
|
||||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
|
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
|
||||||
import { AssetType, ManualJobName } from 'src/enum';
|
import { AssetType, ManualJobName } from 'src/enum';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
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 {
|
import {
|
||||||
ConcurrentQueueName,
|
ConcurrentQueueName,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
|
@ -45,6 +46,7 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobService {
|
export class JobService {
|
||||||
private configCore: SystemConfigCore;
|
private configCore: SystemConfigCore;
|
||||||
|
private isMicroservices = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
|
@ -59,6 +61,28 @@ export class JobService {
|
||||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
|
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> {
|
async create(dto: JobCreateDto): Promise<void> {
|
||||||
await this.jobRepository.queue(asJobItem(dto));
|
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 {
|
private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { SystemConfig } from 'src/config';
|
import { defaults, SystemConfig } from 'src/config';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
|
||||||
import { mapLibrary } from 'src/dtos/library.dto';
|
import { mapLibrary } from 'src/dtos/library.dto';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserEntity } from 'src/entities/user.entity';
|
||||||
import { AssetType } from 'src/enum';
|
import { AssetType } from 'src/enum';
|
||||||
|
@ -81,22 +80,26 @@ describe(LibraryService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('onBootstrapEvent', () => {
|
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);
|
systemMock.get.mockResolvedValue(systemConfigStub.libraryScan);
|
||||||
|
|
||||||
await sut.onBootstrap();
|
await sut.onBootstrap();
|
||||||
expect(systemMock.get).toHaveBeenCalled();
|
|
||||||
expect(jobMock.addCronJob).toHaveBeenCalled();
|
|
||||||
|
|
||||||
SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({
|
expect(jobMock.addCronJob).toHaveBeenCalled();
|
||||||
library: {
|
expect(systemMock.get).toHaveBeenCalled();
|
||||||
scan: {
|
|
||||||
enabled: true,
|
await sut.onConfigUpdate({
|
||||||
cronExpression: '0 1 * * *',
|
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);
|
expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true);
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,7 +4,7 @@ import path, { basename, parse } from 'node:path';
|
||||||
import picomatch from 'picomatch';
|
import picomatch from 'picomatch';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||||
import { OnEmit } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import {
|
import {
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
LibraryResponseDto,
|
LibraryResponseDto,
|
||||||
|
@ -61,7 +61,7 @@ export class LibraryService {
|
||||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.bootstrap' })
|
@OnEvent({ name: 'app.bootstrap' })
|
||||||
async onBootstrap() {
|
async onBootstrap() {
|
||||||
const config = await this.configCore.getConfig({ withCache: false });
|
const config = await this.configCore.getConfig({ withCache: false });
|
||||||
|
|
||||||
|
@ -83,19 +83,24 @@ export class LibraryService {
|
||||||
if (this.watchLibraries) {
|
if (this.watchLibraries) {
|
||||||
await this.watchAll();
|
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'>) {
|
onConfigValidate({ newConfig }: ArgOf<'config.validate'>) {
|
||||||
const { scan } = newConfig.library;
|
const { scan } = newConfig.library;
|
||||||
if (!validateCronExpression(scan.cronExpression)) {
|
if (!validateCronExpression(scan.cronExpression)) {
|
||||||
|
@ -185,7 +190,7 @@ export class LibraryService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.shutdown' })
|
@OnEvent({ name: 'app.shutdown' })
|
||||||
async onShutdown() {
|
async onShutdown() {
|
||||||
await this.unwatchAll();
|
await this.unwatchAll();
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import path from 'node:path';
|
||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.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 { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.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'>) {
|
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
|
||||||
if (app !== 'microservices') {
|
if (app !== 'microservices') {
|
||||||
return;
|
return;
|
||||||
|
@ -141,7 +141,12 @@ export class MetadataService {
|
||||||
await this.init(config);
|
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'>) {
|
async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) {
|
||||||
await this.init(newConfig);
|
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> {
|
async handleLivePhotoLinking(job: IEntityJob): Promise<JobStatus> {
|
||||||
const { id } = job;
|
const { id } = job;
|
||||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
||||||
|
@ -333,12 +333,12 @@ export class MetadataService {
|
||||||
return this.processSidecar(id, false);
|
return this.processSidecar(id, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'asset.tag' })
|
@OnEvent({ name: 'asset.tag' })
|
||||||
async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) {
|
async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) {
|
||||||
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
|
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'>) {
|
async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) {
|
||||||
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
|
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OnEmit } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { ArgOf } from 'src/interfaces/event.interface';
|
import { ArgOf } from 'src/interfaces/event.interface';
|
||||||
import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface';
|
import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface';
|
||||||
import { AssetService } from 'src/services/asset.service';
|
import { AssetService } from 'src/services/asset.service';
|
||||||
|
@ -43,7 +43,7 @@ export class MicroservicesService {
|
||||||
private versionService: VersionService,
|
private versionService: VersionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.bootstrap' })
|
@OnEvent({ name: 'app.bootstrap' })
|
||||||
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
|
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
|
||||||
if (app !== 'microservices') {
|
if (app !== 'microservices') {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
||||||
import { AssetFileType, UserMetadataKey } from 'src/enum';
|
import { AssetFileType, UserMetadataKey } from 'src/enum';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.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 { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
|
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
|
||||||
|
@ -100,6 +100,15 @@ describe(NotificationService.name, () => {
|
||||||
expect(sut).toBeDefined();
|
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', () => {
|
describe('onConfigValidateEvent', () => {
|
||||||
it('validates smtp config when enabling smtp', async () => {
|
it('validates smtp config when enabling smtp', async () => {
|
||||||
const oldConfig = configs.smtpDisabled;
|
const oldConfig = configs.smtpDisabled;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
|
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
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 { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
import { AlbumEntity } from 'src/entities/album.entity';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
|
@ -43,7 +43,13 @@ export class NotificationService {
|
||||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
|
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'>) {
|
async onConfigValidate({ oldConfig, newConfig }: ArgOf<'config.validate'>) {
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
|
@ -58,74 +64,74 @@ export class NotificationService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'asset.hide' })
|
@OnEvent({ name: 'asset.hide' })
|
||||||
onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
|
onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId);
|
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'asset.show' })
|
@OnEvent({ name: 'asset.show' })
|
||||||
async onAssetShow({ assetId }: ArgOf<'asset.show'>) {
|
async onAssetShow({ assetId }: ArgOf<'asset.show'>) {
|
||||||
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } });
|
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'>) {
|
onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) {
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]);
|
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, [assetId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'asset.delete' })
|
@OnEvent({ name: 'asset.delete' })
|
||||||
onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) {
|
onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) {
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId);
|
this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, userId, assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'assets.trash' })
|
@OnEvent({ name: 'assets.trash' })
|
||||||
onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) {
|
onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) {
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds);
|
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, userId, assetIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'assets.restore' })
|
@OnEvent({ name: 'assets.restore' })
|
||||||
onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) {
|
onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) {
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds);
|
this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, userId, assetIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'stack.create' })
|
@OnEvent({ name: 'stack.create' })
|
||||||
onStackCreate({ userId }: ArgOf<'stack.create'>) {
|
onStackCreate({ userId }: ArgOf<'stack.create'>) {
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'stack.update' })
|
@OnEvent({ name: 'stack.update' })
|
||||||
onStackUpdate({ userId }: ArgOf<'stack.update'>) {
|
onStackUpdate({ userId }: ArgOf<'stack.update'>) {
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'stack.delete' })
|
@OnEvent({ name: 'stack.delete' })
|
||||||
onStackDelete({ userId }: ArgOf<'stack.delete'>) {
|
onStackDelete({ userId }: ArgOf<'stack.delete'>) {
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'stacks.delete' })
|
@OnEvent({ name: 'stacks.delete' })
|
||||||
onStacksDelete({ userId }: ArgOf<'stacks.delete'>) {
|
onStacksDelete({ userId }: ArgOf<'stacks.delete'>) {
|
||||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, userId, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'user.signup' })
|
@OnEvent({ name: 'user.signup' })
|
||||||
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
|
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
|
||||||
if (notify) {
|
if (notify) {
|
||||||
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } });
|
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'>) {
|
async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) {
|
||||||
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } });
|
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'>) {
|
async onAlbumInvite({ id, userId }: ArgOf<'album.invite'>) {
|
||||||
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } });
|
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'>) {
|
onSessionDelete({ sessionId }: ArgOf<'session.delete'>) {
|
||||||
// after the response is sent
|
// after the response is sent
|
||||||
setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500);
|
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 { serverVersion } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.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 { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||||
import {
|
import {
|
||||||
ServerAboutResponseDto,
|
ServerAboutResponseDto,
|
||||||
|
@ -42,7 +42,7 @@ export class ServerService {
|
||||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.bootstrap' })
|
@OnEvent({ name: 'app.bootstrap' })
|
||||||
async onBootstrap(): Promise<void> {
|
async onBootstrap(): Promise<void> {
|
||||||
const featureFlags = await this.getFeatures();
|
const featureFlags = await this.getFeatures();
|
||||||
if (featureFlags.configFile) {
|
if (featureFlags.configFile) {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
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 { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||||
import { ArgOf } from 'src/interfaces/event.interface';
|
import { ArgOf } from 'src/interfaces/event.interface';
|
||||||
|
@ -39,7 +39,7 @@ export class SmartInfoService {
|
||||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.bootstrap' })
|
@OnEvent({ name: 'app.bootstrap' })
|
||||||
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
|
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
|
||||||
if (app !== 'microservices') {
|
if (app !== 'microservices') {
|
||||||
return;
|
return;
|
||||||
|
@ -49,7 +49,12 @@ export class SmartInfoService {
|
||||||
await this.init(config);
|
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'>) {
|
onConfigValidate({ newConfig }: ArgOf<'config.validate'>) {
|
||||||
try {
|
try {
|
||||||
getCLIPModelInfo(newConfig.machineLearning.clip.modelName);
|
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) {
|
private async init(newConfig: SystemConfig, oldConfig?: SystemConfig) {
|
||||||
if (!isSmartSearchEnabled(newConfig.machineLearning)) {
|
if (!isSmartSearchEnabled(newConfig.machineLearning)) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { SystemConfig, defaults } from 'src/config';
|
import { SystemConfig, defaults } from 'src/config';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { AssetPathType } from 'src/enum';
|
import { AssetPathType } from 'src/enum';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
|
@ -74,7 +73,7 @@ describe(StorageTemplateService.name, () => {
|
||||||
loggerMock,
|
loggerMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults);
|
sut.onConfigUpdate({ newConfig: defaults });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('onConfigValidate', () => {
|
describe('onConfigValidate', () => {
|
||||||
|
@ -164,13 +163,15 @@ describe(StorageTemplateService.name, () => {
|
||||||
originalPath: newMotionPicturePath,
|
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 asset = assetStub.image;
|
||||||
const user = userStub.user1;
|
const user = userStub.user1;
|
||||||
const album = albumStub.oneAsset;
|
const album = albumStub.oneAsset;
|
||||||
const config = structuredClone(defaults);
|
const config = structuredClone(defaults);
|
||||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
|
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);
|
userMock.get.mockResolvedValue(user);
|
||||||
assetMock.getByIds.mockResolvedValueOnce([asset]);
|
assetMock.getByIds.mockResolvedValueOnce([asset]);
|
||||||
|
@ -185,12 +186,13 @@ describe(StorageTemplateService.name, () => {
|
||||||
pathType: AssetPathType.ORIGINAL,
|
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 asset = assetStub.image;
|
||||||
const user = userStub.user1;
|
const user = userStub.user1;
|
||||||
const config = structuredClone(defaults);
|
const config = structuredClone(defaults);
|
||||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}';
|
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);
|
userMock.get.mockResolvedValue(user);
|
||||||
assetMock.getByIds.mockResolvedValueOnce([asset]);
|
assetMock.getByIds.mockResolvedValueOnce([asset]);
|
||||||
|
@ -205,6 +207,7 @@ describe(StorageTemplateService.name, () => {
|
||||||
pathType: AssetPathType.ORIGINAL,
|
pathType: AssetPathType.ORIGINAL,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should migrate previously failed move from original path when it still exists', async () => {
|
it('should migrate previously failed move from original path when it still exists', async () => {
|
||||||
userMock.get.mockResolvedValue(userStub.user1);
|
userMock.get.mockResolvedValue(userStub.user1);
|
||||||
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;
|
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;
|
||||||
|
@ -242,6 +245,7 @@ describe(StorageTemplateService.name, () => {
|
||||||
originalPath: newPath,
|
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 () => {
|
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);
|
userMock.get.mockResolvedValue(userStub.user1);
|
||||||
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;
|
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 { DateTime } from 'luxon';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import sanitize from 'sanitize-filename';
|
import sanitize from 'sanitize-filename';
|
||||||
import { SystemConfig } from 'src/config';
|
|
||||||
import {
|
import {
|
||||||
supportedDayTokens,
|
supportedDayTokens,
|
||||||
supportedHourTokens,
|
supportedHourTokens,
|
||||||
|
@ -15,7 +14,7 @@ import {
|
||||||
} from 'src/constants';
|
} from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.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 { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
|
import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
|
@ -76,7 +75,6 @@ export class StorageTemplateService {
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(StorageTemplateService.name);
|
this.logger.setContext(StorageTemplateService.name);
|
||||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||||
this.configCore.config$.subscribe((config) => this.onConfig(config));
|
|
||||||
this.storageCore = StorageCore.create(
|
this.storageCore = StorageCore.create(
|
||||||
assetRepository,
|
assetRepository,
|
||||||
cryptoRepository,
|
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'>) {
|
onConfigValidate({ newConfig }: ArgOf<'config.validate'>) {
|
||||||
try {
|
try {
|
||||||
const { compiled } = this.compile(newConfig.storageTemplate.template);
|
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) {
|
private compile(template: string) {
|
||||||
return {
|
return {
|
||||||
raw: template,
|
raw: template,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { OnEmit } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { StorageFolder, SystemMetadataKey } from 'src/enum';
|
import { StorageFolder, SystemMetadataKey } from 'src/enum';
|
||||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||||
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
|
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
|
||||||
|
@ -21,7 +21,7 @@ export class StorageService {
|
||||||
this.logger.setContext(StorageService.name);
|
this.logger.setContext(StorageService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.bootstrap' })
|
@OnEvent({ name: 'app.bootstrap' })
|
||||||
async onBootstrap() {
|
async onBootstrap() {
|
||||||
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
|
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
|
||||||
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
|
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
|
||||||
|
|
|
@ -6,14 +6,13 @@ import {
|
||||||
CQMode,
|
CQMode,
|
||||||
ImageFormat,
|
ImageFormat,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
SystemMetadataKey,
|
|
||||||
ToneMapping,
|
ToneMapping,
|
||||||
TranscodeHWAccel,
|
TranscodeHWAccel,
|
||||||
TranscodePolicy,
|
TranscodePolicy,
|
||||||
VideoCodec,
|
VideoCodec,
|
||||||
VideoContainer,
|
VideoContainer,
|
||||||
} from 'src/enum';
|
} 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 { QueueName } from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||||
|
@ -381,14 +380,13 @@ describe(SystemConfigService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateConfig', () => {
|
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);
|
systemMock.get.mockResolvedValue(partialConfig);
|
||||||
|
|
||||||
await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig);
|
await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig);
|
||||||
|
expect(eventMock.emit).toHaveBeenCalledWith(
|
||||||
expect(eventMock.clientBroadcast).toHaveBeenCalled();
|
'config.update',
|
||||||
expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null);
|
expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }),
|
||||||
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if a config file is in use', async () => {
|
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 { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { instanceToPlain } from 'class-transformer';
|
import { instanceToPlain } from 'class-transformer';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { SystemConfig, defaults } from 'src/config';
|
import { defaults } from 'src/config';
|
||||||
import {
|
import {
|
||||||
supportedDayTokens,
|
supportedDayTokens,
|
||||||
supportedHourTokens,
|
supportedHourTokens,
|
||||||
|
@ -13,10 +13,10 @@ import {
|
||||||
supportedYearTokens,
|
supportedYearTokens,
|
||||||
} from 'src/constants';
|
} from 'src/constants';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
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 { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto';
|
||||||
import { LogLevel } from 'src/enum';
|
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 { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||||
import { toPlainObject } from 'src/utils/object';
|
import { toPlainObject } from 'src/utils/object';
|
||||||
|
@ -32,13 +32,12 @@ export class SystemConfigService {
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(SystemConfigService.name);
|
this.logger.setContext(SystemConfigService.name);
|
||||||
this.core = SystemConfigCore.create(repository, this.logger);
|
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() {
|
async onBootstrap() {
|
||||||
const config = await this.core.getConfig({ withCache: false });
|
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> {
|
async getConfig(): Promise<SystemConfigDto> {
|
||||||
|
@ -50,7 +49,18 @@ export class SystemConfigService {
|
||||||
return mapConfig(defaults);
|
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'>) {
|
onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) {
|
||||||
if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) {
|
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.');
|
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);
|
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 });
|
await this.eventRepository.emit('config.update', { newConfig, oldConfig });
|
||||||
|
|
||||||
return mapConfig(newConfig);
|
return mapConfig(newConfig);
|
||||||
|
@ -101,19 +108,6 @@ export class SystemConfigService {
|
||||||
return theme.customCss;
|
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() {
|
private getEnvLogLevel() {
|
||||||
return process.env.IMMICH_LOG_LEVEL as LogLevel;
|
return process.env.IMMICH_LOG_LEVEL as LogLevel;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Inject } from '@nestjs/common';
|
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 { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { TrashResponseDto } from 'src/dtos/trash.dto';
|
import { TrashResponseDto } from 'src/dtos/trash.dto';
|
||||||
|
@ -54,7 +54,7 @@ export class TrashService {
|
||||||
return { count };
|
return { count };
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'assets.delete' })
|
@OnEvent({ name: 'assets.delete' })
|
||||||
async onAssetsDelete() {
|
async onAssetsDelete() {
|
||||||
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
|
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,11 @@ import { DateTime } from 'luxon';
|
||||||
import semver, { SemVer } from 'semver';
|
import semver, { SemVer } from 'semver';
|
||||||
import { isDev, serverVersion } from 'src/constants';
|
import { isDev, serverVersion } from 'src/constants';
|
||||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
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 { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||||
import { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
|
import { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
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 { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||||
|
@ -37,7 +37,7 @@ export class VersionService {
|
||||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.bootstrap' })
|
@OnEvent({ name: 'app.bootstrap' })
|
||||||
async onBootstrap(): Promise<void> {
|
async onBootstrap(): Promise<void> {
|
||||||
await this.handleVersionCheck();
|
await this.handleVersionCheck();
|
||||||
}
|
}
|
||||||
|
@ -90,8 +90,8 @@ export class VersionService {
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnServerEvent(ServerEvent.WEBSOCKET_CONNECT)
|
@OnEvent({ name: 'websocket.connect' })
|
||||||
async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) {
|
async onWebsocketConnection({ userId }: ArgOf<'websocket.connect'>) {
|
||||||
this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion);
|
this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion);
|
||||||
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE);
|
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE);
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { EmitConfig } from 'src/decorators';
|
import { EventConfig } from 'src/decorators';
|
||||||
import { MetadataKey } from 'src/enum';
|
import { MetadataKey } from 'src/enum';
|
||||||
import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface';
|
import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import { services } from 'src/services';
|
import { services } from 'src/services';
|
||||||
|
@ -9,6 +9,7 @@ type Item<T extends EmitEvent> = {
|
||||||
event: T;
|
event: T;
|
||||||
handler: EmitHandler<T>;
|
handler: EmitHandler<T>;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
server: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -35,14 +36,15 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = reflector.get<EmitConfig>(MetadataKey.ON_EMIT_CONFIG, handler);
|
const event = reflector.get<EventConfig>(MetadataKey.EVENT_CONFIG, handler);
|
||||||
if (!options) {
|
if (!event) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
event: options.event,
|
event: event.name,
|
||||||
priority: options.priority || 0,
|
priority: event.priority || 0,
|
||||||
|
server: event.server ?? false,
|
||||||
handler: handler.bind(instance),
|
handler: handler.bind(instance),
|
||||||
label: `${Service.name}.${handler.name}`,
|
label: `${Service.name}.${handler.name}`,
|
||||||
});
|
});
|
||||||
|
@ -52,8 +54,8 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => {
|
||||||
const handlers = _.orderBy(items, ['priority'], ['asc']);
|
const handlers = _.orderBy(items, ['priority'], ['asc']);
|
||||||
|
|
||||||
// register by priority
|
// register by priority
|
||||||
for (const { event, handler } of handlers) {
|
for (const handler of handlers) {
|
||||||
repository.on(event as EmitEvent, handler);
|
repository.on(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
return handlers;
|
return handlers;
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Mocked, vitest } from 'vitest';
|
||||||
|
|
||||||
export const newEventRepositoryMock = (): Mocked<IEventRepository> => {
|
export const newEventRepositoryMock = (): Mocked<IEventRepository> => {
|
||||||
return {
|
return {
|
||||||
on: vitest.fn(),
|
on: vitest.fn() as any,
|
||||||
emit: vitest.fn() as any,
|
emit: vitest.fn() as any,
|
||||||
clientSend: vitest.fn(),
|
clientSend: vitest.fn(),
|
||||||
clientBroadcast: vitest.fn(),
|
clientBroadcast: vitest.fn(),
|
||||||
|
|
Loading…
Reference in a new issue