Compare commits

..

7 Commits

Author SHA1 Message Date
Alex Tran 7ec1f51cdf Merge branch 'main' of github.com:immich-app/immich into feat/show-archived-assets-for-a-person 2024-10-23 07:53:50 -05:00
martabal 507793235c Merge branch 'main' into feat/show-archived-assets-for-a-person 2024-10-18 15:08:15 +02:00
martabal cd26b6260b fix: change PropertyLifecycle 2024-10-16 23:23:21 +02:00
martin b08ddf4c61 fix: undefined param
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2024-10-16 23:23:21 +02:00
martin 352abd6188 fix: typo
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2024-10-16 23:23:21 +02:00
martabal 24c27eb366 pr feedback 2024-10-16 23:23:21 +02:00
martabal 882d9bee04 feat: show archived assets for a person 2024-10-16 23:23:21 +02:00
66 changed files with 579 additions and 397 deletions
-52
View File
@@ -1,52 +0,0 @@
name: Fix formatting
on:
pull_request:
types: [labeled]
jobs:
fix-formatting:
runs-on: ubuntu-latest
if: ${{ github.event.label.name == 'fix:formatting' }}
permissions:
pull-requests: write
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: 'Checkout'
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: './server/.nvmrc'
- name: Fix formatting
run: make install-all && make format-all
- name: Commit and push
uses: EndBug/add-and-commit@v9
with:
default_author: github_actions
message: 'chore: fix formatting'
- name: Remove label
uses: actions/github-script@v7
if: always()
with:
script: |
github.rest.issues.removeLabel({
issue_number: context.payload.pull_request.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: 'fix:formatting'
})
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.27", "version": "2.2.26",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.27", "version": "2.2.26",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.119.0", "version": "1.118.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.27", "version": "2.2.26",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
+2 -2
View File
@@ -25,10 +25,10 @@ The metrics in immich are grouped into API (endpoint calls and response times),
### Configuration ### Configuration
Immich will not expose an endpoint for metrics by default. To enable this endpoint, you can add the `IMMICH_TELEMETRY_INCLUDE=all` environmental variable to your `.env` file. Note that only the server container currently use this variable. Immich will not expose an endpoint for metrics by default. To enable this endpoint, you can add the `IMMICH_METRICS=true` environmental variable to your `.env` file. Note that only the server and microservices containers currently use this variable.
:::tip :::tip
`IMMICH_TELEMETRY_INCLUDE=all` enables all metrics. For a more granular configuration you can enumerate the telemetry metrics that should be included as a comma separated list (e.g. `IMMICH_TELEMETRY_INCLUDE=repo,api`). Alternatively, you can also exclude specific metrics with `IMMICH_TELEMETRY_EXCLUDE`. For more information refer to the [environment section](/docs/install/environment-variables.md#prometheus). `IMMICH_METRICS` enables all metrics, but there are also [environmental variables](/docs/install/environment-variables.md#prometheus) to toggle specific metric groups. If you'd like to only expose certain kinds of metrics, you can set only those environmental variables to `true`. Explicitly setting the environmental variable for a metric group overrides `IMMICH_METRICS` for that group. For example, setting `IMMICH_METRICS=true` and `IMMICH_API_METRICS=false` will enable all metrics except API metrics.
::: :::
The next step is to configure a new or existing Prometheus instance to scrape this endpoint. The following steps assume that you do not have an existing Prometheus instance, but the steps will be similar either way. The next step is to configure a new or existing Prometheus instance to scrape this endpoint. The following steps assume that you do not have an existing Prometheus instance, but the steps will be similar either way.
+9 -4
View File
@@ -183,10 +183,15 @@ Other machine learning parameters can be tuned from the admin UI.
## Prometheus ## Prometheus
| Variable | Description | Default | Containers | Workers | | Variable | Description | Default | Containers | Workers |
| :------------------------- | :-------------------------------------------------------------------------------------------------------------------- | :-----: | :--------- | :----------------- | | :----------------------------- | :-------------------------------------------------------------------------------------------- | :-----: | :--------- | :----------------- |
| `IMMICH_TELEMETRY_INCLUDE` | Collect these telemetries. List of `host`, `api`, `io`, `repo`, `job`. Note: You can also specify `all` to enable all | | server | api, microservices | | `IMMICH_METRICS`<sup>\*1</sup> | Toggle all metrics (one of [`true`, `false`]) | | server | api, microservices |
| `IMMICH_TELEMETRY_EXCLUDE` | Do not collect these telemetries. List of `host`, `api`, `io`, `repo`, `job` | | server | api, microservices | | `IMMICH_API_METRICS` | Toggle metrics for endpoints and response times (one of [`true`, `false`]) | | server | api, microservices |
| `IMMICH_HOST_METRICS` | Toggle metrics for CPU and memory utilization for host and process (one of [`true`, `false`]) | | server | api, microservices |
| `IMMICH_IO_METRICS` | Toggle metrics for database queries, image processing, etc. (one of [`true`, `false`]) | | server | api, microservices |
| `IMMICH_JOB_METRICS` | Toggle metrics for jobs and queues (one of [`true`, `false`]) | | server | api, microservices |
\*1: Overridden for a metric group when its corresponding environmental variable is set.
## Docker Secrets ## Docker Secrets
-1
View File
@@ -77,7 +77,6 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following: 7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following:
- `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION` - `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION`
- `DB_DATA_LOCATION`: Change this to use an Unraid share (preferably a cache pool, e.g. `/mnt/user/appdata`). If left at default it will try to use Unraid's `/boot/config/plugins/compose.manager/projects/[stack_name]/postgres` folder which it doesn't have permissions to, resulting in this container continuously restarting.
<img <img
src={require('./img/unraid05.webp').default} src={require('./img/unraid05.webp').default}
-4
View File
@@ -1,8 +1,4 @@
[ [
{
"label": "v1.119.0",
"url": "https://v1.119.0.archive.immich.app"
},
{ {
"label": "v1.118.2", "label": "v1.118.2",
"url": "https://v1.118.2.archive.immich.app" "url": "https://v1.118.2.archive.immich.app"
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.119.0", "version": "1.118.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.119.0", "version": "1.118.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.27", "version": "2.2.26",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@@ -92,7 +92,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.119.0", "version": "1.118.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.119.0", "version": "1.118.2",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
+1
View File
@@ -1141,6 +1141,7 @@
"show_albums": "Show albums", "show_albums": "Show albums",
"show_all_people": "Show all people", "show_all_people": "Show all people",
"show_and_hide_people": "Show & hide people", "show_and_hide_people": "Show & hide people",
"show_archived_or_unarchived_assets": "{with, select, true {With} other {Without}} archived assets",
"show_file_location": "Show file location", "show_file_location": "Show file location",
"show_gallery": "Show gallery", "show_gallery": "Show gallery",
"show_hidden_people": "Show hidden people", "show_hidden_people": "Show hidden people",
+2 -2
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]] [[package]]
name = "aiocache" name = "aiocache"
@@ -3776,4 +3776,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<4.0" python-versions = ">=3.10,<4.0"
content-hash = "f95dddfd343a4b2f4d19ffee71ce6b2f5137e5514a60765424164259c4dc1044" content-hash = "f4594d26ee661fb239c7b5750a4c79e5e049480182928af816ccf5e34e8b641f"
+2 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.119.0" version = "1.118.2"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"
@@ -18,6 +18,7 @@ pydantic-settings = "^2.5.2"
aiocache = ">=0.12.1,<1.0" aiocache = ">=0.12.1,<1.0"
rich = ">=13.4.2" rich = ">=13.4.2"
ftfy = ">=6.1.1" ftfy = ">=6.1.1"
setuptools = "^70.0.0"
python-multipart = ">=0.0.6,<1.0" python-multipart = ">=0.0.6,<1.0"
orjson = ">=3.9.5" orjson = ">=3.9.5"
gunicorn = ">=21.1.0" gunicorn = ">=21.1.0"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 164, "android.injected.version.code" => 163,
"android.injected.version.name" => "1.119.0", "android.injected.version.name" => "1.118.2",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
increment_version_number( increment_version_number(
version_number: "1.119.0" version_number: "1.118.2"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.119.0 - API version: 1.118.2
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+11 -3
View File
@@ -184,7 +184,9 @@ class PeopleApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] id (required): /// * [String] id (required):
Future<Response> getPersonStatisticsWithHttpInfo(String id,) async { ///
/// * [bool] withArchived:
Future<Response> getPersonStatisticsWithHttpInfo(String id, { bool? withArchived, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final path = r'/people/{id}/statistics' final path = r'/people/{id}/statistics'
.replaceAll('{id}', id); .replaceAll('{id}', id);
@@ -196,6 +198,10 @@ class PeopleApi {
final headerParams = <String, String>{}; final headerParams = <String, String>{};
final formParams = <String, String>{}; final formParams = <String, String>{};
if (withArchived != null) {
queryParams.addAll(_queryParams('', 'withArchived', withArchived));
}
const contentTypes = <String>[]; const contentTypes = <String>[];
@@ -213,8 +219,10 @@ class PeopleApi {
/// Parameters: /// Parameters:
/// ///
/// * [String] id (required): /// * [String] id (required):
Future<PersonStatisticsResponseDto?> getPersonStatistics(String id,) async { ///
final response = await getPersonStatisticsWithHttpInfo(id,); /// * [bool] withArchived:
Future<PersonStatisticsResponseDto?> getPersonStatistics(String id, { bool? withArchived, }) async {
final response = await getPersonStatisticsWithHttpInfo(id, withArchived: withArchived, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+21 -3
View File
@@ -18,6 +18,7 @@ class PeopleUpdateItem {
required this.id, required this.id,
this.isHidden, this.isHidden,
this.name, this.name,
this.withArchived,
}); });
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null. /// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
@@ -53,13 +54,23 @@ class PeopleUpdateItem {
/// ///
String? name; String? name;
/// This property was added in v1.119.0
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? withArchived;
@override @override
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem && bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem &&
other.birthDate == birthDate && other.birthDate == birthDate &&
other.featureFaceAssetId == featureFaceAssetId && other.featureFaceAssetId == featureFaceAssetId &&
other.id == id && other.id == id &&
other.isHidden == isHidden && other.isHidden == isHidden &&
other.name == name; other.name == name &&
other.withArchived == withArchived;
@override @override
int get hashCode => int get hashCode =>
@@ -68,10 +79,11 @@ class PeopleUpdateItem {
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(id.hashCode) + (id.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) +
(name == null ? 0 : name!.hashCode); (name == null ? 0 : name!.hashCode) +
(withArchived == null ? 0 : withArchived!.hashCode);
@override @override
String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name]'; String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name, withArchived=$withArchived]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -96,6 +108,11 @@ class PeopleUpdateItem {
} else { } else {
// json[r'name'] = null; // json[r'name'] = null;
} }
if (this.withArchived != null) {
json[r'withArchived'] = this.withArchived;
} else {
// json[r'withArchived'] = null;
}
return json; return json;
} }
@@ -113,6 +130,7 @@ class PeopleUpdateItem {
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isHidden: mapValueOfType<bool>(json, r'isHidden'), isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'), name: mapValueOfType<String>(json, r'name'),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
); );
} }
return null; return null;
+21 -3
View File
@@ -19,6 +19,7 @@ class PersonResponseDto {
required this.name, required this.name,
required this.thumbnailPath, required this.thumbnailPath,
this.updatedAt, this.updatedAt,
this.withArchived,
}); });
DateTime? birthDate; DateTime? birthDate;
@@ -40,6 +41,15 @@ class PersonResponseDto {
/// ///
DateTime? updatedAt; DateTime? updatedAt;
/// This property was added in v1.119.0
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? withArchived;
@override @override
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
other.birthDate == birthDate && other.birthDate == birthDate &&
@@ -47,7 +57,8 @@ class PersonResponseDto {
other.isHidden == isHidden && other.isHidden == isHidden &&
other.name == name && other.name == name &&
other.thumbnailPath == thumbnailPath && other.thumbnailPath == thumbnailPath &&
other.updatedAt == updatedAt; other.updatedAt == updatedAt &&
other.withArchived == withArchived;
@override @override
int get hashCode => int get hashCode =>
@@ -57,10 +68,11 @@ class PersonResponseDto {
(isHidden.hashCode) + (isHidden.hashCode) +
(name.hashCode) + (name.hashCode) +
(thumbnailPath.hashCode) + (thumbnailPath.hashCode) +
(updatedAt == null ? 0 : updatedAt!.hashCode); (updatedAt == null ? 0 : updatedAt!.hashCode) +
(withArchived == null ? 0 : withArchived!.hashCode);
@override @override
String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt, withArchived=$withArchived]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -78,6 +90,11 @@ class PersonResponseDto {
} else { } else {
// json[r'updatedAt'] = null; // json[r'updatedAt'] = null;
} }
if (this.withArchived != null) {
json[r'withArchived'] = this.withArchived;
} else {
// json[r'withArchived'] = null;
}
return json; return json;
} }
@@ -96,6 +113,7 @@ class PersonResponseDto {
name: mapValueOfType<String>(json, r'name')!, name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!, thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
updatedAt: mapDateTime(json, r'updatedAt', r''), updatedAt: mapDateTime(json, r'updatedAt', r''),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
); );
} }
return null; return null;
+21 -3
View File
@@ -17,6 +17,7 @@ class PersonUpdateDto {
this.featureFaceAssetId, this.featureFaceAssetId,
this.isHidden, this.isHidden,
this.name, this.name,
this.withArchived,
}); });
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null. /// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
@@ -49,12 +50,22 @@ class PersonUpdateDto {
/// ///
String? name; String? name;
/// This property was added in v1.119.0
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? withArchived;
@override @override
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto && bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
other.birthDate == birthDate && other.birthDate == birthDate &&
other.featureFaceAssetId == featureFaceAssetId && other.featureFaceAssetId == featureFaceAssetId &&
other.isHidden == isHidden && other.isHidden == isHidden &&
other.name == name; other.name == name &&
other.withArchived == withArchived;
@override @override
int get hashCode => int get hashCode =>
@@ -62,10 +73,11 @@ class PersonUpdateDto {
(birthDate == null ? 0 : birthDate!.hashCode) + (birthDate == null ? 0 : birthDate!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) +
(name == null ? 0 : name!.hashCode); (name == null ? 0 : name!.hashCode) +
(withArchived == null ? 0 : withArchived!.hashCode);
@override @override
String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name]'; String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name, withArchived=$withArchived]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -89,6 +101,11 @@ class PersonUpdateDto {
} else { } else {
// json[r'name'] = null; // json[r'name'] = null;
} }
if (this.withArchived != null) {
json[r'withArchived'] = this.withArchived;
} else {
// json[r'withArchived'] = null;
}
return json; return json;
} }
@@ -105,6 +122,7 @@ class PersonUpdateDto {
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'), featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
isHidden: mapValueOfType<bool>(json, r'isHidden'), isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'), name: mapValueOfType<String>(json, r'name'),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
); );
} }
return null; return null;
+21 -3
View File
@@ -20,6 +20,7 @@ class PersonWithFacesResponseDto {
required this.name, required this.name,
required this.thumbnailPath, required this.thumbnailPath,
this.updatedAt, this.updatedAt,
this.withArchived,
}); });
DateTime? birthDate; DateTime? birthDate;
@@ -43,6 +44,15 @@ class PersonWithFacesResponseDto {
/// ///
DateTime? updatedAt; DateTime? updatedAt;
/// This property was added in v1.119.0
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? withArchived;
@override @override
bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto && bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto &&
other.birthDate == birthDate && other.birthDate == birthDate &&
@@ -51,7 +61,8 @@ class PersonWithFacesResponseDto {
other.isHidden == isHidden && other.isHidden == isHidden &&
other.name == name && other.name == name &&
other.thumbnailPath == thumbnailPath && other.thumbnailPath == thumbnailPath &&
other.updatedAt == updatedAt; other.updatedAt == updatedAt &&
other.withArchived == withArchived;
@override @override
int get hashCode => int get hashCode =>
@@ -62,10 +73,11 @@ class PersonWithFacesResponseDto {
(isHidden.hashCode) + (isHidden.hashCode) +
(name.hashCode) + (name.hashCode) +
(thumbnailPath.hashCode) + (thumbnailPath.hashCode) +
(updatedAt == null ? 0 : updatedAt!.hashCode); (updatedAt == null ? 0 : updatedAt!.hashCode) +
(withArchived == null ? 0 : withArchived!.hashCode);
@override @override
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt, withArchived=$withArchived]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -84,6 +96,11 @@ class PersonWithFacesResponseDto {
} else { } else {
// json[r'updatedAt'] = null; // json[r'updatedAt'] = null;
} }
if (this.withArchived != null) {
json[r'withArchived'] = this.withArchived;
} else {
// json[r'withArchived'] = null;
}
return json; return json;
} }
@@ -103,6 +120,7 @@ class PersonWithFacesResponseDto {
name: mapValueOfType<String>(json, r'name')!, name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!, thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
updatedAt: mapDateTime(json, r'updatedAt', r''), updatedAt: mapDateTime(json, r'updatedAt', r''),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
); );
} }
return null; return null;
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.119.0+164 version: 1.118.2+163
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
+25 -1
View File
@@ -4152,6 +4152,14 @@
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
} }
},
{
"name": "withArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
} }
], ],
"responses": { "responses": {
@@ -7385,7 +7393,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.119.0", "version": "1.118.2",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
@@ -10098,6 +10106,10 @@
"name": { "name": {
"description": "Person name.", "description": "Person name.",
"type": "string" "type": "string"
},
"withArchived": {
"description": "This property was added in v1.119.0",
"type": "boolean"
} }
}, },
"required": [ "required": [
@@ -10230,6 +10242,10 @@
"description": "This property was added in v1.107.0", "description": "This property was added in v1.107.0",
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
},
"withArchived": {
"description": "This property was added in v1.119.0",
"type": "boolean"
} }
}, },
"required": [ "required": [
@@ -10271,6 +10287,10 @@
"name": { "name": {
"description": "Person name.", "description": "Person name.",
"type": "string" "type": "string"
},
"withArchived": {
"description": "This property was added in v1.119.0",
"type": "boolean"
} }
}, },
"type": "object" "type": "object"
@@ -10304,6 +10324,10 @@
"description": "This property was added in v1.107.0", "description": "This property was added in v1.107.0",
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
},
"withArchived": {
"description": "This property was added in v1.119.0",
"type": "boolean"
} }
}, },
"required": [ "required": [
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.119.0", "version": "1.118.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.119.0", "version": "1.118.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.119.0", "version": "1.118.2",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+14 -3
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.119.0 * 1.118.2
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
@@ -220,6 +220,8 @@ export type PersonWithFacesResponseDto = {
thumbnailPath: string; thumbnailPath: string;
/** This property was added in v1.107.0 */ /** This property was added in v1.107.0 */
updatedAt?: string; updatedAt?: string;
/** This property was added in v1.119.0 */
withArchived?: boolean;
}; };
export type SmartInfoResponseDto = { export type SmartInfoResponseDto = {
objects?: string[] | null; objects?: string[] | null;
@@ -502,6 +504,8 @@ export type PersonResponseDto = {
thumbnailPath: string; thumbnailPath: string;
/** This property was added in v1.107.0 */ /** This property was added in v1.107.0 */
updatedAt?: string; updatedAt?: string;
/** This property was added in v1.119.0 */
withArchived?: boolean;
}; };
export type AssetFaceResponseDto = { export type AssetFaceResponseDto = {
boundingBoxX1: number; boundingBoxX1: number;
@@ -703,6 +707,8 @@ export type PeopleUpdateItem = {
isHidden?: boolean; isHidden?: boolean;
/** Person name. */ /** Person name. */
name?: string; name?: string;
/** This property was added in v1.119.0 */
withArchived?: boolean;
}; };
export type PeopleUpdateDto = { export type PeopleUpdateDto = {
people: PeopleUpdateItem[]; people: PeopleUpdateItem[];
@@ -717,6 +723,8 @@ export type PersonUpdateDto = {
isHidden?: boolean; isHidden?: boolean;
/** Person name. */ /** Person name. */
name?: string; name?: string;
/** This property was added in v1.119.0 */
withArchived?: boolean;
}; };
export type MergePersonDto = { export type MergePersonDto = {
ids: string[]; ids: string[];
@@ -2410,13 +2418,16 @@ export function reassignFaces({ id, assetFaceUpdateDto }: {
body: assetFaceUpdateDto body: assetFaceUpdateDto
}))); })));
} }
export function getPersonStatistics({ id }: { export function getPersonStatistics({ id, withArchived }: {
id: string; id: string;
withArchived?: boolean;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: PersonStatisticsResponseDto; data: PersonStatisticsResponseDto;
}>(`/people/${encodeURIComponent(id)}/statistics`, { }>(`/people/${encodeURIComponent(id)}/statistics${QS.query(QS.explode({
withArchived
}))}`, {
...opts ...opts
})); }));
} }
+2 -2
View File
@@ -1,5 +1,5 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:20241025@sha256:a55edebdb00f2cd034f99245612833ae4fbb05745f4b1503b8fc97f6a4311502 AS dev FROM ghcr.io/immich-app/base-server-dev:20241022@sha256:22941f8bd36e27a2a659e755ce8ee3e3906adfa41a3ad15e81cad0ed333c14ff AS dev
RUN apt-get install --no-install-recommends -yqq tini RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app WORKDIR /usr/src/app
@@ -42,7 +42,7 @@ RUN npm run build
# prod build # prod build
FROM ghcr.io/immich-app/base-server-prod:20241025@sha256:ee25f3135ee6e70810c810cd9789068594ffb1a48e7c28fe77303553e904979e FROM ghcr.io/immich-app/base-server-prod:20241022@sha256:6676a716a11106887c98a2d4ac4677a92c6f80ba6da3e496de1b302a56882ef5
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "1.119.0", "version": "1.118.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "1.119.0", "version": "1.118.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^10.0.1", "@nestjs/bullmq": "^10.0.1",
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.119.0", "version": "1.118.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -24,10 +24,10 @@
"typeorm": "typeorm", "typeorm": "typeorm",
"lifecycle": "node ./dist/utils/lifecycle.js", "lifecycle": "node ./dist/utils/lifecycle.js",
"typeorm:migrations:create": "typeorm migration:create", "typeorm:migrations:create": "typeorm migration:create",
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/bin/database.js", "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/database.config.js",
"typeorm:migrations:run": "typeorm migration:run -d ./dist/bin/database.js", "typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js",
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/bin/database.js", "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js",
"typeorm:schema:drop": "typeorm query -d ./dist/bin/database.js 'DROP schema public cascade; CREATE schema public;'", "typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'",
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run", "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run",
"sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js", "sync:sql": "node ./dist/bin/sync-sql.js",
+3 -2
View File
@@ -9,6 +9,7 @@ import { OpenTelemetryModule } from 'nestjs-otel';
import { commands } from 'src/commands'; import { commands } from 'src/commands';
import { clsConfig, immichAppConfig } from 'src/config'; import { clsConfig, immichAppConfig } from 'src/config';
import { controllers } from 'src/controllers'; import { controllers } from 'src/controllers';
import { databaseConfig } from 'src/database.config';
import { entities } from 'src/entities'; import { entities } from 'src/entities';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
@@ -37,7 +38,7 @@ const middleware = [
]; ];
const configRepository = new ConfigRepository(); const configRepository = new ConfigRepository();
const { bull, database, otel } = configRepository.getEnv(); const { bull, otel } = configRepository.getEnv();
const imports = [ const imports = [
BullModule.forRoot(bull.config), BullModule.forRoot(bull.config),
@@ -49,7 +50,7 @@ const imports = [
inject: [ModuleRef], inject: [ModuleRef],
useFactory: (moduleRef: ModuleRef) => { useFactory: (moduleRef: ModuleRef) => {
return { return {
...database.config, ...databaseConfig,
poolErrorHandler: (error) => { poolErrorHandler: (error) => {
moduleRef.get(DatabaseService, { strict: false }).handleConnectionError(error); moduleRef.get(DatabaseService, { strict: false }).handleConnectionError(error);
}, },
-11
View File
@@ -1,11 +0,0 @@
import { ConfigRepository } from 'src/repositories/config.repository';
import { DataSource } from 'typeorm';
const { database } = new ConfigRepository().getEnv();
/**
* @deprecated - DO NOT USE THIS
*
* this export is ONLY to be used for TypeORM commands in package.json#scripts
*/
export const dataSource = new DataSource({ ...database.config, host: 'localhost' });
+3 -2
View File
@@ -8,6 +8,7 @@ import { OpenTelemetryModule } from 'nestjs-otel';
import { mkdir, rm, writeFile } from 'node:fs/promises'; import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { format } from 'sql-formatter'; import { format } from 'sql-formatter';
import { databaseConfig } from 'src/database.config';
import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators'; import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators';
import { entities } from 'src/entities'; import { entities } from 'src/entities';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
@@ -73,12 +74,12 @@ class SqlGenerator {
await rm(this.options.targetDir, { force: true, recursive: true }); await rm(this.options.targetDir, { force: true, recursive: true });
await mkdir(this.options.targetDir); await mkdir(this.options.targetDir);
const { database, otel } = new ConfigRepository().getEnv(); const { otel } = new ConfigRepository().getEnv();
const moduleFixture = await Test.createTestingModule({ const moduleFixture = await Test.createTestingModule({
imports: [ imports: [
TypeOrmModule.forRoot({ TypeOrmModule.forRoot({
...database.config, ...databaseConfig,
host: 'localhost', host: 'localhost',
entities, entities,
logging: ['query'], logging: ['query'],
+7 -2
View File
@@ -12,6 +12,7 @@ import {
PersonResponseDto, PersonResponseDto,
PersonSearchDto, PersonSearchDto,
PersonStatisticsResponseDto, PersonStatisticsResponseDto,
PersonStatsDto,
PersonUpdateDto, PersonUpdateDto,
} from 'src/dtos/person.dto'; } from 'src/dtos/person.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
@@ -65,8 +66,12 @@ export class PersonController {
@Get(':id/statistics') @Get(':id/statistics')
@Authenticated({ permission: Permission.PERSON_STATISTICS }) @Authenticated({ permission: Permission.PERSON_STATISTICS })
getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonStatisticsResponseDto> { getPersonStatistics(
return this.service.getStatistics(auth, id); @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: PersonStatsDto,
): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id, dto);
} }
@Get(':id/thumbnail') @Get(':id/thumbnail')
+35
View File
@@ -0,0 +1,35 @@
import { ConfigRepository } from 'src/repositories/config.repository';
import { DataSource } from 'typeorm';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
const { database } = new ConfigRepository().getEnv();
const { url, host, port, username, password, name } = database;
const urlOrParts = url
? { url }
: {
host,
port,
username,
password,
database: name,
};
/* eslint unicorn/prefer-module: "off" -- We can fix this when migrating to ESM*/
export const databaseConfig: PostgresConnectionOptions = {
type: 'postgres',
entities: [__dirname + '/entities/*.entity.{js,ts}'],
migrations: [__dirname + '/migrations/*.{js,ts}'],
subscribers: [__dirname + '/subscribers/*.{js,ts}'],
migrationsRun: false,
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...urlOrParts,
};
/**
* @deprecated - DO NOT USE THIS
*
* this export is ONLY to be used for TypeORM commands in package.json#scripts
*/
export const dataSource = new DataSource({ ...databaseConfig, host: 'localhost' });
+14 -1
View File
@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsString, Max, Min, ValidateNested } from 'class-validator'; import { IsArray, IsBoolean, IsInt, IsNotEmpty, IsString, Max, Min, ValidateNested } from 'class-validator';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { PropertyLifecycle } from 'src/decorators'; import { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@@ -41,6 +41,11 @@ export class PersonUpdateDto extends PersonCreateDto {
@Optional() @Optional()
@IsString() @IsString()
featureFaceAssetId?: string; featureFaceAssetId?: string;
@Optional()
@IsBoolean()
@PropertyLifecycle({ addedAt: 'v1.119.0' })
withArchived?: boolean;
} }
export class PeopleUpdateDto { export class PeopleUpdateDto {
@@ -93,6 +98,8 @@ export class PersonResponseDto {
isHidden!: boolean; isHidden!: boolean;
@PropertyLifecycle({ addedAt: 'v1.107.0' }) @PropertyLifecycle({ addedAt: 'v1.107.0' })
updatedAt?: Date; updatedAt?: Date;
@PropertyLifecycle({ addedAt: 'v1.119.0' })
withArchived?: boolean;
} }
export class PersonWithFacesResponseDto extends PersonResponseDto { export class PersonWithFacesResponseDto extends PersonResponseDto {
@@ -147,6 +154,11 @@ export class PersonStatisticsResponseDto {
assets!: number; assets!: number;
} }
export class PersonStatsDto {
@ValidateBoolean({ optional: true })
withArchived?: boolean;
}
export class PeopleResponseDto { export class PeopleResponseDto {
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
total!: number; total!: number;
@@ -167,6 +179,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
thumbnailPath: person.thumbnailPath, thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden, isHidden: person.isHidden,
updatedAt: person.updatedAt, updatedAt: person.updatedAt,
withArchived: person.withArchived,
}; };
} }
+1 -1
View File
@@ -52,7 +52,7 @@ export class AlbumEntity {
albumUsers!: AlbumUserEntity[]; albumUsers!: AlbumUserEntity[];
@ManyToMany(() => AssetEntity, (asset) => asset.albums) @ManyToMany(() => AssetEntity, (asset) => asset.albums)
@JoinTable({ synchronize: false }) @JoinTable()
assets!: AssetEntity[]; assets!: AssetEntity[];
@OneToMany(() => SharedLinkEntity, (link) => link.album) @OneToMany(() => SharedLinkEntity, (link) => link.album)
+3
View File
@@ -49,4 +49,7 @@ export class PersonEntity {
@Column({ default: false }) @Column({ default: false })
isHidden!: boolean; isHidden!: boolean;
@Column({ default: false })
withArchived!: boolean;
} }
-8
View File
@@ -363,11 +363,3 @@ export enum ImmichWorker {
API = 'api', API = 'api',
MICROSERVICES = 'microservices', MICROSERVICES = 'microservices',
} }
export enum ImmichTelemetry {
HOST = 'host',
API = 'api',
IO = 'io',
REPO = 'repo',
JOB = 'job',
}
+12 -4
View File
@@ -2,9 +2,8 @@ import { RegisterQueueOptions } from '@nestjs/bullmq';
import { QueueOptions } from 'bullmq'; import { QueueOptions } from 'bullmq';
import { RedisOptions } from 'ioredis'; import { RedisOptions } from 'ioredis';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { ImmichEnvironment, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum'; import { ImmichEnvironment, ImmichWorker, LogLevel } from 'src/enum';
import { VectorExtension } from 'src/interfaces/database.interface'; import { VectorExtension } from 'src/interfaces/database.interface';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
export const IConfigRepository = 'IConfigRepository'; export const IConfigRepository = 'IConfigRepository';
@@ -37,7 +36,12 @@ export interface EnvData {
}; };
database: { database: {
config: PostgresConnectionOptions; url?: string;
host: string;
port: number;
username: string;
password: string;
name: string;
skipMigrations: boolean; skipMigrations: boolean;
vectorExtension: VectorExtension; vectorExtension: VectorExtension;
}; };
@@ -73,7 +77,11 @@ export interface EnvData {
telemetry: { telemetry: {
apiPort: number; apiPort: number;
microservicesPort: number; microservicesPort: number;
metrics: Set<ImmichTelemetry>; enabled: boolean;
apiMetrics: boolean;
hostMetrics: boolean;
repoMetrics: boolean;
jobMetrics: boolean;
}; };
storage: { storage: {
+1 -1
View File
@@ -19,7 +19,7 @@ export enum DatabaseLock {
StorageTemplateMigration = 420, StorageTemplateMigration = 420,
VersionHistory = 500, VersionHistory = 500,
CLIPDimSize = 512, CLIPDimSize = 512,
Library = 1337, LibraryWatch = 1337,
GetSystemConfig = 69, GetSystemConfig = 69,
} }
+5 -1
View File
@@ -45,6 +45,10 @@ export interface DeleteFacesOptions {
sourceType: SourceType; sourceType: SourceType;
} }
export interface PersonStatsOptions {
withArchived?: boolean;
}
export type UnassignFacesOptions = DeleteFacesOptions; export type UnassignFacesOptions = DeleteFacesOptions;
export interface IPersonRepository { export interface IPersonRepository {
@@ -74,7 +78,7 @@ export interface IPersonRepository {
getFaces(assetId: string): Promise<AssetFaceEntity[]>; getFaces(assetId: string): Promise<AssetFaceEntity[]>;
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>; getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>; getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
getStatistics(personId: string): Promise<PersonStatistics>; getStatistics(personId: string, options: PersonStatsOptions): Promise<PersonStatistics>;
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>; reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>; getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>; reassignFaces(data: UpdateFacesData): Promise<number>;
@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddWithArchivedToPerson1728944141526 implements MigrationInterface {
name = 'AddWithArchivedToPerson1728944141526'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" ADD "withArchived" boolean NOT NULL DEFAULT false`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "withArchived"`);
}
}
@@ -1,13 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAlbumAssetCreatedAt1729793521993 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "albums_assets_assets" ADD COLUMN "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums_assets_assets" DROP COLUMN "createdAt"`);
}
}
+2 -1
View File
@@ -87,7 +87,7 @@ WHERE
) )
AND ("entity"."deletedAt" IS NULL) AND ("entity"."deletedAt" IS NULL)
ORDER BY ORDER BY
"entity"."fileCreatedAt" ASC "entity"."localDateTime" ASC
-- AssetRepository.getByIds -- AssetRepository.getByIds
SELECT SELECT
@@ -212,6 +212,7 @@ SELECT
"8258e303a73a72cf6abb13d73fb592dde0d68280"."thumbnailPath" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_thumbnailPath", "8258e303a73a72cf6abb13d73fb592dde0d68280"."thumbnailPath" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_thumbnailPath",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."faceAssetId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_faceAssetId", "8258e303a73a72cf6abb13d73fb592dde0d68280"."faceAssetId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_faceAssetId",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."isHidden" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_isHidden", "8258e303a73a72cf6abb13d73fb592dde0d68280"."isHidden" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_isHidden",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."withArchived" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_withArchived",
"AssetEntity__AssetEntity_stack"."id" AS "AssetEntity__AssetEntity_stack_id", "AssetEntity__AssetEntity_stack"."id" AS "AssetEntity__AssetEntity_stack_id",
"AssetEntity__AssetEntity_stack"."ownerId" AS "AssetEntity__AssetEntity_stack_ownerId", "AssetEntity__AssetEntity_stack"."ownerId" AS "AssetEntity__AssetEntity_stack_ownerId",
"AssetEntity__AssetEntity_stack"."primaryAssetId" AS "AssetEntity__AssetEntity_stack_primaryAssetId", "AssetEntity__AssetEntity_stack"."primaryAssetId" AS "AssetEntity__AssetEntity_stack_primaryAssetId",
+19 -6
View File
@@ -17,7 +17,8 @@ SELECT
"person"."birthDate" AS "person_birthDate", "person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath", "person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId", "person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden" "person"."isHidden" AS "person_isHidden",
"person"."withArchived" AS "person_withArchived"
FROM FROM
"person" "person" "person" "person"
LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
@@ -54,7 +55,8 @@ SELECT
"person"."birthDate" AS "person_birthDate", "person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath", "person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId", "person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden" "person"."isHidden" AS "person_isHidden",
"person"."withArchived" AS "person_withArchived"
FROM FROM
"person" "person" "person" "person"
LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
@@ -83,7 +85,8 @@ SELECT
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden" "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden",
"AssetFaceEntity__AssetFaceEntity_person"."withArchived" AS "AssetFaceEntity__AssetFaceEntity_person_withArchived"
FROM FROM
"asset_faces" "AssetFaceEntity" "asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
@@ -116,7 +119,8 @@ FROM
"AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate",
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden" "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden",
"AssetFaceEntity__AssetFaceEntity_person"."withArchived" AS "AssetFaceEntity__AssetFaceEntity_person_withArchived"
FROM FROM
"asset_faces" "AssetFaceEntity" "asset_faces" "AssetFaceEntity"
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
@@ -153,6 +157,7 @@ FROM
"AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden", "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden",
"AssetFaceEntity__AssetFaceEntity_person"."withArchived" AS "AssetFaceEntity__AssetFaceEntity_person_withArchived",
"AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id", "AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId", "AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId", "AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
@@ -213,7 +218,8 @@ SELECT
"person"."birthDate" AS "person_birthDate", "person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath", "person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId", "person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden" "person"."isHidden" AS "person_isHidden",
"person"."withArchived" AS "person_withArchived"
FROM FROM
"person" "person" "person" "person"
WHERE WHERE
@@ -242,11 +248,18 @@ FROM
"asset_faces" "face" "asset_faces" "face"
LEFT JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
LEFT JOIN "person" "person" ON "person"."id" = "face"."personId"
WHERE WHERE
"face"."personId" = $1 "face"."personId" = $1
AND "asset"."isArchived" = false
AND "asset"."deletedAt" IS NULL AND "asset"."deletedAt" IS NULL
AND "asset"."livePhotoVideoId" IS NULL AND "asset"."livePhotoVideoId" IS NULL
AND (
(
"person"."withArchived" = false
AND "asset"."isArchived" = false
)
OR "person"."withArchived" = true
)
-- PersonRepository.getNumberOfPeople -- PersonRepository.getNumberOfPeople
SELECT SELECT
+1 -1
View File
@@ -93,7 +93,7 @@ export class AssetRepository implements IAssetRepository {
) )
.leftJoinAndSelect('entity.exifInfo', 'exifInfo') .leftJoinAndSelect('entity.exifInfo', 'exifInfo')
.leftJoinAndSelect('entity.files', 'files') .leftJoinAndSelect('entity.files', 'files')
.orderBy('entity.fileCreatedAt', 'ASC') .orderBy('entity.localDateTime', 'ASC')
.getMany(); .getMany();
} }
@@ -1,4 +1,3 @@
import { ImmichTelemetry } from 'src/enum';
import { clearEnvCache, ConfigRepository } from 'src/repositories/config.repository'; import { clearEnvCache, ConfigRepository } from 'src/repositories/config.repository';
const getEnv = () => { const getEnv = () => {
@@ -13,8 +12,11 @@ const resetEnv = () => {
'IMMICH_TRUSTED_PROXIES', 'IMMICH_TRUSTED_PROXIES',
'IMMICH_API_METRICS_PORT', 'IMMICH_API_METRICS_PORT',
'IMMICH_MICROSERVICES_METRICS_PORT', 'IMMICH_MICROSERVICES_METRICS_PORT',
'IMMICH_TELEMETRY_INCLUDE', 'IMMICH_METRICS',
'IMMICH_TELEMETRY_EXCLUDE', 'IMMICH_API_METRICS',
'IMMICH_HOST_METRICS',
'IMMICH_IO_METRICS',
'IMMICH_JOB_METRICS',
'DB_URL', 'DB_URL',
'DB_HOSTNAME', 'DB_HOSTNAME',
@@ -66,14 +68,12 @@ describe('getEnv', () => {
it('should use defaults', () => { it('should use defaults', () => {
const { database } = getEnv(); const { database } = getEnv();
expect(database).toEqual({ expect(database).toEqual({
config: expect.objectContaining({ url: undefined,
type: 'postgres', host: 'database',
host: 'database', port: 5432,
port: 5432, name: 'immich',
database: 'immich', username: 'postgres',
username: 'postgres', password: 'postgres',
password: 'postgres',
}),
skipMigrations: false, skipMigrations: false,
vectorExtension: 'vectors', vectorExtension: 'vectors',
}); });
@@ -210,7 +210,11 @@ describe('getEnv', () => {
expect(telemetry).toEqual({ expect(telemetry).toEqual({
apiPort: 8081, apiPort: 8081,
microservicesPort: 8082, microservicesPort: 8082,
metrics: new Set([]), enabled: false,
apiMetrics: false,
hostMetrics: false,
jobMetrics: false,
repoMetrics: false,
}); });
}); });
@@ -221,29 +225,32 @@ describe('getEnv', () => {
expect(telemetry).toMatchObject({ expect(telemetry).toMatchObject({
apiPort: 2001, apiPort: 2001,
microservicesPort: 2002, microservicesPort: 2002,
metrics: expect.any(Set),
}); });
}); });
it('should run with telemetry enabled', () => { it('should run with telemetry enabled', () => {
process.env.IMMICH_TELEMETRY_INCLUDE = 'all'; process.env.IMMICH_METRICS = 'true';
const { telemetry } = getEnv(); const { telemetry } = getEnv();
expect(telemetry.metrics).toEqual(new Set(Object.values(ImmichTelemetry))); expect(telemetry).toMatchObject({
enabled: true,
apiMetrics: true,
hostMetrics: true,
jobMetrics: true,
repoMetrics: true,
});
}); });
it('should run with telemetry enabled and jobs disabled', () => { it('should run with telemetry enabled and jobs disabled', () => {
process.env.IMMICH_TELEMETRY_INCLUDE = 'all'; process.env.IMMICH_METRICS = 'true';
process.env.IMMICH_TELEMETRY_EXCLUDE = 'job'; process.env.IMMICH_JOB_METRICS = 'false';
const { telemetry } = getEnv(); const { telemetry } = getEnv();
expect(telemetry.metrics).toEqual( expect(telemetry).toMatchObject({
new Set([ImmichTelemetry.API, ImmichTelemetry.HOST, ImmichTelemetry.IO, ImmichTelemetry.REPO]), enabled: true,
); apiMetrics: true,
}); hostMetrics: true,
jobMetrics: false,
it('should run with specific telemetry metrics', () => { repoMetrics: true,
process.env.IMMICH_TELEMETRY_INCLUDE = 'io, host, api'; });
const { telemetry } = getEnv();
expect(telemetry.metrics).toEqual(new Set([ImmichTelemetry.API, ImmichTelemetry.HOST, ImmichTelemetry.IO]));
}); });
}); });
}); });
+28 -46
View File
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { join, resolve } from 'node:path'; import { join } from 'node:path';
import { citiesFile, excludePaths } from 'src/constants'; import { citiesFile, excludePaths } from 'src/constants';
import { Telemetry } from 'src/decorators'; import { Telemetry } from 'src/decorators';
import { ImmichEnvironment, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum'; import { ImmichEnvironment, ImmichWorker, LogLevel } from 'src/enum';
import { EnvData, IConfigRepository } from 'src/interfaces/config.interface'; import { EnvData, IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseExtension } from 'src/interfaces/database.interface'; import { DatabaseExtension } from 'src/interfaces/database.interface';
import { QueueName } from 'src/interfaces/job.interface'; import { QueueName } from 'src/interfaces/job.interface';
@@ -25,17 +25,18 @@ const stagingKeys = {
}; };
const WORKER_TYPES = new Set(Object.values(ImmichWorker)); const WORKER_TYPES = new Set(Object.values(ImmichWorker));
const TELEMETRY_TYPES = new Set(Object.values(ImmichTelemetry));
const asSet = <T>(value: string | undefined, defaults: T[]) => { const asSet = (value: string | undefined, defaults: ImmichWorker[]) => {
const values = (value || '').replaceAll(/\s/g, '').split(',').filter(Boolean); const values = (value || '').replaceAll(/\s/g, '').split(',').filter(Boolean);
return new Set(values.length === 0 ? defaults : (values as T[])); return new Set(values.length === 0 ? defaults : (values as ImmichWorker[]));
}; };
const parseBoolean = (value: string | undefined, defaultValue: boolean) => (value ? value === 'true' : defaultValue);
const getEnv = (): EnvData => { const getEnv = (): EnvData => {
const includedWorkers = asSet(process.env.IMMICH_WORKERS_INCLUDE, [ImmichWorker.API, ImmichWorker.MICROSERVICES]); const included = asSet(process.env.IMMICH_WORKERS_INCLUDE, [ImmichWorker.API, ImmichWorker.MICROSERVICES]);
const excludedWorkers = asSet(process.env.IMMICH_WORKERS_EXCLUDE, []); const excluded = asSet(process.env.IMMICH_WORKERS_EXCLUDE, []);
const workers = [...setDifference(includedWorkers, excludedWorkers)]; const workers = [...setDifference(included, excluded)];
for (const worker of workers) { for (const worker of workers) {
if (!WORKER_TYPES.has(worker)) { if (!WORKER_TYPES.has(worker)) {
throw new Error(`Invalid worker(s) found: ${workers.join(',')}`); throw new Error(`Invalid worker(s) found: ${workers.join(',')}`);
@@ -46,14 +47,10 @@ const getEnv = (): EnvData => {
const isProd = environment === ImmichEnvironment.PRODUCTION; const isProd = environment === ImmichEnvironment.PRODUCTION;
const buildFolder = process.env.IMMICH_BUILD_DATA || '/build'; const buildFolder = process.env.IMMICH_BUILD_DATA || '/build';
const folders = { const folders = {
// eslint-disable-next-line unicorn/prefer-module
dist: resolve(`${__dirname}/..`),
geodata: join(buildFolder, 'geodata'), geodata: join(buildFolder, 'geodata'),
web: join(buildFolder, 'www'), web: join(buildFolder, 'www'),
}; };
const databaseUrl = process.env.DB_URL;
let redisConfig = { let redisConfig = {
host: process.env.REDIS_HOSTNAME || 'redis', host: process.env.REDIS_HOSTNAME || 'redis',
port: Number.parseInt(process.env.REDIS_PORT || '') || 6379, port: Number.parseInt(process.env.REDIS_PORT || '') || 6379,
@@ -72,18 +69,12 @@ const getEnv = (): EnvData => {
} }
} }
const includedTelemetries = const globalEnabled = parseBoolean(process.env.IMMICH_METRICS, false);
process.env.IMMICH_TELEMETRY_INCLUDE === 'all' const hostMetrics = parseBoolean(process.env.IMMICH_HOST_METRICS, globalEnabled);
? new Set(Object.values(ImmichTelemetry)) const apiMetrics = parseBoolean(process.env.IMMICH_API_METRICS, globalEnabled);
: asSet<ImmichTelemetry>(process.env.IMMICH_TELEMETRY_INCLUDE, []); const repoMetrics = parseBoolean(process.env.IMMICH_IO_METRICS, globalEnabled);
const jobMetrics = parseBoolean(process.env.IMMICH_JOB_METRICS, globalEnabled);
const excludedTelemetries = asSet<ImmichTelemetry>(process.env.IMMICH_TELEMETRY_EXCLUDE, []); const telemetryEnabled = globalEnabled || hostMetrics || apiMetrics || repoMetrics || jobMetrics;
const telemetries = setDifference(includedTelemetries, excludedTelemetries);
for (const telemetry of telemetries) {
if (!TELEMETRY_TYPES.has(telemetry)) {
throw new Error(`Invalid telemetry found: ${telemetry}`);
}
}
return { return {
host: process.env.IMMICH_HOST, host: process.env.IMMICH_HOST,
@@ -122,25 +113,12 @@ const getEnv = (): EnvData => {
}, },
database: { database: {
config: { url: process.env.DB_URL,
type: 'postgres', host: process.env.DB_HOSTNAME || 'database',
entities: [`${folders.dist}/entities` + '/*.entity.{js,ts}'], port: Number(process.env.DB_PORT) || 5432,
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'], username: process.env.DB_USERNAME || 'postgres',
subscribers: [`${folders.dist}/subscribers` + '/*.{js,ts}'], password: process.env.DB_PASSWORD || 'postgres',
migrationsRun: false, name: process.env.DB_DATABASE_NAME || 'immich',
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...(databaseUrl
? { url: databaseUrl }
: {
host: process.env.DB_HOSTNAME || 'database',
port: Number(process.env.DB_PORT) || 5432,
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_DATABASE_NAME || 'immich',
}),
},
skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true', skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true',
vectorExtension: vectorExtension:
@@ -158,9 +136,9 @@ const getEnv = (): EnvData => {
otel: { otel: {
metrics: { metrics: {
hostMetrics: telemetries.has(ImmichTelemetry.HOST), hostMetrics,
apiMetrics: { apiMetrics: {
enable: telemetries.has(ImmichTelemetry.API), enable: apiMetrics,
ignoreRoutes: excludePaths, ignoreRoutes: excludePaths,
}, },
}, },
@@ -190,7 +168,11 @@ const getEnv = (): EnvData => {
telemetry: { telemetry: {
apiPort: Number(process.env.IMMICH_API_METRICS_PORT || '') || 8081, apiPort: Number(process.env.IMMICH_API_METRICS_PORT || '') || 8081,
microservicesPort: Number(process.env.IMMICH_MICROSERVICES_METRICS_PORT || '') || 8082, microservicesPort: Number(process.env.IMMICH_MICROSERVICES_METRICS_PORT || '') || 8082,
metrics: telemetries, enabled: telemetryEnabled,
hostMetrics,
apiMetrics,
repoMetrics,
jobMetrics,
}, },
workers, workers,
+23 -6
View File
@@ -17,6 +17,7 @@ import {
PersonNameSearchOptions, PersonNameSearchOptions,
PersonSearchOptions, PersonSearchOptions,
PersonStatistics, PersonStatistics,
PersonStatsOptions,
UnassignFacesOptions, UnassignFacesOptions,
UpdateFacesData, UpdateFacesData,
} from 'src/interfaces/person.interface'; } from 'src/interfaces/person.interface';
@@ -212,17 +213,33 @@ export class PersonRepository implements IPersonRepository {
return queryBuilder.getMany(); return queryBuilder.getMany();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID, {}] })
async getStatistics(personId: string): Promise<PersonStatistics> { async getStatistics(personId: string, options: PersonStatsOptions): Promise<PersonStatistics> {
const items = await this.assetFaceRepository /*
* withArchived: true -> Return the count of all assets for a given person
* withArchived: false -> Return the count of all unarchived assets for a given person
* withArchived: undefined ->
* - If person.withArchived = true -> Return the count of all assets for a given person
* - If person.withArchived = false -> Return the count of all unarchived assets for a given person
*/
const queryBuilder = this.assetFaceRepository
.createQueryBuilder('face') .createQueryBuilder('face')
.leftJoin('face.asset', 'asset') .leftJoin('face.asset', 'asset')
.where('face.personId = :personId', { personId }) .where('face.personId = :personId', { personId })
.andWhere('asset.isArchived = false')
.andWhere('asset.deletedAt IS NULL') .andWhere('asset.deletedAt IS NULL')
.andWhere('asset.livePhotoVideoId IS NULL') .andWhere('asset.livePhotoVideoId IS NULL')
.select('COUNT(DISTINCT(asset.id))', 'count') .select('COUNT(DISTINCT(asset.id))', 'count');
.getRawOne();
if (options.withArchived === false) {
queryBuilder.andWhere('asset.isArchived = false');
} else if (options.withArchived === undefined) {
queryBuilder
.leftJoin('face.person', 'person')
.andWhere('((person.withArchived = false AND asset.isArchived = false) OR person.withArchived = true)');
}
const items = await queryBuilder.getRawOne();
return { return {
assets: items.count ?? 0, assets: items.count ?? 0,
}; };
@@ -14,7 +14,7 @@ import { snakeCase, startCase } from 'lodash';
import { MetricService } from 'nestjs-otel'; import { MetricService } from 'nestjs-otel';
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils'; import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { ImmichTelemetry, MetadataKey } from 'src/enum'; import { MetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface'; import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricGroupRepository, ITelemetryRepository, MetricGroupOptions } from 'src/interfaces/telemetry.interface'; import { IMetricGroupRepository, ITelemetryRepository, MetricGroupOptions } from 'src/interfaces/telemetry.interface';
@@ -99,18 +99,17 @@ export class TelemetryRepository implements ITelemetryRepository {
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
const { telemetry } = this.configRepository.getEnv(); const { telemetry } = this.configRepository.getEnv();
const { metrics } = telemetry; const { apiMetrics, hostMetrics, jobMetrics, repoMetrics } = telemetry;
this.api = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.API) }); this.api = new MetricGroupRepository(metricService).configure({ enabled: apiMetrics });
this.host = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.HOST) }); this.host = new MetricGroupRepository(metricService).configure({ enabled: hostMetrics });
this.jobs = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.JOB) }); this.jobs = new MetricGroupRepository(metricService).configure({ enabled: jobMetrics });
this.repo = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.REPO) }); this.repo = new MetricGroupRepository(metricService).configure({ enabled: repoMetrics });
} }
setup({ repositories }: { repositories: ClassConstructor<unknown>[] }) { setup({ repositories }: { repositories: ClassConstructor<unknown>[] }) {
const { telemetry } = this.configRepository.getEnv(); const { telemetry } = this.configRepository.getEnv();
const { metrics } = telemetry; if (!telemetry.enabled || !telemetry.repoMetrics) {
if (!metrics.has(ImmichTelemetry.REPO)) {
return; return;
} }
+21 -14
View File
@@ -23,6 +23,7 @@ import { OAuthProfile } from 'src/interfaces/oauth.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { isGranted } from 'src/utils/access'; import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
import { createUser } from 'src/utils/user';
export interface LoginDetails { export interface LoginDetails {
isSecure: boolean; isSecure: boolean;
@@ -114,13 +115,16 @@ export class AuthService extends BaseService {
throw new BadRequestException('The server already has an admin'); throw new BadRequestException('The server already has an admin');
} }
const admin = await this.createUser({ const admin = await createUser(
isAdmin: true, { userRepo: this.userRepository, cryptoRepo: this.cryptoRepository },
email: dto.email, {
name: dto.name, isAdmin: true,
password: dto.password, email: dto.email,
storageLabel: 'admin', name: dto.name,
}); password: dto.password,
storageLabel: 'admin',
},
);
return mapUserAdmin(admin); return mapUserAdmin(admin);
} }
@@ -230,13 +234,16 @@ export class AuthService extends BaseService {
}); });
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`; const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
user = await this.createUser({ user = await createUser(
name: userName, { userRepo: this.userRepository, cryptoRepo: this.cryptoRepository },
email: profile.email, {
oauthId: profile.sub, name: userName,
quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null, email: profile.email,
storageLabel: storageLabel || null, oauthId: profile.sub,
}); quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null,
storageLabel: storageLabel || null,
},
);
} }
return this.createLoginResponse(user, loginDetails); return this.createLoginResponse(user, loginDetails);
+1 -28
View File
@@ -1,9 +1,6 @@
import { BadRequestException, Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { UserEntity } from 'src/entities/user.entity';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface'; import { IActivityRepository } from 'src/interfaces/activity.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
@@ -122,28 +119,4 @@ export class BaseService {
checkAccess(request: AccessRequest) { checkAccess(request: AccessRequest) {
return checkAccess(this.accessRepository, request); return checkAccess(this.accessRepository, request);
} }
async createUser(dto: Partial<UserEntity> & { email: string }): Promise<UserEntity> {
const user = await this.userRepository.getByEmail(dto.email);
if (user) {
throw new BadRequestException('User exists');
}
if (!dto.isAdmin) {
const localAdmin = await this.userRepository.getAdmin();
if (!localAdmin) {
throw new BadRequestException('The first registered account must the administrator.');
}
}
const payload: Partial<UserEntity> = { ...dto };
if (payload.password) {
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
}
if (payload.storageLabel) {
payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
}
return this.userRepository.create(payload);
}
} }
+15 -24
View File
@@ -60,14 +60,11 @@ describe(DatabaseService.name, () => {
configMock.getEnv.mockReturnValue( configMock.getEnv.mockReturnValue(
mockEnvData({ mockEnvData({
database: { database: {
config: { host: 'database',
type: 'postgres', port: 5432,
host: 'database', username: 'postgres',
port: 5432, password: 'postgres',
username: 'postgres', name: 'immich',
password: 'postgres',
database: 'immich',
},
skipMigrations: false, skipMigrations: false,
vectorExtension: extension, vectorExtension: extension,
}, },
@@ -289,14 +286,11 @@ describe(DatabaseService.name, () => {
configMock.getEnv.mockReturnValue( configMock.getEnv.mockReturnValue(
mockEnvData({ mockEnvData({
database: { database: {
config: { host: 'database',
type: 'postgres', port: 5432,
host: 'database', username: 'postgres',
port: 5432, password: 'postgres',
username: 'postgres', name: 'immich',
password: 'postgres',
database: 'immich',
},
skipMigrations: true, skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS, vectorExtension: DatabaseExtension.VECTORS,
}, },
@@ -312,14 +306,11 @@ describe(DatabaseService.name, () => {
configMock.getEnv.mockReturnValue( configMock.getEnv.mockReturnValue(
mockEnvData({ mockEnvData({
database: { database: {
config: { host: 'database',
type: 'postgres', port: 5432,
host: 'database', username: 'postgres',
port: 5432, password: 'postgres',
username: 'postgres', name: 'immich',
password: 'postgres',
database: 'immich',
},
skipMigrations: true, skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR, vectorExtension: DatabaseExtension.VECTOR,
}, },
+13 -28
View File
@@ -3,7 +3,7 @@ import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/config';
import { mapLibrary } from 'src/dtos/library.dto'; import { mapLibrary } from 'src/dtos/library.dto';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AssetType, ImmichWorker } from 'src/enum'; import { AssetType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { import {
@@ -55,7 +55,7 @@ describe(LibraryService.name, () => {
it('should init cron job and handle config changes', async () => { it('should init cron job and handle config changes', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); systemMock.get.mockResolvedValue(systemConfigStub.libraryScan);
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
expect(jobMock.addCronJob).toHaveBeenCalled(); expect(jobMock.addCronJob).toHaveBeenCalled();
expect(systemMock.get).toHaveBeenCalled(); expect(systemMock.get).toHaveBeenCalled();
@@ -91,7 +91,7 @@ describe(LibraryService.name, () => {
), ),
); );
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
expect(storageMock.watch.mock.calls).toEqual( expect(storageMock.watch.mock.calls).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@@ -104,7 +104,7 @@ describe(LibraryService.name, () => {
it('should not initialize watcher when watching is disabled', async () => { it('should not initialize watcher when watching is disabled', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
expect(storageMock.watch).not.toHaveBeenCalled(); expect(storageMock.watch).not.toHaveBeenCalled();
}); });
@@ -113,32 +113,17 @@ describe(LibraryService.name, () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
databaseMock.tryLock.mockResolvedValue(false); databaseMock.tryLock.mockResolvedValue(false);
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
expect(storageMock.watch).not.toHaveBeenCalled(); expect(storageMock.watch).not.toHaveBeenCalled();
}); });
it('should not initialize library scan cron job when lock is taken', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
databaseMock.tryLock.mockResolvedValue(false);
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
expect(jobMock.addCronJob).not.toHaveBeenCalled();
});
it('should not initialize watcher or library scan job when running on api', async () => {
await sut.onBootstrap(ImmichWorker.API);
expect(jobMock.addCronJob).not.toHaveBeenCalled();
});
}); });
describe('onConfigUpdateEvent', () => { describe('onConfigUpdateEvent', () => {
beforeEach(async () => { beforeEach(async () => {
systemMock.get.mockResolvedValue(defaults); systemMock.get.mockResolvedValue(defaults);
databaseMock.tryLock.mockResolvedValue(true); databaseMock.tryLock.mockResolvedValue(true);
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
}); });
it('should do nothing if oldConfig is not provided', async () => { it('should do nothing if oldConfig is not provided', async () => {
@@ -148,7 +133,7 @@ describe(LibraryService.name, () => {
it('should do nothing if instance does not have the watch lock', async () => { it('should do nothing if instance does not have the watch lock', async () => {
databaseMock.tryLock.mockResolvedValue(false); databaseMock.tryLock.mockResolvedValue(false);
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScan as SystemConfig, oldConfig: defaults }); await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScan as SystemConfig, oldConfig: defaults });
expect(jobMock.updateCronJob).not.toHaveBeenCalled(); expect(jobMock.updateCronJob).not.toHaveBeenCalled();
}); });
@@ -708,7 +693,7 @@ describe(LibraryService.name, () => {
const mockClose = vitest.fn(); const mockClose = vitest.fn();
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); await sut.delete(libraryStub.externalLibraryWithImportPaths1.id);
expect(mockClose).toHaveBeenCalled(); expect(mockClose).toHaveBeenCalled();
@@ -842,7 +827,7 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([]); libraryMock.getAll.mockResolvedValue([]);
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
await sut.create({ await sut.create({
ownerId: authStub.admin.user.id, ownerId: authStub.admin.user.id,
importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
@@ -905,7 +890,7 @@ describe(LibraryService.name, () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.getAll.mockResolvedValue([]); libraryMock.getAll.mockResolvedValue([]);
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
}); });
it('should throw an error if an import path is invalid', async () => { it('should throw an error if an import path is invalid', async () => {
@@ -946,7 +931,7 @@ describe(LibraryService.name, () => {
beforeEach(async () => { beforeEach(async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
}); });
it('should not watch library', async () => { it('should not watch library', async () => {
@@ -962,7 +947,7 @@ describe(LibraryService.name, () => {
beforeEach(async () => { beforeEach(async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled); systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.getAll.mockResolvedValue([]); libraryMock.getAll.mockResolvedValue([]);
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
}); });
it('should watch library', async () => { it('should watch library', async () => {
@@ -1128,7 +1113,7 @@ describe(LibraryService.name, () => {
const mockClose = vitest.fn(); const mockClose = vitest.fn();
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
await sut.onBootstrap(ImmichWorker.MICROSERVICES); await sut.onBootstrap();
await sut.onShutdown(); await sut.onShutdown();
expect(mockClose).toHaveBeenCalledTimes(2); expect(mockClose).toHaveBeenCalledTimes(2);
+15 -20
View File
@@ -16,7 +16,7 @@ import {
} from 'src/dtos/library.dto'; } from 'src/dtos/library.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryEntity } from 'src/entities/library.entity'; import { LibraryEntity } from 'src/entities/library.entity';
import { AssetType, ImmichWorker } from 'src/enum'; import { AssetType } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { import {
@@ -36,32 +36,27 @@ import { validateCronExpression } from 'src/validation';
@Injectable() @Injectable()
export class LibraryService extends BaseService { export class LibraryService extends BaseService {
private watchLibraries = false; private watchLibraries = false;
private lock = false; private watchLock = false;
private watchers: Record<string, () => Promise<void>> = {}; private watchers: Record<string, () => Promise<void>> = {};
@OnEvent({ name: 'app.bootstrap' }) @OnEvent({ name: 'app.bootstrap' })
async onBootstrap(workerType: ImmichWorker) { async onBootstrap() {
if (workerType !== ImmichWorker.MICROSERVICES) {
return;
}
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
const { watch, scan } = config.library; const { watch, scan } = config.library;
// This ensures that library watching only occurs in one microservice // This ensures that library watching only occurs in one microservice
this.lock = await this.databaseRepository.tryLock(DatabaseLock.Library); // TODO: we could make the lock be per-library instead of global
this.watchLock = await this.databaseRepository.tryLock(DatabaseLock.LibraryWatch);
this.watchLibraries = this.lock && watch.enabled; this.watchLibraries = this.watchLock && watch.enabled;
if (this.lock) { this.jobRepository.addCronJob(
this.jobRepository.addCronJob( 'libraryScan',
'libraryScan', scan.cronExpression,
scan.cronExpression, () => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger),
() => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger), scan.enabled,
scan.enabled, );
);
}
if (this.watchLibraries) { if (this.watchLibraries) {
await this.watchAll(); await this.watchAll();
@@ -70,7 +65,7 @@ export class LibraryService extends BaseService {
@OnEvent({ name: 'config.update', server: true }) @OnEvent({ name: 'config.update', server: true })
async onConfigUpdate({ newConfig: { library }, oldConfig }: ArgOf<'config.update'>) { async onConfigUpdate({ newConfig: { library }, oldConfig }: ArgOf<'config.update'>) {
if (!oldConfig || !this.lock) { if (!oldConfig || !this.watchLock) {
return; return;
} }
@@ -185,7 +180,7 @@ export class LibraryService extends BaseService {
} }
private async unwatchAll() { private async unwatchAll() {
if (!this.lock) { if (!this.watchLock) {
return false; return false;
} }
@@ -195,7 +190,7 @@ export class LibraryService extends BaseService {
} }
async watchAll() { async watchAll() {
if (!this.lock) { if (!this.watchLock) {
return false; return false;
} }
+16 -2
View File
@@ -31,6 +31,7 @@ const responseDto: PersonResponseDto = {
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false, isHidden: false,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
withArchived: false,
}; };
const statistics = { assets: 3 }; const statistics = { assets: 3 };
@@ -118,6 +119,7 @@ describe(PersonService.name, () => {
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: true, isHidden: true,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
withArchived: false,
}, },
], ],
}); });
@@ -218,6 +220,16 @@ describe(PersonService.name, () => {
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
}); });
it("should update a person's withArchived", async () => {
personMock.update.mockResolvedValue(personStub.withName);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { withArchived: true })).resolves.toEqual(responseDto);
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', withArchived: true });
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it("should update a person's date of birth", async () => { it("should update a person's date of birth", async () => {
personMock.update.mockResolvedValue(personStub.withBirthDate); personMock.update.mockResolvedValue(personStub.withBirthDate);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
@@ -229,6 +241,7 @@ describe(PersonService.name, () => {
thumbnailPath: '/path/to/thumbnail.jpg', thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false, isHidden: false,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
withArchived: false,
}); });
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
@@ -381,6 +394,7 @@ describe(PersonService.name, () => {
name: personStub.noName.name, name: personStub.noName.name,
thumbnailPath: personStub.noName.thumbnailPath, thumbnailPath: personStub.noName.thumbnailPath,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
withArchived: personStub.noName.withArchived,
}); });
expect(jobMock.queue).not.toHaveBeenCalledWith(); expect(jobMock.queue).not.toHaveBeenCalledWith();
@@ -1171,13 +1185,13 @@ describe(PersonService.name, () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson); personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getStatistics.mockResolvedValue(statistics); personMock.getStatistics.mockResolvedValue(statistics);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); await expect(sut.getStatistics(authStub.admin, 'person-1', {})).resolves.toEqual({ assets: 3 });
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
}); });
it('should require person.read permission', async () => { it('should require person.read permission', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson); personMock.getById.mockResolvedValue(personStub.primaryPerson);
await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.getStatistics(authStub.admin, 'person-1', {})).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
}); });
}); });
+12 -4
View File
@@ -14,6 +14,7 @@ import {
PersonResponseDto, PersonResponseDto,
PersonSearchDto, PersonSearchDto,
PersonStatisticsResponseDto, PersonStatisticsResponseDto,
PersonStatsDto,
PersonUpdateDto, PersonUpdateDto,
mapFaces, mapFaces,
mapPerson, mapPerson,
@@ -153,9 +154,9 @@ export class PersonService extends BaseService {
return this.findOrFail(id).then(mapPerson); return this.findOrFail(id).then(mapPerson);
} }
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> { async getStatistics(auth: AuthDto, id: string, dto: PersonStatsDto): Promise<PersonStatisticsResponseDto> {
await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] });
return this.personRepository.getStatistics(id); return this.personRepository.getStatistics(id, dto);
} }
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> { async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
@@ -184,7 +185,7 @@ export class PersonService extends BaseService {
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; const { name, birthDate, isHidden, featureFaceAssetId: assetId, withArchived } = dto;
// TODO: set by faceId directly // TODO: set by faceId directly
let faceId: string | undefined = undefined; let faceId: string | undefined = undefined;
if (assetId) { if (assetId) {
@@ -197,7 +198,14 @@ export class PersonService extends BaseService {
faceId = face.id; faceId = face.id;
} }
const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden }); const person = await this.personRepository.update({
id,
faceAssetId: faceId,
name,
birthDate,
isHidden,
withArchived,
});
if (assetId) { if (assetId) {
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
+5 -5
View File
@@ -15,6 +15,7 @@ import { JobName } from 'src/interfaces/job.interface';
import { UserFindOptions } from 'src/interfaces/user.interface'; import { UserFindOptions } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
import { createUser } from 'src/utils/user';
@Injectable() @Injectable()
export class UserAdminService extends BaseService { export class UserAdminService extends BaseService {
@@ -24,18 +25,17 @@ export class UserAdminService extends BaseService {
} }
async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> { async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
const { notify, ...userDto } = dto; const { notify, ...rest } = dto;
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
if (!config.oauth.enabled && !userDto.password) { if (!config.oauth.enabled && !rest.password) {
throw new BadRequestException('password is required'); throw new BadRequestException('password is required');
} }
const user = await createUser({ userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, rest);
const user = await this.createUser(userDto);
await this.eventRepository.emit('user.signup', { await this.eventRepository.emit('user.signup', {
notify: !!notify, notify: !!notify,
id: user.id, id: user.id,
tempPassword: user.shouldChangePassword ? userDto.password : undefined, tempPassword: user.shouldChangePassword ? rest.password : undefined,
}); });
return mapUserAdmin(user); return mapUserAdmin(user);
+35
View File
@@ -0,0 +1,35 @@
import { BadRequestException } from '@nestjs/common';
import sanitize from 'sanitize-filename';
import { SALT_ROUNDS } from 'src/constants';
import { UserEntity } from 'src/entities/user.entity';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
type RepoDeps = { userRepo: IUserRepository; cryptoRepo: ICryptoRepository };
export const createUser = async (
{ userRepo, cryptoRepo }: RepoDeps,
dto: Partial<UserEntity> & { email: string },
): Promise<UserEntity> => {
const user = await userRepo.getByEmail(dto.email);
if (user) {
throw new BadRequestException('User exists');
}
if (!dto.isAdmin) {
const localAdmin = await userRepo.getAdmin();
if (!localAdmin) {
throw new BadRequestException('The first registered account must the administrator.');
}
}
const payload: Partial<UserEntity> = { ...dto };
if (payload.password) {
payload.password = await cryptoRepo.hashBcrypt(payload.password, SALT_ROUNDS);
}
if (payload.storageLabel) {
payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
}
return userRepo.create(payload);
};
+1 -1
View File
@@ -20,7 +20,7 @@ async function bootstrap() {
process.title = 'immich-api'; process.title = 'immich-api';
const { telemetry, network } = new ConfigRepository().getEnv(); const { telemetry, network } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) { if (telemetry.enabled) {
bootstrapTelemetry(telemetry.apiPort); bootstrapTelemetry(telemetry.apiPort);
} }
+1 -1
View File
@@ -11,7 +11,7 @@ import { isStartUpError } from 'src/services/storage.service';
export async function bootstrap() { export async function bootstrap() {
const { telemetry } = new ConfigRepository().getEnv(); const { telemetry } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) { if (telemetry.enabled) {
bootstrapTelemetry(telemetry.microservicesPort); bootstrapTelemetry(telemetry.microservicesPort);
} }
+9
View File
@@ -15,6 +15,7 @@ export const personStub = {
faceAssetId: null, faceAssetId: null,
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
withArchived: false,
}), }),
hidden: Object.freeze<PersonEntity>({ hidden: Object.freeze<PersonEntity>({
id: 'person-1', id: 'person-1',
@@ -29,6 +30,7 @@ export const personStub = {
faceAssetId: null, faceAssetId: null,
faceAsset: null, faceAsset: null,
isHidden: true, isHidden: true,
withArchived: false,
}), }),
withName: Object.freeze<PersonEntity>({ withName: Object.freeze<PersonEntity>({
id: 'person-1', id: 'person-1',
@@ -43,6 +45,7 @@ export const personStub = {
faceAssetId: 'assetFaceId', faceAssetId: 'assetFaceId',
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
withArchived: false,
}), }),
withBirthDate: Object.freeze<PersonEntity>({ withBirthDate: Object.freeze<PersonEntity>({
id: 'person-1', id: 'person-1',
@@ -57,6 +60,7 @@ export const personStub = {
faceAssetId: null, faceAssetId: null,
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
withArchived: false,
}), }),
noThumbnail: Object.freeze<PersonEntity>({ noThumbnail: Object.freeze<PersonEntity>({
id: 'person-1', id: 'person-1',
@@ -71,6 +75,7 @@ export const personStub = {
faceAssetId: null, faceAssetId: null,
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
withArchived: false,
}), }),
newThumbnail: Object.freeze<PersonEntity>({ newThumbnail: Object.freeze<PersonEntity>({
id: 'person-1', id: 'person-1',
@@ -85,6 +90,7 @@ export const personStub = {
faceAssetId: 'asset-id', faceAssetId: 'asset-id',
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
withArchived: false,
}), }),
primaryPerson: Object.freeze<PersonEntity>({ primaryPerson: Object.freeze<PersonEntity>({
id: 'person-1', id: 'person-1',
@@ -99,6 +105,7 @@ export const personStub = {
faceAssetId: null, faceAssetId: null,
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
withArchived: false,
}), }),
mergePerson: Object.freeze<PersonEntity>({ mergePerson: Object.freeze<PersonEntity>({
id: 'person-2', id: 'person-2',
@@ -113,6 +120,7 @@ export const personStub = {
faceAssetId: null, faceAssetId: null,
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
withArchived: false,
}), }),
randomPerson: Object.freeze<PersonEntity>({ randomPerson: Object.freeze<PersonEntity>({
id: 'person-3', id: 'person-3',
@@ -127,5 +135,6 @@ export const personStub = {
faceAssetId: null, faceAssetId: null,
faceAsset: null, faceAsset: null,
isHidden: false, isHidden: false,
withArchived: false,
}), }),
}; };
@@ -16,16 +16,11 @@ const envData: EnvData = {
}, },
database: { database: {
config: { host: 'database',
type: 'postgres', port: 5432,
host: 'database', username: 'postgres',
port: 5432, password: 'postgres',
username: 'postgres', name: 'immich',
password: 'postgres',
name: 'immich',
synchronize: false,
migrationsRun: true,
},
skipMigrations: false, skipMigrations: false,
vectorExtension: DatabaseExtension.VECTORS, vectorExtension: DatabaseExtension.VECTORS,
@@ -78,7 +73,11 @@ const envData: EnvData = {
telemetry: { telemetry: {
apiPort: 8081, apiPort: 8081,
microservicesPort: 8082, microservicesPort: 8082,
metrics: new Set(), enabled: false,
hostMetrics: false,
apiMetrics: false,
jobMetrics: false,
repoMetrics: false,
}, },
workers: [ImmichWorker.API, ImmichWorker.MICROSERVICES], workers: [ImmichWorker.API, ImmichWorker.MICROSERVICES],
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.119.0", "version": "1.118.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-web", "name": "immich-web",
"version": "1.119.0", "version": "1.118.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@formatjs/icu-messageformat-parser": "^2.7.8", "@formatjs/icu-messageformat-parser": "^2.7.8",
@@ -74,7 +74,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.119.0", "version": "1.118.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.119.0", "version": "1.118.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000", "dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -45,6 +45,8 @@
import { import {
mdiAccountBoxOutline, mdiAccountBoxOutline,
mdiAccountMultipleCheckOutline, mdiAccountMultipleCheckOutline,
mdiArchiveArrowDown,
mdiArchiveArrowDownOutline,
mdiArrowLeft, mdiArrowLeft,
mdiCalendarEditOutline, mdiCalendarEditOutline,
mdiDotsVertical, mdiDotsVertical,
@@ -72,8 +74,10 @@
UNASSIGN_ASSETS = 'unassign-faces', UNASSIGN_ASSETS = 'unassign-faces',
} }
$: isArchived = person.withArchived ? undefined : person.withArchived;
let assetStore = new AssetStore({ let assetStore = new AssetStore({
isArchived: false, isArchived,
personId: data.person.id, personId: data.person.id,
}); });
@@ -81,7 +85,7 @@
$: thumbnailData = getPeopleThumbnailUrl(person); $: thumbnailData = getPeopleThumbnailUrl(person);
$: if (person) { $: if (person) {
handlePromiseError(updateAssetCount()); handlePromiseError(updateAssetCount());
handlePromiseError(assetStore.updateOptions({ personId: person.id })); handlePromiseError(assetStore.updateOptions({ personId: person.id, isArchived }));
} }
const assetInteractionStore = createAssetInteractionStore(); const assetInteractionStore = createAssetInteractionStore();
@@ -338,6 +342,27 @@
} }
}; };
const handleToggleWithArhived = async () => {
const withArchived = !person.withArchived;
try {
await updatePerson({
id: person.id,
personUpdateDto: { withArchived },
});
data.person.withArchived = withArchived;
refreshAssetGrid = !refreshAssetGrid;
} catch (error) {
handleError(error, $t('errors.unable_to_archive_unarchive', { values: { archived: withArchived } }));
}
};
const handleDeleteAssets = async (assetIds: string[]) => {
$assetStore.removeAssets(assetIds);
await updateAssetCount();
};
onDestroy(() => { onDestroy(() => {
assetStore.destroy(); assetStore.destroy();
}); });
@@ -395,7 +420,7 @@
<ChangeDate menuItem /> <ChangeDate menuItem />
<ChangeLocation menuItem /> <ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} /> <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} /> <DeleteAssets menuItem onAssetDelete={handleDeleteAssets} />
</ButtonContextMenu> </ButtonContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}
@@ -423,6 +448,11 @@
icon={mdiAccountMultipleCheckOutline} icon={mdiAccountMultipleCheckOutline}
onClick={() => (viewMode = ViewMode.MERGE_PEOPLE)} onClick={() => (viewMode = ViewMode.MERGE_PEOPLE)}
/> />
<MenuOption
text={$t('show_archived_or_unarchived_assets', { values: { with: !person.withArchived } })}
icon={person.withArchived ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
onClick={handleToggleWithArhived}
/>
</ButtonContextMenu> </ButtonContextMenu>
</svelte:fragment> </svelte:fragment>
</ControlAppBar> </ControlAppBar>
+1 -1
View File
@@ -86,7 +86,7 @@
<svelte:head> <svelte:head>
<title>{$page.data.meta?.title || 'Web'} - Immich</title> <title>{$page.data.meta?.title || 'Web'} - Immich</title>
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" /> <link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="currentColor" /> <meta name="theme-color" content="currentColor" />
<AppleHeader /> <AppleHeader />