Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ec1f51cdf | |||
| 507793235c | |||
| cd26b6260b | |||
| b08ddf4c61 | |||
| 352abd6188 | |||
| 24c27eb366 | |||
| 882d9bee04 |
@@ -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'
|
||||
})
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Vendored
-4
@@ -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"
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.119.0",
|
||||
"version": "1.118.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
|
||||
Generated
+11
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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' });
|
||||
@@ -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'],
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' });
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -49,4 +49,7 @@ export class PersonEntity {
|
||||
|
||||
@Column({ default: false })
|
||||
isHidden!: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
withArchived!: boolean;
|
||||
}
|
||||
|
||||
@@ -363,11 +363,3 @@ export enum ImmichWorker {
|
||||
API = 'api',
|
||||
MICROSERVICES = 'microservices',
|
||||
}
|
||||
|
||||
export enum ImmichTelemetry {
|
||||
HOST = 'host',
|
||||
API = 'api',
|
||||
IO = 'io',
|
||||
REPO = 'repo',
|
||||
JOB = 'job',
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -19,7 +19,7 @@ export enum DatabaseLock {
|
||||
StorageTemplateMigration = 420,
|
||||
VersionHistory = 500,
|
||||
CLIPDimSize = 512,
|
||||
Library = 1337,
|
||||
LibraryWatch = 1337,
|
||||
GetSystemConfig = 69,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+9
@@ -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],
|
||||
|
||||
Generated
+3
-3
@@ -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
@@ -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",
|
||||
|
||||
+33
-3
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user