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
74 changed files with 613 additions and 510 deletions
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.9.0
uses: stumpylog/image-cleaner-action/ephemeral@v0.8.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.9.0
uses: stumpylog/image-cleaner-action/untagged@v0.8.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"
-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'
})
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.28",
"version": "2.2.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.28",
"version": "2.2.26",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@@ -24,7 +24,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",
@@ -52,14 +52,14 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.119.1",
"version": "1.118.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"typescript": "^5.3.3"
}
},
@@ -1378,9 +1378,9 @@
}
},
"node_modules/@types/node": {
"version": "20.17.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.2.tgz",
"integrity": "sha512-OOHK4sjXqkL7yQ7VEEHcf6+0jSvKjWqwnaCtY7AKD/VLEvRHMsxxu7eI8ErnjxHS8VwmekD4PeVCpu4qZEZSxg==",
"version": "20.16.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.13.tgz",
"integrity": "sha512-GjQ7im10B0labo8ZGXDGROUl9k0BNyDgzfGpb4g/cl+4yYDWVKcozANF4FGr4/p0O/rAkQClM6Wiwkije++1Tg==",
"dev": true,
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.28",
"version": "2.2.26",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -20,7 +20,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",
-20
View File
@@ -40,26 +40,6 @@ server {
}
```
#### Compatibility with Let's Encrypt
In the event that your nginx configuration includes a section for Let's Encrypt, it's likely that you have a segment similar to the following:
```nginx
location ~ /.well-known {
...
}
```
This particular `location` directive can inadvertently prevent mobile clients from reaching the `/.well-known/immich` path, which is crucial for discovery. Usual error message for this case is: "Your app major version is not compatible with the server". To remedy this, you should introduce an additional location block specifically for this path, ensuring that requests are correctly proxied to the Immich server:
```nginx
location = /.well-known/immich {
proxy_pass http://<backend_url>:2283;
}
```
By doing so, you'll maintain the functionality of Let's Encrypt while allowing mobile clients to access the necessary Immich path without obstruction.
### Caddy example config
As an alternative to nginx, you can also use [Caddy](https://caddyserver.com/) as a reverse proxy (with automatic HTTPS configuration). Below is an example config.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

-16
View File
@@ -149,22 +149,6 @@ If you get an error here, please rename the other external library to something
Within seconds, the assets from the old-pics and videos folders should show up in the main timeline.
### Folder view
:::info
This feature also exists for assets uploaded other than through external libraries.
:::tip
You can use the storage template migration feature for the best experience with uploaded assets in this view.
:::
You can browse your photos and videos by folder like in a file explorer.
Enable this feature from the Users Settings > Features > Folders.
The UI is currently only available for the web; mobile will come in a subsequent release.
<img src={require('./img/folder-view.png').default} width="75%" title='Folder-view' />
### Set Custom Scan Interval
:::note
-36
View File
@@ -27,39 +27,3 @@ The beta release channel allows users to test upcoming changes before they are o
:::info
You can enable automatic backup on supported devices. For more information see [Automatic Backup](/docs/features/automatic-backup.md).
:::
## Album Sync
You can sync or mirror an album from your phone to the Immich server on your account. For example, if you select Recents, Camera and Videos album for backup, the corresponding album with the same name will be created on the server. Once the assets from those albums are uploaded, they will be put into the target albums automatically.
### Album Synchronization Highlights
- **One-Way Sync:** Synchronization is one-way, from the device to the server.
- **Name Matching:** If an album on the server has the same name as the album on the device, images from the device will be merged with the existing images in the server album.
- **Shared Albums:** If the matching album on the server is shared, the new photos merged into the album will also be shared.
- **Album Structure:** When an album is created for the first time, its structure is based on the initial state. Future updates made on the phone (such as deleting or repositioning photos) will not be reflected in Immich.
- **User-Specific Sync:** Album synchronization is unique to each server user and does not sync between different users or partners.
- **Mobile-Only Feature:** Album synchronization is currently only available on mobile. For similar options on a computer, refer to [Libraries](/docs/features/libraries) for further details.
### Synchronizing albums from the past
Albums can be synchronized to the server even if they did not exist on the server before. In order to apply this setting you have to:
Enter the cloud on the top right -> cog wheel on the top right -> select the sync option under Sync albums.
:::info Sync albums delete/move photos
If you delete/move photos in the local album on your device, it will not be reflected in the album on the server **even if** you click Sync albums
It will only reflect files you add.
:::
If the same asset is in more than one album it will only sync to the first album it's in, after that it won't sync again even if the user clicks sync albums manually.
To overcome this limitation, the files must be removed from the blacklist by
App settings -> Advanced -> Duplicate Assets -> Clear
:::info
Cleaning duplicate assets from the list will cause all the previously uploaded duplicate files to be re-uploaded, the files will not actually be uploaded and will be rejected on the server side (due to duplication) but will be synchronized to the album and at the end will be added to the black list again at the end of the synchronization.
:::
+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}
-8
View File
@@ -1,12 +1,4 @@
[
{
"label": "v1.119.1",
"url": "https://v1.119.1.archive.immich.app"
},
{
"label": "v1.119.0",
"url": "https://v1.119.0.archive.immich.app"
},
{
"label": "v1.118.2",
"url": "https://v1.118.2.archive.immich.app"
+10 -10
View File
@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.119.1",
"version": "1.118.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.119.1",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
@@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.28",
"version": "2.2.26",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -64,7 +64,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",
@@ -92,14 +92,14 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.119.1",
"version": "1.118.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"typescript": "^5.3.3"
}
},
@@ -1614,9 +1614,9 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.17.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.2.tgz",
"integrity": "sha512-OOHK4sjXqkL7yQ7VEEHcf6+0jSvKjWqwnaCtY7AKD/VLEvRHMsxxu7eI8ErnjxHS8VwmekD4PeVCpu4qZEZSxg==",
"version": "20.16.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.13.tgz",
"integrity": "sha512-GjQ7im10B0labo8ZGXDGROUl9k0BNyDgzfGpb4g/cl+4yYDWVKcozANF4FGr4/p0O/rAkQClM6Wiwkije++1Tg==",
"dev": true,
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.119.1",
"version": "1.118.2",
"description": "",
"main": "index.js",
"type": "module",
@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
+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.1"
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.1",
"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')
+3 -3
View File
@@ -401,7 +401,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 179;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 179;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -571,7 +571,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 181;
CURRENT_PROJECT_VERSION = 179;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
+2 -2
View File
@@ -58,11 +58,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.119.0</string>
<string>1.117.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>181</string>
<string>179</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.119.1"
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.1
- 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.1+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.1",
"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": [
+6 -6
View File
@@ -1,18 +1,18 @@
{
"name": "@immich/sdk",
"version": "1.119.1",
"version": "1.118.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.119.1",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"typescript": "^5.3.3"
}
},
@@ -22,9 +22,9 @@
"integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g=="
},
"node_modules/@types/node": {
"version": "20.17.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.2.tgz",
"integrity": "sha512-OOHK4sjXqkL7yQ7VEEHcf6+0jSvKjWqwnaCtY7AKD/VLEvRHMsxxu7eI8ErnjxHS8VwmekD4PeVCpu4qZEZSxg==",
"version": "20.16.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.13.tgz",
"integrity": "sha512-GjQ7im10B0labo8ZGXDGROUl9k0BNyDgzfGpb4g/cl+4yYDWVKcozANF4FGr4/p0O/rAkQClM6Wiwkije++1Tg==",
"dev": true,
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.119.1",
"version": "1.118.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"typescript": "^5.3.3"
},
"repository": {
+14 -3
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 1.119.1
* 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 \
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.119.1",
"version": "1.118.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.119.1",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^10.0.1",
@@ -83,7 +83,7 @@
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5",
@@ -5494,9 +5494,9 @@
}
},
"node_modules/@types/node": {
"version": "20.17.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.2.tgz",
"integrity": "sha512-OOHK4sjXqkL7yQ7VEEHcf6+0jSvKjWqwnaCtY7AKD/VLEvRHMsxxu7eI8ErnjxHS8VwmekD4PeVCpu4qZEZSxg==",
"version": "20.16.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.13.tgz",
"integrity": "sha512-GjQ7im10B0labo8ZGXDGROUl9k0BNyDgzfGpb4g/cl+4yYDWVKcozANF4FGr4/p0O/rAkQClM6Wiwkije++1Tg==",
"dependencies": {
"undici-types": "~6.19.2"
}
@@ -18890,9 +18890,9 @@
}
},
"@types/node": {
"version": "20.17.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.2.tgz",
"integrity": "sha512-OOHK4sjXqkL7yQ7VEEHcf6+0jSvKjWqwnaCtY7AKD/VLEvRHMsxxu7eI8ErnjxHS8VwmekD4PeVCpu4qZEZSxg==",
"version": "20.16.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.13.tgz",
"integrity": "sha512-GjQ7im10B0labo8ZGXDGROUl9k0BNyDgzfGpb4g/cl+4yYDWVKcozANF4FGr4/p0O/rAkQClM6Wiwkije++1Tg==",
"requires": {
"undici-types": "~6.19.2"
}
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.119.1",
"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",
@@ -108,7 +108,7 @@
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5",
+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],
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.119.1",
"version": "1.118.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.119.1",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.7.8",
@@ -74,13 +74,13 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.119.1",
"version": "1.118.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.17.0",
"@types/node": "^20.16.12",
"typescript": "^5.3.3"
}
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.119.1",
"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>
@@ -41,7 +41,6 @@
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { t } from 'svelte-i18n';
import { afterUpdate, tick } from 'svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore;
@@ -247,8 +246,6 @@
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
<DeleteAssets menuItem {onAssetDelete} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
+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 />