Merge branch 'immich-app:main' into FAQ-edit
This commit is contained in:
1334
cli/package-lock.json
generated
1334
cli/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.0.5",
|
||||
"version": "2.0.6",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
@@ -38,7 +38,7 @@
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jest": "^27.2.2",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-unicorn": "^49.0.0",
|
||||
"eslint-plugin-unicorn": "^50.0.0",
|
||||
"immich": "file:../server",
|
||||
"jest": "^29.5.0",
|
||||
"jest-extended": "^4.0.0",
|
||||
|
||||
@@ -60,7 +60,7 @@ export default class Upload extends BaseCommand {
|
||||
|
||||
for (const asset of assetsToUpload) {
|
||||
// Compute total size first
|
||||
await asset.process();
|
||||
await asset.prepare();
|
||||
totalSize += asset.fileSize;
|
||||
|
||||
if (options.albumName) {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import * as fs from 'graceful-fs';
|
||||
import { basename } from 'node:path';
|
||||
import crypto from 'crypto';
|
||||
import Os from 'os';
|
||||
import FormData from 'form-data';
|
||||
import * as fs from 'graceful-fs';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import Os from 'os';
|
||||
|
||||
export class Asset {
|
||||
readonly path: string;
|
||||
readonly deviceId!: string;
|
||||
|
||||
assetData?: fs.ReadStream;
|
||||
deviceAssetId?: string;
|
||||
fileCreatedAt?: string;
|
||||
fileModifiedAt?: string;
|
||||
sidecarData?: fs.ReadStream;
|
||||
sidecarPath?: string;
|
||||
fileSize!: number;
|
||||
albumName?: string;
|
||||
@@ -21,32 +20,30 @@ export class Asset {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
async process() {
|
||||
async prepare() {
|
||||
const stats = await fs.promises.stat(this.path);
|
||||
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replace(/\s+/g, '');
|
||||
this.fileCreatedAt = stats.mtime.toISOString();
|
||||
this.fileModifiedAt = stats.mtime.toISOString();
|
||||
this.fileSize = stats.size;
|
||||
this.albumName = this.extractAlbumName();
|
||||
|
||||
this.assetData = this.getReadStream(this.path);
|
||||
|
||||
// TODO: doesn't xmp replace the file extension? Will need investigation
|
||||
const sideCarPath = `${this.path}.xmp`;
|
||||
try {
|
||||
fs.accessSync(sideCarPath, fs.constants.R_OK);
|
||||
this.sidecarData = this.getReadStream(sideCarPath);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
getUploadFormData(): FormData {
|
||||
if (!this.assetData) throw new Error('Asset data not set');
|
||||
if (!this.deviceAssetId) throw new Error('Device asset id not set');
|
||||
if (!this.fileCreatedAt) throw new Error('File created at not set');
|
||||
if (!this.fileModifiedAt) throw new Error('File modified at not set');
|
||||
|
||||
// TODO: doesn't xmp replace the file extension? Will need investigation
|
||||
const sideCarPath = `${this.path}.xmp`;
|
||||
let sidecarData: fs.ReadStream | undefined = undefined;
|
||||
try {
|
||||
fs.accessSync(sideCarPath, fs.constants.R_OK);
|
||||
sidecarData = createReadStream(sideCarPath);
|
||||
} catch (error) {}
|
||||
|
||||
const data: any = {
|
||||
assetData: this.assetData as any,
|
||||
assetData: createReadStream(this.path),
|
||||
deviceAssetId: this.deviceAssetId,
|
||||
deviceId: 'CLI',
|
||||
fileCreatedAt: this.fileCreatedAt,
|
||||
@@ -59,17 +56,13 @@ export class Asset {
|
||||
formData.append(prop, data[prop]);
|
||||
}
|
||||
|
||||
if (this.sidecarData) {
|
||||
formData.append('sidecarData', this.sidecarData);
|
||||
if (sidecarData) {
|
||||
formData.append('sidecarData', sidecarData);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
private getReadStream(path: string): fs.ReadStream {
|
||||
return fs.createReadStream(path);
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
return fs.promises.unlink(this.path);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,6 @@ Additional actions you can do with a detected person are:
|
||||
- Merge two or more detected faces into one person
|
||||
- Hide face
|
||||
|
||||
It can be found from the app bar when you access the detial view of a person
|
||||
It can be found from the app bar when you access the detail view of a person.
|
||||
|
||||
<img src={require('./img/facial-recognition-4.png').default} title='Facial Recognition 4' width="70%"/>
|
||||
|
||||
@@ -43,12 +43,28 @@ As this is a new feature, it is still experimental and may not work on all syste
|
||||
|
||||
## Setup
|
||||
|
||||
#### Initial Setup
|
||||
|
||||
1. If you do not already have it, download the latest [`hwaccel.yml`][hw-file] file and ensure it's in the same folder as the `docker-compose.yml`.
|
||||
2. Uncomment the lines that apply to your system and desired usage.
|
||||
3. In the `docker-compose.yml` under `immich-microservices`, uncomment the lines relating to the `hwaccel.yml` file.
|
||||
4. Redeploy the `immich-microservices` container with these updated settings.
|
||||
5. In the Admin page under `FFmpeg settings`, change the hardware acceleration setting to the appropriate option and save.
|
||||
|
||||
#### All-In-One - Unraid Setup
|
||||
|
||||
##### NVENC - NVIDIA GPUs
|
||||
|
||||
- If you are using other backends. You will still need to implement [`hwaccel.yml`][hw-file] file into the `immich-microservices` service directly, please see the "Initial Setup" section above on how to do that.
|
||||
- As of v1.92.0, steps 1 and 2 are no longer necessary. If your version of Immich is below that or missing the environment variables, please follow these steps. Otherwise, skip to step 3.
|
||||
- Please note that`NVIDIA_DRIVER_CAPABILITIES` is no longer required to enter as a variable.
|
||||
|
||||
1. Assuming you already have the Nvidia Driver Plugin installed on your Unraid Server. Please confirm that your Nvida GPU is showing up with its GPU ID in the Nvidia Driver Plugin. The ID will be `GPU-LONG_STRING_OF_CHARACTERS`. Copy the GPU ID.
|
||||
2. In the Imagegenius/Immich Docker Container app, add two new variables: Key=`NVIDIA_VISIBLE_DEVICES` Value=`GPU-LONG_STRING_OF_CHARACTERS` and Key=`NVIDIA_DRIVER_CAPABILITIES` Value=`all`
|
||||
3. While you are in the docker container app, change the Container from Basic Mode to Advanced Mode and add the following parameter to the Extra Parameters field: `--runtime=nvidia`
|
||||
4. Restart the Imagegenius/Immich Docker Container app.
|
||||
5. In the Admin page under FFmpeg settings, change the hardware acceleration setting to the appropriate option and save.
|
||||
|
||||
## Tips
|
||||
|
||||
- You may want to choose a slower preset than for software transcoding to maintain quality and efficiency
|
||||
|
||||
@@ -12,6 +12,26 @@ Immich comes preconfigured with an upload library for each user. All assets uplo
|
||||
|
||||
External libraries tracks assets stored outside of immich, i.e. in the file system. Immich will only read data from the files, and will not modify them in any way. Therefore, the delete button is disabled for external assets. When the external library is scanned, immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc.
|
||||
|
||||
# Step by Step Guide
|
||||
|
||||
In order to create an external library, the administrator must:
|
||||
|
||||
- Click on 'Administration' (top right).
|
||||
- Click on 'Edit' next to the user.
|
||||
- Set the external path - this would be the path that is exposed to the immich microservices service.
|
||||
- Click 'Confirm'.
|
||||
- Click into the user profile (top right) then click into 'Libraries'.
|
||||
- Click 'Create External Library'.
|
||||
- Next to the new library, click the three dots, then click 'Edit Import Paths'.
|
||||
- Set up your path relative to the external path you set earlier - e.g., if you set the external path to be `/mnt/media/external` and your Import Paths to `/photos`, then you will end up with `/mnt/media/external/photos`.
|
||||
- Click 'Save'.
|
||||
- Click 'Scan All Libraries'.
|
||||
|
||||
To confirm it's working, you can open up a Docker shell for the immich microservices, and you should see similar outputs to this:
|
||||
`[Nest] 7 - 11/24/2023, 1:06:39 AM LOG [MediaService] Successfully generated WEBP image thumbnail for asset b6d37673-5931-4b25-8380-89135021f48b`
|
||||
|
||||
# Scanning functionality
|
||||
|
||||
If a file is modified outside of Immich, the changes will not be reflected in immich until the library is scanned again. There are different ways to scan a library depending on the use case:
|
||||
|
||||
- Scan Library Files: This is the default scan method and also the quickest. It will scan all files in the library and add new files to the library. It will notice if any files are missing (see below) but not check existing assets
|
||||
|
||||
@@ -42,7 +42,7 @@ If you are unable to open a port on your router for Wireguard or OpenVPN to your
|
||||
|
||||
A reverse proxy is a service that sits between web servers and clients. A reverse proxy can either be hosted on the server itself or remotely. Clients can connect to the reverse proxy via https, and the proxy relays data to Immich. This setup makes most sense if you have your own domain and want to access your Immich instance just like any other website, from outside your LAN. You can also use a DDNS provider like DuckDNS or no-ip if you don't have a domain. This configuration allows the Immich Android and iphone apps to connect to your server without a VPN or tailscale app on the client side.
|
||||
|
||||
If you're hosting your own reverse proxy, [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) is a great option. An example configuration for Nginx is provided [here](https://immich.app/docs/administration/reverse-proxy).
|
||||
If you're hosting your own reverse proxy, [Nginx](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) is a great option. An example configuration for Nginx is provided [here](/docs/administration/reverse-proxy.md).
|
||||
|
||||
You'll also need your own certificate to authenticate https connections. If you're making Immich publicly accesible, [Let's Encrypt](https://letsencrypt.org/) can provide a free certificate for your domain and is the recommended option. Alternatively, a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate) allows you to encrypt your connection to Immich, but it raises a security warning on the client's browser.
|
||||
|
||||
|
||||
@@ -142,4 +142,4 @@ So you can just grab it from there, paste it into a file and you're pretty much
|
||||
### Step 2 - Specify the file location
|
||||
|
||||
In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config.
|
||||
For more information, refer to the [Environment Variables](https://docs.immich.app/docs/install/environment-variables) section.
|
||||
For more information, refer to the [Environment Variables](/docs/install/environment-variables.md) section.
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"backup_controller_page_status_off": "Automatic foreground backup is off",
|
||||
"backup_controller_page_status_on": "Automatic foreground backup is on",
|
||||
"backup_controller_page_storage_format": "{} of {} used",
|
||||
"backup_controller_page_to_backup": "Albums to be backup",
|
||||
"backup_controller_page_to_backup": "Albums to back up",
|
||||
"backup_controller_page_total": "Total",
|
||||
"backup_controller_page_total_sub": "All unique photos and videos from selected albums",
|
||||
"backup_controller_page_turn_off": "Turn off foreground backup",
|
||||
@@ -103,7 +103,7 @@
|
||||
"backup_info_card_assets": "assets",
|
||||
"backup_manual_cancelled": "Cancelled",
|
||||
"backup_manual_failed": "Failed",
|
||||
"backup_manual_in_progress": "Upload already in progress. Try after sometime",
|
||||
"backup_manual_in_progress": "Upload already in progress. Try again after some time",
|
||||
"backup_manual_success": "Success",
|
||||
"backup_manual_title": "Upload status",
|
||||
"cache_settings_album_thumbnails": "Library page thumbnails ({} assets)",
|
||||
@@ -190,7 +190,7 @@
|
||||
"home_page_delete_err_partner": "Can not delete partner assets, skipping",
|
||||
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
|
||||
"home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping",
|
||||
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
|
||||
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose backup album(s) so that the timeline can populate photos and videos in the album(s).",
|
||||
"home_page_share_err_local": "Can not share local assets via link, skipping",
|
||||
"home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
|
||||
"image_viewer_page_state_provider_download_error": "Download Error",
|
||||
@@ -230,7 +230,7 @@
|
||||
"login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL",
|
||||
"login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server",
|
||||
"login_form_failed_login": "Error logging you in, check server URL, email and password",
|
||||
"login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.",
|
||||
"login_form_handshake_exception": "There was a Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.",
|
||||
"login_form_label_email": "Email",
|
||||
"login_form_label_password": "Password",
|
||||
"login_form_next_button": "Next",
|
||||
@@ -379,8 +379,8 @@
|
||||
"shared_link_create_error": "Error while creating shared link",
|
||||
"shared_link_create_info": "Let anyone with the link see the selected photo(s)",
|
||||
"shared_link_create_submit_button": "Create link",
|
||||
"shared_link_edit_allow_download": "Allow public user to download",
|
||||
"shared_link_edit_allow_upload": "Allow public user to upload",
|
||||
"shared_link_edit_allow_download": "Allow public users to download",
|
||||
"shared_link_edit_allow_upload": "Allow public users to upload",
|
||||
"shared_link_edit_app_bar_title": "Edit link",
|
||||
"shared_link_edit_change_expiry": "Change expiration time",
|
||||
"shared_link_edit_description": "Description",
|
||||
@@ -430,7 +430,7 @@
|
||||
"theme_setting_dark_mode_switch": "Dark mode",
|
||||
"theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer",
|
||||
"theme_setting_image_viewer_quality_title": "Image viewer quality",
|
||||
"theme_setting_system_theme_switch": "Automatic (Follow system setting)",
|
||||
"theme_setting_system_theme_switch": "Automatic (follow system settings)",
|
||||
"theme_setting_theme_subtitle": "Choose the app's theme setting",
|
||||
"theme_setting_theme_title": "Theme",
|
||||
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
|
||||
@@ -461,4 +461,4 @@
|
||||
"viewer_remove_from_stack": "Remove from Stack",
|
||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||
"viewer_unstack": "Un-Stack"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,7 @@ class BackupService {
|
||||
final String deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
try {
|
||||
return await _apiService.assetApi.getUserAssetsByDeviceId(deviceId);
|
||||
|
||||
// TODO! Start using this in 1.92.0
|
||||
// return await _apiService.assetApi.getAllUserAssetsByDeviceId(deviceId);
|
||||
return await _apiService.assetApi.getAllUserAssetsByDeviceId(deviceId);
|
||||
} catch (e) {
|
||||
debugPrint('Error [getDeviceBackupAsset] ${e.toString()}');
|
||||
return null;
|
||||
|
||||
@@ -33,6 +33,12 @@
|
||||
"matchPackagePrefixes": ["exiftool"],
|
||||
"schedule": "on tuesday"
|
||||
},
|
||||
{
|
||||
"groupName": "svelte",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"matchPackagePrefixes": ["@sveltejs"],
|
||||
"schedule": "on tuesday"
|
||||
},
|
||||
{
|
||||
"matchFileNames": ["web/**"],
|
||||
"groupName": "web",
|
||||
@@ -62,9 +68,7 @@
|
||||
"versioning": "node"
|
||||
}
|
||||
],
|
||||
"ignorePaths": [
|
||||
"mobile/openapi/pubspec.yaml"
|
||||
],
|
||||
"ignorePaths": ["mobile/openapi/pubspec.yaml"],
|
||||
"ignoreDeps": [
|
||||
"http",
|
||||
"latlong2",
|
||||
|
||||
111
server/package-lock.json
generated
111
server/package-lock.json
generated
@@ -17,7 +17,7 @@
|
||||
"@nestjs/core": "^10.2.2",
|
||||
"@nestjs/platform-express": "^10.2.2",
|
||||
"@nestjs/platform-socket.io": "^10.2.2",
|
||||
"@nestjs/schedule": "^3.0.3",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.1.8",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.2.2",
|
||||
@@ -73,7 +73,7 @@
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.5.7",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
@@ -2466,11 +2466,11 @@
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-3.0.4.tgz",
|
||||
"integrity": "sha512-uFJpuZsXfpvgx2y7/KrIZW9e1L68TLiwRodZ6+Gc8xqQiHSUzAVn+9F4YMxWFlHITZvvkjWziUFgRNCitDcTZQ==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.0.0.tgz",
|
||||
"integrity": "sha512-zz4h54m/F/1qyQKvMJCRphmuwGqJltDAkFxUXCVqJBXEs5kbPt93Pza3heCQOcMH22MZNhGlc9DmDMLXVHmgVQ==",
|
||||
"dependencies": {
|
||||
"cron": "2.4.3",
|
||||
"cron": "3.1.3",
|
||||
"uuid": "9.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -3336,9 +3336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
|
||||
"integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==",
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
@@ -3522,6 +3522,12 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.5.tgz",
|
||||
"integrity": "sha512-1cyf6Ge/94zlaWIZA2ei1pE6SZ8xpad2hXaYa5JEFiaUH0YS494CZwyi4MXNpXD9oEuv6ZH0Bmh0e7F9sPhmZA=="
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.3.tgz",
|
||||
@@ -3699,22 +3705,24 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/superagent": {
|
||||
"version": "4.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.19.tgz",
|
||||
"integrity": "sha512-McM1mlc7PBZpCaw0fw/36uFqo0YeA6m8JqoyE4OfqXsZCIg0hPP2xdE6FM7r6fdprDZHlJwDpydUj1R++93hCA==",
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.1.tgz",
|
||||
"integrity": "sha512-YQyEXA4PgCl7EVOoSAS3o0fyPFU6erv5mMixztQYe1bqbWmmn8c+IrqoxjQeZe4MgwXikgcaZPiI/DsbmOVlzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/cookiejar": "*",
|
||||
"@types/cookiejar": "^2.1.5",
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supertest": {
|
||||
"version": "2.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.16.tgz",
|
||||
"integrity": "sha512-6c2ogktZ06tr2ENoZivgm7YnprnhYE4ZoXGMY+oA7IuAf17M8FWvujXZGmxLv8y0PTyts4x5A+erSwVUFA8XSg==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz",
|
||||
"integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/superagent": "*"
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/through": {
|
||||
@@ -5610,12 +5618,12 @@
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-2.4.3.tgz",
|
||||
"integrity": "sha512-YBvExkQYF7w0PxyeFLRyr817YVDhGxaCi5/uRRMqa4aWD3IFKRd+uNbpW1VWMdqQy8PZ7CElc+accXJcauPKzQ==",
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-3.1.3.tgz",
|
||||
"integrity": "sha512-KVxeKTKYj2eNzN4ElnT6nRSbjbfhyxR92O/Jdp6SH3pc05CDJws59jBrZWEMQlxevCiE6QUTrXy+Im3vC3oD3A==",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.3.0",
|
||||
"luxon": "~3.3.0"
|
||||
"luxon": "~3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
@@ -5629,14 +5637,6 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cron/node_modules/luxon": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz",
|
||||
"integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -14601,11 +14601,11 @@
|
||||
}
|
||||
},
|
||||
"@nestjs/schedule": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-3.0.4.tgz",
|
||||
"integrity": "sha512-uFJpuZsXfpvgx2y7/KrIZW9e1L68TLiwRodZ6+Gc8xqQiHSUzAVn+9F4YMxWFlHITZvvkjWziUFgRNCitDcTZQ==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.0.0.tgz",
|
||||
"integrity": "sha512-zz4h54m/F/1qyQKvMJCRphmuwGqJltDAkFxUXCVqJBXEs5kbPt93Pza3heCQOcMH22MZNhGlc9DmDMLXVHmgVQ==",
|
||||
"requires": {
|
||||
"cron": "2.4.3",
|
||||
"cron": "3.1.3",
|
||||
"uuid": "9.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -15254,9 +15254,9 @@
|
||||
}
|
||||
},
|
||||
"@types/cookiejar": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
|
||||
"integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==",
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/cors": {
|
||||
@@ -15440,6 +15440,12 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.5.tgz",
|
||||
"integrity": "sha512-1cyf6Ge/94zlaWIZA2ei1pE6SZ8xpad2hXaYa5JEFiaUH0YS494CZwyi4MXNpXD9oEuv6ZH0Bmh0e7F9sPhmZA=="
|
||||
},
|
||||
"@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/mime": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.3.tgz",
|
||||
@@ -15604,22 +15610,24 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/superagent": {
|
||||
"version": "4.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.19.tgz",
|
||||
"integrity": "sha512-McM1mlc7PBZpCaw0fw/36uFqo0YeA6m8JqoyE4OfqXsZCIg0hPP2xdE6FM7r6fdprDZHlJwDpydUj1R++93hCA==",
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.1.tgz",
|
||||
"integrity": "sha512-YQyEXA4PgCl7EVOoSAS3o0fyPFU6erv5mMixztQYe1bqbWmmn8c+IrqoxjQeZe4MgwXikgcaZPiI/DsbmOVlzA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/cookiejar": "*",
|
||||
"@types/cookiejar": "^2.1.5",
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/supertest": {
|
||||
"version": "2.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.16.tgz",
|
||||
"integrity": "sha512-6c2ogktZ06tr2ENoZivgm7YnprnhYE4ZoXGMY+oA7IuAf17M8FWvujXZGmxLv8y0PTyts4x5A+erSwVUFA8XSg==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz",
|
||||
"integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/superagent": "*"
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"@types/through": {
|
||||
@@ -17029,19 +17037,12 @@
|
||||
"devOptional": true
|
||||
},
|
||||
"cron": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-2.4.3.tgz",
|
||||
"integrity": "sha512-YBvExkQYF7w0PxyeFLRyr817YVDhGxaCi5/uRRMqa4aWD3IFKRd+uNbpW1VWMdqQy8PZ7CElc+accXJcauPKzQ==",
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-3.1.3.tgz",
|
||||
"integrity": "sha512-KVxeKTKYj2eNzN4ElnT6nRSbjbfhyxR92O/Jdp6SH3pc05CDJws59jBrZWEMQlxevCiE6QUTrXy+Im3vC3oD3A==",
|
||||
"requires": {
|
||||
"@types/luxon": "~3.3.0",
|
||||
"luxon": "~3.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"luxon": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz",
|
||||
"integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg=="
|
||||
}
|
||||
"luxon": "~3.4.0"
|
||||
}
|
||||
},
|
||||
"cron-parser": {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@nestjs/core": "^10.2.2",
|
||||
"@nestjs/platform-express": "^10.2.2",
|
||||
"@nestjs/platform-socket.io": "^10.2.2",
|
||||
"@nestjs/schedule": "^3.0.3",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.1.8",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.2.2",
|
||||
@@ -100,7 +100,7 @@
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.5.7",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
|
||||
@@ -784,9 +784,9 @@ describe(AssetService.name, () => {
|
||||
|
||||
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[{ name: JobName.ASSET_DELETION, data: { id: 'asset1' } }],
|
||||
[{ name: JobName.ASSET_DELETION, data: { id: 'asset2' } }],
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.ASSET_DELETION, data: { id: 'asset1' } },
|
||||
{ name: JobName.ASSET_DELETION, data: { id: 'asset2' } },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -895,6 +895,7 @@ describe(AssetService.name, () => {
|
||||
await sut.handleAssetDeletion({ id: assetStub.external.id });
|
||||
|
||||
expect(jobMock.queue).not.toBeCalled();
|
||||
expect(jobMock.queueAll).not.toBeCalled();
|
||||
expect(assetMock.remove).not.toBeCalled();
|
||||
});
|
||||
|
||||
@@ -952,19 +953,21 @@ describe(AssetService.name, () => {
|
||||
it('should run the refresh metadata job', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }),
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } });
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]);
|
||||
});
|
||||
|
||||
it('should run the refresh thumbnails job', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }),
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } });
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should run the transcode video', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }),
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
ImmichReadStream,
|
||||
JobItem,
|
||||
TimeBucketOptions,
|
||||
} from '../repositories';
|
||||
import { StorageCore, StorageFolder } from '../storage';
|
||||
@@ -449,9 +450,9 @@ export class AssetService {
|
||||
);
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -504,9 +505,7 @@ export class AssetService {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_DELETE, ids);
|
||||
|
||||
if (force) {
|
||||
for (const id of ids) {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(ids.map((id) => ({ name: JobName.ASSET_DELETION, data: { id } })));
|
||||
} else {
|
||||
await this.assetRepository.softDeleteAll(ids);
|
||||
this.communicationRepository.send(ClientEvent.ASSET_TRASH, auth.user.id, ids);
|
||||
@@ -529,9 +528,9 @@ export class AssetService {
|
||||
|
||||
if (action == TrashAction.EMPTY_ALL) {
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -566,21 +565,25 @@ export class AssetService {
|
||||
async run(auth: AuthDto, dto: AssetJobsDto) {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, dto.assetIds);
|
||||
|
||||
const jobs: JobItem[] = [];
|
||||
|
||||
for (const id of dto.assetIds) {
|
||||
switch (dto.name) {
|
||||
case AssetJobName.REFRESH_METADATA:
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id } });
|
||||
jobs.push({ name: JobName.METADATA_EXTRACTION, data: { id } });
|
||||
break;
|
||||
|
||||
case AssetJobName.REGENERATE_THUMBNAIL:
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id } });
|
||||
jobs.push({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id } });
|
||||
break;
|
||||
|
||||
case AssetJobName.TRANSCODE_VIDEO:
|
||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id } });
|
||||
jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id } });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
}
|
||||
|
||||
private async updateMetadata(dto: ISidecarWriteJob) {
|
||||
|
||||
@@ -55,12 +55,12 @@ describe(JobService.name, () => {
|
||||
it('should run the scheduled jobs', async () => {
|
||||
await sut.handleNightlyJobs();
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[{ name: JobName.ASSET_DELETION_CHECK }],
|
||||
[{ name: JobName.USER_DELETE_CHECK }],
|
||||
[{ name: JobName.PERSON_CLEANUP }],
|
||||
[{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }],
|
||||
[{ name: JobName.CLEAN_OLD_AUDIT_LOGS }],
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.ASSET_DELETION_CHECK },
|
||||
{ name: JobName.USER_DELETE_CHECK },
|
||||
{ name: JobName.PERSON_CLEANUP },
|
||||
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
|
||||
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -138,6 +138,7 @@ describe(JobService.name, () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a start video conversion command', async () => {
|
||||
@@ -204,6 +205,7 @@ describe(JobService.name, () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,18 +278,18 @@ describe(JobService.name, () => {
|
||||
item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } },
|
||||
jobs: [
|
||||
JobName.GENERATE_WEBP_THUMBNAIL,
|
||||
JobName.GENERATE_THUMBHASH_THUMBNAIL,
|
||||
JobName.ENCODE_CLIP,
|
||||
JobName.RECOGNIZE_FACES,
|
||||
JobName.GENERATE_THUMBHASH_THUMBNAIL,
|
||||
],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1', source: 'upload' } },
|
||||
jobs: [
|
||||
JobName.GENERATE_WEBP_THUMBNAIL,
|
||||
JobName.GENERATE_THUMBHASH_THUMBNAIL,
|
||||
JobName.ENCODE_CLIP,
|
||||
JobName.RECOGNIZE_FACES,
|
||||
JobName.GENERATE_THUMBHASH_THUMBNAIL,
|
||||
JobName.VIDEO_CONVERSION,
|
||||
],
|
||||
},
|
||||
@@ -295,9 +297,9 @@ describe(JobService.name, () => {
|
||||
item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-live-image', source: 'upload' } },
|
||||
jobs: [
|
||||
JobName.GENERATE_WEBP_THUMBNAIL,
|
||||
JobName.RECOGNIZE_FACES,
|
||||
JobName.GENERATE_THUMBHASH_THUMBNAIL,
|
||||
JobName.ENCODE_CLIP,
|
||||
JobName.RECOGNIZE_FACES,
|
||||
JobName.VIDEO_CONVERSION,
|
||||
],
|
||||
},
|
||||
@@ -327,9 +329,15 @@ describe(JobService.name, () => {
|
||||
await jobMock.addHandler.mock.calls[0][2](item);
|
||||
await asyncTick(3);
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledTimes(jobs.length);
|
||||
for (const jobName of jobs) {
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() });
|
||||
if (jobs.length > 1) {
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith(
|
||||
jobs.map((jobName) => ({ name: jobName, data: expect.anything() })),
|
||||
);
|
||||
} else {
|
||||
expect(jobMock.queue).toHaveBeenCalledTimes(jobs.length);
|
||||
for (const jobName of jobs) {
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: jobName, data: expect.anything() });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -338,7 +346,7 @@ describe(JobService.name, () => {
|
||||
await jobMock.addHandler.mock.calls[0][2](item);
|
||||
await asyncTick(3);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -158,11 +158,13 @@ export class JobService {
|
||||
}
|
||||
|
||||
async handleNightlyJobs() {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION_CHECK });
|
||||
await this.jobRepository.queue({ name: JobName.USER_DELETE_CHECK });
|
||||
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
|
||||
await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } });
|
||||
await this.jobRepository.queue({ name: JobName.CLEAN_OLD_AUDIT_LOGS });
|
||||
await this.jobRepository.queueAll([
|
||||
{ name: JobName.ASSET_DELETION_CHECK },
|
||||
{ name: JobName.USER_DELETE_CHECK },
|
||||
{ name: JobName.PERSON_CLEANUP },
|
||||
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
|
||||
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,19 +212,23 @@ export class JobService {
|
||||
break;
|
||||
|
||||
case JobName.GENERATE_JPEG_THUMBNAIL: {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data });
|
||||
const jobs: JobItem[] = [
|
||||
{ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data },
|
||||
{ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: item.data },
|
||||
{ name: JobName.ENCODE_CLIP, data: item.data },
|
||||
{ name: JobName.RECOGNIZE_FACES, data: item.data },
|
||||
];
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([item.data.id]);
|
||||
if (asset) {
|
||||
if (asset.type === AssetType.VIDEO) {
|
||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: item.data });
|
||||
jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data });
|
||||
} else if (asset.livePhotoVideoId) {
|
||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } });
|
||||
jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } });
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -135,18 +135,16 @@ describe(LibraryService.name, () => {
|
||||
|
||||
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.LIBRARY_SCAN_ASSET,
|
||||
data: {
|
||||
id: libraryStub.externalLibrary1.id,
|
||||
ownerId: libraryStub.externalLibrary1.owner.id,
|
||||
assetPath: '/data/user1/photo.jpg',
|
||||
force: false,
|
||||
},
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.LIBRARY_SCAN_ASSET,
|
||||
data: {
|
||||
id: libraryStub.externalLibrary1.id,
|
||||
ownerId: libraryStub.externalLibrary1.owner.id,
|
||||
assetPath: '/data/user1/photo.jpg',
|
||||
force: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -420,6 +418,7 @@ describe(LibraryService.name, () => {
|
||||
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should import an asset when mtime differs from db asset', async () => {
|
||||
@@ -468,6 +467,7 @@ describe(LibraryService.name, () => {
|
||||
|
||||
expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true });
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should online a previously-offline asset', async () => {
|
||||
@@ -607,6 +607,7 @@ describe(LibraryService.name, () => {
|
||||
);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
expect(libraryMock.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -953,9 +954,9 @@ describe(LibraryService.name, () => {
|
||||
libraryMock.getAllDeleted.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]);
|
||||
await expect(sut.handleQueueCleanup()).resolves.toBe(true);
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[{ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.uploadLibrary1.id } }],
|
||||
[{ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id } }],
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.uploadLibrary1.id } },
|
||||
{ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1101,16 +1102,16 @@ describe(LibraryService.name, () => {
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: JobName.LIBRARY_SCAN,
|
||||
data: {
|
||||
id: libraryStub.externalLibrary1.id,
|
||||
refreshModifiedFiles: true,
|
||||
refreshAllFiles: false,
|
||||
},
|
||||
]);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.LIBRARY_SCAN,
|
||||
data: {
|
||||
id: libraryStub.externalLibrary1.id,
|
||||
refreshModifiedFiles: true,
|
||||
refreshAllFiles: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1126,16 +1127,16 @@ describe(LibraryService.name, () => {
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: JobName.LIBRARY_SCAN,
|
||||
data: {
|
||||
id: libraryStub.externalLibrary1.id,
|
||||
refreshModifiedFiles: false,
|
||||
refreshAllFiles: true,
|
||||
},
|
||||
]);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.LIBRARY_SCAN,
|
||||
data: {
|
||||
id: libraryStub.externalLibrary1.id,
|
||||
refreshModifiedFiles: false,
|
||||
refreshAllFiles: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1147,13 +1148,11 @@ describe(LibraryService.name, () => {
|
||||
|
||||
await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(true);
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.ASSET_DELETION,
|
||||
data: { id: assetStub.image1.id, fromExternal: true },
|
||||
},
|
||||
],
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.ASSET_DELETION,
|
||||
data: { id: assetStub.image1.id, fromExternal: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,9 +94,9 @@ export class LibraryService {
|
||||
async handleQueueCleanup(): Promise<boolean> {
|
||||
this.logger.debug('Cleaning up any pending library deletions');
|
||||
const pendingDeletion = await this.repository.getAllDeleted();
|
||||
for (const libraryToDelete of pendingDeletion) {
|
||||
await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -160,9 +160,9 @@ export class LibraryService {
|
||||
// TODO use pagination
|
||||
const assetIds = await this.repository.getAssetIds(job.id, true);
|
||||
this.logger.debug(`Will delete ${assetIds.length} asset(s) in library ${job.id}`);
|
||||
for (const assetId of assetIds) {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: assetId, fromExternal: true } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
assetIds.map((assetId) => ({ name: JobName.ASSET_DELETION, data: { id: assetId, fromExternal: true } })),
|
||||
);
|
||||
|
||||
if (assetIds.length === 0) {
|
||||
this.logger.log(`Deleting library ${job.id}`);
|
||||
@@ -333,16 +333,16 @@ export class LibraryService {
|
||||
|
||||
// Queue all library refresh
|
||||
const libraries = await this.repository.getAll(true, LibraryType.EXTERNAL);
|
||||
for (const library of libraries) {
|
||||
await this.jobRepository.queue({
|
||||
await this.jobRepository.queueAll(
|
||||
libraries.map((library) => ({
|
||||
name: JobName.LIBRARY_SCAN,
|
||||
data: {
|
||||
id: library.id,
|
||||
refreshModifiedFiles: !job.force,
|
||||
refreshAllFiles: job.force ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
})),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -353,9 +353,9 @@ export class LibraryService {
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
this.logger.debug(`Removing ${assets.length} offline assets`);
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.id, fromExternal: true } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id, fromExternal: true } })),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -411,16 +411,17 @@ export class LibraryService {
|
||||
this.logger.debug(`Will import ${filteredPaths.length} new asset(s)`);
|
||||
}
|
||||
|
||||
for (const assetPath of filteredPaths) {
|
||||
const libraryJobData: ILibraryFileJob = {
|
||||
id: job.id,
|
||||
assetPath: path.normalize(assetPath),
|
||||
ownerId: library.ownerId,
|
||||
force: job.refreshAllFiles ?? false,
|
||||
};
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.LIBRARY_SCAN_ASSET, data: libraryJobData });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
filteredPaths.map((assetPath) => ({
|
||||
name: JobName.LIBRARY_SCAN_ASSET,
|
||||
data: {
|
||||
id: job.id,
|
||||
assetPath: path.normalize(assetPath),
|
||||
ownerId: library.ownerId,
|
||||
force: job.refreshAllFiles ?? false,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
await this.repository.update({ id: job.id, refreshedAt: new Date() });
|
||||
|
||||
@@ -77,17 +77,21 @@ describe(MediaService.name, () => {
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
||||
data: { id: assetStub.image.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(personMock.getAll).toHaveBeenCalled();
|
||||
expect(personMock.getAllWithoutThumbnail).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should queue all people with missing thumbnail path', async () => {
|
||||
@@ -106,12 +110,14 @@ describe(MediaService.name, () => {
|
||||
expect(personMock.getAll).not.toHaveBeenCalled();
|
||||
expect(personMock.getAllWithoutThumbnail).toHaveBeenCalled();
|
||||
expect(personMock.getRandomFace).toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: {
|
||||
id: personStub.newThumbnail.id,
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: {
|
||||
id: personStub.newThumbnail.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
});
|
||||
|
||||
it('should queue all assets with missing resize path', async () => {
|
||||
@@ -125,10 +131,12 @@ describe(MediaService.name, () => {
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
||||
data: { id: assetStub.image.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(personMock.getAll).not.toHaveBeenCalled();
|
||||
expect(personMock.getAllWithoutThumbnail).toHaveBeenCalled();
|
||||
@@ -145,10 +153,12 @@ describe(MediaService.name, () => {
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_WEBP_THUMBNAIL,
|
||||
data: { id: assetStub.image.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.GENERATE_WEBP_THUMBNAIL,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(personMock.getAll).not.toHaveBeenCalled();
|
||||
expect(personMock.getAllWithoutThumbnail).toHaveBeenCalled();
|
||||
@@ -165,10 +175,12 @@ describe(MediaService.name, () => {
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_THUMBHASH_THUMBNAIL,
|
||||
data: { id: assetStub.image.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.GENERATE_THUMBHASH_THUMBNAIL,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(personMock.getAll).not.toHaveBeenCalled();
|
||||
expect(personMock.getAllWithoutThumbnail).toHaveBeenCalled();
|
||||
@@ -388,10 +400,12 @@ describe(MediaService.name, () => {
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO });
|
||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.VIDEO_CONVERSION,
|
||||
data: { id: assetStub.video.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.VIDEO_CONVERSION,
|
||||
data: { id: assetStub.video.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should queue all video assets without encoded videos', async () => {
|
||||
@@ -404,10 +418,12 @@ describe(MediaService.name, () => {
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.VIDEO_CONVERSION,
|
||||
data: { id: assetStub.video.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.VIDEO_CONVERSION,
|
||||
data: { id: assetStub.video.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IPersonRepository,
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
JobItem,
|
||||
VideoCodecHWConfig,
|
||||
VideoStreamInfo,
|
||||
WithoutProperty,
|
||||
@@ -74,22 +75,27 @@ export class MediaService {
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
const jobs: JobItem[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
if (!asset.resizePath || force) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
|
||||
jobs.push({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
|
||||
continue;
|
||||
}
|
||||
if (!asset.webpPath) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { id: asset.id } });
|
||||
jobs.push({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { id: asset.id } });
|
||||
}
|
||||
if (!asset.thumbhash) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: { id: asset.id } });
|
||||
jobs.push({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
}
|
||||
|
||||
const people = force ? await this.personRepository.getAll() : await this.personRepository.getAllWithoutThumbnail();
|
||||
|
||||
const jobs: JobItem[] = [];
|
||||
for (const person of people) {
|
||||
if (!person.faceAssetId) {
|
||||
const face = await this.personRepository.getRandomFace(person.id);
|
||||
@@ -100,9 +106,11 @@ export class MediaService {
|
||||
await this.personRepository.update({ id: person.id, faceAssetId: face.assetId });
|
||||
}
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
||||
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -118,15 +126,15 @@ export class MediaService {
|
||||
}
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
const people = await this.personRepository.getAll();
|
||||
for (const person of people) {
|
||||
await this.jobRepository.queue({ name: JobName.MIGRATE_PERSON, data: { id: person.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
people.map((person) => ({ name: JobName.MIGRATE_PERSON, data: { id: person.id } })),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -224,9 +232,9 @@ export class MediaService {
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -208,10 +208,12 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(true);
|
||||
expect(assetMock.getWithout).toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.METADATA_EXTRACTION,
|
||||
data: { id: assetStub.image.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.METADATA_EXTRACTION,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should queue metadata extraction for all assets', async () => {
|
||||
@@ -219,10 +221,12 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(true);
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.METADATA_EXTRACTION,
|
||||
data: { id: assetStub.image.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.METADATA_EXTRACTION,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -320,6 +324,7 @@ describe(MetadataService.name, () => {
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
|
||||
expect(storageMock.writeFile).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.save).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ assetType: AssetType.VIDEO, isVisible: false }),
|
||||
);
|
||||
@@ -512,10 +517,12 @@ describe(MetadataService.name, () => {
|
||||
|
||||
expect(assetMock.getWith).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithProperty.SIDECAR);
|
||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SIDECAR_SYNC,
|
||||
data: { id: assetStub.sidecar.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.SIDECAR_SYNC,
|
||||
data: { id: assetStub.sidecar.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should queue assets without sidecar files', async () => {
|
||||
@@ -525,10 +532,12 @@ describe(MetadataService.name, () => {
|
||||
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR);
|
||||
expect(assetMock.getWith).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SIDECAR_DISCOVERY,
|
||||
data: { id: assetStub.image.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.SIDECAR_DISCOVERY,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -196,9 +196,9 @@ export class MetadataService {
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -264,10 +264,12 @@ export class MetadataService {
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
const name = force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY;
|
||||
await this.jobRepository.queue({ name, data: { id: asset.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({
|
||||
name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY,
|
||||
data: { id: asset.id },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -286,6 +286,7 @@ describe(PersonService.name, () => {
|
||||
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
@@ -403,6 +404,7 @@ describe(PersonService.name, () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(jobMock.queue).not.toHaveBeenCalledWith();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalledWith();
|
||||
});
|
||||
it('should reassign a face', async () => {
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id]));
|
||||
@@ -417,10 +419,12 @@ describe(PersonService.name, () => {
|
||||
}),
|
||||
).resolves.toEqual([personStub.noName]);
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -452,10 +456,12 @@ describe(PersonService.name, () => {
|
||||
it('should change person feature photo', async () => {
|
||||
personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1);
|
||||
await sut.createNewFeaturePhoto([personStub.newThumbnail.id]);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -480,6 +486,7 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalledWith();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should fail if user has not the correct permissions on the asset', async () => {
|
||||
@@ -495,6 +502,7 @@ describe(PersonService.name, () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalledWith();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -542,7 +550,9 @@ describe(PersonService.name, () => {
|
||||
|
||||
await sut.handlePersonCleanup();
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.PERSON_DELETE, data: { id: personStub.noName.id } });
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.PERSON_DELETE, data: { id: personStub.noName.id } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -552,6 +562,7 @@ describe(PersonService.name, () => {
|
||||
|
||||
await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(true);
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
expect(configMock.load).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -563,10 +574,12 @@ describe(PersonService.name, () => {
|
||||
await sut.handleQueueRecognizeFaces({});
|
||||
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.RECOGNIZE_FACES,
|
||||
data: { id: assetStub.image.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.RECOGNIZE_FACES,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should queue all assets', async () => {
|
||||
@@ -580,14 +593,18 @@ describe(PersonService.name, () => {
|
||||
await sut.handleQueueRecognizeFaces({ force: true });
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.RECOGNIZE_FACES,
|
||||
data: { id: assetStub.image.id },
|
||||
});
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.PERSON_DELETE,
|
||||
data: { id: personStub.withName.id },
|
||||
});
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.RECOGNIZE_FACES,
|
||||
data: { id: assetStub.image.id },
|
||||
},
|
||||
]);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.PERSON_DELETE,
|
||||
data: { id: personStub.withName.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -644,6 +661,7 @@ describe(PersonService.name, () => {
|
||||
);
|
||||
expect(personMock.createFace).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
|
||||
expect(assetMock.upsertJobStatus).toHaveBeenCalledWith({
|
||||
assetId: assetStub.image.id,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ISmartInfoRepository,
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
JobItem,
|
||||
UpdateFacesData,
|
||||
WithoutProperty,
|
||||
} from '../repositories';
|
||||
@@ -153,6 +154,8 @@ export class PersonService {
|
||||
this.logger.debug(
|
||||
`Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`,
|
||||
);
|
||||
|
||||
const jobs: JobItem[] = [];
|
||||
for (const personId of changeFeaturePhoto) {
|
||||
const assetFace = await this.repository.getRandomFace(personId);
|
||||
|
||||
@@ -161,15 +164,11 @@ export class PersonService {
|
||||
id: personId,
|
||||
faceAssetId: assetFace.id,
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: {
|
||||
id: personId,
|
||||
},
|
||||
});
|
||||
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } });
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
}
|
||||
|
||||
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
|
||||
@@ -270,8 +269,10 @@ export class PersonService {
|
||||
const people = await this.repository.getAllWithoutFaces();
|
||||
for (const person of people) {
|
||||
this.logger.debug(`Person ${person.name || person.id} no longer has any faces, deleting.`);
|
||||
await this.jobRepository.queue({ name: JobName.PERSON_DELETE, data: { id: person.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
people.map((person) => ({ name: JobName.PERSON_DELETE, data: { id: person.id } })),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -290,16 +291,16 @@ export class PersonService {
|
||||
|
||||
if (force) {
|
||||
const people = await this.repository.getAll();
|
||||
for (const person of people) {
|
||||
await this.jobRepository.queue({ name: JobName.PERSON_DELETE, data: { id: person.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
people.map((person) => ({ name: JobName.PERSON_DELETE, data: { id: person.id } })),
|
||||
);
|
||||
this.logger.debug(`Deleted ${people.length} people`);
|
||||
}
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: { id: asset.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.RECOGNIZE_FACES, data: { id: asset.id } })),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -333,7 +334,7 @@ export class PersonService {
|
||||
|
||||
for (const { embedding, ...rest } of faces) {
|
||||
const matches = await this.smartInfoRepository.searchFaces({
|
||||
ownerId: asset.ownerId,
|
||||
userIds: [asset.ownerId],
|
||||
embedding,
|
||||
numResults: 1,
|
||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||
|
||||
@@ -199,5 +199,5 @@ export interface IAssetRepository {
|
||||
search(options: AssetSearchOptions): Promise<AssetEntity[]>;
|
||||
getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
||||
getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
||||
searchMetadata(query: string, userId: string, options: MetadataSearchOptions): Promise<AssetEntity[]>;
|
||||
searchMetadata(query: string, userIds: string[], options: MetadataSearchOptions): Promise<AssetEntity[]>;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface IJobRepository {
|
||||
deleteCronJob(name: string): void;
|
||||
setConcurrency(queueName: QueueName, concurrency: number): void;
|
||||
queue(item: JobItem): Promise<void>;
|
||||
queueAll(items: JobItem[]): Promise<void>;
|
||||
pause(name: QueueName): Promise<void>;
|
||||
resume(name: QueueName): Promise<void>;
|
||||
empty(name: QueueName): Promise<void>;
|
||||
|
||||
@@ -5,7 +5,7 @@ export const ISmartInfoRepository = 'ISmartInfoRepository';
|
||||
export type Embedding = number[];
|
||||
|
||||
export interface EmbeddingSearch {
|
||||
ownerId: string;
|
||||
userIds: string[];
|
||||
embedding: Embedding;
|
||||
numResults: number;
|
||||
maxDistance?: number;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
authStub,
|
||||
newAssetRepositoryMock,
|
||||
newMachineLearningRepositoryMock,
|
||||
newPartnerRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newSmartInfoRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
@@ -13,6 +14,7 @@ import { mapAsset } from '../asset';
|
||||
import {
|
||||
IAssetRepository,
|
||||
IMachineLearningRepository,
|
||||
IPartnerRepository,
|
||||
IPersonRepository,
|
||||
ISmartInfoRepository,
|
||||
ISystemConfigRepository,
|
||||
@@ -29,6 +31,7 @@ describe(SearchService.name, () => {
|
||||
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let smartInfoMock: jest.Mocked<ISmartInfoRepository>;
|
||||
let partnerMock: jest.Mocked<IPartnerRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
@@ -36,7 +39,8 @@ describe(SearchService.name, () => {
|
||||
machineMock = newMachineLearningRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
smartInfoMock = newSmartInfoRepositoryMock();
|
||||
sut = new SearchService(configMock, machineMock, personMock, smartInfoMock, assetMock);
|
||||
partnerMock = newPartnerRepositoryMock();
|
||||
sut = new SearchService(configMock, machineMock, personMock, smartInfoMock, assetMock, partnerMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -87,6 +91,7 @@ describe(SearchService.name, () => {
|
||||
it('should search by metadata if `clip` option is false', async () => {
|
||||
const dto: SearchDto = { q: 'test query', clip: false };
|
||||
assetMock.searchMetadata.mockResolvedValueOnce([assetStub.image]);
|
||||
partnerMock.getAll.mockResolvedValueOnce([]);
|
||||
const expectedResponse = {
|
||||
albums: {
|
||||
total: 0,
|
||||
@@ -105,7 +110,7 @@ describe(SearchService.name, () => {
|
||||
const result = await sut.search(authStub.user1, dto);
|
||||
|
||||
expect(result).toEqual(expectedResponse);
|
||||
expect(assetMock.searchMetadata).toHaveBeenCalledWith(dto.q, authStub.user1.user.id, { numResults: 250 });
|
||||
expect(assetMock.searchMetadata).toHaveBeenCalledWith(dto.q, [authStub.user1.user.id], { numResults: 250 });
|
||||
expect(smartInfoMock.searchCLIP).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -114,6 +119,7 @@ describe(SearchService.name, () => {
|
||||
const embedding = [1, 2, 3];
|
||||
smartInfoMock.searchCLIP.mockResolvedValueOnce([assetStub.image]);
|
||||
machineMock.encodeText.mockResolvedValueOnce(embedding);
|
||||
partnerMock.getAll.mockResolvedValueOnce([]);
|
||||
const expectedResponse = {
|
||||
albums: {
|
||||
total: 0,
|
||||
@@ -133,7 +139,7 @@ describe(SearchService.name, () => {
|
||||
|
||||
expect(result).toEqual(expectedResponse);
|
||||
expect(smartInfoMock.searchCLIP).toHaveBeenCalledWith({
|
||||
ownerId: authStub.user1.user.id,
|
||||
userIds: [authStub.user1.user.id],
|
||||
embedding,
|
||||
numResults: 100,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PersonResponseDto } from '../person';
|
||||
import {
|
||||
IAssetRepository,
|
||||
IMachineLearningRepository,
|
||||
IPartnerRepository,
|
||||
IPersonRepository,
|
||||
ISmartInfoRepository,
|
||||
ISystemConfigRepository,
|
||||
@@ -28,6 +29,7 @@ export class SearchService {
|
||||
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
||||
@Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
||||
) {
|
||||
this.configCore = SystemConfigCore.create(configRepository);
|
||||
}
|
||||
@@ -64,6 +66,7 @@ export class SearchService {
|
||||
throw new Error('CLIP is not enabled');
|
||||
}
|
||||
const strategy = dto.clip ? SearchStrategy.CLIP : SearchStrategy.TEXT;
|
||||
const userIds = await this.getUserIdsToSearch(auth);
|
||||
|
||||
let assets: AssetEntity[] = [];
|
||||
|
||||
@@ -74,10 +77,10 @@ export class SearchService {
|
||||
{ text: query },
|
||||
machineLearning.clip,
|
||||
);
|
||||
assets = await this.smartInfoRepository.searchCLIP({ ownerId: auth.user.id, embedding, numResults: 100 });
|
||||
assets = await this.smartInfoRepository.searchCLIP({ userIds: userIds, embedding, numResults: 100 });
|
||||
break;
|
||||
case SearchStrategy.TEXT:
|
||||
assets = await this.assetRepository.searchMetadata(query, auth.user.id, { numResults: 250 });
|
||||
assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: 250 });
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -97,4 +100,14 @@ export class SearchService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
|
||||
const userIds: string[] = [auth.user.id];
|
||||
const partners = await this.partnerRepository.getAll(auth.user.id);
|
||||
const partnersIds = partners
|
||||
.filter((partner) => partner.sharedBy && partner.inTimeline)
|
||||
.map((partner) => partner.sharedById);
|
||||
userIds.push(...partnersIds);
|
||||
return userIds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
await sut.handleQueueEncodeClip({ force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { id: assetStub.image.id } });
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.ENCODE_CLIP, data: { id: assetStub.image.id } }]);
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.CLIP_ENCODING);
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
await sut.handleQueueEncodeClip({ force: true });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { id: assetStub.image.id } });
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.ENCODE_CLIP, data: { id: assetStub.image.id } }]);
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,9 +64,7 @@ export class SmartInfoService {
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { id: asset.id } });
|
||||
}
|
||||
await this.jobRepository.queueAll(assets.map((asset) => ({ name: JobName.ENCODE_CLIP, data: { id: asset.id } })));
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -97,7 +97,7 @@ export class UserCore {
|
||||
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
|
||||
}
|
||||
if (payload.storageLabel) {
|
||||
payload.storageLabel = sanitize(payload.storageLabel);
|
||||
payload.storageLabel = sanitize(payload.storageLabel.replace(/\./g, ''));
|
||||
}
|
||||
const userEntity = await this.userRepository.create(payload);
|
||||
await this.libraryRepository.create({
|
||||
|
||||
@@ -342,7 +342,7 @@ describe(UserService.name, () => {
|
||||
userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
|
||||
|
||||
await sut.createProfileImage(authStub.admin, file);
|
||||
await expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]);
|
||||
expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]);
|
||||
});
|
||||
|
||||
it('should not delete the profile image if it has not been set', async () => {
|
||||
@@ -352,6 +352,7 @@ describe(UserService.name, () => {
|
||||
|
||||
await sut.createProfileImage(authStub.admin, file);
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -361,6 +362,7 @@ describe(UserService.name, () => {
|
||||
|
||||
await expect(sut.deleteProfileImage(authStub.admin)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete the profile image if user has one', async () => {
|
||||
@@ -368,7 +370,7 @@ describe(UserService.name, () => {
|
||||
const files = [userStub.profilePath.profileImagePath];
|
||||
|
||||
await sut.deleteProfileImage(authStub.admin);
|
||||
await expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]);
|
||||
expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -456,6 +458,7 @@ describe(UserService.name, () => {
|
||||
|
||||
expect(userMock.getDeletedUsers).toHaveBeenCalled();
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should queue user ready for deletion', async () => {
|
||||
@@ -465,7 +468,7 @@ describe(UserService.name, () => {
|
||||
await sut.handleUserDeleteCheck();
|
||||
|
||||
expect(userMock.getDeletedUsers).toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.USER_DELETION, data: { id: user.id } });
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -129,12 +129,11 @@ export class UserService {
|
||||
|
||||
async handleUserDeleteCheck() {
|
||||
const users = await this.userRepository.getDeletedUsers();
|
||||
for (const user of users) {
|
||||
if (this.isReadyForDeletion(user)) {
|
||||
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id } });
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(
|
||||
users.flatMap((user) =>
|
||||
this.isReadyForDeletion(user) ? [{ name: JobName.USER_DELETION, data: { id: user.id } }] : [],
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -804,10 +804,14 @@ export class AssetRepository implements IAssetRepository {
|
||||
return builder;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING, DummyValue.UUID, { numResults: 250 }] })
|
||||
async searchMetadata(query: string, ownerId: string, { numResults }: MetadataSearchOptions): Promise<AssetEntity[]> {
|
||||
@GenerateSql({ params: [DummyValue.STRING, [DummyValue.UUID], { numResults: 250 }] })
|
||||
async searchMetadata(
|
||||
query: string,
|
||||
userIds: string[],
|
||||
{ numResults }: MetadataSearchOptions,
|
||||
): Promise<AssetEntity[]> {
|
||||
const rows = await this.getBuilder({
|
||||
userIds: [ownerId],
|
||||
userIds: userIds,
|
||||
exifInfo: false,
|
||||
isArchived: false,
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ export class JobRepository implements IJobRepository {
|
||||
}
|
||||
|
||||
addCronJob(name: string, expression: string, onTick: () => void, start = true): void {
|
||||
const job = new CronJob(
|
||||
const job = new CronJob<null, null>(
|
||||
expression,
|
||||
onTick,
|
||||
// function to run onComplete
|
||||
@@ -116,12 +116,31 @@ export class JobRepository implements IJobRepository {
|
||||
) as unknown as Promise<JobCounts>;
|
||||
}
|
||||
|
||||
async queue(item: JobItem): Promise<void> {
|
||||
const jobName = item.name;
|
||||
const jobData = (item as { data?: any })?.data || {};
|
||||
const jobOptions = this.getJobOptions(item) || undefined;
|
||||
async queueAll(items: JobItem[]): Promise<void> {
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.getQueue(JOBS_TO_QUEUE[jobName]).add(jobName, jobData, jobOptions);
|
||||
const itemsByQueue = items.reduce<Record<string, JobItem[]>>((acc, item) => {
|
||||
const queueName = JOBS_TO_QUEUE[item.name];
|
||||
acc[queueName] = acc[queueName] || [];
|
||||
acc[queueName].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for (const [queueName, items] of Object.entries(itemsByQueue)) {
|
||||
const queue = this.getQueue(queueName as QueueName);
|
||||
const jobs = items.map((item) => ({
|
||||
name: item.name,
|
||||
data: (item as { data?: any })?.data || {},
|
||||
options: this.getJobOptions(item) || undefined,
|
||||
}));
|
||||
await queue.addBulk(jobs);
|
||||
}
|
||||
}
|
||||
|
||||
async queue(item: JobItem): Promise<void> {
|
||||
await this.queueAll([item]);
|
||||
}
|
||||
|
||||
private getJobOptions(item: JobItem): JobsOptions | null {
|
||||
|
||||
@@ -41,9 +41,9 @@ export class SmartInfoRepository implements ISmartInfoRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
params: [{ ownerId: DummyValue.UUID, embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }],
|
||||
params: [{ userIds: [DummyValue.UUID], embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }],
|
||||
})
|
||||
async searchCLIP({ ownerId, embedding, numResults }: EmbeddingSearch): Promise<AssetEntity[]> {
|
||||
async searchCLIP({ userIds, embedding, numResults }: EmbeddingSearch): Promise<AssetEntity[]> {
|
||||
if (!isValidInteger(numResults, { min: 1 })) {
|
||||
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
||||
}
|
||||
@@ -55,13 +55,13 @@ export class SmartInfoRepository implements ISmartInfoRepository {
|
||||
results = await manager
|
||||
.createQueryBuilder(AssetEntity, 'a')
|
||||
.innerJoin('a.smartSearch', 's')
|
||||
.where('a.ownerId = :ownerId')
|
||||
.where('a.ownerId IN (:...userIds )')
|
||||
.andWhere('a.isVisible = true')
|
||||
.andWhere('a.isArchived = false')
|
||||
.andWhere('a.fileCreatedAt < NOW()')
|
||||
.leftJoinAndSelect('a.exifInfo', 'e')
|
||||
.orderBy('s.embedding <=> :embedding')
|
||||
.setParameters({ ownerId, embedding: asVector(embedding) })
|
||||
.setParameters({ userIds, embedding: asVector(embedding) })
|
||||
.limit(numResults)
|
||||
.getMany();
|
||||
});
|
||||
@@ -72,14 +72,14 @@ export class SmartInfoRepository implements ISmartInfoRepository {
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{
|
||||
ownerId: DummyValue.UUID,
|
||||
userIds: [DummyValue.UUID],
|
||||
embedding: Array.from({ length: 512 }, Math.random),
|
||||
numResults: 100,
|
||||
maxDistance: 0.6,
|
||||
},
|
||||
],
|
||||
})
|
||||
async searchFaces({ ownerId, embedding, numResults, maxDistance }: EmbeddingSearch): Promise<AssetFaceEntity[]> {
|
||||
async searchFaces({ userIds, embedding, numResults, maxDistance }: EmbeddingSearch): Promise<AssetFaceEntity[]> {
|
||||
if (!isValidInteger(numResults, { min: 1 })) {
|
||||
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
||||
}
|
||||
@@ -91,9 +91,9 @@ export class SmartInfoRepository implements ISmartInfoRepository {
|
||||
.createQueryBuilder(AssetFaceEntity, 'faces')
|
||||
.select('1 + (faces.embedding <=> :embedding)', 'distance')
|
||||
.innerJoin('faces.asset', 'asset')
|
||||
.where('asset.ownerId = :ownerId')
|
||||
.where('asset.ownerId IN (:...userIds )')
|
||||
.orderBy('1 + (faces.embedding <=> :embedding)')
|
||||
.setParameters({ ownerId, embedding: asVector(embedding) })
|
||||
.setParameters({ userIds, embedding: asVector(embedding) })
|
||||
.limit(numResults);
|
||||
|
||||
this.faceColumns.forEach((col) => cte.addSelect(`faces.${col}`, col));
|
||||
|
||||
@@ -69,7 +69,7 @@ FROM
|
||||
LEFT JOIN "exif" "e" ON "e"."assetId" = "a"."id"
|
||||
WHERE
|
||||
(
|
||||
"a"."ownerId" = $1
|
||||
"a"."ownerId" IN ($1)
|
||||
AND "a"."isVisible" = true
|
||||
AND "a"."isArchived" = false
|
||||
AND "a"."fileCreatedAt" < NOW()
|
||||
@@ -103,7 +103,7 @@ WITH
|
||||
INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" = $2
|
||||
"asset"."ownerId" IN ($2)
|
||||
ORDER BY
|
||||
1 + ("faces"."embedding" <= > $3) ASC
|
||||
LIMIT
|
||||
|
||||
@@ -11,6 +11,7 @@ export const newJobRepositoryMock = (): jest.Mocked<IJobRepository> => {
|
||||
pause: jest.fn(),
|
||||
resume: jest.fn(),
|
||||
queue: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
queueAll: jest.fn().mockImplementation(() => Promise.resolve()),
|
||||
getQueueStatus: jest.fn(),
|
||||
getJobCounts: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
|
||||
@@ -77,6 +77,7 @@ export const testApp = {
|
||||
deleteCronJob: jest.fn(),
|
||||
validateCronExpression: jest.fn(),
|
||||
queue: (item: JobItem) => jobs && _handler(item),
|
||||
queueAll: (items: JobItem[]) => jobs && Promise.all(items.map(_handler)).then(() => Promise.resolve()),
|
||||
resume: jest.fn(),
|
||||
empty: jest.fn(),
|
||||
setConcurrency: jest.fn(),
|
||||
|
||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -21,7 +21,6 @@
|
||||
"luxon": "^3.2.1",
|
||||
"maplibre-gl": "^3.6.0",
|
||||
"socket.io-client": "^4.6.1",
|
||||
"svelte-loading-spinners": "^0.3.4",
|
||||
"svelte-local-storage-store": "^0.6.0",
|
||||
"svelte-maplibre": "^0.7.0",
|
||||
"thumbhash": "^0.1.1"
|
||||
@@ -6862,11 +6861,6 @@
|
||||
"svelte": "^3.19.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-loading-spinners": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte-loading-spinners/-/svelte-loading-spinners-0.3.4.tgz",
|
||||
"integrity": "sha512-vKaW71QMCBcTNijAGc0mUl8k3DQ66iYmp6MB8BMGCXyWk82bTrcLy8FOnSm9fE+8q6TwzD6PLUoYFHt0II93Xw=="
|
||||
},
|
||||
"node_modules/svelte-local-storage-store": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte-local-storage-store/-/svelte-local-storage-store-0.6.4.tgz",
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
"luxon": "^3.2.1",
|
||||
"maplibre-gl": "^3.6.0",
|
||||
"socket.io-client": "^4.6.1",
|
||||
"svelte-loading-spinners": "^0.3.4",
|
||||
"svelte-local-storage-store": "^0.6.0",
|
||||
"svelte-maplibre": "^0.7.0",
|
||||
"thumbhash": "^0.1.1"
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
export let size: string = '24';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<svg
|
||||
role="status"
|
||||
class={`h-[24px] w-[24px] animate-spin fill-immich-primary text-gray-400 dark:text-gray-600`}
|
||||
style:height="{size}px"
|
||||
style:width="{size}px"
|
||||
class="animate-spin fill-immich-primary text-gray-400 dark:text-gray-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import Pulse from 'svelte-loading-spinners/Pulse.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import LibraryImportPathsForm from '../forms/library-import-paths-form.svelte';
|
||||
import LibraryScanSettingsForm from '../forms/library-scan-settings-form.svelte';
|
||||
@@ -18,6 +17,7 @@
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
import { getContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { mdiDatabase, mdiDotsVertical, mdiUpload } from '@mdi/js';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
|
||||
let libraries: LibraryResponseDto[] = [];
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
<td class="w-1/3 text-ellipsis px-4 text-sm">{library.name}</td>
|
||||
{#if totalCount[index] == undefined}
|
||||
<td colspan="2" class="flex w-1/3 items-center justify-center text-ellipsis px-4 text-sm">
|
||||
<Pulse color="gray" size="40" unit="px" />
|
||||
<LoadingSpinner size="40" />
|
||||
</td>
|
||||
{:else}
|
||||
<td class="w-1/6 text-ellipsis px-4 text-sm">
|
||||
|
||||
@@ -58,8 +58,10 @@
|
||||
Please enter the password to view this page.
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input type="password" class="immich-form-input mr-2" placeholder="Password" bind:value={password} />
|
||||
<Button on:click={handlePasswordSubmit}>Submit</Button>
|
||||
<form novalidate autocomplete="off" on:submit|preventDefault={handlePasswordSubmit}>
|
||||
<input type="password" class="immich-form-input mr-2" placeholder="Password" bind:value={password} />
|
||||
<Button type="submit">Submit</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user