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",
"version": "2.2.27",
"version": "2.2.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.27",
"version": "2.2.26",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.119.0",
"version": "1.118.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.27",
"version": "2.2.26",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"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
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
`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.
+9 -4
View File
@@ -183,10 +183,15 @@ Other machine learning parameters can be tuned from the admin UI.
## Prometheus
| 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_TELEMETRY_EXCLUDE` | Do not collect these telemetries. List of `host`, `api`, `io`, `repo`, `job` | | server | api, microservices |
| Variable | Description | Default | Containers | Workers |
| :----------------------------- | :-------------------------------------------------------------------------------------------- | :-----: | :--------- | :----------------- |
| `IMMICH_METRICS`<sup>\*1</sup> | Toggle all metrics (one of [`true`, `false`]) | | 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
-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:
- `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
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",
"url": "https://v1.118.2.archive.immich.app"
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.119.0",
"version": "1.118.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.119.0",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.27",
"version": "2.2.26",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -92,7 +92,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.119.0",
"version": "1.118.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.119.0",
"version": "1.118.2",
"description": "",
"main": "index.js",
"type": "module",
+1
View File
@@ -1141,6 +1141,7 @@
"show_albums": "Show albums",
"show_all_people": "Show all 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_gallery": "Show gallery",
"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]]
name = "aiocache"
@@ -3776,4 +3776,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4.0"
content-hash = "f95dddfd343a4b2f4d19ffee71ce6b2f5137e5514a60765424164259c4dc1044"
content-hash = "f4594d26ee661fb239c7b5750a4c79e5e049480182928af816ccf5e34e8b641f"
+2 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.119.0"
version = "1.118.2"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
@@ -18,6 +18,7 @@ pydantic-settings = "^2.5.2"
aiocache = ">=0.12.1,<1.0"
rich = ">=13.4.2"
ftfy = ">=6.1.1"
setuptools = "^70.0.0"
python-multipart = ">=0.0.6,<1.0"
orjson = ">=3.9.5"
gunicorn = ">=21.1.0"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 164,
"android.injected.version.name" => "1.119.0",
"android.injected.version.code" => 163,
"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')
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.119.0"
version_number: "1.118.2"
)
increment_build_number(
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:
- API version: 1.119.0
- API version: 1.118.2
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+11 -3
View File
@@ -184,7 +184,9 @@ class PeopleApi {
/// Parameters:
///
/// * [String] id (required):
Future<Response> getPersonStatisticsWithHttpInfo(String id,) async {
///
/// * [bool] withArchived:
Future<Response> getPersonStatisticsWithHttpInfo(String id, { bool? withArchived, }) async {
// ignore: prefer_const_declarations
final path = r'/people/{id}/statistics'
.replaceAll('{id}', id);
@@ -196,6 +198,10 @@ class PeopleApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (withArchived != null) {
queryParams.addAll(_queryParams('', 'withArchived', withArchived));
}
const contentTypes = <String>[];
@@ -213,8 +219,10 @@ class PeopleApi {
/// Parameters:
///
/// * [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) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+21 -3
View File
@@ -18,6 +18,7 @@ class PeopleUpdateItem {
required this.id,
this.isHidden,
this.name,
this.withArchived,
});
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
@@ -53,13 +54,23 @@ class PeopleUpdateItem {
///
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
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem &&
other.birthDate == birthDate &&
other.featureFaceAssetId == featureFaceAssetId &&
other.id == id &&
other.isHidden == isHidden &&
other.name == name;
other.name == name &&
other.withArchived == withArchived;
@override
int get hashCode =>
@@ -68,10 +79,11 @@ class PeopleUpdateItem {
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(id.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode) +
(name == null ? 0 : name!.hashCode);
(name == null ? 0 : name!.hashCode) +
(withArchived == null ? 0 : withArchived!.hashCode);
@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() {
final json = <String, dynamic>{};
@@ -96,6 +108,11 @@ class PeopleUpdateItem {
} else {
// json[r'name'] = null;
}
if (this.withArchived != null) {
json[r'withArchived'] = this.withArchived;
} else {
// json[r'withArchived'] = null;
}
return json;
}
@@ -113,6 +130,7 @@ class PeopleUpdateItem {
id: mapValueOfType<String>(json, r'id')!,
isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
);
}
return null;
+21 -3
View File
@@ -19,6 +19,7 @@ class PersonResponseDto {
required this.name,
required this.thumbnailPath,
this.updatedAt,
this.withArchived,
});
DateTime? birthDate;
@@ -40,6 +41,15 @@ class PersonResponseDto {
///
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
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
other.birthDate == birthDate &&
@@ -47,7 +57,8 @@ class PersonResponseDto {
other.isHidden == isHidden &&
other.name == name &&
other.thumbnailPath == thumbnailPath &&
other.updatedAt == updatedAt;
other.updatedAt == updatedAt &&
other.withArchived == withArchived;
@override
int get hashCode =>
@@ -57,10 +68,11 @@ class PersonResponseDto {
(isHidden.hashCode) +
(name.hashCode) +
(thumbnailPath.hashCode) +
(updatedAt == null ? 0 : updatedAt!.hashCode);
(updatedAt == null ? 0 : updatedAt!.hashCode) +
(withArchived == null ? 0 : withArchived!.hashCode);
@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() {
final json = <String, dynamic>{};
@@ -78,6 +90,11 @@ class PersonResponseDto {
} else {
// json[r'updatedAt'] = null;
}
if (this.withArchived != null) {
json[r'withArchived'] = this.withArchived;
} else {
// json[r'withArchived'] = null;
}
return json;
}
@@ -96,6 +113,7 @@ class PersonResponseDto {
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
updatedAt: mapDateTime(json, r'updatedAt', r''),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
);
}
return null;
+21 -3
View File
@@ -17,6 +17,7 @@ class PersonUpdateDto {
this.featureFaceAssetId,
this.isHidden,
this.name,
this.withArchived,
});
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
@@ -49,12 +50,22 @@ class PersonUpdateDto {
///
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
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
other.birthDate == birthDate &&
other.featureFaceAssetId == featureFaceAssetId &&
other.isHidden == isHidden &&
other.name == name;
other.name == name &&
other.withArchived == withArchived;
@override
int get hashCode =>
@@ -62,10 +73,11 @@ class PersonUpdateDto {
(birthDate == null ? 0 : birthDate!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode) +
(name == null ? 0 : name!.hashCode);
(name == null ? 0 : name!.hashCode) +
(withArchived == null ? 0 : withArchived!.hashCode);
@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() {
final json = <String, dynamic>{};
@@ -89,6 +101,11 @@ class PersonUpdateDto {
} else {
// json[r'name'] = null;
}
if (this.withArchived != null) {
json[r'withArchived'] = this.withArchived;
} else {
// json[r'withArchived'] = null;
}
return json;
}
@@ -105,6 +122,7 @@ class PersonUpdateDto {
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
);
}
return null;
+21 -3
View File
@@ -20,6 +20,7 @@ class PersonWithFacesResponseDto {
required this.name,
required this.thumbnailPath,
this.updatedAt,
this.withArchived,
});
DateTime? birthDate;
@@ -43,6 +44,15 @@ class PersonWithFacesResponseDto {
///
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
bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto &&
other.birthDate == birthDate &&
@@ -51,7 +61,8 @@ class PersonWithFacesResponseDto {
other.isHidden == isHidden &&
other.name == name &&
other.thumbnailPath == thumbnailPath &&
other.updatedAt == updatedAt;
other.updatedAt == updatedAt &&
other.withArchived == withArchived;
@override
int get hashCode =>
@@ -62,10 +73,11 @@ class PersonWithFacesResponseDto {
(isHidden.hashCode) +
(name.hashCode) +
(thumbnailPath.hashCode) +
(updatedAt == null ? 0 : updatedAt!.hashCode);
(updatedAt == null ? 0 : updatedAt!.hashCode) +
(withArchived == null ? 0 : withArchived!.hashCode);
@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() {
final json = <String, dynamic>{};
@@ -84,6 +96,11 @@ class PersonWithFacesResponseDto {
} else {
// json[r'updatedAt'] = null;
}
if (this.withArchived != null) {
json[r'withArchived'] = this.withArchived;
} else {
// json[r'withArchived'] = null;
}
return json;
}
@@ -103,6 +120,7 @@ class PersonWithFacesResponseDto {
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
updatedAt: mapDateTime(json, r'updatedAt', r''),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
);
}
return null;
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.119.0+164
version: 1.118.2+163
environment:
sdk: '>=3.3.0 <4.0.0'
+25 -1
View File
@@ -4152,6 +4152,14 @@
"format": "uuid",
"type": "string"
}
},
{
"name": "withArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -7385,7 +7393,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.119.0",
"version": "1.118.2",
"contact": {}
},
"tags": [],
@@ -10098,6 +10106,10 @@
"name": {
"description": "Person name.",
"type": "string"
},
"withArchived": {
"description": "This property was added in v1.119.0",
"type": "boolean"
}
},
"required": [
@@ -10230,6 +10242,10 @@
"description": "This property was added in v1.107.0",
"format": "date-time",
"type": "string"
},
"withArchived": {
"description": "This property was added in v1.119.0",
"type": "boolean"
}
},
"required": [
@@ -10271,6 +10287,10 @@
"name": {
"description": "Person name.",
"type": "string"
},
"withArchived": {
"description": "This property was added in v1.119.0",
"type": "boolean"
}
},
"type": "object"
@@ -10304,6 +10324,10 @@
"description": "This property was added in v1.107.0",
"format": "date-time",
"type": "string"
},
"withArchived": {
"description": "This property was added in v1.119.0",
"type": "boolean"
}
},
"required": [
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.119.0",
"version": "1.118.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.119.0",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.119.0",
"version": "1.118.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
+14 -3
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 1.119.0
* 1.118.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -220,6 +220,8 @@ export type PersonWithFacesResponseDto = {
thumbnailPath: string;
/** This property was added in v1.107.0 */
updatedAt?: string;
/** This property was added in v1.119.0 */
withArchived?: boolean;
};
export type SmartInfoResponseDto = {
objects?: string[] | null;
@@ -502,6 +504,8 @@ export type PersonResponseDto = {
thumbnailPath: string;
/** This property was added in v1.107.0 */
updatedAt?: string;
/** This property was added in v1.119.0 */
withArchived?: boolean;
};
export type AssetFaceResponseDto = {
boundingBoxX1: number;
@@ -703,6 +707,8 @@ export type PeopleUpdateItem = {
isHidden?: boolean;
/** Person name. */
name?: string;
/** This property was added in v1.119.0 */
withArchived?: boolean;
};
export type PeopleUpdateDto = {
people: PeopleUpdateItem[];
@@ -717,6 +723,8 @@ export type PersonUpdateDto = {
isHidden?: boolean;
/** Person name. */
name?: string;
/** This property was added in v1.119.0 */
withArchived?: boolean;
};
export type MergePersonDto = {
ids: string[];
@@ -2410,13 +2418,16 @@ export function reassignFaces({ id, assetFaceUpdateDto }: {
body: assetFaceUpdateDto
})));
}
export function getPersonStatistics({ id }: {
export function getPersonStatistics({ id, withArchived }: {
id: string;
withArchived?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PersonStatisticsResponseDto;
}>(`/people/${encodeURIComponent(id)}/statistics`, {
}>(`/people/${encodeURIComponent(id)}/statistics${QS.query(QS.explode({
withArchived
}))}`, {
...opts
}));
}
+2 -2
View File
@@ -1,5 +1,5 @@
# 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
WORKDIR /usr/src/app
@@ -42,7 +42,7 @@ RUN npm run 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
ENV NODE_ENV=production \
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.119.0",
"version": "1.118.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.119.0",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^10.0.1",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.119.0",
"version": "1.118.2",
"description": "",
"author": "",
"private": true,
@@ -24,10 +24,10 @@
"typeorm": "typeorm",
"lifecycle": "node ./dist/utils/lifecycle.js",
"typeorm:migrations:create": "typeorm migration:create",
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/bin/database.js",
"typeorm:migrations:run": "typeorm migration:run -d ./dist/bin/database.js",
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/bin/database.js",
"typeorm:schema:drop": "typeorm query -d ./dist/bin/database.js 'DROP schema public cascade; CREATE schema public;'",
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/database.config.js",
"typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js",
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js",
"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",
"sync:open-api": "node ./dist/bin/sync-open-api.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 { clsConfig, immichAppConfig } from 'src/config';
import { controllers } from 'src/controllers';
import { databaseConfig } from 'src/database.config';
import { entities } from 'src/entities';
import { ImmichWorker } from 'src/enum';
import { IEventRepository } from 'src/interfaces/event.interface';
@@ -37,7 +38,7 @@ const middleware = [
];
const configRepository = new ConfigRepository();
const { bull, database, otel } = configRepository.getEnv();
const { bull, otel } = configRepository.getEnv();
const imports = [
BullModule.forRoot(bull.config),
@@ -49,7 +50,7 @@ const imports = [
inject: [ModuleRef],
useFactory: (moduleRef: ModuleRef) => {
return {
...database.config,
...databaseConfig,
poolErrorHandler: (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 { join } from 'node:path';
import { format } from 'sql-formatter';
import { databaseConfig } from 'src/database.config';
import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators';
import { entities } from 'src/entities';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@@ -73,12 +74,12 @@ class SqlGenerator {
await rm(this.options.targetDir, { force: true, recursive: true });
await mkdir(this.options.targetDir);
const { database, otel } = new ConfigRepository().getEnv();
const { otel } = new ConfigRepository().getEnv();
const moduleFixture = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
...database.config,
...databaseConfig,
host: 'localhost',
entities,
logging: ['query'],
+7 -2
View File
@@ -12,6 +12,7 @@ import {
PersonResponseDto,
PersonSearchDto,
PersonStatisticsResponseDto,
PersonStatsDto,
PersonUpdateDto,
} from 'src/dtos/person.dto';
import { Permission } from 'src/enum';
@@ -65,8 +66,12 @@ export class PersonController {
@Get(':id/statistics')
@Authenticated({ permission: Permission.PERSON_STATISTICS })
getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id);
getPersonStatistics(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: PersonStatsDto,
): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id, dto);
}
@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 { 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 { PropertyLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -41,6 +41,11 @@ export class PersonUpdateDto extends PersonCreateDto {
@Optional()
@IsString()
featureFaceAssetId?: string;
@Optional()
@IsBoolean()
@PropertyLifecycle({ addedAt: 'v1.119.0' })
withArchived?: boolean;
}
export class PeopleUpdateDto {
@@ -93,6 +98,8 @@ export class PersonResponseDto {
isHidden!: boolean;
@PropertyLifecycle({ addedAt: 'v1.107.0' })
updatedAt?: Date;
@PropertyLifecycle({ addedAt: 'v1.119.0' })
withArchived?: boolean;
}
export class PersonWithFacesResponseDto extends PersonResponseDto {
@@ -147,6 +154,11 @@ export class PersonStatisticsResponseDto {
assets!: number;
}
export class PersonStatsDto {
@ValidateBoolean({ optional: true })
withArchived?: boolean;
}
export class PeopleResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;
@@ -167,6 +179,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
updatedAt: person.updatedAt,
withArchived: person.withArchived,
};
}
+1 -1
View File
@@ -52,7 +52,7 @@ export class AlbumEntity {
albumUsers!: AlbumUserEntity[];
@ManyToMany(() => AssetEntity, (asset) => asset.albums)
@JoinTable({ synchronize: false })
@JoinTable()
assets!: AssetEntity[];
@OneToMany(() => SharedLinkEntity, (link) => link.album)
+3
View File
@@ -49,4 +49,7 @@ export class PersonEntity {
@Column({ default: false })
isHidden!: boolean;
@Column({ default: false })
withArchived!: boolean;
}
-8
View File
@@ -363,11 +363,3 @@ export enum ImmichWorker {
API = 'api',
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 { RedisOptions } from 'ioredis';
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 { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
export const IConfigRepository = 'IConfigRepository';
@@ -37,7 +36,12 @@ export interface EnvData {
};
database: {
config: PostgresConnectionOptions;
url?: string;
host: string;
port: number;
username: string;
password: string;
name: string;
skipMigrations: boolean;
vectorExtension: VectorExtension;
};
@@ -73,7 +77,11 @@ export interface EnvData {
telemetry: {
apiPort: number;
microservicesPort: number;
metrics: Set<ImmichTelemetry>;
enabled: boolean;
apiMetrics: boolean;
hostMetrics: boolean;
repoMetrics: boolean;
jobMetrics: boolean;
};
storage: {
+1 -1
View File
@@ -19,7 +19,7 @@ export enum DatabaseLock {
StorageTemplateMigration = 420,
VersionHistory = 500,
CLIPDimSize = 512,
Library = 1337,
LibraryWatch = 1337,
GetSystemConfig = 69,
}
+5 -1
View File
@@ -45,6 +45,10 @@ export interface DeleteFacesOptions {
sourceType: SourceType;
}
export interface PersonStatsOptions {
withArchived?: boolean;
}
export type UnassignFacesOptions = DeleteFacesOptions;
export interface IPersonRepository {
@@ -74,7 +78,7 @@ export interface IPersonRepository {
getFaces(assetId: string): Promise<AssetFaceEntity[]>;
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
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>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
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)
ORDER BY
"entity"."fileCreatedAt" ASC
"entity"."localDateTime" ASC
-- AssetRepository.getByIds
SELECT
@@ -212,6 +212,7 @@ SELECT
"8258e303a73a72cf6abb13d73fb592dde0d68280"."thumbnailPath" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_thumbnailPath",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."faceAssetId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_faceAssetId",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."isHidden" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_isHidden",
"8258e303a73a72cf6abb13d73fb592dde0d68280"."withArchived" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_withArchived",
"AssetEntity__AssetEntity_stack"."id" AS "AssetEntity__AssetEntity_stack_id",
"AssetEntity__AssetEntity_stack"."ownerId" AS "AssetEntity__AssetEntity_stack_ownerId",
"AssetEntity__AssetEntity_stack"."primaryAssetId" AS "AssetEntity__AssetEntity_stack_primaryAssetId",
+19 -6
View File
@@ -17,7 +17,8 @@ SELECT
"person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden"
"person"."isHidden" AS "person_isHidden",
"person"."withArchived" AS "person_withArchived"
FROM
"person" "person"
LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id"
@@ -54,7 +55,8 @@ SELECT
"person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden"
"person"."isHidden" AS "person_isHidden",
"person"."withArchived" AS "person_withArchived"
FROM
"person" "person"
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"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"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
"asset_faces" "AssetFaceEntity"
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"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath",
"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
"asset_faces" "AssetFaceEntity"
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"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId",
"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"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId",
"AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId",
@@ -213,7 +218,8 @@ SELECT
"person"."birthDate" AS "person_birthDate",
"person"."thumbnailPath" AS "person_thumbnailPath",
"person"."faceAssetId" AS "person_faceAssetId",
"person"."isHidden" AS "person_isHidden"
"person"."isHidden" AS "person_isHidden",
"person"."withArchived" AS "person_withArchived"
FROM
"person" "person"
WHERE
@@ -242,11 +248,18 @@ FROM
"asset_faces" "face"
LEFT JOIN "assets" "asset" ON "asset"."id" = "face"."assetId"
AND ("asset"."deletedAt" IS NULL)
LEFT JOIN "person" "person" ON "person"."id" = "face"."personId"
WHERE
"face"."personId" = $1
AND "asset"."isArchived" = false
AND "asset"."deletedAt" IS NULL
AND "asset"."livePhotoVideoId" IS NULL
AND (
(
"person"."withArchived" = false
AND "asset"."isArchived" = false
)
OR "person"."withArchived" = true
)
-- PersonRepository.getNumberOfPeople
SELECT
+1 -1
View File
@@ -93,7 +93,7 @@ export class AssetRepository implements IAssetRepository {
)
.leftJoinAndSelect('entity.exifInfo', 'exifInfo')
.leftJoinAndSelect('entity.files', 'files')
.orderBy('entity.fileCreatedAt', 'ASC')
.orderBy('entity.localDateTime', 'ASC')
.getMany();
}
@@ -1,4 +1,3 @@
import { ImmichTelemetry } from 'src/enum';
import { clearEnvCache, ConfigRepository } from 'src/repositories/config.repository';
const getEnv = () => {
@@ -13,8 +12,11 @@ const resetEnv = () => {
'IMMICH_TRUSTED_PROXIES',
'IMMICH_API_METRICS_PORT',
'IMMICH_MICROSERVICES_METRICS_PORT',
'IMMICH_TELEMETRY_INCLUDE',
'IMMICH_TELEMETRY_EXCLUDE',
'IMMICH_METRICS',
'IMMICH_API_METRICS',
'IMMICH_HOST_METRICS',
'IMMICH_IO_METRICS',
'IMMICH_JOB_METRICS',
'DB_URL',
'DB_HOSTNAME',
@@ -66,14 +68,12 @@ describe('getEnv', () => {
it('should use defaults', () => {
const { database } = getEnv();
expect(database).toEqual({
config: expect.objectContaining({
type: 'postgres',
host: 'database',
port: 5432,
database: 'immich',
username: 'postgres',
password: 'postgres',
}),
url: undefined,
host: 'database',
port: 5432,
name: 'immich',
username: 'postgres',
password: 'postgres',
skipMigrations: false,
vectorExtension: 'vectors',
});
@@ -210,7 +210,11 @@ describe('getEnv', () => {
expect(telemetry).toEqual({
apiPort: 8081,
microservicesPort: 8082,
metrics: new Set([]),
enabled: false,
apiMetrics: false,
hostMetrics: false,
jobMetrics: false,
repoMetrics: false,
});
});
@@ -221,29 +225,32 @@ describe('getEnv', () => {
expect(telemetry).toMatchObject({
apiPort: 2001,
microservicesPort: 2002,
metrics: expect.any(Set),
});
});
it('should run with telemetry enabled', () => {
process.env.IMMICH_TELEMETRY_INCLUDE = 'all';
process.env.IMMICH_METRICS = 'true';
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', () => {
process.env.IMMICH_TELEMETRY_INCLUDE = 'all';
process.env.IMMICH_TELEMETRY_EXCLUDE = 'job';
process.env.IMMICH_METRICS = 'true';
process.env.IMMICH_JOB_METRICS = 'false';
const { telemetry } = getEnv();
expect(telemetry.metrics).toEqual(
new Set([ImmichTelemetry.API, ImmichTelemetry.HOST, ImmichTelemetry.IO, ImmichTelemetry.REPO]),
);
});
it('should run with specific telemetry metrics', () => {
process.env.IMMICH_TELEMETRY_INCLUDE = 'io, host, api';
const { telemetry } = getEnv();
expect(telemetry.metrics).toEqual(new Set([ImmichTelemetry.API, ImmichTelemetry.HOST, ImmichTelemetry.IO]));
expect(telemetry).toMatchObject({
enabled: true,
apiMetrics: true,
hostMetrics: true,
jobMetrics: false,
repoMetrics: true,
});
});
});
});
+28 -46
View File
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { join, resolve } from 'node:path';
import { join } from 'node:path';
import { citiesFile, excludePaths } from 'src/constants';
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 { DatabaseExtension } from 'src/interfaces/database.interface';
import { QueueName } from 'src/interfaces/job.interface';
@@ -25,17 +25,18 @@ const stagingKeys = {
};
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);
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 includedWorkers = asSet(process.env.IMMICH_WORKERS_INCLUDE, [ImmichWorker.API, ImmichWorker.MICROSERVICES]);
const excludedWorkers = asSet(process.env.IMMICH_WORKERS_EXCLUDE, []);
const workers = [...setDifference(includedWorkers, excludedWorkers)];
const included = asSet(process.env.IMMICH_WORKERS_INCLUDE, [ImmichWorker.API, ImmichWorker.MICROSERVICES]);
const excluded = asSet(process.env.IMMICH_WORKERS_EXCLUDE, []);
const workers = [...setDifference(included, excluded)];
for (const worker of workers) {
if (!WORKER_TYPES.has(worker)) {
throw new Error(`Invalid worker(s) found: ${workers.join(',')}`);
@@ -46,14 +47,10 @@ const getEnv = (): EnvData => {
const isProd = environment === ImmichEnvironment.PRODUCTION;
const buildFolder = process.env.IMMICH_BUILD_DATA || '/build';
const folders = {
// eslint-disable-next-line unicorn/prefer-module
dist: resolve(`${__dirname}/..`),
geodata: join(buildFolder, 'geodata'),
web: join(buildFolder, 'www'),
};
const databaseUrl = process.env.DB_URL;
let redisConfig = {
host: process.env.REDIS_HOSTNAME || 'redis',
port: Number.parseInt(process.env.REDIS_PORT || '') || 6379,
@@ -72,18 +69,12 @@ const getEnv = (): EnvData => {
}
}
const includedTelemetries =
process.env.IMMICH_TELEMETRY_INCLUDE === 'all'
? new Set(Object.values(ImmichTelemetry))
: asSet<ImmichTelemetry>(process.env.IMMICH_TELEMETRY_INCLUDE, []);
const excludedTelemetries = asSet<ImmichTelemetry>(process.env.IMMICH_TELEMETRY_EXCLUDE, []);
const telemetries = setDifference(includedTelemetries, excludedTelemetries);
for (const telemetry of telemetries) {
if (!TELEMETRY_TYPES.has(telemetry)) {
throw new Error(`Invalid telemetry found: ${telemetry}`);
}
}
const globalEnabled = parseBoolean(process.env.IMMICH_METRICS, false);
const hostMetrics = parseBoolean(process.env.IMMICH_HOST_METRICS, globalEnabled);
const apiMetrics = parseBoolean(process.env.IMMICH_API_METRICS, globalEnabled);
const repoMetrics = parseBoolean(process.env.IMMICH_IO_METRICS, globalEnabled);
const jobMetrics = parseBoolean(process.env.IMMICH_JOB_METRICS, globalEnabled);
const telemetryEnabled = globalEnabled || hostMetrics || apiMetrics || repoMetrics || jobMetrics;
return {
host: process.env.IMMICH_HOST,
@@ -122,25 +113,12 @@ const getEnv = (): EnvData => {
},
database: {
config: {
type: 'postgres',
entities: [`${folders.dist}/entities` + '/*.entity.{js,ts}'],
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
subscribers: [`${folders.dist}/subscribers` + '/*.{js,ts}'],
migrationsRun: false,
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',
}),
},
url: process.env.DB_URL,
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',
name: process.env.DB_DATABASE_NAME || 'immich',
skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true',
vectorExtension:
@@ -158,9 +136,9 @@ const getEnv = (): EnvData => {
otel: {
metrics: {
hostMetrics: telemetries.has(ImmichTelemetry.HOST),
hostMetrics,
apiMetrics: {
enable: telemetries.has(ImmichTelemetry.API),
enable: apiMetrics,
ignoreRoutes: excludePaths,
},
},
@@ -190,7 +168,11 @@ const getEnv = (): EnvData => {
telemetry: {
apiPort: Number(process.env.IMMICH_API_METRICS_PORT || '') || 8081,
microservicesPort: Number(process.env.IMMICH_MICROSERVICES_METRICS_PORT || '') || 8082,
metrics: telemetries,
enabled: telemetryEnabled,
hostMetrics,
apiMetrics,
repoMetrics,
jobMetrics,
},
workers,
+23 -6
View File
@@ -17,6 +17,7 @@ import {
PersonNameSearchOptions,
PersonSearchOptions,
PersonStatistics,
PersonStatsOptions,
UnassignFacesOptions,
UpdateFacesData,
} from 'src/interfaces/person.interface';
@@ -212,17 +213,33 @@ export class PersonRepository implements IPersonRepository {
return queryBuilder.getMany();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(personId: string): Promise<PersonStatistics> {
const items = await this.assetFaceRepository
@GenerateSql({ params: [DummyValue.UUID, {}] })
async getStatistics(personId: string, options: PersonStatsOptions): Promise<PersonStatistics> {
/*
* 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')
.leftJoin('face.asset', 'asset')
.where('face.personId = :personId', { personId })
.andWhere('asset.isArchived = false')
.andWhere('asset.deletedAt IS NULL')
.andWhere('asset.livePhotoVideoId IS NULL')
.select('COUNT(DISTINCT(asset.id))', 'count')
.getRawOne();
.select('COUNT(DISTINCT(asset.id))', 'count');
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 {
assets: items.count ?? 0,
};
@@ -14,7 +14,7 @@ import { snakeCase, startCase } from 'lodash';
import { MetricService } from 'nestjs-otel';
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
import { serverVersion } from 'src/constants';
import { ImmichTelemetry, MetadataKey } from 'src/enum';
import { MetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricGroupRepository, ITelemetryRepository, MetricGroupOptions } from 'src/interfaces/telemetry.interface';
@@ -99,18 +99,17 @@ export class TelemetryRepository implements ITelemetryRepository {
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
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.host = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.HOST) });
this.jobs = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.JOB) });
this.repo = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.REPO) });
this.api = new MetricGroupRepository(metricService).configure({ enabled: apiMetrics });
this.host = new MetricGroupRepository(metricService).configure({ enabled: hostMetrics });
this.jobs = new MetricGroupRepository(metricService).configure({ enabled: jobMetrics });
this.repo = new MetricGroupRepository(metricService).configure({ enabled: repoMetrics });
}
setup({ repositories }: { repositories: ClassConstructor<unknown>[] }) {
const { telemetry } = this.configRepository.getEnv();
const { metrics } = telemetry;
if (!metrics.has(ImmichTelemetry.REPO)) {
if (!telemetry.enabled || !telemetry.repoMetrics) {
return;
}
+21 -14
View File
@@ -23,6 +23,7 @@ import { OAuthProfile } from 'src/interfaces/oauth.interface';
import { BaseService } from 'src/services/base.service';
import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes';
import { createUser } from 'src/utils/user';
export interface LoginDetails {
isSecure: boolean;
@@ -114,13 +115,16 @@ export class AuthService extends BaseService {
throw new BadRequestException('The server already has an admin');
}
const admin = await this.createUser({
isAdmin: true,
email: dto.email,
name: dto.name,
password: dto.password,
storageLabel: 'admin',
});
const admin = await createUser(
{ userRepo: this.userRepository, cryptoRepo: this.cryptoRepository },
{
isAdmin: true,
email: dto.email,
name: dto.name,
password: dto.password,
storageLabel: 'admin',
},
);
return mapUserAdmin(admin);
}
@@ -230,13 +234,16 @@ export class AuthService extends BaseService {
});
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
user = await this.createUser({
name: userName,
email: profile.email,
oauthId: profile.sub,
quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null,
storageLabel: storageLabel || null,
});
user = await createUser(
{ userRepo: this.userRepository, cryptoRepo: this.cryptoRepository },
{
name: userName,
email: profile.email,
oauthId: profile.sub,
quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null,
storageLabel: storageLabel || null,
},
);
}
return this.createLoginResponse(user, loginDetails);
+1 -28
View File
@@ -1,9 +1,6 @@
import { BadRequestException, Inject } from '@nestjs/common';
import sanitize from 'sanitize-filename';
import { Inject } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { UserEntity } from 'src/entities/user.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
@@ -122,28 +119,4 @@ export class BaseService {
checkAccess(request: AccessRequest) {
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(
mockEnvData({
database: {
config: {
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
skipMigrations: false,
vectorExtension: extension,
},
@@ -289,14 +286,11 @@ describe(DatabaseService.name, () => {
configMock.getEnv.mockReturnValue(
mockEnvData({
database: {
config: {
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS,
},
@@ -312,14 +306,11 @@ describe(DatabaseService.name, () => {
configMock.getEnv.mockReturnValue(
mockEnvData({
database: {
config: {
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR,
},
+13 -28
View File
@@ -3,7 +3,7 @@ import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config';
import { mapLibrary } from 'src/dtos/library.dto';
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 { IDatabaseRepository } from 'src/interfaces/database.interface';
import {
@@ -55,7 +55,7 @@ describe(LibraryService.name, () => {
it('should init cron job and handle config changes', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryScan);
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
expect(jobMock.addCronJob).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.arrayContaining([
@@ -104,7 +104,7 @@ describe(LibraryService.name, () => {
it('should not initialize watcher when watching is disabled', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
expect(storageMock.watch).not.toHaveBeenCalled();
});
@@ -113,32 +113,17 @@ describe(LibraryService.name, () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
databaseMock.tryLock.mockResolvedValue(false);
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
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', () => {
beforeEach(async () => {
systemMock.get.mockResolvedValue(defaults);
databaseMock.tryLock.mockResolvedValue(true);
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
});
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 () => {
databaseMock.tryLock.mockResolvedValue(false);
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScan as SystemConfig, oldConfig: defaults });
expect(jobMock.updateCronJob).not.toHaveBeenCalled();
});
@@ -708,7 +693,7 @@ describe(LibraryService.name, () => {
const mockClose = vitest.fn();
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
await sut.delete(libraryStub.externalLibraryWithImportPaths1.id);
expect(mockClose).toHaveBeenCalled();
@@ -842,7 +827,7 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
libraryMock.getAll.mockResolvedValue([]);
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
await sut.create({
ownerId: authStub.admin.user.id,
importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths,
@@ -905,7 +890,7 @@ describe(LibraryService.name, () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.getAll.mockResolvedValue([]);
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
});
it('should throw an error if an import path is invalid', async () => {
@@ -946,7 +931,7 @@ describe(LibraryService.name, () => {
beforeEach(async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
});
it('should not watch library', async () => {
@@ -962,7 +947,7 @@ describe(LibraryService.name, () => {
beforeEach(async () => {
systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchEnabled);
libraryMock.getAll.mockResolvedValue([]);
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
});
it('should watch library', async () => {
@@ -1128,7 +1113,7 @@ describe(LibraryService.name, () => {
const mockClose = vitest.fn();
storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose }));
await sut.onBootstrap(ImmichWorker.MICROSERVICES);
await sut.onBootstrap();
await sut.onShutdown();
expect(mockClose).toHaveBeenCalledTimes(2);
+15 -20
View File
@@ -16,7 +16,7 @@ import {
} from 'src/dtos/library.dto';
import { AssetEntity } from 'src/entities/asset.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 { ArgOf } from 'src/interfaces/event.interface';
import {
@@ -36,32 +36,27 @@ import { validateCronExpression } from 'src/validation';
@Injectable()
export class LibraryService extends BaseService {
private watchLibraries = false;
private lock = false;
private watchLock = false;
private watchers: Record<string, () => Promise<void>> = {};
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(workerType: ImmichWorker) {
if (workerType !== ImmichWorker.MICROSERVICES) {
return;
}
async onBootstrap() {
const config = await this.getConfig({ withCache: false });
const { watch, scan } = config.library;
// 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(
'libraryScan',
scan.cronExpression,
() => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger),
scan.enabled,
);
}
this.jobRepository.addCronJob(
'libraryScan',
scan.cronExpression,
() => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger),
scan.enabled,
);
if (this.watchLibraries) {
await this.watchAll();
@@ -70,7 +65,7 @@ export class LibraryService extends BaseService {
@OnEvent({ name: 'config.update', server: true })
async onConfigUpdate({ newConfig: { library }, oldConfig }: ArgOf<'config.update'>) {
if (!oldConfig || !this.lock) {
if (!oldConfig || !this.watchLock) {
return;
}
@@ -185,7 +180,7 @@ export class LibraryService extends BaseService {
}
private async unwatchAll() {
if (!this.lock) {
if (!this.watchLock) {
return false;
}
@@ -195,7 +190,7 @@ export class LibraryService extends BaseService {
}
async watchAll() {
if (!this.lock) {
if (!this.watchLock) {
return false;
}
+16 -2
View File
@@ -31,6 +31,7 @@ const responseDto: PersonResponseDto = {
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
updatedAt: expect.any(Date),
withArchived: false,
};
const statistics = { assets: 3 };
@@ -118,6 +119,7 @@ describe(PersonService.name, () => {
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: true,
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']));
});
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 () => {
personMock.update.mockResolvedValue(personStub.withBirthDate);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
@@ -229,6 +241,7 @@ describe(PersonService.name, () => {
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
updatedAt: expect.any(Date),
withArchived: false,
});
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
expect(jobMock.queue).not.toHaveBeenCalled();
@@ -381,6 +394,7 @@ describe(PersonService.name, () => {
name: personStub.noName.name,
thumbnailPath: personStub.noName.thumbnailPath,
updatedAt: expect.any(Date),
withArchived: personStub.noName.withArchived,
});
expect(jobMock.queue).not.toHaveBeenCalledWith();
@@ -1171,13 +1185,13 @@ describe(PersonService.name, () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getStatistics.mockResolvedValue(statistics);
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']));
});
it('should require person.read permission', async () => {
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']));
});
});
+12 -4
View File
@@ -14,6 +14,7 @@ import {
PersonResponseDto,
PersonSearchDto,
PersonStatisticsResponseDto,
PersonStatsDto,
PersonUpdateDto,
mapFaces,
mapPerson,
@@ -153,9 +154,9 @@ export class PersonService extends BaseService {
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] });
return this.personRepository.getStatistics(id);
return this.personRepository.getStatistics(id, dto);
}
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> {
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
let faceId: string | undefined = undefined;
if (assetId) {
@@ -197,7 +198,14 @@ export class PersonService extends BaseService {
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) {
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 { BaseService } from 'src/services/base.service';
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
import { createUser } from 'src/utils/user';
@Injectable()
export class UserAdminService extends BaseService {
@@ -24,18 +25,17 @@ export class UserAdminService extends BaseService {
}
async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
const { notify, ...userDto } = dto;
const { notify, ...rest } = dto;
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');
}
const user = await this.createUser(userDto);
const user = await createUser({ userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, rest);
await this.eventRepository.emit('user.signup', {
notify: !!notify,
id: user.id,
tempPassword: user.shouldChangePassword ? userDto.password : undefined,
tempPassword: user.shouldChangePassword ? rest.password : undefined,
});
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';
const { telemetry, network } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
if (telemetry.enabled) {
bootstrapTelemetry(telemetry.apiPort);
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { isStartUpError } from 'src/services/storage.service';
export async function bootstrap() {
const { telemetry } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
if (telemetry.enabled) {
bootstrapTelemetry(telemetry.microservicesPort);
}
+9
View File
@@ -15,6 +15,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
hidden: Object.freeze<PersonEntity>({
id: 'person-1',
@@ -29,6 +30,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: true,
withArchived: false,
}),
withName: Object.freeze<PersonEntity>({
id: 'person-1',
@@ -43,6 +45,7 @@ export const personStub = {
faceAssetId: 'assetFaceId',
faceAsset: null,
isHidden: false,
withArchived: false,
}),
withBirthDate: Object.freeze<PersonEntity>({
id: 'person-1',
@@ -57,6 +60,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
noThumbnail: Object.freeze<PersonEntity>({
id: 'person-1',
@@ -71,6 +75,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
newThumbnail: Object.freeze<PersonEntity>({
id: 'person-1',
@@ -85,6 +90,7 @@ export const personStub = {
faceAssetId: 'asset-id',
faceAsset: null,
isHidden: false,
withArchived: false,
}),
primaryPerson: Object.freeze<PersonEntity>({
id: 'person-1',
@@ -99,6 +105,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
mergePerson: Object.freeze<PersonEntity>({
id: 'person-2',
@@ -113,6 +120,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
randomPerson: Object.freeze<PersonEntity>({
id: 'person-3',
@@ -127,5 +135,6 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
};
@@ -16,16 +16,11 @@ const envData: EnvData = {
},
database: {
config: {
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
synchronize: false,
migrationsRun: true,
},
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
skipMigrations: false,
vectorExtension: DatabaseExtension.VECTORS,
@@ -78,7 +73,11 @@ const envData: EnvData = {
telemetry: {
apiPort: 8081,
microservicesPort: 8082,
metrics: new Set(),
enabled: false,
hostMetrics: false,
apiMetrics: false,
jobMetrics: false,
repoMetrics: false,
},
workers: [ImmichWorker.API, ImmichWorker.MICROSERVICES],
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.119.0",
"version": "1.118.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.119.0",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.7.8",
@@ -74,7 +74,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.119.0",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.119.0",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -45,6 +45,8 @@
import {
mdiAccountBoxOutline,
mdiAccountMultipleCheckOutline,
mdiArchiveArrowDown,
mdiArchiveArrowDownOutline,
mdiArrowLeft,
mdiCalendarEditOutline,
mdiDotsVertical,
@@ -72,8 +74,10 @@
UNASSIGN_ASSETS = 'unassign-faces',
}
$: isArchived = person.withArchived ? undefined : person.withArchived;
let assetStore = new AssetStore({
isArchived: false,
isArchived,
personId: data.person.id,
});
@@ -81,7 +85,7 @@
$: thumbnailData = getPeopleThumbnailUrl(person);
$: if (person) {
handlePromiseError(updateAssetCount());
handlePromiseError(assetStore.updateOptions({ personId: person.id }));
handlePromiseError(assetStore.updateOptions({ personId: person.id, isArchived }));
}
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(() => {
assetStore.destroy();
});
@@ -395,7 +420,7 @@
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
<DeleteAssets menuItem onAssetDelete={handleDeleteAssets} />
</ButtonContextMenu>
</AssetSelectControlBar>
{:else}
@@ -423,6 +448,11 @@
icon={mdiAccountMultipleCheckOutline}
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>
</svelte:fragment>
</ControlAppBar>
+1 -1
View File
@@ -86,7 +86,7 @@
<svelte:head>
<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" />
<AppleHeader />