Merge branch 'main' of github.com:immich-app/immich into feat/show-archived-assets-for-a-person
This commit is contained in:
+3
-2
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:20241016@sha256:00ea9ef1c9aed4499353d5cccbd63f52d125c18e264aac54d1139b6da6715a62 AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:20241022@sha256:22941f8bd36e27a2a659e755ce8ee3e3906adfa41a3ad15e81cad0ed333c14ff AS dev
|
||||
|
||||
RUN apt-get install --no-install-recommends -yqq tini
|
||||
WORKDIR /usr/src/app
|
||||
@@ -37,11 +37,12 @@ WORKDIR /usr/src/app
|
||||
COPY web/package*.json web/svelte.config.js ./
|
||||
RUN npm ci
|
||||
COPY web ./
|
||||
COPY i18n ../i18n
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# prod build
|
||||
FROM ghcr.io/immich-app/base-server-prod:20241016@sha256:063563dc3a1d9e7a105b1847f93eb82a8dad808b03e7acae8e7c5007fb732cee
|
||||
FROM ghcr.io/immich-app/base-server-prod:20241022@sha256:6676a716a11106887c98a2d4ac4677a92c6f80ba6da3e496de1b302a56882ef5
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
Generated
+508
-389
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -45,7 +45,7 @@
|
||||
"@nestjs/swagger": "^7.1.8",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.2.2",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.51.0",
|
||||
"@opentelemetry/context-async-hooks": "^1.24.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.53.0",
|
||||
"@opentelemetry/sdk-node": "^0.53.0",
|
||||
@@ -108,7 +108,7 @@
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.16.11",
|
||||
"@types/node": "^20.16.12",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/picomatch": "^3.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { entities } from 'src/entities';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
|
||||
import { AuthGuard } from 'src/middleware/auth.guard';
|
||||
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
@@ -21,6 +22,7 @@ import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
|
||||
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
|
||||
import { repositories } from 'src/repositories';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { teardownTelemetry } from 'src/repositories/telemetry.repository';
|
||||
import { services } from 'src/services';
|
||||
import { DatabaseService } from 'src/services/database.service';
|
||||
|
||||
@@ -66,6 +68,7 @@ abstract class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
@Inject(ILoggerRepository) logger: ILoggerRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ITelemetryRepository) private telemetryRepository: ITelemetryRepository,
|
||||
) {
|
||||
logger.setAppName(this.worker);
|
||||
}
|
||||
@@ -73,12 +76,14 @@ abstract class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
abstract getWorker(): ImmichWorker;
|
||||
|
||||
async onModuleInit() {
|
||||
this.telemetryRepository.setup({ repositories: repositories.map(({ useClass }) => useClass) });
|
||||
this.eventRepository.setup({ services });
|
||||
await this.eventRepository.emit('app.bootstrap', this.worker);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.eventRepository.emit('app.shutdown', this.worker);
|
||||
await teardownTelemetry();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -354,9 +354,9 @@ export const immichAppConfig: ConfigModuleOptions = {
|
||||
),
|
||||
|
||||
IMMICH_METRICS: Joi.boolean().optional().default(false),
|
||||
IMMICH_HOST_METRICS: Joi.boolean().optional().default(false),
|
||||
IMMICH_API_METRICS: Joi.boolean().optional().default(false),
|
||||
IMMICH_IO_METRICS: Joi.boolean().optional().default(false),
|
||||
IMMICH_HOST_METRICS: Joi.boolean().optional(),
|
||||
IMMICH_API_METRICS: Joi.boolean().optional(),
|
||||
IMMICH_IO_METRICS: Joi.boolean().optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -86,27 +86,6 @@ export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator {
|
||||
return Chunked({ ...options, mergeFn: setUnion });
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/74898678
|
||||
export function DecorateAll(
|
||||
decorator: <T>(
|
||||
target: any,
|
||||
propertyKey: string,
|
||||
descriptor: TypedPropertyDescriptor<T>,
|
||||
) => TypedPropertyDescriptor<T> | void,
|
||||
) {
|
||||
return (target: any) => {
|
||||
const descriptors = Object.getOwnPropertyDescriptors(target.prototype);
|
||||
for (const [propName, descriptor] of Object.entries(descriptors)) {
|
||||
const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor';
|
||||
if (!isMethod) {
|
||||
continue;
|
||||
}
|
||||
decorator({ ...target, constructor: { ...target.constructor, name: target.name } as any }, propName, descriptor);
|
||||
Object.defineProperty(target.prototype, propName, descriptor);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const UUID = '00000000-0000-4000-a000-000000000000';
|
||||
|
||||
export const DummyValue = {
|
||||
@@ -128,6 +107,9 @@ export interface GenerateSqlQueries {
|
||||
params: unknown[];
|
||||
}
|
||||
|
||||
export const Telemetry = (options: { enabled?: boolean }) =>
|
||||
SetMetadata(MetadataKey.TELEMETRY_ENABLED, options?.enabled ?? true);
|
||||
|
||||
/** Decorator to enable versioning/tracking of generated Sql */
|
||||
export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options);
|
||||
|
||||
|
||||
@@ -334,6 +334,7 @@ export enum MetadataKey {
|
||||
SHARED_ROUTE = 'shared_route',
|
||||
API_KEY_SECURITY = 'api_key',
|
||||
EVENT_CONFIG = 'event_config',
|
||||
TELEMETRY_ENABLED = 'telemetry_enabled',
|
||||
}
|
||||
|
||||
export enum RouteKey {
|
||||
|
||||
@@ -22,7 +22,7 @@ type EventMap = {
|
||||
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
||||
|
||||
// album events
|
||||
'album.update': [{ id: string; updatedBy: string }];
|
||||
'album.update': [{ id: string; recipientIds: string[] }];
|
||||
'album.invite': [{ id: string; userId: string }];
|
||||
|
||||
// asset events
|
||||
|
||||
@@ -120,6 +120,11 @@ export interface IBaseJob {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface IDelayedJob extends IBaseJob {
|
||||
/** The minimum time to wait to execute this job, in milliseconds. */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export interface IEntityJob extends IBaseJob {
|
||||
id: string;
|
||||
source?: 'upload' | 'sidecar-write' | 'copy';
|
||||
@@ -181,8 +186,8 @@ export interface INotifyAlbumInviteJob extends IEntityJob {
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export interface INotifyAlbumUpdateJob extends IEntityJob {
|
||||
senderId: string;
|
||||
export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
|
||||
recipientIds: string[];
|
||||
}
|
||||
|
||||
export interface JobCounts {
|
||||
@@ -310,4 +315,5 @@ export interface IJobRepository {
|
||||
getQueueStatus(name: QueueName): Promise<QueueStatus>;
|
||||
getJobCounts(name: QueueName): Promise<JobCounts>;
|
||||
waitForQueueCompletion(...queues: QueueName[]): Promise<void>;
|
||||
removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined>;
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
import { MetricOptions } from '@opentelemetry/api';
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
|
||||
export const IMetricRepository = 'IMetricRepository';
|
||||
export const ITelemetryRepository = 'ITelemetryRepository';
|
||||
|
||||
export interface MetricGroupOptions {
|
||||
enabled: boolean;
|
||||
@@ -13,7 +14,8 @@ export interface IMetricGroupRepository {
|
||||
configure(options: MetricGroupOptions): this;
|
||||
}
|
||||
|
||||
export interface IMetricRepository {
|
||||
export interface ITelemetryRepository {
|
||||
setup(options: { repositories: ClassConstructor<unknown>[] }): void;
|
||||
api: IMetricGroupRepository;
|
||||
host: IMetricGroupRepository;
|
||||
jobs: IMetricGroupRepository;
|
||||
@@ -15,7 +15,6 @@ import { StackEntity } from 'src/entities/stack.entity';
|
||||
import { TagEntity } from 'src/entities/tag.entity';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Brackets, In, Repository } from 'typeorm';
|
||||
|
||||
type IActivityAccess = IAccessRepository['activity'];
|
||||
@@ -29,7 +28,6 @@ type IStackAccess = IAccessRepository['stack'];
|
||||
type ITagAccess = IAccessRepository['tag'];
|
||||
type ITimelineAccess = IAccessRepository['timeline'];
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
class ActivityAccess implements IActivityAccess {
|
||||
constructor(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { ActivityEntity } from 'src/entities/activity.entity';
|
||||
import { IActivityRepository } from 'src/interfaces/activity.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
export interface ActivitySearch {
|
||||
@@ -13,7 +12,6 @@ export interface ActivitySearch {
|
||||
isLiked?: boolean;
|
||||
}
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class ActivityRepository implements IActivityRepository {
|
||||
constructor(@InjectRepository(ActivityEntity) private repository: Repository<ActivityEntity>) {}
|
||||
|
||||
@@ -2,10 +2,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { AlbumUserEntity } from 'src/entities/album-user.entity';
|
||||
import { AlbumPermissionId, IAlbumUserRepository } from 'src/interfaces/album-user.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class AlbumUserRepository implements IAlbumUserRepository {
|
||||
constructor(@InjectRepository(AlbumUserEntity) private repository: Repository<AlbumUserEntity>) {}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
@@ -23,7 +22,6 @@ const withoutDeletedUsers = <T extends AlbumEntity | null>(album: T) => {
|
||||
return album;
|
||||
};
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class AlbumRepository implements IAlbumRepository {
|
||||
constructor(
|
||||
|
||||
@@ -3,10 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { APIKeyEntity } from 'src/entities/api-key.entity';
|
||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class ApiKeyRepository implements IKeyRepository {
|
||||
constructor(@InjectRepository(APIKeyEntity) private repository: Repository<APIKeyEntity>) {}
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from 'src/interfaces/asset.interface';
|
||||
import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface';
|
||||
import { searchAssetBuilder } from 'src/utils/database';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
|
||||
import {
|
||||
Brackets,
|
||||
@@ -54,7 +53,6 @@ const dateTrunc = (options: TimeBucketOptions) =>
|
||||
truncateMap[options.size]
|
||||
}', (asset."localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`;
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class AssetRepository implements IAssetRepository {
|
||||
constructor(
|
||||
|
||||
@@ -2,10 +2,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { AuditEntity } from 'src/entities/audit.entity';
|
||||
import { AuditSearch, IAuditRepository } from 'src/interfaces/audit.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { In, LessThan, MoreThan, Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class AuditRepository implements IAuditRepository {
|
||||
constructor(@InjectRepository(AuditEntity) private repository: Repository<AuditEntity>) {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { join } from 'node:path';
|
||||
import { citiesFile, excludePaths } from 'src/constants';
|
||||
import { Telemetry } from 'src/decorators';
|
||||
import { ImmichEnvironment, ImmichWorker, LogLevel } from 'src/enum';
|
||||
import { EnvData, IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { DatabaseExtension } from 'src/interfaces/database.interface';
|
||||
@@ -74,9 +75,6 @@ const getEnv = (): EnvData => {
|
||||
const repoMetrics = parseBoolean(process.env.IMMICH_IO_METRICS, globalEnabled);
|
||||
const jobMetrics = parseBoolean(process.env.IMMICH_JOB_METRICS, globalEnabled);
|
||||
const telemetryEnabled = globalEnabled || hostMetrics || apiMetrics || repoMetrics || jobMetrics;
|
||||
if (!telemetryEnabled && process.env.OTEL_SDK_DISABLED === undefined) {
|
||||
process.env.OTEL_SDK_DISABLED = 'true';
|
||||
}
|
||||
|
||||
return {
|
||||
host: process.env.IMMICH_HOST,
|
||||
@@ -186,6 +184,7 @@ const getEnv = (): EnvData => {
|
||||
let cached: EnvData | undefined;
|
||||
|
||||
@Injectable()
|
||||
@Telemetry({ enabled: false })
|
||||
export class ConfigRepository implements IConfigRepository {
|
||||
getEnv(): EnvData {
|
||||
if (!cached) {
|
||||
|
||||
@@ -3,9 +3,7 @@ import { compareSync, hash } from 'bcrypt';
|
||||
import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class CryptoRepository implements ICryptoRepository {
|
||||
randomUUID() {
|
||||
|
||||
@@ -15,11 +15,9 @@ import {
|
||||
VectorUpdateResult,
|
||||
} from 'src/interfaces/database.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
import { DataSource, EntityManager, QueryRunner } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class DatabaseRepository implements IDatabaseRepository {
|
||||
private vectorExtension: VectorExtension;
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} 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]: Array<EventItem<T>> }>;
|
||||
@@ -37,7 +36,6 @@ type Item<T extends EmitEvent> = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
@Instrumentation()
|
||||
@WebSocketGateway({
|
||||
cors: true,
|
||||
path: '/api/socket.io',
|
||||
|
||||
@@ -17,7 +17,6 @@ import { IMapRepository } from 'src/interfaces/map.interface';
|
||||
import { IMediaRepository } from 'src/interfaces/media.interface';
|
||||
import { IMemoryRepository } from 'src/interfaces/memory.interface';
|
||||
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
|
||||
import { IMetricRepository } from 'src/interfaces/metric.interface';
|
||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||
import { INotificationRepository } from 'src/interfaces/notification.interface';
|
||||
import { IOAuthRepository } from 'src/interfaces/oauth.interface';
|
||||
@@ -31,6 +30,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
|
||||
import { ITrashRepository } from 'src/interfaces/trash.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
|
||||
@@ -54,7 +54,6 @@ import { MapRepository } from 'src/repositories/map.repository';
|
||||
import { MediaRepository } from 'src/repositories/media.repository';
|
||||
import { MemoryRepository } from 'src/repositories/memory.repository';
|
||||
import { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||
import { MetricRepository } from 'src/repositories/metric.repository';
|
||||
import { MoveRepository } from 'src/repositories/move.repository';
|
||||
import { NotificationRepository } from 'src/repositories/notification.repository';
|
||||
import { OAuthRepository } from 'src/repositories/oauth.repository';
|
||||
@@ -68,6 +67,7 @@ import { StackRepository } from 'src/repositories/stack.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
@@ -93,7 +93,6 @@ export const repositories = [
|
||||
{ provide: IMediaRepository, useClass: MediaRepository },
|
||||
{ provide: IMemoryRepository, useClass: MemoryRepository },
|
||||
{ provide: IMetadataRepository, useClass: MetadataRepository },
|
||||
{ provide: IMetricRepository, useClass: MetricRepository },
|
||||
{ provide: IMoveRepository, useClass: MoveRepository },
|
||||
{ provide: INotificationRepository, useClass: NotificationRepository },
|
||||
{ provide: IOAuthRepository, useClass: OAuthRepository },
|
||||
@@ -107,6 +106,7 @@ export const repositories = [
|
||||
{ provide: IStorageRepository, useClass: StorageRepository },
|
||||
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
|
||||
{ provide: ITagRepository, useClass: TagRepository },
|
||||
{ provide: ITelemetryRepository, useClass: TelemetryRepository },
|
||||
{ provide: ITrashRepository, useClass: TrashRepository },
|
||||
{ provide: IUserRepository, useClass: UserRepository },
|
||||
{ provide: IVersionHistoryRepository, useClass: VersionHistoryRepository },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CronJob, CronTime } from 'cron';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import {
|
||||
IEntityJob,
|
||||
IJobRepository,
|
||||
JobCounts,
|
||||
JobItem,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
QueueStatus,
|
||||
} from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
|
||||
export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||
// misc
|
||||
@@ -98,7 +98,6 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||
[JobName.QUEUE_TRASH_EMPTY]: QueueName.BACKGROUND_TASK,
|
||||
};
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class JobRepository implements IJobRepository {
|
||||
private workers: Partial<Record<QueueName, Worker>> = {};
|
||||
@@ -252,6 +251,9 @@ export class JobRepository implements IJobRepository {
|
||||
|
||||
private getJobOptions(item: JobItem): JobsOptions | null {
|
||||
switch (item.name) {
|
||||
case JobName.NOTIFY_ALBUM_UPDATE: {
|
||||
return { jobId: item.data.id, delay: item.data?.delay };
|
||||
}
|
||||
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
|
||||
return { jobId: item.data.id };
|
||||
}
|
||||
@@ -261,7 +263,6 @@ export class JobRepository implements IJobRepository {
|
||||
case JobName.QUEUE_FACIAL_RECOGNITION: {
|
||||
return { jobId: JobName.QUEUE_FACIAL_RECOGNITION };
|
||||
}
|
||||
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
@@ -271,4 +272,20 @@ export class JobRepository implements IJobRepository {
|
||||
private getQueue(queue: QueueName): Queue {
|
||||
return this.moduleReference.get<Queue>(getQueueToken(queue), { strict: false });
|
||||
}
|
||||
|
||||
public async removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined> {
|
||||
const existingJob = await this.getQueue(JOBS_TO_QUEUE[name]).getJob(jobId);
|
||||
if (!existingJob) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await existingJob.remove();
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('Missing key for job')) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return existingJob.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { LibraryStatsResponseDto } from 'src/dtos/library.dto';
|
||||
import { LibraryEntity } from 'src/entities/library.entity';
|
||||
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { Repository } from 'typeorm/repository/Repository.js';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class LibraryRepository implements ILibraryRepository {
|
||||
constructor(@InjectRepository(LibraryEntity) private repository: Repository<LibraryEntity>) {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common';
|
||||
import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { Telemetry } from 'src/decorators';
|
||||
import { LogLevel } from 'src/enum';
|
||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
@@ -17,6 +18,7 @@ enum LogColor {
|
||||
}
|
||||
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
@Telemetry({ enabled: false })
|
||||
export class LoggerRepository extends ConsoleLogger implements ILoggerRepository {
|
||||
private static logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];
|
||||
private noColor: boolean;
|
||||
|
||||
@@ -12,11 +12,9 @@ import {
|
||||
ModelTask,
|
||||
ModelType,
|
||||
} from 'src/interfaces/machine-learning.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
|
||||
const errorPrefix = 'Machine learning request';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class MachineLearningRepository implements IMachineLearningRepository {
|
||||
private async predict<T>(url: string, payload: ModelPayload, config: MachineLearningRequest): Promise<T> {
|
||||
|
||||
@@ -20,11 +20,9 @@ import {
|
||||
} from 'src/interfaces/map.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { OptionalBetween } from 'src/utils/database';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { DataSource, In, IsNull, Not, QueryRunner, Repository } from 'typeorm';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class MapRepository implements IMapRepository {
|
||||
constructor(
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
TranscodeCommand,
|
||||
VideoInfo,
|
||||
} from 'src/interfaces/media.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
|
||||
const probe = (input: string, options: string[]): Promise<FfprobeData> =>
|
||||
@@ -36,7 +35,6 @@ type ProgressEvent = {
|
||||
percent?: number;
|
||||
};
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class MediaRepository implements IMediaRepository {
|
||||
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
|
||||
@@ -202,7 +200,7 @@ export class MediaRepository implements IMediaRepository {
|
||||
|
||||
lastProgressFrame = progress.frames;
|
||||
const percent = ((progress.frames / frameCount) * 100).toFixed(2);
|
||||
const ms = Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000;
|
||||
const ms = progress.currentFps ? Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000 : 0;
|
||||
const duration = ms ? Duration.fromMillis(ms).rescale().toHuman({ unitDisplay: 'narrow' }) : '';
|
||||
const outputText = output instanceof Writable ? 'stream' : output.split('/').pop();
|
||||
this.logger.debug(
|
||||
|
||||
@@ -3,10 +3,8 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { MemoryEntity } from 'src/entities/memory.entity';
|
||||
import { IMemoryRepository } from 'src/interfaces/memory.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class MemoryRepository implements IMemoryRepository {
|
||||
constructor(
|
||||
|
||||
@@ -3,9 +3,7 @@ import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
||||
import geotz from 'geo-tz';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class MetadataRepository implements IMetadataRepository {
|
||||
private exiftool = new ExifTool({
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { MetricOptions } from '@opentelemetry/api';
|
||||
import { MetricService } from 'nestjs-otel';
|
||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { IMetricGroupRepository, IMetricRepository, MetricGroupOptions } from 'src/interfaces/metric.interface';
|
||||
|
||||
class MetricGroupRepository implements IMetricGroupRepository {
|
||||
private enabled = false;
|
||||
|
||||
constructor(private metricService: MetricService) {}
|
||||
|
||||
addToCounter(name: string, value: number, options?: MetricOptions): void {
|
||||
if (this.enabled) {
|
||||
this.metricService.getCounter(name, options).add(value);
|
||||
}
|
||||
}
|
||||
|
||||
addToGauge(name: string, value: number, options?: MetricOptions): void {
|
||||
if (this.enabled) {
|
||||
this.metricService.getUpDownCounter(name, options).add(value);
|
||||
}
|
||||
}
|
||||
|
||||
addToHistogram(name: string, value: number, options?: MetricOptions): void {
|
||||
if (this.enabled) {
|
||||
this.metricService.getHistogram(name, options).record(value);
|
||||
}
|
||||
}
|
||||
|
||||
configure(options: MetricGroupOptions): this {
|
||||
this.enabled = options.enabled;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MetricRepository implements IMetricRepository {
|
||||
api: MetricGroupRepository;
|
||||
host: MetricGroupRepository;
|
||||
jobs: MetricGroupRepository;
|
||||
repo: MetricGroupRepository;
|
||||
|
||||
constructor(metricService: MetricService, @Inject(IConfigRepository) configRepository: IConfigRepository) {
|
||||
const { telemetry } = configRepository.getEnv();
|
||||
this.api = new MetricGroupRepository(metricService).configure({ enabled: telemetry.apiMetrics });
|
||||
this.host = new MetricGroupRepository(metricService).configure({ enabled: telemetry.hostMetrics });
|
||||
this.jobs = new MetricGroupRepository(metricService).configure({ enabled: telemetry.jobMetrics });
|
||||
this.repo = new MetricGroupRepository(metricService).configure({ enabled: telemetry.repoMetrics });
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,8 @@ import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { MoveEntity } from 'src/entities/move.entity';
|
||||
import { PathType } from 'src/enum';
|
||||
import { IMoveRepository, MoveCreate } from 'src/interfaces/move.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class MoveRepository implements IMoveRepository {
|
||||
constructor(@InjectRepository(MoveEntity) private repository: Repository<MoveEntity>) {}
|
||||
|
||||
@@ -15,9 +15,7 @@ import {
|
||||
SendEmailResponse,
|
||||
SmtpOptions,
|
||||
} from 'src/interfaces/notification.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class NotificationRepository implements INotificationRepository {
|
||||
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
|
||||
|
||||
@@ -2,9 +2,7 @@ import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common
|
||||
import { custom, generators, Issuer } from 'openid-client';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IOAuthRepository, OAuthConfig, OAuthProfile } from 'src/interfaces/oauth.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class OAuthRepository implements IOAuthRepository {
|
||||
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
|
||||
|
||||
@@ -2,10 +2,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { PartnerEntity } from 'src/entities/partner.entity';
|
||||
import { IPartnerRepository, PartnerIds } from 'src/interfaces/partner.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { DeepPartial, Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class PartnerRepository implements IPartnerRepository {
|
||||
constructor(@InjectRepository(PartnerEntity) private repository: Repository<PartnerEntity>) {}
|
||||
|
||||
@@ -21,11 +21,9 @@ import {
|
||||
UnassignFacesOptions,
|
||||
UpdateFacesData,
|
||||
} from 'src/interfaces/person.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination';
|
||||
import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class PersonRepository implements IPersonRepository {
|
||||
constructor(
|
||||
|
||||
@@ -23,12 +23,10 @@ import {
|
||||
SmartSearchOptions,
|
||||
} from 'src/interfaces/search.interface';
|
||||
import { asVector, searchAssetBuilder } from 'src/utils/database';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Paginated, PaginationResult, paginatedBuilder } from 'src/utils/pagination';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class SearchRepository implements ISearchRepository {
|
||||
private vectorExtension: VectorExtension;
|
||||
|
||||
@@ -7,7 +7,6 @@ import sharp from 'sharp';
|
||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
|
||||
const exec = promisify(execCallback);
|
||||
const maybeFirstLine = async (command: string): Promise<string> => {
|
||||
@@ -34,7 +33,6 @@ const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => {
|
||||
return item?.version;
|
||||
};
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class ServerInfoRepository implements IServerInfoRepository {
|
||||
constructor(
|
||||
|
||||
@@ -3,10 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
import { ISessionRepository, SessionSearchOptions } from 'src/interfaces/session.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class SessionRepository implements ISessionRepository {
|
||||
constructor(@InjectRepository(SessionEntity) private repository: Repository<SessionEntity>) {}
|
||||
|
||||
@@ -3,10 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class SharedLinkRepository implements ISharedLinkRepository {
|
||||
constructor(@InjectRepository(SharedLinkEntity) private repository: Repository<SharedLinkEntity>) {}
|
||||
|
||||
@@ -3,10 +3,8 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { StackEntity } from 'src/entities/stack.entity';
|
||||
import { IStackRepository, StackSearch } from 'src/interfaces/stack.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class StackRepository implements IStackRepository {
|
||||
constructor(
|
||||
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
ImmichZipStream,
|
||||
WatchEvents,
|
||||
} from 'src/interfaces/storage.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class StorageRepository implements IStorageRepository {
|
||||
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
|
||||
|
||||
@@ -3,10 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { SystemMetadata, SystemMetadataEntity } from 'src/entities/system-metadata.entity';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class SystemMetadataRepository implements ISystemMetadataRepository {
|
||||
constructor(
|
||||
|
||||
@@ -4,10 +4,8 @@ import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { TagEntity } from 'src/entities/tag.entity';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { DataSource, In, Repository, TreeRepository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class TagRepository implements ITagRepository {
|
||||
constructor(
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { MetricOptions } from '@opentelemetry/api';
|
||||
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
|
||||
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
|
||||
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
|
||||
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
|
||||
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
|
||||
import { PgInstrumentation } from '@opentelemetry/instrumentation-pg';
|
||||
import { NodeSDK, contextBase, metrics, resources } from '@opentelemetry/sdk-node';
|
||||
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { snakeCase, startCase } from 'lodash';
|
||||
import { MetricService } from 'nestjs-otel';
|
||||
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { MetadataKey } from 'src/enum';
|
||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IMetricGroupRepository, ITelemetryRepository, MetricGroupOptions } from 'src/interfaces/telemetry.interface';
|
||||
|
||||
class MetricGroupRepository implements IMetricGroupRepository {
|
||||
private enabled = false;
|
||||
|
||||
constructor(private metricService: MetricService) {}
|
||||
|
||||
addToCounter(name: string, value: number, options?: MetricOptions): void {
|
||||
if (this.enabled) {
|
||||
this.metricService.getCounter(name, options).add(value);
|
||||
}
|
||||
}
|
||||
|
||||
addToGauge(name: string, value: number, options?: MetricOptions): void {
|
||||
if (this.enabled) {
|
||||
this.metricService.getUpDownCounter(name, options).add(value);
|
||||
}
|
||||
}
|
||||
|
||||
addToHistogram(name: string, value: number, options?: MetricOptions): void {
|
||||
if (this.enabled) {
|
||||
this.metricService.getHistogram(name, options).record(value);
|
||||
}
|
||||
}
|
||||
|
||||
configure(options: MetricGroupOptions): this {
|
||||
this.enabled = options.enabled;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const aggregation = new metrics.ExplicitBucketHistogramAggregation(
|
||||
[0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10_000],
|
||||
true,
|
||||
);
|
||||
|
||||
let instance: NodeSDK | undefined;
|
||||
|
||||
export const bootstrapTelemetry = (port: number) => {
|
||||
if (instance) {
|
||||
throw new Error('OpenTelemetry SDK already started');
|
||||
}
|
||||
instance = new NodeSDK({
|
||||
resource: new resources.Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: `immich`,
|
||||
[SemanticResourceAttributes.SERVICE_VERSION]: serverVersion.toString(),
|
||||
}),
|
||||
metricReader: new PrometheusExporter({ port }),
|
||||
contextManager: new AsyncLocalStorageContextManager(),
|
||||
instrumentations: [
|
||||
new HttpInstrumentation(),
|
||||
new IORedisInstrumentation(),
|
||||
new NestInstrumentation(),
|
||||
new PgInstrumentation(),
|
||||
],
|
||||
views: [new metrics.View({ aggregation, instrumentName: '*', instrumentUnit: 'ms' })],
|
||||
});
|
||||
|
||||
instance.start();
|
||||
};
|
||||
|
||||
export const teardownTelemetry = async () => {
|
||||
if (instance) {
|
||||
await instance.shutdown();
|
||||
instance = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TelemetryRepository implements ITelemetryRepository {
|
||||
api: MetricGroupRepository;
|
||||
host: MetricGroupRepository;
|
||||
jobs: MetricGroupRepository;
|
||||
repo: MetricGroupRepository;
|
||||
|
||||
constructor(
|
||||
private metricService: MetricService,
|
||||
private reflect: Reflector,
|
||||
@Inject(IConfigRepository) private configRepository: IConfigRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
const { telemetry } = this.configRepository.getEnv();
|
||||
const { apiMetrics, hostMetrics, jobMetrics, repoMetrics } = telemetry;
|
||||
|
||||
this.api = new MetricGroupRepository(metricService).configure({ enabled: apiMetrics });
|
||||
this.host = new MetricGroupRepository(metricService).configure({ enabled: hostMetrics });
|
||||
this.jobs = new MetricGroupRepository(metricService).configure({ enabled: jobMetrics });
|
||||
this.repo = new MetricGroupRepository(metricService).configure({ enabled: repoMetrics });
|
||||
}
|
||||
|
||||
setup({ repositories }: { repositories: ClassConstructor<unknown>[] }) {
|
||||
const { telemetry } = this.configRepository.getEnv();
|
||||
if (!telemetry.enabled || !telemetry.repoMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const Repository of repositories) {
|
||||
const isEnabled = this.reflect.get(MetadataKey.TELEMETRY_ENABLED, Repository) ?? true;
|
||||
if (!isEnabled) {
|
||||
this.logger.debug(`Telemetry disabled for ${Repository.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.wrap(Repository);
|
||||
}
|
||||
}
|
||||
|
||||
private wrap(Repository: ClassConstructor<unknown>) {
|
||||
const className = Repository.name;
|
||||
const descriptors = Object.getOwnPropertyDescriptors(Repository.prototype);
|
||||
const unit = 'ms';
|
||||
|
||||
for (const [propName, descriptor] of Object.entries(descriptors)) {
|
||||
const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor';
|
||||
if (!isMethod) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const method = descriptor.value;
|
||||
const propertyName = snakeCase(String(propName));
|
||||
const metricName = `${snakeCase(className).replaceAll(/_(?=(repository)|(controller)|(provider)|(service)|(module))/g, '.')}.${propertyName}.duration`;
|
||||
|
||||
const histogram = this.metricService.getHistogram(metricName, {
|
||||
prefix: 'immich',
|
||||
description: `The elapsed time in ${unit} for the ${startCase(className)} to ${propertyName.toLowerCase()}`,
|
||||
unit,
|
||||
valueType: contextBase.ValueType.DOUBLE,
|
||||
});
|
||||
|
||||
descriptor.value = function (...args: any[]) {
|
||||
const start = performance.now();
|
||||
const result = method.apply(this, args);
|
||||
|
||||
void Promise.resolve(result)
|
||||
.then(() => histogram.record(performance.now() - start, {}))
|
||||
.catch(() => {
|
||||
// noop
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
copyMetadataFromFunctionToFunction(method, descriptor.value);
|
||||
Object.defineProperty(Repository.prototype, propName, descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
UserListFilter,
|
||||
UserStatsQueryResponse,
|
||||
} from 'src/interfaces/user.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class UserRepository implements IUserRepository {
|
||||
constructor(
|
||||
|
||||
@@ -2,10 +2,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { VersionHistoryEntity } from 'src/entities/version-history.entity';
|
||||
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class VersionHistoryRepository implements IVersionHistoryRepository {
|
||||
constructor(@InjectRepository(VersionHistoryEntity) private repository: Repository<VersionHistoryEntity>) {}
|
||||
|
||||
@@ -537,10 +537,6 @@ describe(AlbumService.name, () => {
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('album.update', {
|
||||
id: 'album-123',
|
||||
updatedBy: authStub.admin.user.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set the thumbnail if the album has one already', async () => {
|
||||
@@ -583,7 +579,7 @@ describe(AlbumService.name, () => {
|
||||
expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('album.update', {
|
||||
id: 'album-123',
|
||||
updatedBy: authStub.user1.user.id,
|
||||
recipientIds: ['admin_id'],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -174,7 +174,13 @@ export class AlbumService extends BaseService {
|
||||
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
|
||||
});
|
||||
|
||||
await this.eventRepository.emit('album.update', { id, updatedBy: auth.user.id });
|
||||
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
|
||||
(userId) => userId !== auth.user.id,
|
||||
);
|
||||
|
||||
if (allUsersExceptUs.length > 0) {
|
||||
await this.eventRepository.emit('album.update', { id, recipientIds: allUsersExceptUs });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -20,7 +20,6 @@ import { IMapRepository } from 'src/interfaces/map.interface';
|
||||
import { IMediaRepository } from 'src/interfaces/media.interface';
|
||||
import { IMemoryRepository } from 'src/interfaces/memory.interface';
|
||||
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
|
||||
import { IMetricRepository } from 'src/interfaces/metric.interface';
|
||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||
import { INotificationRepository } from 'src/interfaces/notification.interface';
|
||||
import { IOAuthRepository } from 'src/interfaces/oauth.interface';
|
||||
@@ -34,6 +33,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
|
||||
import { ITrashRepository } from 'src/interfaces/trash.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
|
||||
@@ -64,7 +64,6 @@ export class BaseService {
|
||||
@Inject(IMediaRepository) protected mediaRepository: IMediaRepository,
|
||||
@Inject(IMemoryRepository) protected memoryRepository: IMemoryRepository,
|
||||
@Inject(IMetadataRepository) protected metadataRepository: IMetadataRepository,
|
||||
@Inject(IMetricRepository) protected metricRepository: IMetricRepository,
|
||||
@Inject(IMoveRepository) protected moveRepository: IMoveRepository,
|
||||
@Inject(INotificationRepository) protected notificationRepository: INotificationRepository,
|
||||
@Inject(IOAuthRepository) protected oauthRepository: IOAuthRepository,
|
||||
@@ -78,6 +77,7 @@ export class BaseService {
|
||||
@Inject(IStorageRepository) protected storageRepository: IStorageRepository,
|
||||
@Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(ITagRepository) protected tagRepository: ITagRepository,
|
||||
@Inject(ITelemetryRepository) protected telemetryRepository: ITelemetryRepository,
|
||||
@Inject(ITrashRepository) protected trashRepository: ITrashRepository,
|
||||
@Inject(IUserRepository) protected userRepository: IUserRepository,
|
||||
@Inject(IVersionHistoryRepository) protected versionRepository: IVersionHistoryRepository,
|
||||
|
||||
@@ -124,7 +124,7 @@ export class JobService extends BaseService {
|
||||
throw new BadRequestException(`Job is already running`);
|
||||
}
|
||||
|
||||
this.metricRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1);
|
||||
this.telemetryRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1);
|
||||
|
||||
switch (name) {
|
||||
case QueueName.VIDEO_CONVERSION: {
|
||||
@@ -197,19 +197,19 @@ export class JobService extends BaseService {
|
||||
}
|
||||
|
||||
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
|
||||
this.metricRepository.jobs.addToGauge(queueMetric, 1);
|
||||
this.telemetryRepository.jobs.addToGauge(queueMetric, 1);
|
||||
|
||||
try {
|
||||
const status = await handler(data);
|
||||
const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`;
|
||||
this.metricRepository.jobs.addToCounter(jobMetric, 1);
|
||||
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
|
||||
if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) {
|
||||
await this.onDone(item);
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Unable to run job handler (${queueName}/${name}): ${error}`, error?.stack, data);
|
||||
} finally {
|
||||
this.metricRepository.jobs.addToGauge(queueMetric, -1);
|
||||
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -907,7 +907,9 @@ describe(LibraryService.name, () => {
|
||||
storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
|
||||
storageMock.checkFileExists.mockResolvedValue(true);
|
||||
|
||||
await expect(sut.update('library-id', { importPaths: ['foo/bar'] })).resolves.toEqual(
|
||||
const cwd = process.cwd();
|
||||
|
||||
await expect(sut.update('library-id', { importPaths: [`${cwd}/foo/bar`] })).resolves.toEqual(
|
||||
mapLibrary(libraryStub.externalLibrary1),
|
||||
);
|
||||
expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' }));
|
||||
@@ -1300,14 +1302,31 @@ describe(LibraryService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect when import path is not absolute', async () => {
|
||||
const cwd = process.cwd();
|
||||
|
||||
await expect(sut.validate('library-id', { importPaths: ['relative/path'] })).resolves.toEqual({
|
||||
importPaths: [
|
||||
{
|
||||
importPath: 'relative/path',
|
||||
isValid: false,
|
||||
message: `Import path must be absolute, try ${cwd}/relative/path`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect when import path is in immich media folder', async () => {
|
||||
storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
|
||||
const validImport = libraryStub.hasImmichPaths.importPaths[1];
|
||||
const cwd = process.cwd();
|
||||
|
||||
const validImport = `${cwd}/${libraryStub.hasImmichPaths.importPaths[1]}`;
|
||||
storageMock.checkFileExists.mockImplementation((importPath) => Promise.resolve(importPath === validImport));
|
||||
|
||||
await expect(
|
||||
sut.validate('library-id', { importPaths: libraryStub.hasImmichPaths.importPaths }),
|
||||
).resolves.toEqual({
|
||||
const pathStubs = libraryStub.hasImmichPaths.importPaths;
|
||||
const importPaths = [pathStubs[0], validImport, pathStubs[2]];
|
||||
|
||||
await expect(sut.validate('library-id', { importPaths })).resolves.toEqual({
|
||||
importPaths: [
|
||||
{
|
||||
importPath: libraryStub.hasImmichPaths.importPaths[0],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { R_OK } from 'node:constants';
|
||||
import path, { basename, parse } from 'node:path';
|
||||
import path, { basename, isAbsolute, parse } from 'node:path';
|
||||
import picomatch from 'picomatch';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
@@ -268,6 +268,11 @@ export class LibraryService extends BaseService {
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (!isAbsolute(importPath)) {
|
||||
validation.message = `Import path must be absolute, try ${path.resolve(importPath)}`;
|
||||
return validation;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await this.storageRepository.stat(importPath);
|
||||
if (!stat.isDirectory()) {
|
||||
|
||||
@@ -1327,7 +1327,6 @@ describe(MediaService.name, () => {
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-strict unofficial',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 12',
|
||||
@@ -1453,7 +1452,6 @@ describe(MediaService.name, () => {
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-strict unofficial',
|
||||
'-g 256',
|
||||
'-v verbose',
|
||||
'-vf format=nv12,hwupload_cuda,scale_cuda=-2:720',
|
||||
@@ -1614,7 +1612,6 @@ describe(MediaService.name, () => {
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-strict unofficial',
|
||||
'-bf 7',
|
||||
'-refs 5',
|
||||
'-g 256',
|
||||
@@ -1800,7 +1797,6 @@ describe(MediaService.name, () => {
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-strict unofficial',
|
||||
'-g 256',
|
||||
'-v verbose',
|
||||
'-vf format=nv12,hwupload,scale_vaapi=-2:720:mode=hq:out_range=pc',
|
||||
@@ -2071,7 +2067,6 @@ describe(MediaService.name, () => {
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-strict unofficial',
|
||||
'-g 256',
|
||||
'-v verbose',
|
||||
'-vf scale_rkrga=-2:720:format=nv12:afbc=1',
|
||||
@@ -2292,6 +2287,22 @@ describe(MediaService.name, () => {
|
||||
|
||||
expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false });
|
||||
});
|
||||
|
||||
it('should process unknown audio stream', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.audioStreamUnknown);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
expect.objectContaining({
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.arrayContaining(['-c:a copy']),
|
||||
twoPass: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSRGB', () => {
|
||||
|
||||
@@ -147,10 +147,10 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer };
|
||||
if (asset.type === AssetType.IMAGE) {
|
||||
generated = await this.generateImageThumbnails(asset);
|
||||
} else if (asset.type === AssetType.VIDEO) {
|
||||
if (asset.type === AssetType.VIDEO || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
||||
generated = await this.generateVideoThumbnails(asset);
|
||||
} else if (asset.type === AssetType.IMAGE) {
|
||||
generated = await this.generateImageThumbnails(asset);
|
||||
} else {
|
||||
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -349,7 +349,9 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
private getMainStream<T extends VideoStreamInfo | AudioStreamInfo>(streams: T[]): T {
|
||||
return streams.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0];
|
||||
return streams
|
||||
.filter((stream) => stream.codecName !== 'unknown')
|
||||
.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0];
|
||||
}
|
||||
|
||||
private getTranscodeTarget(
|
||||
|
||||
@@ -579,7 +579,7 @@ export class MetadataService extends BaseService {
|
||||
|
||||
private getDates(asset: AssetEntity, exifTags: ImmichTags) {
|
||||
const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS);
|
||||
this.logger.debug(`Asset ${asset.id} date time is ${dateTime}`);
|
||||
this.logger.verbose(`Asset ${asset.id} date time is ${dateTime}`);
|
||||
|
||||
// timezone
|
||||
let timeZone = exifTags.tz ?? null;
|
||||
@@ -590,7 +590,7 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
|
||||
if (timeZone) {
|
||||
this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`);
|
||||
this.logger.verbose(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`);
|
||||
} else {
|
||||
this.logger.warn(`Asset ${asset.id} has no time zone information`);
|
||||
}
|
||||
@@ -603,7 +603,7 @@ export class MetadataService extends BaseService {
|
||||
localDateTime = asset.fileCreatedAt;
|
||||
}
|
||||
|
||||
this.logger.debug(`Asset ${asset.id} has a local time of ${localDateTime.toISOString()}`);
|
||||
this.logger.verbose(`Asset ${asset.id} has a local time of ${localDateTime.toISOString()}`);
|
||||
|
||||
let modifyDate = asset.fileModifiedAt;
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { TagService } from 'src/services/tag.service';
|
||||
import { TrashService } from 'src/services/trash.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { otelShutdown } from 'src/utils/instrumentation';
|
||||
|
||||
@Injectable()
|
||||
export class MicroservicesService {
|
||||
@@ -101,8 +100,4 @@ export class MicroservicesService {
|
||||
[JobName.QUEUE_TRASH_EMPTY]: () => this.trashService.handleQueueEmptyTrash(),
|
||||
});
|
||||
}
|
||||
|
||||
async onShutdown() {
|
||||
await otelShutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { IJobRepository, INotifyAlbumUpdateJob, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
@@ -170,10 +170,10 @@ describe(NotificationService.name, () => {
|
||||
|
||||
describe('onAlbumUpdateEvent', () => {
|
||||
it('should queue notify album update event', async () => {
|
||||
await sut.onAlbumUpdate({ id: '', updatedBy: '42' });
|
||||
await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] });
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||
data: { id: '', senderId: '42' },
|
||||
data: { id: 'album', recipientIds: ['42'], delay: 300_000 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -512,34 +512,17 @@ describe(NotificationService.name, () => {
|
||||
|
||||
describe('handleAlbumUpdate', () => {
|
||||
it('should skip if album could not be found', async () => {
|
||||
await expect(sut.handleAlbumUpdate({ id: '', senderId: '' })).resolves.toBe(JobStatus.SKIPPED);
|
||||
await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED);
|
||||
expect(userMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip if owner could not be found', async () => {
|
||||
albumMock.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail);
|
||||
|
||||
await expect(sut.handleAlbumUpdate({ id: '', senderId: '' })).resolves.toBe(JobStatus.SKIPPED);
|
||||
await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED);
|
||||
expect(systemMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter out the sender', async () => {
|
||||
albumMock.getById.mockResolvedValue({
|
||||
...albumStub.emptyWithValidThumbnail,
|
||||
albumUsers: [
|
||||
{ user: { id: userStub.user1.id } } as AlbumUserEntity,
|
||||
{ user: { id: userStub.user2.id } } as AlbumUserEntity,
|
||||
],
|
||||
});
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
|
||||
await sut.handleAlbumUpdate({ id: '', senderId: userStub.user1.id });
|
||||
expect(userMock.get).not.toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||
expect(userMock.get).toHaveBeenCalledWith(userStub.user2.id, { withDeleted: false });
|
||||
expect(notificationMock.renderEmail).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should skip recipient that could not be looked up', async () => {
|
||||
albumMock.getById.mockResolvedValue({
|
||||
...albumStub.emptyWithValidThumbnail,
|
||||
@@ -548,7 +531,7 @@ describe(NotificationService.name, () => {
|
||||
userMock.get.mockResolvedValueOnce(userStub.user1);
|
||||
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
|
||||
await sut.handleAlbumUpdate({ id: '', senderId: '' });
|
||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
||||
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||
expect(notificationMock.renderEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -571,7 +554,7 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
|
||||
await sut.handleAlbumUpdate({ id: '', senderId: '' });
|
||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
||||
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||
expect(notificationMock.renderEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -594,7 +577,7 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
|
||||
await sut.handleAlbumUpdate({ id: '', senderId: '' });
|
||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
||||
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||
expect(notificationMock.renderEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -607,11 +590,24 @@ describe(NotificationService.name, () => {
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
|
||||
await sut.handleAlbumUpdate({ id: '', senderId: '' });
|
||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
||||
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||
expect(notificationMock.renderEmail).toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add new recipients for new images if job is already queued', async () => {
|
||||
jobMock.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob);
|
||||
await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||
data: {
|
||||
id: '1',
|
||||
delay: 300_000,
|
||||
recipientIds: ['1', '2', '3', '4'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSendEmail', () => {
|
||||
|
||||
@@ -5,9 +5,11 @@ import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IEmailJob,
|
||||
IEntityJob,
|
||||
INotifyAlbumInviteJob,
|
||||
INotifyAlbumUpdateJob,
|
||||
INotifySignupJob,
|
||||
JobItem,
|
||||
JobName,
|
||||
JobStatus,
|
||||
} from 'src/interfaces/job.interface';
|
||||
@@ -21,6 +23,8 @@ import { getPreferences } from 'src/utils/preferences';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService extends BaseService {
|
||||
private static albumUpdateEmailDelayMs = 300_000;
|
||||
|
||||
@OnEvent({ name: 'config.update' })
|
||||
onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) {
|
||||
this.eventRepository.clientBroadcast('on_config_update');
|
||||
@@ -100,8 +104,30 @@ export class NotificationService extends BaseService {
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'album.update' })
|
||||
async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) {
|
||||
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } });
|
||||
async onAlbumUpdate({ id, recipientIds }: ArgOf<'album.update'>) {
|
||||
// if recipientIds is empty, album likely only has one user part of it, don't queue notification if so
|
||||
if (recipientIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job: JobItem = {
|
||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||
data: { id, recipientIds, delay: NotificationService.albumUpdateEmailDelayMs },
|
||||
};
|
||||
|
||||
const previousJobData = await this.jobRepository.removeJob(id, JobName.NOTIFY_ALBUM_UPDATE);
|
||||
if (previousJobData && this.isAlbumUpdateJob(previousJobData)) {
|
||||
for (const id of previousJobData.recipientIds) {
|
||||
if (!recipientIds.includes(id)) {
|
||||
recipientIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.jobRepository.queue(job);
|
||||
}
|
||||
|
||||
private isAlbumUpdateJob(job: IEntityJob): job is INotifyAlbumUpdateJob {
|
||||
return 'recipientIds' in job;
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'album.invite' })
|
||||
@@ -228,7 +254,7 @@ export class NotificationService extends BaseService {
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
async handleAlbumUpdate({ id, senderId }: INotifyAlbumUpdateJob) {
|
||||
async handleAlbumUpdate({ id, recipientIds }: INotifyAlbumUpdateJob) {
|
||||
const album = await this.albumRepository.getById(id, { withAssets: false });
|
||||
|
||||
if (!album) {
|
||||
@@ -240,7 +266,9 @@ export class NotificationService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) => user.id !== senderId);
|
||||
const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) =>
|
||||
recipientIds.includes(user.id),
|
||||
);
|
||||
const attachment = await this.getAlbumThumbnailAttachment(album);
|
||||
|
||||
const { server } = await this.getConfig({ withCache: false });
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
|
||||
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
|
||||
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
|
||||
import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
|
||||
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
|
||||
import { PgInstrumentation } from '@opentelemetry/instrumentation-pg';
|
||||
import { NodeSDK, contextBase, metrics, resources } from '@opentelemetry/sdk-node';
|
||||
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
||||
import { snakeCase, startCase } from 'lodash';
|
||||
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { DecorateAll } from 'src/decorators';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
|
||||
const aggregation = new metrics.ExplicitBucketHistogramAggregation(
|
||||
[0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10_000],
|
||||
true,
|
||||
);
|
||||
|
||||
const { telemetry } = new ConfigRepository().getEnv();
|
||||
|
||||
let otelSingleton: NodeSDK | undefined;
|
||||
|
||||
export const otelStart = (port: number) => {
|
||||
if (otelSingleton) {
|
||||
throw new Error('OpenTelemetry SDK already started');
|
||||
}
|
||||
otelSingleton = new NodeSDK({
|
||||
resource: new resources.Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: `immich`,
|
||||
[SemanticResourceAttributes.SERVICE_VERSION]: serverVersion.toString(),
|
||||
}),
|
||||
metricReader: new PrometheusExporter({ port }),
|
||||
contextManager: new AsyncLocalStorageContextManager(),
|
||||
instrumentations: [
|
||||
new HttpInstrumentation(),
|
||||
new IORedisInstrumentation(),
|
||||
new NestInstrumentation(),
|
||||
new PgInstrumentation(),
|
||||
],
|
||||
views: [new metrics.View({ aggregation, instrumentName: '*', instrumentUnit: 'ms' })],
|
||||
});
|
||||
otelSingleton.start();
|
||||
};
|
||||
|
||||
export const otelShutdown = async () => {
|
||||
if (otelSingleton) {
|
||||
await otelSingleton.shutdown();
|
||||
otelSingleton = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
function ExecutionTimeHistogram({
|
||||
description,
|
||||
unit = 'ms',
|
||||
valueType = contextBase.ValueType.DOUBLE,
|
||||
}: contextBase.MetricOptions = {}) {
|
||||
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
|
||||
if (!telemetry.repoMetrics || process.env.OTEL_SDK_DISABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const method = descriptor.value;
|
||||
const className = target.constructor.name as string;
|
||||
const propertyName = String(propertyKey);
|
||||
const metricName = `${snakeCase(className).replaceAll(/_(?=(repository)|(controller)|(provider)|(service)|(module))/g, '.')}.${snakeCase(propertyName)}.duration`;
|
||||
|
||||
const metricDescription =
|
||||
description ??
|
||||
`The elapsed time in ${unit} for the ${startCase(className)} to ${startCase(propertyName).toLowerCase()}`;
|
||||
|
||||
let histogram: contextBase.Histogram | undefined;
|
||||
|
||||
descriptor.value = function (...args: any[]) {
|
||||
const start = performance.now();
|
||||
const result = method.apply(this, args);
|
||||
|
||||
void Promise.resolve(result)
|
||||
.then(() => {
|
||||
const end = performance.now();
|
||||
if (!histogram) {
|
||||
histogram = contextBase.metrics
|
||||
.getMeter('immich')
|
||||
.createHistogram(metricName, { description: metricDescription, unit, valueType });
|
||||
}
|
||||
histogram.record(end - start, {});
|
||||
})
|
||||
.catch(() => {
|
||||
// noop
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
copyMetadataFromFunctionToFunction(method, descriptor.value);
|
||||
};
|
||||
}
|
||||
|
||||
export const Instrumentation = () => DecorateAll(ExecutionTimeHistogram());
|
||||
@@ -118,7 +118,6 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
'-fps_mode passthrough',
|
||||
// explicitly selects the video stream instead of leaving it up to FFmpeg
|
||||
`-map 0:${videoStream.index}`,
|
||||
'-strict unofficial',
|
||||
];
|
||||
|
||||
if (audioStream) {
|
||||
|
||||
@@ -11,16 +11,18 @@ import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { isStartUpError } from 'src/services/storage.service';
|
||||
import { otelStart } from 'src/utils/instrumentation';
|
||||
import { useSwagger } from 'src/utils/misc';
|
||||
|
||||
async function bootstrap() {
|
||||
process.title = 'immich-api';
|
||||
|
||||
const { telemetry, network } = new ConfigRepository().getEnv();
|
||||
otelStart(telemetry.apiPort);
|
||||
if (telemetry.enabled) {
|
||||
bootstrapTelemetry(telemetry.apiPort);
|
||||
}
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
|
||||
const logger = await app.resolve<ILoggerRepository>(ILoggerRepository);
|
||||
|
||||
@@ -6,12 +6,14 @@ import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
||||
import { isStartUpError } from 'src/services/storage.service';
|
||||
import { otelStart } from 'src/utils/instrumentation';
|
||||
|
||||
export async function bootstrap() {
|
||||
const { telemetry } = new ConfigRepository().getEnv();
|
||||
otelStart(telemetry.microservicesPort);
|
||||
if (telemetry.enabled) {
|
||||
bootstrapTelemetry(telemetry.microservicesPort);
|
||||
}
|
||||
|
||||
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
|
||||
const logger = await app.resolve(ILoggerRepository);
|
||||
|
||||
@@ -4,6 +4,7 @@ echo "Initializing Immich $IMMICH_SOURCE_REF"
|
||||
|
||||
lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
|
||||
export LD_PRELOAD="$lib_path"
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib"
|
||||
|
||||
read_file_and_export() {
|
||||
if [ -n "${!1}" ]; then
|
||||
|
||||
Vendored
+1
-1
@@ -68,7 +68,7 @@ export const libraryStub = {
|
||||
assets: [],
|
||||
owner: userStub.admin,
|
||||
ownerId: 'user-id',
|
||||
importPaths: ['upload/thumbs', '/xyz', 'upload/library'],
|
||||
importPaths: ['upload/thumbs', 'xyz', 'upload/library'],
|
||||
createdAt: new Date('2023-01-01'),
|
||||
updatedAt: new Date('2023-01-01'),
|
||||
refreshedAt: null,
|
||||
|
||||
Vendored
+7
@@ -154,6 +154,13 @@ export const probeStub = {
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'aac', frameCount: 100 }],
|
||||
}),
|
||||
audioStreamUnknown: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [
|
||||
{ index: 0, codecName: 'aac', frameCount: 100 },
|
||||
{ index: 1, codecName: 'unknown', frameCount: 200 },
|
||||
],
|
||||
}),
|
||||
matroskaContainer: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
format: {
|
||||
|
||||
Vendored
+1
@@ -7,6 +7,7 @@ export const userStub = {
|
||||
...authStub.admin.user,
|
||||
password: 'admin_password',
|
||||
name: 'admin_name',
|
||||
id: 'admin_id',
|
||||
storageLabel: 'admin',
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
|
||||
@@ -16,5 +16,6 @@ export const newJobRepositoryMock = (): Mocked<IJobRepository> => {
|
||||
getJobCounts: vitest.fn(),
|
||||
clear: vitest.fn(),
|
||||
waitForQueueCompletion: vitest.fn(),
|
||||
removeJob: vitest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { IMetricRepository } from 'src/interfaces/metric.interface';
|
||||
import { Mocked, vitest } from 'vitest';
|
||||
|
||||
export const newMetricRepositoryMock = (): Mocked<IMetricRepository> => {
|
||||
return {
|
||||
api: {
|
||||
addToCounter: vitest.fn(),
|
||||
addToGauge: vitest.fn(),
|
||||
addToHistogram: vitest.fn(),
|
||||
configure: vitest.fn(),
|
||||
},
|
||||
host: {
|
||||
addToCounter: vitest.fn(),
|
||||
addToGauge: vitest.fn(),
|
||||
addToHistogram: vitest.fn(),
|
||||
configure: vitest.fn(),
|
||||
},
|
||||
jobs: {
|
||||
addToCounter: vitest.fn(),
|
||||
addToGauge: vitest.fn(),
|
||||
addToHistogram: vitest.fn(),
|
||||
configure: vitest.fn(),
|
||||
},
|
||||
repo: {
|
||||
addToCounter: vitest.fn(),
|
||||
addToGauge: vitest.fn(),
|
||||
addToHistogram: vitest.fn(),
|
||||
configure: vitest.fn(),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
|
||||
import { Mocked, vitest } from 'vitest';
|
||||
|
||||
const newMetricGroupMock = () => {
|
||||
return {
|
||||
addToCounter: vitest.fn(),
|
||||
addToGauge: vitest.fn(),
|
||||
addToHistogram: vitest.fn(),
|
||||
configure: vitest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
export const newTelemetryRepositoryMock = (): Mocked<ITelemetryRepository> => {
|
||||
return {
|
||||
setup: vitest.fn(),
|
||||
api: newMetricGroupMock(),
|
||||
host: newMetricGroupMock(),
|
||||
jobs: newMetricGroupMock(),
|
||||
repo: newMetricGroupMock(),
|
||||
};
|
||||
};
|
||||
@@ -20,7 +20,6 @@ import { newMapRepositoryMock } from 'test/repositories/map.repository.mock';
|
||||
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
|
||||
import { newMemoryRepositoryMock } from 'test/repositories/memory.repository.mock';
|
||||
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
|
||||
import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock';
|
||||
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
|
||||
import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock';
|
||||
import { newOAuthRepositoryMock } from 'test/repositories/oauth.repository.mock';
|
||||
@@ -34,6 +33,7 @@ import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock'
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
|
||||
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
||||
import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock';
|
||||
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
||||
import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock';
|
||||
@@ -73,7 +73,6 @@ export const newTestService = <T extends BaseService>(
|
||||
const mediaMock = newMediaRepositoryMock();
|
||||
const memoryMock = newMemoryRepositoryMock();
|
||||
const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked<IMetadataRepository>;
|
||||
const metricMock = newMetricRepositoryMock();
|
||||
const moveMock = newMoveRepositoryMock();
|
||||
const notificationMock = newNotificationRepositoryMock();
|
||||
const oauthMock = newOAuthRepositoryMock();
|
||||
@@ -87,6 +86,7 @@ export const newTestService = <T extends BaseService>(
|
||||
const storageMock = newStorageRepositoryMock();
|
||||
const systemMock = newSystemMetadataRepositoryMock();
|
||||
const tagMock = newTagRepositoryMock();
|
||||
const telemetryMock = newTelemetryRepositoryMock();
|
||||
const trashMock = newTrashRepositoryMock();
|
||||
const userMock = newUserRepositoryMock();
|
||||
const versionHistoryMock = newVersionHistoryRepositoryMock();
|
||||
@@ -112,7 +112,6 @@ export const newTestService = <T extends BaseService>(
|
||||
mediaMock,
|
||||
memoryMock,
|
||||
metadataMock,
|
||||
metricMock,
|
||||
moveMock,
|
||||
notificationMock,
|
||||
oauthMock,
|
||||
@@ -126,6 +125,7 @@ export const newTestService = <T extends BaseService>(
|
||||
storageMock,
|
||||
systemMock,
|
||||
tagMock,
|
||||
telemetryMock,
|
||||
trashMock,
|
||||
userMock,
|
||||
versionHistoryMock,
|
||||
@@ -153,7 +153,6 @@ export const newTestService = <T extends BaseService>(
|
||||
mediaMock,
|
||||
memoryMock,
|
||||
metadataMock,
|
||||
metricMock,
|
||||
moveMock,
|
||||
notificationMock,
|
||||
oauthMock,
|
||||
@@ -167,6 +166,7 @@ export const newTestService = <T extends BaseService>(
|
||||
storageMock,
|
||||
systemMock,
|
||||
tagMock,
|
||||
telemetryMock,
|
||||
trashMock,
|
||||
userMock,
|
||||
versionHistoryMock,
|
||||
|
||||
Reference in New Issue
Block a user