From 3fb5adb31c10a942926143c277271bbe99890a91 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 18 Oct 2024 14:50:32 -0400 Subject: [PATCH 01/36] refactor(server): rename metrics to telemetry (#13584) --- ...ic.interface.ts => telemetry.interface.ts} | 4 +-- server/src/repositories/index.ts | 6 ++-- ....repository.ts => telemetry.repository.ts} | 14 +++++---- server/src/services/base.service.ts | 4 +-- server/src/services/job.service.ts | 8 ++--- .../repositories/metric.repository.mock.ts | 31 ------------------- .../repositories/telemetry.repository.mock.ts | 20 ++++++++++++ server/test/utils.ts | 8 ++--- 8 files changed, 43 insertions(+), 52 deletions(-) rename server/src/interfaces/{metric.interface.ts => telemetry.interface.ts} (84%) rename server/src/repositories/{metric.repository.ts => telemetry.repository.ts} (80%) delete mode 100644 server/test/repositories/metric.repository.mock.ts create mode 100644 server/test/repositories/telemetry.repository.mock.ts diff --git a/server/src/interfaces/metric.interface.ts b/server/src/interfaces/telemetry.interface.ts similarity index 84% rename from server/src/interfaces/metric.interface.ts rename to server/src/interfaces/telemetry.interface.ts index a87a849833..070014f2e0 100644 --- a/server/src/interfaces/metric.interface.ts +++ b/server/src/interfaces/telemetry.interface.ts @@ -1,6 +1,6 @@ import { MetricOptions } from '@opentelemetry/api'; -export const IMetricRepository = 'IMetricRepository'; +export const ITelemetryRepository = 'ITelemetryRepository'; export interface MetricGroupOptions { enabled: boolean; @@ -13,7 +13,7 @@ export interface IMetricGroupRepository { configure(options: MetricGroupOptions): this; } -export interface IMetricRepository { +export interface ITelemetryRepository { api: IMetricGroupRepository; host: IMetricGroupRepository; jobs: IMetricGroupRepository; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 5bf08d0d78..94a0212204 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -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 }, diff --git a/server/src/repositories/metric.repository.ts b/server/src/repositories/telemetry.repository.ts similarity index 80% rename from server/src/repositories/metric.repository.ts rename to server/src/repositories/telemetry.repository.ts index b59bcf9ed1..d1dc66ae85 100644 --- a/server/src/repositories/metric.repository.ts +++ b/server/src/repositories/telemetry.repository.ts @@ -2,7 +2,7 @@ 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'; +import { IMetricGroupRepository, ITelemetryRepository, MetricGroupOptions } from 'src/interfaces/telemetry.interface'; class MetricGroupRepository implements IMetricGroupRepository { private enabled = false; @@ -34,7 +34,7 @@ class MetricGroupRepository implements IMetricGroupRepository { } @Injectable() -export class MetricRepository implements IMetricRepository { +export class TelemetryRepository implements ITelemetryRepository { api: MetricGroupRepository; host: MetricGroupRepository; jobs: MetricGroupRepository; @@ -42,9 +42,11 @@ export class MetricRepository implements IMetricRepository { 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 }); + 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 }); } } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 2bb717b45b..441a81cf91 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -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, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 971509447f..46771ff046 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -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); } }); } diff --git a/server/test/repositories/metric.repository.mock.ts b/server/test/repositories/metric.repository.mock.ts deleted file mode 100644 index e2c3e2aac1..0000000000 --- a/server/test/repositories/metric.repository.mock.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { IMetricRepository } from 'src/interfaces/metric.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newMetricRepositoryMock = (): Mocked => { - 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(), - }, - }; -}; diff --git a/server/test/repositories/telemetry.repository.mock.ts b/server/test/repositories/telemetry.repository.mock.ts new file mode 100644 index 0000000000..737463065c --- /dev/null +++ b/server/test/repositories/telemetry.repository.mock.ts @@ -0,0 +1,20 @@ +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 => { + return { + api: newMetricGroupMock(), + host: newMetricGroupMock(), + jobs: newMetricGroupMock(), + repo: newMetricGroupMock(), + }; +}; diff --git a/server/test/utils.ts b/server/test/utils.ts index 3b7e80994d..9a40a22c2c 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -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 = ( const mediaMock = newMediaRepositoryMock(); const memoryMock = newMemoryRepositoryMock(); const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked; - const metricMock = newMetricRepositoryMock(); const moveMock = newMoveRepositoryMock(); const notificationMock = newNotificationRepositoryMock(); const oauthMock = newOAuthRepositoryMock(); @@ -87,6 +86,7 @@ export const newTestService = ( 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 = ( mediaMock, memoryMock, metadataMock, - metricMock, moveMock, notificationMock, oauthMock, @@ -126,6 +125,7 @@ export const newTestService = ( storageMock, systemMock, tagMock, + telemetryMock, trashMock, userMock, versionHistoryMock, @@ -153,7 +153,6 @@ export const newTestService = ( mediaMock, memoryMock, metadataMock, - metricMock, moveMock, notificationMock, oauthMock, @@ -167,6 +166,7 @@ export const newTestService = ( storageMock, systemMock, tagMock, + telemetryMock, trashMock, userMock, versionHistoryMock, From e1e3ae811dff92f0af2d19a97e6b54e669aa6398 Mon Sep 17 00:00:00 2001 From: akara <55230837+richeyphu@users.noreply.github.com> Date: Sat, 19 Oct 2024 02:41:32 +0700 Subject: [PATCH 02/36] chore(docs): add Thai README (#13591) * chore(docs): add Thai README * chore(docs): add links to Thai README --- README.md | 34 ++++----- readme_i18n/README_ar_JO.md | 1 + readme_i18n/README_ca_ES.md | 1 + readme_i18n/README_de_DE.md | 1 + readme_i18n/README_es_ES.md | 1 + readme_i18n/README_fr_FR.md | 1 + readme_i18n/README_it_IT.md | 1 + readme_i18n/README_ja_JP.md | 1 + readme_i18n/README_ko_KR.md | 1 + readme_i18n/README_nl_NL.md | 1 + readme_i18n/README_pt_BR.md | 1 + readme_i18n/README_ru_RU.md | 1 + readme_i18n/README_sv_SE.md | 1 + readme_i18n/README_th_TH.md | 134 ++++++++++++++++++++++++++++++++++++ readme_i18n/README_tr_TR.md | 1 + readme_i18n/README_vi_VN.md | 1 + readme_i18n/README_zh_CN.md | 3 +- 17 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 readme_i18n/README_th_TH.md diff --git a/README.md b/README.md index 5c4b9c39ed..7ad539c4cd 100644 --- a/README.md +++ b/README.md @@ -17,24 +17,24 @@
+

- -Català -Español -Français -Italiano -日本語 -한국어 -Deutsch -Nederlands -Türkçe -中文 -Русский -Português Brasileiro -Svenska -العربية -Tiếng Việt - + Català + Español + Français + Italiano + 日本語 + 한국어 + Deutsch + Nederlands + Türkçe + 中文 + Русский + Português Brasileiro + Svenska + العربية + Tiếng Việt + ภาษาไทย

## Disclaimer diff --git a/readme_i18n/README_ar_JO.md b/readme_i18n/README_ar_JO.md index 7df39d226b..8fa4ac1195 100644 --- a/readme_i18n/README_ar_JO.md +++ b/readme_i18n/README_ar_JO.md @@ -32,6 +32,7 @@ Русский Português Brasileiro Svenska + ภาษาไทย

## تنصل diff --git a/readme_i18n/README_ca_ES.md b/readme_i18n/README_ca_ES.md index ed14649e0a..66a8b584fd 100644 --- a/readme_i18n/README_ca_ES.md +++ b/readme_i18n/README_ca_ES.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Avís legal diff --git a/readme_i18n/README_de_DE.md b/readme_i18n/README_de_DE.md index 7a59e3444e..d6c69106f3 100644 --- a/readme_i18n/README_de_DE.md +++ b/readme_i18n/README_de_DE.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Warnung diff --git a/readme_i18n/README_es_ES.md b/readme_i18n/README_es_ES.md index 726a504526..0b0dbf919d 100644 --- a/readme_i18n/README_es_ES.md +++ b/readme_i18n/README_es_ES.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Advertencia diff --git a/readme_i18n/README_fr_FR.md b/readme_i18n/README_fr_FR.md index da52fe28a6..e2f979d254 100644 --- a/readme_i18n/README_fr_FR.md +++ b/readme_i18n/README_fr_FR.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Clause de non-responsabilité diff --git a/readme_i18n/README_it_IT.md b/readme_i18n/README_it_IT.md index 1523143f06..7208df7e24 100644 --- a/readme_i18n/README_it_IT.md +++ b/readme_i18n/README_it_IT.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Declino di responsabilità diff --git a/readme_i18n/README_ja_JP.md b/readme_i18n/README_ja_JP.md index 98ff8e68d9..828afa9812 100644 --- a/readme_i18n/README_ja_JP.md +++ b/readme_i18n/README_ja_JP.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## 免責事項 diff --git a/readme_i18n/README_ko_KR.md b/readme_i18n/README_ko_KR.md index 66df040d75..8b280e0a9b 100644 --- a/readme_i18n/README_ko_KR.md +++ b/readme_i18n/README_ko_KR.md @@ -33,6 +33,7 @@ Português Brasileiro Svenska العربية +ภาษาไทย

diff --git a/readme_i18n/README_nl_NL.md b/readme_i18n/README_nl_NL.md index 1c877d9d3e..e1cf6d66f5 100644 --- a/readme_i18n/README_nl_NL.md +++ b/readme_i18n/README_nl_NL.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Disclaimer diff --git a/readme_i18n/README_pt_BR.md b/readme_i18n/README_pt_BR.md index 51ea8238da..5468ebb4c4 100644 --- a/readme_i18n/README_pt_BR.md +++ b/readme_i18n/README_pt_BR.md @@ -34,6 +34,7 @@ Svenska العربية Tiếng Việt +ภาษาไทย

diff --git a/readme_i18n/README_ru_RU.md b/readme_i18n/README_ru_RU.md index 11a2a34f33..0ff3e3f08f 100644 --- a/readme_i18n/README_ru_RU.md +++ b/readme_i18n/README_ru_RU.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Предупреждение diff --git a/readme_i18n/README_sv_SE.md b/readme_i18n/README_sv_SE.md index 3673eab57c..29706acb55 100644 --- a/readme_i18n/README_sv_SE.md +++ b/readme_i18n/README_sv_SE.md @@ -33,6 +33,7 @@ Русский Português Brasileiro العربية + ภาษาไทย

## Ansvarsfriskrivning diff --git a/readme_i18n/README_th_TH.md b/readme_i18n/README_th_TH.md new file mode 100644 index 0000000000..6a6b70d435 --- /dev/null +++ b/readme_i18n/README_th_TH.md @@ -0,0 +1,134 @@ +

+
+ License: AGPLv3 + + Discord + +
+
+

+ +

+ +

+ +

โซลูชันการจัดการภาพถ่ายและวิดีโอแบบโฮสต์เองที่มีประสิทธิภาพสูง

+
+ + + + +
+ +

+ English + Català + Español + Français + Italiano + 日本語 + 한국어 + Deutsch + Nederlands + Türkçe + 中文 + Русский + Português Brasileiro + Svenska + العربية + Tiếng Việt +

+ +## ข้อจำกัดความรับผิดชอบ + +- ⚠️ โพรเจกต์นี้กำลังอยู่ระหว่างการพัฒนา**ที่มีการเปลี่ยนแปลงบ่อยมาก** +- ⚠️ คาดว่าจะมีข้อผิดพลาดและการเปลี่ยนแปลงที่ส่งผลเสีย +- ⚠️ **ห้ามใช้แอปนี้เป็นวิธีการเดียวในการจัดเก็บภาพถ่ายและวิดีโอของคุณ** +- ⚠️ ปฏิบัติตามแผนการสำรองข้อมูลแบบ [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) สำหรับภาพถ่ายและวิดีโอที่สำคัญของคุณอยู่เสมอ! + +> [!NOTE] +> คุณสามารถหาคู่มือหลัก รวมถึงคู่มือการติดตั้ง ได้ที่ https://immich.app/ + +## ลิงก์ + +- [คู่มือ](https://immich.app/docs) +- [เกี่ยวกับ](https://immich.app/docs/overview/introduction) +- [การติดตั้ง](https://immich.app/docs/install/requirements) +- [โรดแมป](https://immich.app/roadmap) +- [สาธิต](#สาธิต) +- [คุณสมบัติ](#คุณสมบัติ) +- [การแปลภาษา](https://immich.app/docs/developer/translations) +- [สนับสนุนโพรเจกต์](https://immich.app/docs/overview/support-the-project) + +## สาธิต + +เข้าถึงการสาธิตได้ [ที่นี่](https://demo.immich.app) โดยการสาธิตนี้ทำงานบน Oracle VM Free-tier ตั้งอยู่ที่อัมสเตอร์ดัม ใช้ซีพียู ARM64 quad-core 2.4Ghz และแรม 24GB + +สำหรับแอปมือถือ คุณสามารถใช้ `https://demo.immich.app/api` เป็น `Server Endpoint URL` + +### ข้อมูลการเข้าสู่ระบบ + +| อีเมล | รหัสผ่าน | +| --------------- | -------- | +| demo@immich.app | demo | + +## คุณสมบัติ + +| คุณสมบัติ | มือถือ | เว็บ | +| :----------------------------------------- | ------ | ------ | +| อัปโหลดและดูวิดีโอและภาพถ่าย | ใช่ | ใช่ | +| การสำรองข้อมูลอัตโนมัติเมื่อเปิดแอป | ใช่ | N/A | +| ป้องกันการซ้ำซ้อนของไฟล์ | ใช่ | ใช่ | +| เลือกอัลบั้มสำหรับสำรองข้อมูล | ใช่ | N/A | +| ดาวน์โหลดภาพถ่ายและวิดีโอไปยังอุปกรณ์ | ใช่ | ใช่ | +| รองรับผู้ใช้หลายคน | ใช่ | ใช่ | +| อัลบั้มและอัลบั้มแชร์ | ใช่ | ใช่ | +| แถบเลื่อนแบบลากได้ | ใช่ | ใช่ | +| รองรับรูปแบบไฟล์ RAW | ใช่ | ใช่ | +| ดูข้อมูลเมตา (EXIF, แผนที่) | ใช่ | ใช่ | +| ค้นหาจากข้อมูลเมตา วัตถุ ใบหน้า และ CLIP | ใช่ | ใช่ | +| ฟังก์ชันการจัดการผู้ดูแลระบบ | ไม่ใช่ | ใช่ | +| การสำรองข้อมูลพื้นหลัง | ใช่ | N/A | +| การเลื่อนแบบเสมือน | ใช่ | ใช่ | +| รองรับ OAuth | ใช่ | ใช่ | +| คีย์ API | N/A | ใช่ | +| การสำรองและเล่น LivePhoto/MotionPhoto | ใช่ | ใช่ | +| รองรับการแสดงภาพ 360 องศา | ไม่ใช่ | ใช่ | +| โครงสร้างการจัดเก็บข้อมูลที่ผู้ใช้กำหนดเอง | ใช่ | ใช่ | +| การแชร์สาธารณะ | ใช่ | ใช่ | +| การจัดเก็บและรายการโปรด | ใช่ | ใช่ | +| แผนที่ทั่วโลก | ใช่ | ใช่ | +| การแชร์กับคู่หู | ใช่ | ใช่ | +| การจดจำใบหน้าและการจัดกลุ่ม | ใช่ | ใช่ | +| ความทรงจำ (x ปีที่แล้ว) | ใช่ | ใช่ | +| รองรับแบบออฟไลน์ | ใช่ | ไม่ใช่ | +| แกลเลอรีแบบอ่านอย่างเดียว | ใช่ | ใช่ | +| ภาพถ่ายซ้อนกัน | ใช่ | ใช่ | + +## การแปลภาษา + +อ่านเพิ่มเติมเกี่ยวกับการแปลภาษา [ที่นี่](https://immich.app/docs/developer/translations) + + + สถานะการแปล + + +## กิจกรรมของคลังเก็บข้อมูล + +![กิจกรรม](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "ภาพการวิเคราะห์ของ Repobeats") + +## ประวัติการให้ดาว + + + + + + แผนภูมิประวัติการให้ดาว + + + +## ผู้ร่วมพัฒนา + + + + diff --git a/readme_i18n/README_tr_TR.md b/readme_i18n/README_tr_TR.md index f95d914880..6bf23be5f8 100644 --- a/readme_i18n/README_tr_TR.md +++ b/readme_i18n/README_tr_TR.md @@ -32,6 +32,7 @@ Português Brasileiro Svenska العربية + ภาษาไทย

## Feragatname diff --git a/readme_i18n/README_vi_VN.md b/readme_i18n/README_vi_VN.md index 7ec4b9c948..69d7a151be 100644 --- a/readme_i18n/README_vi_VN.md +++ b/readme_i18n/README_vi_VN.md @@ -35,6 +35,7 @@ Svenska العربية Tiếng Việt +ภาษาไทย

diff --git a/readme_i18n/README_zh_CN.md b/readme_i18n/README_zh_CN.md index 6355cd65ed..380dc25992 100644 --- a/readme_i18n/README_zh_CN.md +++ b/readme_i18n/README_zh_CN.md @@ -36,7 +36,8 @@ Português Brasileiro Svenska العربية - + ภาษาไทย +

## 免责声明 From 76c0b964ebcbd8b35f158b3af40f1e4a6ce34cca Mon Sep 17 00:00:00 2001 From: Christian Koch Date: Fri, 18 Oct 2024 21:43:48 +0200 Subject: [PATCH 03/36] chore(docs): update _storage-template.md (#13578) Update _storage-template.md The example for the {{if}} was a little bit confusing. Just a recommendation --- docs/docs/partials/_storage-template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/partials/_storage-template.md b/docs/docs/partials/_storage-template.md index b6dcd5ad77..0c668d0a3e 100644 --- a/docs/docs/partials/_storage-template.md +++ b/docs/docs/partials/_storage-template.md @@ -31,5 +31,5 @@ Immich also provides a mechanism to migrate between templates so that if the tem If you want to store assets in album folders, but you also have assets that do not belong to any album, you can use `{{#if album}}`, `{{else}}` and `{{/if}}` to create a conditional statement. For example, the following template will store assets in album folders if they belong to an album, and in a folder named "Other/Month" if they do not belong to an album: ``` -{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}} +{{y}}/{{#if album}}{{album}}{{else}}Other{{/if}}/{{MM}}/{{filename}} ``` From 4a2a7b7735980921d47a8b816757bbc91443c6ce Mon Sep 17 00:00:00 2001 From: Hayden Date: Fri, 18 Oct 2024 13:51:34 -0600 Subject: [PATCH 04/36] feat(server): wait five minutes before sending email on new album item (#12223) Album update jobs will now wait five minutes to send. If a new image is added while that job is pending, the old job will be cancelled, and a new one will be enqueued for a minute. This is to prevent a flood of notifications by dragging in images directly to the album, which adds them to the album one at a time. Album updates now include a list of users to email, which is generally everybody except the updater. If somebody else updates the album within that minute, both people will get an album update email in a minute, as they both added images and the other should be notified. --- server/src/interfaces/event.interface.ts | 2 +- server/src/interfaces/job.interface.ts | 10 +++- server/src/repositories/job.repository.ts | 21 +++++++- server/src/services/album.service.spec.ts | 6 +-- server/src/services/album.service.ts | 8 +++- .../src/services/notification.service.spec.ts | 48 +++++++++---------- server/src/services/notification.service.ts | 36 ++++++++++++-- server/test/fixtures/user.stub.ts | 1 + .../test/repositories/job.repository.mock.ts | 1 + 9 files changed, 93 insertions(+), 40 deletions(-) diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 7ea48faf53..40efaf150c 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -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 diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index aa3090675e..82176ffa93 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -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; getJobCounts(name: QueueName): Promise; waitForQueueCompletion(...queues: QueueName[]): Promise; + removeJob(jobId: string, name: JobName): Promise; } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 3ff26f1ba4..846b6dc9cd 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -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, @@ -252,6 +253,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 +265,6 @@ export class JobRepository implements IJobRepository { case JobName.QUEUE_FACIAL_RECOGNITION: { return { jobId: JobName.QUEUE_FACIAL_RECOGNITION }; } - default: { return null; } @@ -271,4 +274,20 @@ export class JobRepository implements IJobRepository { private getQueue(queue: QueueName): Queue { return this.moduleReference.get(getQueueToken(queue), { strict: false }); } + + public async removeJob(jobId: string, name: JobName): Promise { + 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; + } } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 33c8f5dd7f..12c93ee127 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -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'], }); }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index e8acce9b6c..2cf83e9b99 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -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; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 028e512b39..d07d06443a 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -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', () => { diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 122a09ee2e..c3c7727468 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -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 }); diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index b65cd6b395..9553b5344a 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -7,6 +7,7 @@ export const userStub = { ...authStub.admin.user, password: 'admin_password', name: 'admin_name', + id: 'admin_id', storageLabel: 'admin', oauthId: '', shouldChangePassword: false, diff --git a/server/test/repositories/job.repository.mock.ts b/server/test/repositories/job.repository.mock.ts index 871801830a..cfa1826dd8 100644 --- a/server/test/repositories/job.repository.mock.ts +++ b/server/test/repositories/job.repository.mock.ts @@ -16,5 +16,6 @@ export const newJobRepositoryMock = (): Mocked => { getJobCounts: vitest.fn(), clear: vitest.fn(), waitForQueueCompletion: vitest.fn(), + removeJob: vitest.fn(), }; }; From c9c0212ca9aed6ecb1083878d1efa68406ca0030 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 18 Oct 2024 15:53:47 -0500 Subject: [PATCH 05/36] fix(web): intersection observer not triggered to load more people (#13589) --- .../lib/components/faces-page/manage-people-visibility.svelte | 2 +- web/src/lib/components/faces-page/people-infinite-scroll.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/faces-page/manage-people-visibility.svelte b/web/src/lib/components/faces-page/manage-people-visibility.svelte index 23a69e7759..a48fd6bf74 100644 --- a/web/src/lib/components/faces-page/manage-people-visibility.svelte +++ b/web/src/lib/components/faces-page/manage-people-visibility.svelte @@ -145,7 +145,7 @@ {@const hidden = personIsHidden[person.id]}