Merge branch 'immich-app:main' into FAQ-edit
This commit is contained in:
@@ -166,9 +166,9 @@ jobs:
|
|||||||
run: npm run check:typescript
|
run: npm run check:typescript
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
# - name: Run unit tests & coverage
|
- name: Run unit tests & coverage
|
||||||
# run: npm run test:cov
|
run: npm run test:cov
|
||||||
# if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
mobile-unit-tests:
|
mobile-unit-tests:
|
||||||
name: Mobile
|
name: Mobile
|
||||||
@@ -269,6 +269,9 @@ jobs:
|
|||||||
- name: Run existing migrations
|
- name: Run existing migrations
|
||||||
run: npm run typeorm:migrations:run
|
run: npm run typeorm:migrations:run
|
||||||
|
|
||||||
|
- name: Test npm run schema:reset command works
|
||||||
|
run: npm run typeorm:schema:reset
|
||||||
|
|
||||||
- name: Generate new migrations
|
- name: Generate new migrations
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
|
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:20231221@sha256:6746ca1c430578ae05b3a28554e584bece81ef80ee1c9e13b2323fb42c59a0fa as test
|
FROM ghcr.io/immich-app/base-server-dev:20231228@sha256:e631113b47c7e16a06ca47d3a99bdf269e831dfa4b94f6f4cc923781fa82c4e3 as test
|
||||||
|
|
||||||
WORKDIR /usr/src/app/server
|
WORKDIR /usr/src/app/server
|
||||||
COPY server/package.json server/package-lock.json ./
|
COPY server/package.json server/package-lock.json ./
|
||||||
@@ -10,7 +10,7 @@ COPY cli/package.json cli/package-lock.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY ./cli/ .
|
COPY ./cli/ .
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:20231221@sha256:12156a036d4099f4975ed0744e468319a66c75536f2fa71959f47e5f4ff5dc7b
|
FROM ghcr.io/immich-app/base-server-prod:20231228@sha256:e51e418d904124f368eca84b504414e40c5b55f9990be043d1749fdf5d1a045c
|
||||||
|
|
||||||
VOLUME /usr/src/app/upload
|
VOLUME /usr/src/app/upload
|
||||||
|
|
||||||
|
|||||||
Generated
+12
-6
@@ -3780,12 +3780,6 @@ export interface SystemConfigJobDto {
|
|||||||
* @memberof SystemConfigJobDto
|
* @memberof SystemConfigJobDto
|
||||||
*/
|
*/
|
||||||
'smartSearch': JobSettingsDto;
|
'smartSearch': JobSettingsDto;
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @type {JobSettingsDto}
|
|
||||||
* @memberof SystemConfigJobDto
|
|
||||||
*/
|
|
||||||
'storageTemplateMigration': JobSettingsDto;
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {JobSettingsDto}
|
* @type {JobSettingsDto}
|
||||||
@@ -4026,6 +4020,18 @@ export interface SystemConfigReverseGeocodingDto {
|
|||||||
* @interface SystemConfigStorageTemplateDto
|
* @interface SystemConfigStorageTemplateDto
|
||||||
*/
|
*/
|
||||||
export interface SystemConfigStorageTemplateDto {
|
export interface SystemConfigStorageTemplateDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof SystemConfigStorageTemplateDto
|
||||||
|
*/
|
||||||
|
'enabled': boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof SystemConfigStorageTemplateDto
|
||||||
|
*/
|
||||||
|
'hashVerificationEnabled': boolean;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {string}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ x-server-build: &server-common
|
|||||||
volumes:
|
volumes:
|
||||||
- ../server:/usr/src/app
|
- ../server:/usr/src/app
|
||||||
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
|
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
|
||||||
|
- ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload
|
||||||
- /usr/src/app/node_modules
|
- /usr/src/app/node_modules
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
env_file:
|
env_file:
|
||||||
@@ -92,7 +93,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:b6124ab2e45cc332e16398022a411d7e37181f21ff7874835e0180f56a09e82a
|
image: redis:6.2-alpine@sha256:c5a607fb6e1bb15d32bbcf14db22787d19e428d59e31a5da67511b49bb0f1ccc
|
||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:b6124ab2e45cc332e16398022a411d7e37181f21ff7874835e0180f56a09e82a
|
image: redis:6.2-alpine@sha256:c5a607fb6e1bb15d32bbcf14db22787d19e428d59e31a5da67511b49bb0f1ccc
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
database:
|
database:
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:b6124ab2e45cc332e16398022a411d7e37181f21ff7874835e0180f56a09e82a
|
image: redis:6.2-alpine@sha256:c5a607fb6e1bb15d32bbcf14db22787d19e428d59e31a5da67511b49bb0f1ccc
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
database:
|
database:
|
||||||
|
|||||||
Generated
-1
@@ -16,7 +16,6 @@ Name | Type | Description | Notes
|
|||||||
**search** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
**search** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||||
**sidecar** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
**sidecar** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||||
**smartSearch** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
**smartSearch** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||||
**storageTemplateMigration** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
|
||||||
**thumbnailGeneration** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
**thumbnailGeneration** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||||
**videoConversion** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
**videoConversion** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:openapi/api.dart';
|
|||||||
## Properties
|
## Properties
|
||||||
Name | Type | Description | Notes
|
Name | Type | Description | Notes
|
||||||
------------ | ------------- | ------------- | -------------
|
------------ | ------------- | ------------- | -------------
|
||||||
|
**enabled** | **bool** | |
|
||||||
|
**hashVerificationEnabled** | **bool** | |
|
||||||
**template** | **String** | |
|
**template** | **String** | |
|
||||||
|
|
||||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||||
|
|||||||
+1
-9
@@ -21,7 +21,6 @@ class SystemConfigJobDto {
|
|||||||
required this.search,
|
required this.search,
|
||||||
required this.sidecar,
|
required this.sidecar,
|
||||||
required this.smartSearch,
|
required this.smartSearch,
|
||||||
required this.storageTemplateMigration,
|
|
||||||
required this.thumbnailGeneration,
|
required this.thumbnailGeneration,
|
||||||
required this.videoConversion,
|
required this.videoConversion,
|
||||||
});
|
});
|
||||||
@@ -42,8 +41,6 @@ class SystemConfigJobDto {
|
|||||||
|
|
||||||
JobSettingsDto smartSearch;
|
JobSettingsDto smartSearch;
|
||||||
|
|
||||||
JobSettingsDto storageTemplateMigration;
|
|
||||||
|
|
||||||
JobSettingsDto thumbnailGeneration;
|
JobSettingsDto thumbnailGeneration;
|
||||||
|
|
||||||
JobSettingsDto videoConversion;
|
JobSettingsDto videoConversion;
|
||||||
@@ -58,7 +55,6 @@ class SystemConfigJobDto {
|
|||||||
other.search == search &&
|
other.search == search &&
|
||||||
other.sidecar == sidecar &&
|
other.sidecar == sidecar &&
|
||||||
other.smartSearch == smartSearch &&
|
other.smartSearch == smartSearch &&
|
||||||
other.storageTemplateMigration == storageTemplateMigration &&
|
|
||||||
other.thumbnailGeneration == thumbnailGeneration &&
|
other.thumbnailGeneration == thumbnailGeneration &&
|
||||||
other.videoConversion == videoConversion;
|
other.videoConversion == videoConversion;
|
||||||
|
|
||||||
@@ -73,12 +69,11 @@ class SystemConfigJobDto {
|
|||||||
(search.hashCode) +
|
(search.hashCode) +
|
||||||
(sidecar.hashCode) +
|
(sidecar.hashCode) +
|
||||||
(smartSearch.hashCode) +
|
(smartSearch.hashCode) +
|
||||||
(storageTemplateMigration.hashCode) +
|
|
||||||
(thumbnailGeneration.hashCode) +
|
(thumbnailGeneration.hashCode) +
|
||||||
(videoConversion.hashCode);
|
(videoConversion.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, recognizeFaces=$recognizeFaces, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
|
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, recognizeFaces=$recognizeFaces, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@@ -90,7 +85,6 @@ class SystemConfigJobDto {
|
|||||||
json[r'search'] = this.search;
|
json[r'search'] = this.search;
|
||||||
json[r'sidecar'] = this.sidecar;
|
json[r'sidecar'] = this.sidecar;
|
||||||
json[r'smartSearch'] = this.smartSearch;
|
json[r'smartSearch'] = this.smartSearch;
|
||||||
json[r'storageTemplateMigration'] = this.storageTemplateMigration;
|
|
||||||
json[r'thumbnailGeneration'] = this.thumbnailGeneration;
|
json[r'thumbnailGeneration'] = this.thumbnailGeneration;
|
||||||
json[r'videoConversion'] = this.videoConversion;
|
json[r'videoConversion'] = this.videoConversion;
|
||||||
return json;
|
return json;
|
||||||
@@ -112,7 +106,6 @@ class SystemConfigJobDto {
|
|||||||
search: JobSettingsDto.fromJson(json[r'search'])!,
|
search: JobSettingsDto.fromJson(json[r'search'])!,
|
||||||
sidecar: JobSettingsDto.fromJson(json[r'sidecar'])!,
|
sidecar: JobSettingsDto.fromJson(json[r'sidecar'])!,
|
||||||
smartSearch: JobSettingsDto.fromJson(json[r'smartSearch'])!,
|
smartSearch: JobSettingsDto.fromJson(json[r'smartSearch'])!,
|
||||||
storageTemplateMigration: JobSettingsDto.fromJson(json[r'storageTemplateMigration'])!,
|
|
||||||
thumbnailGeneration: JobSettingsDto.fromJson(json[r'thumbnailGeneration'])!,
|
thumbnailGeneration: JobSettingsDto.fromJson(json[r'thumbnailGeneration'])!,
|
||||||
videoConversion: JobSettingsDto.fromJson(json[r'videoConversion'])!,
|
videoConversion: JobSettingsDto.fromJson(json[r'videoConversion'])!,
|
||||||
);
|
);
|
||||||
@@ -170,7 +163,6 @@ class SystemConfigJobDto {
|
|||||||
'search',
|
'search',
|
||||||
'sidecar',
|
'sidecar',
|
||||||
'smartSearch',
|
'smartSearch',
|
||||||
'storageTemplateMigration',
|
|
||||||
'thumbnailGeneration',
|
'thumbnailGeneration',
|
||||||
'videoConversion',
|
'videoConversion',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,25 +13,37 @@ part of openapi.api;
|
|||||||
class SystemConfigStorageTemplateDto {
|
class SystemConfigStorageTemplateDto {
|
||||||
/// Returns a new [SystemConfigStorageTemplateDto] instance.
|
/// Returns a new [SystemConfigStorageTemplateDto] instance.
|
||||||
SystemConfigStorageTemplateDto({
|
SystemConfigStorageTemplateDto({
|
||||||
|
required this.enabled,
|
||||||
|
required this.hashVerificationEnabled,
|
||||||
required this.template,
|
required this.template,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool enabled;
|
||||||
|
|
||||||
|
bool hashVerificationEnabled;
|
||||||
|
|
||||||
String template;
|
String template;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigStorageTemplateDto &&
|
bool operator ==(Object other) => identical(this, other) || other is SystemConfigStorageTemplateDto &&
|
||||||
|
other.enabled == enabled &&
|
||||||
|
other.hashVerificationEnabled == hashVerificationEnabled &&
|
||||||
other.template == template;
|
other.template == template;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
|
(enabled.hashCode) +
|
||||||
|
(hashVerificationEnabled.hashCode) +
|
||||||
(template.hashCode);
|
(template.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'SystemConfigStorageTemplateDto[template=$template]';
|
String toString() => 'SystemConfigStorageTemplateDto[enabled=$enabled, hashVerificationEnabled=$hashVerificationEnabled, template=$template]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
|
json[r'enabled'] = this.enabled;
|
||||||
|
json[r'hashVerificationEnabled'] = this.hashVerificationEnabled;
|
||||||
json[r'template'] = this.template;
|
json[r'template'] = this.template;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
@@ -44,6 +56,8 @@ class SystemConfigStorageTemplateDto {
|
|||||||
final json = value.cast<String, dynamic>();
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
return SystemConfigStorageTemplateDto(
|
return SystemConfigStorageTemplateDto(
|
||||||
|
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||||
|
hashVerificationEnabled: mapValueOfType<bool>(json, r'hashVerificationEnabled')!,
|
||||||
template: mapValueOfType<String>(json, r'template')!,
|
template: mapValueOfType<String>(json, r'template')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,6 +106,8 @@ class SystemConfigStorageTemplateDto {
|
|||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
|
'enabled',
|
||||||
|
'hashVerificationEnabled',
|
||||||
'template',
|
'template',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,6 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
// JobSettingsDto storageTemplateMigration
|
|
||||||
test('to test the property `storageTemplateMigration`', () async {
|
|
||||||
// TODO
|
|
||||||
});
|
|
||||||
|
|
||||||
// JobSettingsDto thumbnailGeneration
|
// JobSettingsDto thumbnailGeneration
|
||||||
test('to test the property `thumbnailGeneration`', () async {
|
test('to test the property `thumbnailGeneration`', () async {
|
||||||
// TODO
|
// TODO
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ void main() {
|
|||||||
// final instance = SystemConfigStorageTemplateDto();
|
// final instance = SystemConfigStorageTemplateDto();
|
||||||
|
|
||||||
group('test SystemConfigStorageTemplateDto', () {
|
group('test SystemConfigStorageTemplateDto', () {
|
||||||
|
// bool enabled
|
||||||
|
test('to test the property `enabled`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
|
// bool hashVerificationEnabled
|
||||||
|
test('to test the property `hashVerificationEnabled`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
// String template
|
// String template
|
||||||
test('to test the property `template`', () async {
|
test('to test the property `template`', () async {
|
||||||
// TODO
|
// TODO
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# dev build
|
# dev build
|
||||||
FROM ghcr.io/immich-app/base-server-dev:20231221@sha256:6746ca1c430578ae05b3a28554e584bece81ef80ee1c9e13b2323fb42c59a0fa as dev
|
FROM ghcr.io/immich-app/base-server-dev:20231228@sha256:e631113b47c7e16a06ca47d3a99bdf269e831dfa4b94f6f4cc923781fa82c4e3 as dev
|
||||||
|
|
||||||
RUN apt-get install --no-install-recommends -yqq tini
|
RUN apt-get install --no-install-recommends -yqq tini
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
@@ -34,7 +34,7 @@ RUN npm run build
|
|||||||
|
|
||||||
|
|
||||||
# prod build
|
# prod build
|
||||||
FROM ghcr.io/immich-app/base-server-prod:20231221@sha256:12156a036d4099f4975ed0744e468319a66c75536f2fa71959f47e5f4ff5dc7b
|
FROM ghcr.io/immich-app/base-server-prod:20231228@sha256:e51e418d904124f368eca84b504414e40c5b55f9990be043d1749fdf5d1a045c
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
@@ -9171,9 +9171,6 @@
|
|||||||
"smartSearch": {
|
"smartSearch": {
|
||||||
"$ref": "#/components/schemas/JobSettingsDto"
|
"$ref": "#/components/schemas/JobSettingsDto"
|
||||||
},
|
},
|
||||||
"storageTemplateMigration": {
|
|
||||||
"$ref": "#/components/schemas/JobSettingsDto"
|
|
||||||
},
|
|
||||||
"thumbnailGeneration": {
|
"thumbnailGeneration": {
|
||||||
"$ref": "#/components/schemas/JobSettingsDto"
|
"$ref": "#/components/schemas/JobSettingsDto"
|
||||||
},
|
},
|
||||||
@@ -9186,7 +9183,6 @@
|
|||||||
"metadataExtraction",
|
"metadataExtraction",
|
||||||
"videoConversion",
|
"videoConversion",
|
||||||
"smartSearch",
|
"smartSearch",
|
||||||
"storageTemplateMigration",
|
|
||||||
"migration",
|
"migration",
|
||||||
"backgroundTask",
|
"backgroundTask",
|
||||||
"search",
|
"search",
|
||||||
@@ -9365,11 +9361,19 @@
|
|||||||
},
|
},
|
||||||
"SystemConfigStorageTemplateDto": {
|
"SystemConfigStorageTemplateDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"hashVerificationEnabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"template": {
|
"template": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"enabled",
|
||||||
|
"hashVerificationEnabled",
|
||||||
"template"
|
"template"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|||||||
Generated
-98
@@ -41,7 +41,6 @@
|
|||||||
"joi": "^17.10.0",
|
"joi": "^17.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^3.4.2",
|
"luxon": "^3.4.2",
|
||||||
"mv": "^2.1.1",
|
|
||||||
"nest-commander": "^3.11.1",
|
"nest-commander": "^3.11.1",
|
||||||
"node-addon-api": "^7.0.0",
|
"node-addon-api": "^7.0.0",
|
||||||
"openid-client": "^5.4.3",
|
"openid-client": "^5.4.3",
|
||||||
@@ -72,7 +71,6 @@
|
|||||||
"@types/lodash": "^4.14.197",
|
"@types/lodash": "^4.14.197",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/mv": "^2.1.2",
|
|
||||||
"@types/node": "^20.5.7",
|
"@types/node": "^20.5.7",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
@@ -3548,12 +3546,6 @@
|
|||||||
"@types/express": "*"
|
"@types/express": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/mv": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/mv/-/mv-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-MgEHBpXnQo44Q43j8G0Bvp/Yi8LYbC8hxKrRFMgDEDZMmzDKZLgiyMWtW49B37ko+QupgZ3G5rtPUnOGe5ixLw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.10.3",
|
"version": "20.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.3.tgz",
|
||||||
@@ -9293,45 +9285,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
|
||||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
|
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
|
||||||
},
|
},
|
||||||
"node_modules/mv": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==",
|
|
||||||
"dependencies": {
|
|
||||||
"mkdirp": "~0.5.1",
|
|
||||||
"ncp": "~2.0.0",
|
|
||||||
"rimraf": "~2.4.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mv/node_modules/glob": {
|
|
||||||
"version": "6.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
|
|
||||||
"integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
|
|
||||||
"dependencies": {
|
|
||||||
"inflight": "^1.0.4",
|
|
||||||
"inherits": "2",
|
|
||||||
"minimatch": "2 || 3",
|
|
||||||
"once": "^1.3.0",
|
|
||||||
"path-is-absolute": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mv/node_modules/rimraf": {
|
|
||||||
"version": "2.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
|
|
||||||
"integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"glob": "^6.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"rimraf": "bin.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -9355,14 +9308,6 @@
|
|||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/ncp": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==",
|
|
||||||
"bin": {
|
|
||||||
"ncp": "bin/ncp"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nearley": {
|
"node_modules/nearley": {
|
||||||
"version": "2.20.1",
|
"version": "2.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
|
||||||
@@ -15519,12 +15464,6 @@
|
|||||||
"@types/express": "*"
|
"@types/express": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@types/mv": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/mv/-/mv-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-MgEHBpXnQo44Q43j8G0Bvp/Yi8LYbC8hxKrRFMgDEDZMmzDKZLgiyMWtW49B37ko+QupgZ3G5rtPUnOGe5ixLw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
"version": "20.10.3",
|
"version": "20.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.3.tgz",
|
||||||
@@ -19842,38 +19781,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
|
||||||
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
|
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
|
||||||
},
|
},
|
||||||
"mv": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==",
|
|
||||||
"requires": {
|
|
||||||
"mkdirp": "~0.5.1",
|
|
||||||
"ncp": "~2.0.0",
|
|
||||||
"rimraf": "~2.4.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"glob": {
|
|
||||||
"version": "6.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
|
|
||||||
"integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
|
|
||||||
"requires": {
|
|
||||||
"inflight": "^1.0.4",
|
|
||||||
"inherits": "2",
|
|
||||||
"minimatch": "2 || 3",
|
|
||||||
"once": "^1.3.0",
|
|
||||||
"path-is-absolute": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rimraf": {
|
|
||||||
"version": "2.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
|
|
||||||
"integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
|
|
||||||
"requires": {
|
|
||||||
"glob": "^6.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mz": {
|
"mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -19897,11 +19804,6 @@
|
|||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ncp": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA=="
|
|
||||||
},
|
|
||||||
"nearley": {
|
"nearley": {
|
||||||
"version": "2.20.1",
|
"version": "2.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
|
||||||
|
|||||||
+1
-3
@@ -28,7 +28,7 @@
|
|||||||
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",
|
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",
|
||||||
"typeorm:migrations:run": "typeorm migration:run -d ./dist/infra/database.config.js",
|
"typeorm:migrations:run": "typeorm migration:run -d ./dist/infra/database.config.js",
|
||||||
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/infra/database.config.js",
|
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/infra/database.config.js",
|
||||||
"typeorm:schema:drop": "typeorm schema:drop -d ./dist/infra/database.config.js",
|
"typeorm:schema:drop": "typeorm query -d ./dist/infra/database.config.js 'DROP schema public cascade; CREATE schema public;'",
|
||||||
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run",
|
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run",
|
||||||
"api:typescript": "bash ./bin/generate-open-api.sh web",
|
"api:typescript": "bash ./bin/generate-open-api.sh web",
|
||||||
"api:dart": "bash ./bin/generate-open-api.sh mobile",
|
"api:dart": "bash ./bin/generate-open-api.sh mobile",
|
||||||
@@ -68,7 +68,6 @@
|
|||||||
"joi": "^17.10.0",
|
"joi": "^17.10.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^3.4.2",
|
"luxon": "^3.4.2",
|
||||||
"mv": "^2.1.1",
|
|
||||||
"nest-commander": "^3.11.1",
|
"nest-commander": "^3.11.1",
|
||||||
"node-addon-api": "^7.0.0",
|
"node-addon-api": "^7.0.0",
|
||||||
"openid-client": "^5.4.3",
|
"openid-client": "^5.4.3",
|
||||||
@@ -99,7 +98,6 @@
|
|||||||
"@types/lodash": "^4.14.197",
|
"@types/lodash": "^4.14.197",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/mv": "^2.1.2",
|
|
||||||
"@types/node": "^20.5.7",
|
"@types/node": "^20.5.7",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"@types/supertest": "^2.0.12",
|
"@types/supertest": "^2.0.12",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { SharedLinkService } from './shared-link';
|
|||||||
import { SmartInfoService } from './smart-info';
|
import { SmartInfoService } from './smart-info';
|
||||||
import { StorageService } from './storage';
|
import { StorageService } from './storage';
|
||||||
import { StorageTemplateService } from './storage-template';
|
import { StorageTemplateService } from './storage-template';
|
||||||
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
|
import { SystemConfigService } from './system-config';
|
||||||
import { TagService } from './tag';
|
import { TagService } from './tag';
|
||||||
import { UserService } from './user';
|
import { UserService } from './user';
|
||||||
|
|
||||||
@@ -47,14 +47,6 @@ const providers: Provider[] = [
|
|||||||
TagService,
|
TagService,
|
||||||
UserService,
|
UserService,
|
||||||
ImmichLogger,
|
ImmichLogger,
|
||||||
{
|
|
||||||
provide: INITIAL_SYSTEM_CONFIG,
|
|
||||||
inject: [SystemConfigService, DatabaseService],
|
|
||||||
useFactory: async (configService: SystemConfigService, databaseService: DatabaseService) => {
|
|
||||||
await databaseService.init();
|
|
||||||
return configService.getConfig();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
|
|||||||
@@ -207,15 +207,15 @@ describe(JobService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registerHandlers', () => {
|
describe('init', () => {
|
||||||
it('should register a handler for each queue', async () => {
|
it('should register a handler for each queue', async () => {
|
||||||
await sut.registerHandlers(makeMockHandlers(true));
|
await sut.init(makeMockHandlers(true));
|
||||||
expect(configMock.load).toHaveBeenCalled();
|
expect(configMock.load).toHaveBeenCalled();
|
||||||
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
|
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should subscribe to config changes', async () => {
|
it('should subscribe to config changes', async () => {
|
||||||
await sut.registerHandlers(makeMockHandlers(false));
|
await sut.init(makeMockHandlers(false));
|
||||||
|
|
||||||
SystemConfigCore.create(newSystemConfigRepositoryMock(false)).config$.next({
|
SystemConfigCore.create(newSystemConfigRepositoryMock(false)).config$.next({
|
||||||
job: {
|
job: {
|
||||||
@@ -226,7 +226,6 @@ describe(JobService.name, () => {
|
|||||||
[QueueName.SEARCH]: { concurrency: 10 },
|
[QueueName.SEARCH]: { concurrency: 10 },
|
||||||
[QueueName.SIDECAR]: { concurrency: 10 },
|
[QueueName.SIDECAR]: { concurrency: 10 },
|
||||||
[QueueName.LIBRARY]: { concurrency: 10 },
|
[QueueName.LIBRARY]: { concurrency: 10 },
|
||||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 10 },
|
|
||||||
[QueueName.MIGRATION]: { concurrency: 10 },
|
[QueueName.MIGRATION]: { concurrency: 10 },
|
||||||
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 },
|
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 },
|
||||||
[QueueName.VIDEO_CONVERSION]: { concurrency: 10 },
|
[QueueName.VIDEO_CONVERSION]: { concurrency: 10 },
|
||||||
@@ -239,7 +238,6 @@ describe(JobService.name, () => {
|
|||||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.RECOGNIZE_FACES, 10);
|
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.RECOGNIZE_FACES, 10);
|
||||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10);
|
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10);
|
||||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10);
|
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10);
|
||||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.STORAGE_TEMPLATE_MIGRATION, 10);
|
|
||||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.MIGRATION, 10);
|
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.MIGRATION, 10);
|
||||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10);
|
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10);
|
||||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10);
|
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10);
|
||||||
@@ -325,7 +323,7 @@ describe(JobService.name, () => {
|
|||||||
assetMock.getByIds.mockResolvedValue([]);
|
assetMock.getByIds.mockResolvedValue([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sut.registerHandlers(makeMockHandlers(true));
|
await sut.init(makeMockHandlers(true));
|
||||||
await jobMock.addHandler.mock.calls[0][2](item);
|
await jobMock.addHandler.mock.calls[0][2](item);
|
||||||
await asyncTick(3);
|
await asyncTick(3);
|
||||||
|
|
||||||
@@ -336,7 +334,7 @@ describe(JobService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => {
|
it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => {
|
||||||
await sut.registerHandlers(makeMockHandlers(false));
|
await sut.init(makeMockHandlers(false));
|
||||||
await jobMock.addHandler.mock.calls[0][2](item);
|
await jobMock.addHandler.mock.calls[0][2](item);
|
||||||
await asyncTick(3);
|
await asyncTick(3);
|
||||||
|
|
||||||
|
|||||||
@@ -120,10 +120,14 @@ export class JobService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerHandlers(jobHandlers: Record<JobName, JobHandler>) {
|
async init(jobHandlers: Record<JobName, JobHandler>) {
|
||||||
const config = await this.configCore.getConfig();
|
const config = await this.configCore.getConfig();
|
||||||
for (const queueName of Object.values(QueueName)) {
|
for (const queueName of Object.values(QueueName)) {
|
||||||
const concurrency = config.job[queueName].concurrency;
|
let concurrency = 1;
|
||||||
|
if (queueName !== QueueName.STORAGE_TEMPLATE_MIGRATION) {
|
||||||
|
concurrency = config.job[queueName].concurrency;
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.debug(`Registering ${queueName} with a concurrency of ${concurrency}`);
|
this.logger.debug(`Registering ${queueName} with a concurrency of ${concurrency}`);
|
||||||
this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
|
this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
|
||||||
const { name, data } = item;
|
const { name, data } = item;
|
||||||
@@ -143,7 +147,10 @@ export class JobService {
|
|||||||
this.configCore.config$.subscribe((config) => {
|
this.configCore.config$.subscribe((config) => {
|
||||||
this.logger.log(`Updating queue concurrency settings`);
|
this.logger.log(`Updating queue concurrency settings`);
|
||||||
for (const queueName of Object.values(QueueName)) {
|
for (const queueName of Object.values(QueueName)) {
|
||||||
const concurrency = config.job[queueName].concurrency;
|
let concurrency = 1;
|
||||||
|
if (queueName !== QueueName.STORAGE_TEMPLATE_MIGRATION) {
|
||||||
|
concurrency = config.job[queueName].concurrency;
|
||||||
|
}
|
||||||
this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`);
|
this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`);
|
||||||
this.jobRepository.setConcurrency(queueName, concurrency);
|
this.jobRepository.setConcurrency(queueName, concurrency);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
assetStub,
|
assetStub,
|
||||||
faceStub,
|
faceStub,
|
||||||
newAssetRepositoryMock,
|
newAssetRepositoryMock,
|
||||||
|
newCryptoRepositoryMock,
|
||||||
newJobRepositoryMock,
|
newJobRepositoryMock,
|
||||||
newMediaRepositoryMock,
|
newMediaRepositoryMock,
|
||||||
newMoveRepositoryMock,
|
newMoveRepositoryMock,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
import { JobName } from '../job';
|
import { JobName } from '../job';
|
||||||
import {
|
import {
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
|
ICryptoRepository,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
IMoveRepository,
|
IMoveRepository,
|
||||||
@@ -43,6 +45,7 @@ describe(MediaService.name, () => {
|
|||||||
let moveMock: jest.Mocked<IMoveRepository>;
|
let moveMock: jest.Mocked<IMoveRepository>;
|
||||||
let personMock: jest.Mocked<IPersonRepository>;
|
let personMock: jest.Mocked<IPersonRepository>;
|
||||||
let storageMock: jest.Mocked<IStorageRepository>;
|
let storageMock: jest.Mocked<IStorageRepository>;
|
||||||
|
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
@@ -52,8 +55,9 @@ describe(MediaService.name, () => {
|
|||||||
moveMock = newMoveRepositoryMock();
|
moveMock = newMoveRepositoryMock();
|
||||||
personMock = newPersonRepositoryMock();
|
personMock = newPersonRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
|
cryptoMock = newCryptoRepositoryMock();
|
||||||
|
|
||||||
sut = new MediaService(assetMock, personMock, jobMock, mediaMock, storageMock, configMock, moveMock);
|
sut = new MediaService(assetMock, personMock, jobMock, mediaMock, storageMock, configMock, moveMock, cryptoMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName }
|
|||||||
import {
|
import {
|
||||||
AudioStreamInfo,
|
AudioStreamInfo,
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
|
ICryptoRepository,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
IMoveRepository,
|
IMoveRepository,
|
||||||
@@ -52,9 +53,17 @@ export class MediaService {
|
|||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
||||||
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||||
) {
|
) {
|
||||||
this.configCore = SystemConfigCore.create(configRepository);
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository);
|
this.storageCore = StorageCore.create(
|
||||||
|
assetRepository,
|
||||||
|
moveRepository,
|
||||||
|
personRepository,
|
||||||
|
cryptoRepository,
|
||||||
|
configRepository,
|
||||||
|
storageRepository,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleQueueGenerateThumbnails({ force }: IBaseJob) {
|
async handleQueueGenerateThumbnails({ force }: IBaseJob) {
|
||||||
|
|||||||
@@ -239,15 +239,6 @@ describe(MetadataService.name, () => {
|
|||||||
expect(assetMock.save).not.toHaveBeenCalled();
|
expect(assetMock.save).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an asset with isVisible set to false', async () => {
|
|
||||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, isVisible: false }]);
|
|
||||||
|
|
||||||
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(false);
|
|
||||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
|
||||||
expect(assetMock.upsertExif).not.toHaveBeenCalled();
|
|
||||||
expect(assetMock.save).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle a date in a sidecar file', async () => {
|
it('should handle a date in a sidecar file', async () => {
|
||||||
const originalDate = new Date('2023-11-21T16:13:17.517Z');
|
const originalDate = new Date('2023-11-21T16:13:17.517Z');
|
||||||
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
|
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
|
||||||
|
|||||||
@@ -114,7 +114,14 @@ export class MetadataService {
|
|||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
) {
|
) {
|
||||||
this.configCore = SystemConfigCore.create(configRepository);
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository);
|
this.storageCore = StorageCore.create(
|
||||||
|
assetRepository,
|
||||||
|
moveRepository,
|
||||||
|
personRepository,
|
||||||
|
cryptoRepository,
|
||||||
|
configRepository,
|
||||||
|
storageRepository,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@@ -199,7 +206,7 @@ export class MetadataService {
|
|||||||
|
|
||||||
async handleMetadataExtraction({ id }: IEntityJob) {
|
async handleMetadataExtraction({ id }: IEntityJob) {
|
||||||
const [asset] = await this.assetRepository.getByIds([id]);
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
if (!asset || !asset.isVisible) {
|
if (!asset) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
faceStub,
|
faceStub,
|
||||||
newAccessRepositoryMock,
|
newAccessRepositoryMock,
|
||||||
newAssetRepositoryMock,
|
newAssetRepositoryMock,
|
||||||
|
newCryptoRepositoryMock,
|
||||||
newJobRepositoryMock,
|
newJobRepositoryMock,
|
||||||
newMachineLearningRepositoryMock,
|
newMachineLearningRepositoryMock,
|
||||||
newMediaRepositoryMock,
|
newMediaRepositoryMock,
|
||||||
@@ -22,6 +23,7 @@ import { CacheControl, ImmichFileResponse } from '../domain.util';
|
|||||||
import { JobName } from '../job';
|
import { JobName } from '../job';
|
||||||
import {
|
import {
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
|
ICryptoRepository,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IMachineLearningRepository,
|
IMachineLearningRepository,
|
||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
@@ -73,6 +75,7 @@ describe(PersonService.name, () => {
|
|||||||
let personMock: jest.Mocked<IPersonRepository>;
|
let personMock: jest.Mocked<IPersonRepository>;
|
||||||
let storageMock: jest.Mocked<IStorageRepository>;
|
let storageMock: jest.Mocked<IStorageRepository>;
|
||||||
let smartInfoMock: jest.Mocked<ISmartInfoRepository>;
|
let smartInfoMock: jest.Mocked<ISmartInfoRepository>;
|
||||||
|
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||||
let sut: PersonService;
|
let sut: PersonService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -86,6 +89,7 @@ describe(PersonService.name, () => {
|
|||||||
personMock = newPersonRepositoryMock();
|
personMock = newPersonRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
smartInfoMock = newSmartInfoRepositoryMock();
|
smartInfoMock = newSmartInfoRepositoryMock();
|
||||||
|
cryptoMock = newCryptoRepositoryMock();
|
||||||
sut = new PersonService(
|
sut = new PersonService(
|
||||||
accessMock,
|
accessMock,
|
||||||
assetMock,
|
assetMock,
|
||||||
@@ -97,6 +101,7 @@ describe(PersonService.name, () => {
|
|||||||
storageMock,
|
storageMock,
|
||||||
jobMock,
|
jobMock,
|
||||||
smartInfoMock,
|
smartInfoMock,
|
||||||
|
cryptoMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
mediaMock.crop.mockResolvedValue(croppedFace);
|
mediaMock.crop.mockResolvedValue(croppedFace);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
CropOptions,
|
CropOptions,
|
||||||
IAccessRepository,
|
IAccessRepository,
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
|
ICryptoRepository,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IMachineLearningRepository,
|
IMachineLearningRepository,
|
||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
@@ -59,10 +60,18 @@ export class PersonService {
|
|||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
@Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository,
|
@Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository,
|
||||||
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||||
) {
|
) {
|
||||||
this.access = AccessCore.create(accessRepository);
|
this.access = AccessCore.create(accessRepository);
|
||||||
this.configCore = SystemConfigCore.create(configRepository);
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
this.storageCore = StorageCore.create(assetRepository, moveRepository, repository, storageRepository);
|
this.storageCore = StorageCore.create(
|
||||||
|
assetRepository,
|
||||||
|
moveRepository,
|
||||||
|
repository,
|
||||||
|
cryptoRepository,
|
||||||
|
configRepository,
|
||||||
|
storageRepository,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
|
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export enum DatabaseExtension {
|
|||||||
|
|
||||||
export enum DatabaseLock {
|
export enum DatabaseLock {
|
||||||
GeodataImport = 100,
|
GeodataImport = 100,
|
||||||
|
StorageTemplateMigration = 420,
|
||||||
CLIPDimSize = 512,
|
CLIPDimSize = 512,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ export interface IStorageRepository {
|
|||||||
unlink(filepath: string): Promise<void>;
|
unlink(filepath: string): Promise<void>;
|
||||||
unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
||||||
removeEmptyDirs(folder: string, self?: boolean): Promise<void>;
|
removeEmptyDirs(folder: string, self?: boolean): Promise<void>;
|
||||||
moveFile(source: string, target: string): Promise<void>;
|
|
||||||
checkFileExists(filepath: string, mode?: number): Promise<boolean>;
|
checkFileExists(filepath: string, mode?: number): Promise<boolean>;
|
||||||
mkdirSync(filepath: string): void;
|
mkdirSync(filepath: string): void;
|
||||||
checkDiskUsage(folder: string): Promise<DiskUsage>;
|
checkDiskUsage(folder: string): Promise<DiskUsage>;
|
||||||
readdir(folder: string): Promise<string[]>;
|
readdir(folder: string): Promise<string[]>;
|
||||||
stat(filepath: string): Promise<Stats>;
|
stat(filepath: string): Promise<Stats>;
|
||||||
crawl(crawlOptions: CrawlOptionsDto): Promise<string[]>;
|
crawl(crawlOptions: CrawlOptionsDto): Promise<string[]>;
|
||||||
|
copyFile(source: string, target: string): Promise<void>;
|
||||||
|
rename(source: string, target: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import { AssetPathType } from '@app/infra/entities';
|
import {
|
||||||
|
IAlbumRepository,
|
||||||
|
IAssetRepository,
|
||||||
|
ICryptoRepository,
|
||||||
|
IDatabaseRepository,
|
||||||
|
IMoveRepository,
|
||||||
|
IPersonRepository,
|
||||||
|
IStorageRepository,
|
||||||
|
ISystemConfigRepository,
|
||||||
|
IUserRepository,
|
||||||
|
StorageTemplateService,
|
||||||
|
defaults,
|
||||||
|
} from '@app/domain';
|
||||||
|
import { AssetPathType, SystemConfigKey } from '@app/infra/entities';
|
||||||
import {
|
import {
|
||||||
assetStub,
|
assetStub,
|
||||||
newAlbumRepositoryMock,
|
newAlbumRepositoryMock,
|
||||||
newAssetRepositoryMock,
|
newAssetRepositoryMock,
|
||||||
|
newCryptoRepositoryMock,
|
||||||
|
newDatabaseRepositoryMock,
|
||||||
newMoveRepositoryMock,
|
newMoveRepositoryMock,
|
||||||
newPersonRepositoryMock,
|
newPersonRepositoryMock,
|
||||||
newStorageRepositoryMock,
|
newStorageRepositoryMock,
|
||||||
@@ -11,17 +26,8 @@ import {
|
|||||||
userStub,
|
userStub,
|
||||||
} from '@test';
|
} from '@test';
|
||||||
import { when } from 'jest-when';
|
import { when } from 'jest-when';
|
||||||
import {
|
import { Stats } from 'node:fs';
|
||||||
IAlbumRepository,
|
import { SystemConfigCore } from '../system-config';
|
||||||
IAssetRepository,
|
|
||||||
IMoveRepository,
|
|
||||||
IPersonRepository,
|
|
||||||
IStorageRepository,
|
|
||||||
ISystemConfigRepository,
|
|
||||||
IUserRepository,
|
|
||||||
} from '../repositories';
|
|
||||||
import { defaults } from '../system-config/system-config.core';
|
|
||||||
import { StorageTemplateService } from './storage-template.service';
|
|
||||||
|
|
||||||
describe(StorageTemplateService.name, () => {
|
describe(StorageTemplateService.name, () => {
|
||||||
let sut: StorageTemplateService;
|
let sut: StorageTemplateService;
|
||||||
@@ -32,50 +38,66 @@ describe(StorageTemplateService.name, () => {
|
|||||||
let personMock: jest.Mocked<IPersonRepository>;
|
let personMock: jest.Mocked<IPersonRepository>;
|
||||||
let storageMock: jest.Mocked<IStorageRepository>;
|
let storageMock: jest.Mocked<IStorageRepository>;
|
||||||
let userMock: jest.Mocked<IUserRepository>;
|
let userMock: jest.Mocked<IUserRepository>;
|
||||||
|
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||||
|
let databaseMock: jest.Mocked<IDatabaseRepository>;
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
expect(sut).toBeDefined();
|
expect(sut).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
configMock = newSystemConfigRepositoryMock();
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
albumMock = newAlbumRepositoryMock();
|
albumMock = newAlbumRepositoryMock();
|
||||||
configMock = newSystemConfigRepositoryMock();
|
|
||||||
moveMock = newMoveRepositoryMock();
|
moveMock = newMoveRepositoryMock();
|
||||||
personMock = newPersonRepositoryMock();
|
personMock = newPersonRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
userMock = newUserRepositoryMock();
|
userMock = newUserRepositoryMock();
|
||||||
|
cryptoMock = newCryptoRepositoryMock();
|
||||||
|
databaseMock = newDatabaseRepositoryMock();
|
||||||
|
|
||||||
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: true }]);
|
||||||
|
|
||||||
sut = new StorageTemplateService(
|
sut = new StorageTemplateService(
|
||||||
albumMock,
|
albumMock,
|
||||||
assetMock,
|
assetMock,
|
||||||
configMock,
|
configMock,
|
||||||
defaults,
|
|
||||||
moveMock,
|
moveMock,
|
||||||
personMock,
|
personMock,
|
||||||
storageMock,
|
storageMock,
|
||||||
userMock,
|
userMock,
|
||||||
|
cryptoMock,
|
||||||
|
databaseMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
SystemConfigCore.create(configMock).config$.next(defaults);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleMigrationSingle', () => {
|
describe('handleMigrationSingle', () => {
|
||||||
|
it('should skip when storage template is disabled', async () => {
|
||||||
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: false }]);
|
||||||
|
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
|
||||||
|
expect(assetMock.getByIds).not.toHaveBeenCalled();
|
||||||
|
expect(storageMock.checkFileExists).not.toHaveBeenCalled();
|
||||||
|
expect(storageMock.rename).not.toHaveBeenCalled();
|
||||||
|
expect(storageMock.copyFile).not.toHaveBeenCalled();
|
||||||
|
expect(assetMock.save).not.toHaveBeenCalled();
|
||||||
|
expect(moveMock.create).not.toHaveBeenCalled();
|
||||||
|
expect(moveMock.update).not.toHaveBeenCalled();
|
||||||
|
expect(storageMock.stat).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should migrate single moving picture', async () => {
|
it('should migrate single moving picture', async () => {
|
||||||
userMock.get.mockResolvedValue(userStub.user1);
|
userMock.get.mockResolvedValue(userStub.user1);
|
||||||
const path = (id: string) => `upload/library/${userStub.user1.id}/2023/2023-02-23/${id}.jpg`;
|
const newMotionPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.mp4`;
|
||||||
const newPath = (id: string) => `upload/library/${userStub.user1.id}/2023/2023-02-23/${id}+1.jpg`;
|
const newStillPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.jpeg`;
|
||||||
|
|
||||||
when(storageMock.checkFileExists).calledWith(path(assetStub.livePhotoStillAsset.id)).mockResolvedValue(true);
|
|
||||||
when(storageMock.checkFileExists).calledWith(newPath(assetStub.livePhotoStillAsset.id)).mockResolvedValue(false);
|
|
||||||
|
|
||||||
when(storageMock.checkFileExists).calledWith(path(assetStub.livePhotoMotionAsset.id)).mockResolvedValue(true);
|
|
||||||
when(storageMock.checkFileExists).calledWith(newPath(assetStub.livePhotoMotionAsset.id)).mockResolvedValue(false);
|
|
||||||
|
|
||||||
when(assetMock.save)
|
when(assetMock.save)
|
||||||
.calledWith({ id: assetStub.livePhotoStillAsset.id, originalPath: newPath(assetStub.livePhotoStillAsset.id) })
|
.calledWith({ id: assetStub.livePhotoStillAsset.id, originalPath: newStillPicturePath })
|
||||||
.mockResolvedValue(assetStub.livePhotoStillAsset);
|
.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||||
|
|
||||||
when(assetMock.save)
|
when(assetMock.save)
|
||||||
.calledWith({ id: assetStub.livePhotoMotionAsset.id, originalPath: newPath(assetStub.livePhotoMotionAsset.id) })
|
.calledWith({ id: assetStub.livePhotoMotionAsset.id, originalPath: newMotionPicturePath })
|
||||||
.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||||
|
|
||||||
when(assetMock.getByIds)
|
when(assetMock.getByIds)
|
||||||
@@ -86,11 +108,265 @@ describe(StorageTemplateService.name, () => {
|
|||||||
.calledWith([assetStub.livePhotoMotionAsset.id])
|
.calledWith([assetStub.livePhotoMotionAsset.id])
|
||||||
.mockResolvedValue([assetStub.livePhotoMotionAsset]);
|
.mockResolvedValue([assetStub.livePhotoMotionAsset]);
|
||||||
|
|
||||||
|
when(moveMock.create)
|
||||||
|
.calledWith({
|
||||||
|
entityId: assetStub.livePhotoStillAsset.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.livePhotoStillAsset.originalPath,
|
||||||
|
newPath: newStillPicturePath,
|
||||||
|
})
|
||||||
|
.mockResolvedValue({
|
||||||
|
id: '123',
|
||||||
|
entityId: assetStub.livePhotoStillAsset.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.livePhotoStillAsset.originalPath,
|
||||||
|
newPath: newStillPicturePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
when(moveMock.create)
|
||||||
|
.calledWith({
|
||||||
|
entityId: assetStub.livePhotoMotionAsset.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.livePhotoMotionAsset.originalPath,
|
||||||
|
newPath: newMotionPicturePath,
|
||||||
|
})
|
||||||
|
.mockResolvedValue({
|
||||||
|
id: '124',
|
||||||
|
entityId: assetStub.livePhotoMotionAsset.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.livePhotoMotionAsset.originalPath,
|
||||||
|
newPath: newMotionPicturePath,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true);
|
await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true);
|
||||||
|
|
||||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
|
||||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
|
||||||
|
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
|
||||||
|
expect(assetMock.save).toHaveBeenCalledWith({
|
||||||
|
id: assetStub.livePhotoStillAsset.id,
|
||||||
|
originalPath: newStillPicturePath,
|
||||||
|
});
|
||||||
|
expect(assetMock.save).toHaveBeenCalledWith({
|
||||||
|
id: assetStub.livePhotoMotionAsset.id,
|
||||||
|
originalPath: newMotionPicturePath,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
it('should migrate previously failed move from original path when it still exists', async () => {
|
||||||
|
userMock.get.mockResolvedValue(userStub.user1);
|
||||||
|
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;
|
||||||
|
const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`;
|
||||||
|
|
||||||
|
when(storageMock.checkFileExists).calledWith(assetStub.image.originalPath).mockResolvedValue(true);
|
||||||
|
when(storageMock.checkFileExists).calledWith(previousFailedNewPath).mockResolvedValue(false);
|
||||||
|
|
||||||
|
when(moveMock.getByEntity).calledWith(assetStub.image.id, AssetPathType.ORIGINAL).mockResolvedValue({
|
||||||
|
id: '123',
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath: previousFailedNewPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
when(assetMock.save)
|
||||||
|
.calledWith({ id: assetStub.image.id, originalPath: newPath })
|
||||||
|
.mockResolvedValue(assetStub.image);
|
||||||
|
|
||||||
|
when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]);
|
||||||
|
|
||||||
|
when(moveMock.update)
|
||||||
|
.calledWith({
|
||||||
|
id: '123',
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath,
|
||||||
|
})
|
||||||
|
.mockResolvedValue({
|
||||||
|
id: '123',
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
||||||
|
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
|
||||||
|
expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath);
|
||||||
|
expect(moveMock.update).toHaveBeenCalledWith({
|
||||||
|
id: '123',
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath,
|
||||||
|
});
|
||||||
|
expect(assetMock.save).toHaveBeenCalledWith({
|
||||||
|
id: assetStub.image.id,
|
||||||
|
originalPath: newPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => {
|
||||||
|
userMock.get.mockResolvedValue(userStub.user1);
|
||||||
|
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;
|
||||||
|
const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`;
|
||||||
|
|
||||||
|
when(storageMock.checkFileExists).calledWith(assetStub.image.originalPath).mockResolvedValue(false);
|
||||||
|
when(storageMock.checkFileExists).calledWith(previousFailedNewPath).mockResolvedValue(true);
|
||||||
|
when(storageMock.stat)
|
||||||
|
.calledWith(previousFailedNewPath)
|
||||||
|
.mockResolvedValue({ size: 5000 } as Stats);
|
||||||
|
when(cryptoMock.hashFile).calledWith(previousFailedNewPath).mockResolvedValue(assetStub.image.checksum);
|
||||||
|
|
||||||
|
when(moveMock.getByEntity).calledWith(assetStub.image.id, AssetPathType.ORIGINAL).mockResolvedValue({
|
||||||
|
id: '123',
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath: previousFailedNewPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
when(assetMock.save)
|
||||||
|
.calledWith({ id: assetStub.image.id, originalPath: newPath })
|
||||||
|
.mockResolvedValue(assetStub.image);
|
||||||
|
|
||||||
|
when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]);
|
||||||
|
|
||||||
|
when(moveMock.update)
|
||||||
|
.calledWith({
|
||||||
|
id: '123',
|
||||||
|
oldPath: previousFailedNewPath,
|
||||||
|
newPath,
|
||||||
|
})
|
||||||
|
.mockResolvedValue({
|
||||||
|
id: '123',
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: previousFailedNewPath,
|
||||||
|
newPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
||||||
|
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
|
||||||
|
expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath);
|
||||||
|
expect(storageMock.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath);
|
||||||
|
expect(storageMock.copyFile).not.toHaveBeenCalled();
|
||||||
|
expect(moveMock.update).toHaveBeenCalledWith({
|
||||||
|
id: '123',
|
||||||
|
oldPath: previousFailedNewPath,
|
||||||
|
newPath,
|
||||||
|
});
|
||||||
|
expect(assetMock.save).toHaveBeenCalledWith({
|
||||||
|
id: assetStub.image.id,
|
||||||
|
originalPath: newPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail move if copying and hash of asset and the new file do not match', async () => {
|
||||||
|
userMock.get.mockResolvedValue(userStub.user1);
|
||||||
|
const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`;
|
||||||
|
|
||||||
|
when(storageMock.rename).calledWith(assetStub.image.originalPath, newPath).mockRejectedValue({ code: 'EXDEV' });
|
||||||
|
when(storageMock.stat)
|
||||||
|
.calledWith(newPath)
|
||||||
|
.mockResolvedValue({ size: 5000 } as Stats);
|
||||||
|
when(cryptoMock.hashFile).calledWith(newPath).mockResolvedValue(Buffer.from('different-hash', 'utf-8'));
|
||||||
|
|
||||||
|
when(assetMock.save)
|
||||||
|
.calledWith({ id: assetStub.image.id, originalPath: newPath })
|
||||||
|
.mockResolvedValue(assetStub.image);
|
||||||
|
|
||||||
|
when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]);
|
||||||
|
|
||||||
|
when(moveMock.create)
|
||||||
|
.calledWith({
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath: newPath,
|
||||||
|
})
|
||||||
|
.mockResolvedValue({
|
||||||
|
id: '123',
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
||||||
|
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storageMock.stat).toHaveBeenCalledWith(newPath);
|
||||||
|
expect(moveMock.create).toHaveBeenCalledWith({
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath: newPath,
|
||||||
|
});
|
||||||
|
expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath);
|
||||||
|
expect(storageMock.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath);
|
||||||
|
expect(storageMock.unlink).toHaveBeenCalledWith(newPath);
|
||||||
|
expect(storageMock.unlink).toHaveBeenCalledTimes(1);
|
||||||
|
expect(assetMock.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each`
|
||||||
|
failedPathChecksum | failedPathSize | reason
|
||||||
|
${assetStub.image.checksum} | ${500} | ${'file size'}
|
||||||
|
${Buffer.from('bad checksum', 'utf-8')} | ${assetStub.image.exifInfo?.fileSizeInByte} | ${'checksum'}
|
||||||
|
`(
|
||||||
|
'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails',
|
||||||
|
async ({ failedPathChecksum, failedPathSize }) => {
|
||||||
|
userMock.get.mockResolvedValue(userStub.user1);
|
||||||
|
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`;
|
||||||
|
const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${assetStub.image.id}.jpg`;
|
||||||
|
|
||||||
|
when(storageMock.checkFileExists).calledWith(assetStub.image.originalPath).mockResolvedValue(false);
|
||||||
|
when(storageMock.checkFileExists).calledWith(previousFailedNewPath).mockResolvedValue(true);
|
||||||
|
when(storageMock.stat)
|
||||||
|
.calledWith(previousFailedNewPath)
|
||||||
|
.mockResolvedValue({ size: failedPathSize } as Stats);
|
||||||
|
when(cryptoMock.hashFile).calledWith(previousFailedNewPath).mockResolvedValue(failedPathChecksum);
|
||||||
|
|
||||||
|
when(moveMock.getByEntity).calledWith(assetStub.image.id, AssetPathType.ORIGINAL).mockResolvedValue({
|
||||||
|
id: '123',
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath: previousFailedNewPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
when(assetMock.save)
|
||||||
|
.calledWith({ id: assetStub.image.id, originalPath: newPath })
|
||||||
|
.mockResolvedValue(assetStub.image);
|
||||||
|
|
||||||
|
when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]);
|
||||||
|
|
||||||
|
when(moveMock.update)
|
||||||
|
.calledWith({
|
||||||
|
id: '123',
|
||||||
|
oldPath: previousFailedNewPath,
|
||||||
|
newPath,
|
||||||
|
})
|
||||||
|
.mockResolvedValue({
|
||||||
|
id: '123',
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: previousFailedNewPath,
|
||||||
|
newPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
||||||
|
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
|
||||||
|
expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath);
|
||||||
|
expect(storageMock.rename).not.toHaveBeenCalled();
|
||||||
|
expect(storageMock.copyFile).not.toHaveBeenCalled();
|
||||||
|
expect(moveMock.update).not.toHaveBeenCalled();
|
||||||
|
expect(assetMock.save).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handle template migration', () => {
|
describe('handle template migration', () => {
|
||||||
@@ -155,7 +431,8 @@ describe(StorageTemplateService.name, () => {
|
|||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
expect(storageMock.moveFile).not.toHaveBeenCalled();
|
expect(storageMock.rename).not.toHaveBeenCalled();
|
||||||
|
expect(storageMock.copyFile).not.toHaveBeenCalled();
|
||||||
expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2);
|
expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2);
|
||||||
expect(assetMock.save).not.toHaveBeenCalled();
|
expect(assetMock.save).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -175,7 +452,8 @@ describe(StorageTemplateService.name, () => {
|
|||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
expect(storageMock.moveFile).not.toHaveBeenCalled();
|
expect(storageMock.rename).not.toHaveBeenCalled();
|
||||||
|
expect(storageMock.copyFile).not.toHaveBeenCalled();
|
||||||
expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2);
|
expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2);
|
||||||
expect(assetMock.save).not.toHaveBeenCalled();
|
expect(assetMock.save).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -198,7 +476,7 @@ describe(StorageTemplateService.name, () => {
|
|||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
expect(storageMock.moveFile).toHaveBeenCalledWith(
|
expect(storageMock.rename).toHaveBeenCalledWith(
|
||||||
'/original/path.jpg',
|
'/original/path.jpg',
|
||||||
'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||||
);
|
);
|
||||||
@@ -226,7 +504,7 @@ describe(StorageTemplateService.name, () => {
|
|||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
expect(storageMock.moveFile).toHaveBeenCalledWith(
|
expect(storageMock.rename).toHaveBeenCalledWith(
|
||||||
'/original/path.jpg',
|
'/original/path.jpg',
|
||||||
'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
|
'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
|
||||||
);
|
);
|
||||||
@@ -236,12 +514,84 @@ describe(StorageTemplateService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => {
|
||||||
|
const newPath = 'upload/library/user-id/2023/2023-02-23/asset-id.jpg';
|
||||||
|
assetMock.getAll.mockResolvedValue({
|
||||||
|
items: [assetStub.image],
|
||||||
|
hasNextPage: false,
|
||||||
|
});
|
||||||
|
storageMock.rename.mockRejectedValue({ code: 'EXDEV' });
|
||||||
|
userMock.getList.mockResolvedValue([userStub.user1]);
|
||||||
|
moveMock.create.mockResolvedValue({
|
||||||
|
id: '123',
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath,
|
||||||
|
});
|
||||||
|
when(storageMock.stat)
|
||||||
|
.calledWith(newPath)
|
||||||
|
.mockResolvedValue({
|
||||||
|
size: 5000,
|
||||||
|
} as Stats);
|
||||||
|
when(cryptoMock.hashFile).calledWith(newPath).mockResolvedValue(assetStub.image.checksum);
|
||||||
|
|
||||||
|
await sut.handleMigration();
|
||||||
|
|
||||||
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
|
expect(storageMock.rename).toHaveBeenCalledWith('/original/path.jpg', newPath);
|
||||||
|
expect(storageMock.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath);
|
||||||
|
expect(storageMock.stat).toHaveBeenCalledWith(newPath);
|
||||||
|
expect(storageMock.unlink).toHaveBeenCalledWith(assetStub.image.originalPath);
|
||||||
|
expect(storageMock.unlink).toHaveBeenCalledTimes(1);
|
||||||
|
expect(assetMock.save).toHaveBeenCalledWith({
|
||||||
|
id: assetStub.image.id,
|
||||||
|
originalPath: newPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update the database if the move fails due to incorrect newPath filesize', async () => {
|
||||||
|
assetMock.getAll.mockResolvedValue({
|
||||||
|
items: [assetStub.image],
|
||||||
|
hasNextPage: false,
|
||||||
|
});
|
||||||
|
storageMock.rename.mockRejectedValue({ code: 'EXDEV' });
|
||||||
|
userMock.getList.mockResolvedValue([userStub.user1]);
|
||||||
|
moveMock.create.mockResolvedValue({
|
||||||
|
id: '123',
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.ORIGINAL,
|
||||||
|
oldPath: assetStub.image.originalPath,
|
||||||
|
newPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||||
|
});
|
||||||
|
when(storageMock.stat)
|
||||||
|
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg')
|
||||||
|
.mockResolvedValue({
|
||||||
|
size: 100,
|
||||||
|
} as Stats);
|
||||||
|
|
||||||
|
await sut.handleMigration();
|
||||||
|
|
||||||
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
|
expect(storageMock.rename).toHaveBeenCalledWith(
|
||||||
|
'/original/path.jpg',
|
||||||
|
'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||||
|
);
|
||||||
|
expect(storageMock.copyFile).toHaveBeenCalledWith(
|
||||||
|
'/original/path.jpg',
|
||||||
|
'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||||
|
);
|
||||||
|
expect(storageMock.stat).toHaveBeenCalledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg');
|
||||||
|
expect(assetMock.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should not update the database if the move fails', async () => {
|
it('should not update the database if the move fails', async () => {
|
||||||
assetMock.getAll.mockResolvedValue({
|
assetMock.getAll.mockResolvedValue({
|
||||||
items: [assetStub.image],
|
items: [assetStub.image],
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
});
|
});
|
||||||
storageMock.moveFile.mockRejectedValue(new Error('Read only system'));
|
storageMock.rename.mockRejectedValue(new Error('Read only system'));
|
||||||
|
storageMock.copyFile.mockRejectedValue(new Error('Read only system'));
|
||||||
moveMock.create.mockResolvedValue({
|
moveMock.create.mockResolvedValue({
|
||||||
id: 'move-123',
|
id: 'move-123',
|
||||||
entityId: '123',
|
entityId: '123',
|
||||||
@@ -254,7 +604,7 @@ describe(StorageTemplateService.name, () => {
|
|||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
expect(storageMock.moveFile).toHaveBeenCalledWith(
|
expect(storageMock.rename).toHaveBeenCalledWith(
|
||||||
'/original/path.jpg',
|
'/original/path.jpg',
|
||||||
'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
|
||||||
);
|
);
|
||||||
@@ -278,7 +628,8 @@ describe(StorageTemplateService.name, () => {
|
|||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(assetMock.getAll).toHaveBeenCalled();
|
expect(assetMock.getAll).toHaveBeenCalled();
|
||||||
expect(storageMock.moveFile).not.toHaveBeenCalled();
|
expect(storageMock.rename).not.toHaveBeenCalled();
|
||||||
|
expect(storageMock.copyFile).not.toHaveBeenCalled();
|
||||||
expect(assetMock.save).not.toHaveBeenCalled();
|
expect(assetMock.save).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import sanitize from 'sanitize-filename';
|
|||||||
import { getLivePhotoMotionFilename, usePagination } from '../domain.util';
|
import { getLivePhotoMotionFilename, usePagination } from '../domain.util';
|
||||||
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||||
import {
|
import {
|
||||||
|
DatabaseLock,
|
||||||
IAlbumRepository,
|
IAlbumRepository,
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
|
ICryptoRepository,
|
||||||
|
IDatabaseRepository,
|
||||||
IMoveRepository,
|
IMoveRepository,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
IStorageRepository,
|
IStorageRepository,
|
||||||
@@ -18,7 +21,6 @@ import {
|
|||||||
} from '../repositories';
|
} from '../repositories';
|
||||||
import { StorageCore, StorageFolder } from '../storage';
|
import { StorageCore, StorageFolder } from '../storage';
|
||||||
import {
|
import {
|
||||||
INITIAL_SYSTEM_CONFIG,
|
|
||||||
supportedDayTokens,
|
supportedDayTokens,
|
||||||
supportedHourTokens,
|
supportedHourTokens,
|
||||||
supportedMinuteTokens,
|
supportedMinuteTokens,
|
||||||
@@ -46,34 +48,49 @@ export class StorageTemplateService {
|
|||||||
private logger = new ImmichLogger(StorageTemplateService.name);
|
private logger = new ImmichLogger(StorageTemplateService.name);
|
||||||
private configCore: SystemConfigCore;
|
private configCore: SystemConfigCore;
|
||||||
private storageCore: StorageCore;
|
private storageCore: StorageCore;
|
||||||
private template: {
|
private _template: {
|
||||||
compiled: HandlebarsTemplateDelegate<any>;
|
compiled: HandlebarsTemplateDelegate<any>;
|
||||||
raw: string;
|
raw: string;
|
||||||
needsAlbum: boolean;
|
needsAlbum: boolean;
|
||||||
};
|
} | null = null;
|
||||||
|
|
||||||
|
private get template() {
|
||||||
|
if (!this._template) {
|
||||||
|
throw new Error('Template not initialized');
|
||||||
|
}
|
||||||
|
return this._template;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
@Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
|
|
||||||
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
||||||
@Inject(IPersonRepository) personRepository: IPersonRepository,
|
@Inject(IPersonRepository) personRepository: IPersonRepository,
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||||
|
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||||
|
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
|
||||||
) {
|
) {
|
||||||
this.template = this.compile(config.storageTemplate.template);
|
|
||||||
this.configCore = SystemConfigCore.create(configRepository);
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
this.configCore.addValidator((config) => this.validate(config));
|
this.configCore.addValidator((config) => this.validate(config));
|
||||||
this.configCore.config$.subscribe((config) => {
|
this.configCore.config$.subscribe((config) => this.onConfig(config));
|
||||||
const template = config.storageTemplate.template;
|
this.storageCore = StorageCore.create(
|
||||||
this.logger.debug(`Received config, compiling storage template: ${template}`);
|
assetRepository,
|
||||||
this.template = this.compile(template);
|
moveRepository,
|
||||||
});
|
personRepository,
|
||||||
this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository);
|
cryptoRepository,
|
||||||
|
configRepository,
|
||||||
|
storageRepository,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleMigrationSingle({ id }: IEntityJob) {
|
async handleMigrationSingle({ id }: IEntityJob) {
|
||||||
|
const storageTemplateEnabled = (await this.configCore.getConfig()).storageTemplate.enabled;
|
||||||
|
if (!storageTemplateEnabled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const [asset] = await this.assetRepository.getByIds([id]);
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
|
|
||||||
const user = await this.userRepository.get(asset.ownerId, {});
|
const user = await this.userRepository.get(asset.ownerId, {});
|
||||||
@@ -87,12 +104,16 @@ export class StorageTemplateService {
|
|||||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleMigration() {
|
async handleMigration() {
|
||||||
this.logger.log('Starting storage template migration');
|
this.logger.log('Starting storage template migration');
|
||||||
|
const storageTemplateEnabled = (await this.configCore.getConfig()).storageTemplate.enabled;
|
||||||
|
if (!storageTemplateEnabled) {
|
||||||
|
this.logger.log('Storage template migration disabled, skipping');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||||
this.assetRepository.getAll(pagination),
|
this.assetRepository.getAll(pagination),
|
||||||
);
|
);
|
||||||
@@ -123,23 +144,36 @@ export class StorageTemplateService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, sidecarPath, originalPath } = asset;
|
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
|
||||||
const oldPath = originalPath;
|
const { id, sidecarPath, originalPath, exifInfo } = asset;
|
||||||
const newPath = await this.getTemplatePath(asset, metadata);
|
const oldPath = originalPath;
|
||||||
|
const newPath = await this.getTemplatePath(asset, metadata);
|
||||||
|
|
||||||
try {
|
if (!exifInfo || !exifInfo.fileSizeInByte) {
|
||||||
await this.storageCore.moveFile({ entityId: id, pathType: AssetPathType.ORIGINAL, oldPath, newPath });
|
this.logger.error(`Asset ${id} missing exif info, skipping storage template migration`);
|
||||||
if (sidecarPath) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await this.storageCore.moveFile({
|
await this.storageCore.moveFile({
|
||||||
entityId: id,
|
entityId: id,
|
||||||
pathType: AssetPathType.SIDECAR,
|
pathType: AssetPathType.ORIGINAL,
|
||||||
oldPath: sidecarPath,
|
oldPath,
|
||||||
newPath: `${newPath}.xmp`,
|
newPath,
|
||||||
|
assetInfo: { sizeInBytes: exifInfo.fileSizeInByte, checksum: asset.checksum },
|
||||||
});
|
});
|
||||||
|
if (sidecarPath) {
|
||||||
|
await this.storageCore.moveFile({
|
||||||
|
entityId: id,
|
||||||
|
pathType: AssetPathType.SIDECAR,
|
||||||
|
oldPath: sidecarPath,
|
||||||
|
newPath: `${newPath}.xmp`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, oldPath, newPath });
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
});
|
||||||
this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, oldPath, newPath });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise<string> {
|
private async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise<string> {
|
||||||
@@ -236,6 +270,14 @@ export class StorageTemplateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onConfig(config: SystemConfig) {
|
||||||
|
const template = config.storageTemplate.template;
|
||||||
|
if (!this._template || template !== this.template.raw) {
|
||||||
|
this.logger.debug(`Compiling new storage template: ${template}`);
|
||||||
|
this._template = this.compile(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private compile(template: string) {
|
private compile(template: string) {
|
||||||
return {
|
return {
|
||||||
raw: template,
|
raw: template,
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import { SystemConfigCore } from '@app/domain/system-config';
|
||||||
import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities';
|
import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities';
|
||||||
import { ImmichLogger } from '@app/infra/logger';
|
import { ImmichLogger } from '@app/infra/logger';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { APP_MEDIA_LOCATION } from '../domain.constant';
|
import { APP_MEDIA_LOCATION } from '../domain.constant';
|
||||||
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
|
import {
|
||||||
|
IAssetRepository,
|
||||||
|
ICryptoRepository,
|
||||||
|
IMoveRepository,
|
||||||
|
IPersonRepository,
|
||||||
|
IStorageRepository,
|
||||||
|
ISystemConfigRepository,
|
||||||
|
} from '../repositories';
|
||||||
|
|
||||||
export enum StorageFolder {
|
export enum StorageFolder {
|
||||||
ENCODED_VIDEO = 'encoded-video',
|
ENCODED_VIDEO = 'encoded-video',
|
||||||
@@ -17,6 +25,10 @@ export interface MoveRequest {
|
|||||||
pathType: PathType;
|
pathType: PathType;
|
||||||
oldPath: string | null;
|
oldPath: string | null;
|
||||||
newPath: string;
|
newPath: string;
|
||||||
|
assetInfo?: {
|
||||||
|
sizeInBytes: number;
|
||||||
|
checksum: Buffer;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeneratedAssetPath = AssetPathType.JPEG_THUMBNAIL | AssetPathType.WEBP_THUMBNAIL | AssetPathType.ENCODED_VIDEO;
|
type GeneratedAssetPath = AssetPathType.JPEG_THUMBNAIL | AssetPathType.WEBP_THUMBNAIL | AssetPathType.ENCODED_VIDEO;
|
||||||
@@ -25,22 +37,35 @@ let instance: StorageCore | null;
|
|||||||
|
|
||||||
export class StorageCore {
|
export class StorageCore {
|
||||||
private logger = new ImmichLogger(StorageCore.name);
|
private logger = new ImmichLogger(StorageCore.name);
|
||||||
|
private configCore;
|
||||||
private constructor(
|
private constructor(
|
||||||
private assetRepository: IAssetRepository,
|
private assetRepository: IAssetRepository,
|
||||||
private moveRepository: IMoveRepository,
|
private moveRepository: IMoveRepository,
|
||||||
private personRepository: IPersonRepository,
|
private personRepository: IPersonRepository,
|
||||||
|
private cryptoRepository: ICryptoRepository,
|
||||||
|
private systemConfigRepository: ISystemConfigRepository,
|
||||||
private repository: IStorageRepository,
|
private repository: IStorageRepository,
|
||||||
) {}
|
) {
|
||||||
|
this.configCore = SystemConfigCore.create(systemConfigRepository);
|
||||||
|
}
|
||||||
|
|
||||||
static create(
|
static create(
|
||||||
assetRepository: IAssetRepository,
|
assetRepository: IAssetRepository,
|
||||||
moveRepository: IMoveRepository,
|
moveRepository: IMoveRepository,
|
||||||
personRepository: IPersonRepository,
|
personRepository: IPersonRepository,
|
||||||
|
cryptoRepository: ICryptoRepository,
|
||||||
|
configRepository: ISystemConfigRepository,
|
||||||
repository: IStorageRepository,
|
repository: IStorageRepository,
|
||||||
) {
|
) {
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
instance = new StorageCore(assetRepository, moveRepository, personRepository, repository);
|
instance = new StorageCore(
|
||||||
|
assetRepository,
|
||||||
|
moveRepository,
|
||||||
|
personRepository,
|
||||||
|
cryptoRepository,
|
||||||
|
configRepository,
|
||||||
|
repository,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return instance;
|
return instance;
|
||||||
@@ -131,7 +156,7 @@ export class StorageCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async moveFile(request: MoveRequest) {
|
async moveFile(request: MoveRequest) {
|
||||||
const { entityId, pathType, oldPath, newPath } = request;
|
const { entityId, pathType, oldPath, newPath, assetInfo } = request;
|
||||||
if (!oldPath || oldPath === newPath) {
|
if (!oldPath || oldPath === newPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,26 +168,94 @@ export class StorageCore {
|
|||||||
this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`);
|
this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`);
|
||||||
const oldPathExists = await this.repository.checkFileExists(move.oldPath);
|
const oldPathExists = await this.repository.checkFileExists(move.oldPath);
|
||||||
const newPathExists = await this.repository.checkFileExists(move.newPath);
|
const newPathExists = await this.repository.checkFileExists(move.newPath);
|
||||||
const actualPath = newPathExists ? move.newPath : oldPathExists ? move.oldPath : null;
|
const actualPath = oldPathExists ? move.oldPath : newPathExists ? move.newPath : null;
|
||||||
if (!actualPath) {
|
if (!actualPath) {
|
||||||
this.logger.warn('Unable to complete move. File does not exist at either location.');
|
this.logger.warn('Unable to complete move. File does not exist at either location.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Found file at ${actualPath === move.oldPath ? 'old' : 'new'} location`);
|
const fileAtNewLocation = actualPath === move.newPath;
|
||||||
|
this.logger.log(`Found file at ${fileAtNewLocation ? 'new' : 'old'} location`);
|
||||||
|
|
||||||
|
if (fileAtNewLocation) {
|
||||||
|
if (!(await this.verifyNewPathContentsMatchesExpected(move.oldPath, move.newPath, assetInfo))) {
|
||||||
|
this.logger.fatal(
|
||||||
|
`Skipping move as file verification failed, old file is missing and new file is different to what was expected`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
move = await this.moveRepository.update({ id: move.id, oldPath: actualPath, newPath });
|
move = await this.moveRepository.update({ id: move.id, oldPath: actualPath, newPath });
|
||||||
} else {
|
} else {
|
||||||
move = await this.moveRepository.create({ entityId, pathType, oldPath, newPath });
|
move = await this.moveRepository.create({ entityId, pathType, oldPath, newPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (move.oldPath !== newPath) {
|
if (pathType === AssetPathType.ORIGINAL && !assetInfo) {
|
||||||
await this.repository.moveFile(move.oldPath, newPath);
|
this.logger.warn(`Unable to complete move. Missing asset info for ${entityId}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (move.oldPath !== newPath) {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Attempting to rename file: ${move.oldPath} => ${newPath}`);
|
||||||
|
await this.repository.rename(move.oldPath, newPath);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code !== 'EXDEV') {
|
||||||
|
this.logger.warn(
|
||||||
|
`Unable to complete move. Error renaming file with code ${err.code} and message: ${err.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.debug(`Unable to rename file. Falling back to copy, verify and delete`);
|
||||||
|
await this.repository.copyFile(move.oldPath, newPath);
|
||||||
|
|
||||||
|
if (!(await this.verifyNewPathContentsMatchesExpected(move.oldPath, newPath, assetInfo))) {
|
||||||
|
this.logger.warn(`Skipping move due to file size mismatch`);
|
||||||
|
await this.repository.unlink(newPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.repository.unlink(move.oldPath);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Unable to delete old file, it will now no longer be tracked by Immich: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.savePath(pathType, entityId, newPath);
|
await this.savePath(pathType, entityId, newPath);
|
||||||
await this.moveRepository.delete(move);
|
await this.moveRepository.delete(move);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async verifyNewPathContentsMatchesExpected(
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string,
|
||||||
|
assetInfo?: { sizeInBytes: number; checksum: Buffer },
|
||||||
|
) {
|
||||||
|
const oldPathSize = assetInfo ? assetInfo.sizeInBytes : (await this.repository.stat(oldPath)).size;
|
||||||
|
const newPathSize = (await this.repository.stat(newPath)).size;
|
||||||
|
this.logger.debug(`File size check: ${newPathSize} === ${oldPathSize}`);
|
||||||
|
if (newPathSize !== oldPathSize) {
|
||||||
|
this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (assetInfo && (await this.configCore.getConfig()).storageTemplate.hashVerificationEnabled) {
|
||||||
|
const { checksum } = assetInfo;
|
||||||
|
const newChecksum = await this.cryptoRepository.hashFile(newPath);
|
||||||
|
if (!newChecksum.equals(checksum)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Unable to complete move. File checksum mismatch: ${newChecksum.toString('base64')} !== ${checksum.toString(
|
||||||
|
'base64',
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.logger.debug(`File checksum check: ${newChecksum.toString('base64')} === ${checksum.toString('base64')}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
ensureFolders(input: string) {
|
ensureFolders(input: string) {
|
||||||
this.repository.mkdirSync(dirname(input));
|
this.repository.mkdirSync(dirname(input));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export class JobSettingsDto {
|
|||||||
concurrency!: number;
|
concurrency!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SystemConfigJobDto implements Record<QueueName, JobSettingsDto> {
|
export class SystemConfigJobDto
|
||||||
|
implements Record<Exclude<QueueName, QueueName.STORAGE_TEMPLATE_MIGRATION>, JobSettingsDto>
|
||||||
|
{
|
||||||
@ApiProperty({ type: JobSettingsDto })
|
@ApiProperty({ type: JobSettingsDto })
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@@ -35,12 +37,6 @@ export class SystemConfigJobDto implements Record<QueueName, JobSettingsDto> {
|
|||||||
@Type(() => JobSettingsDto)
|
@Type(() => JobSettingsDto)
|
||||||
[QueueName.SMART_SEARCH]!: JobSettingsDto;
|
[QueueName.SMART_SEARCH]!: JobSettingsDto;
|
||||||
|
|
||||||
@ApiProperty({ type: JobSettingsDto })
|
|
||||||
@ValidateNested()
|
|
||||||
@IsObject()
|
|
||||||
@Type(() => JobSettingsDto)
|
|
||||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobSettingsDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: JobSettingsDto })
|
@ApiProperty({ type: JobSettingsDto })
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class SystemConfigStorageTemplateDto {
|
export class SystemConfigStorageTemplateDto {
|
||||||
|
@IsBoolean()
|
||||||
|
enabled!: boolean;
|
||||||
|
@IsBoolean()
|
||||||
|
hashVerificationEnabled!: boolean;
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
template!: string;
|
template!: string;
|
||||||
|
|||||||
@@ -25,5 +25,3 @@ export const supportedPresetTokens = [
|
|||||||
'{{y}}/{{y}}-{{WW}}/{{assetId}}',
|
'{{y}}/{{y}}-{{WW}}/{{assetId}}',
|
||||||
'{{album}}/{{filename}}',
|
'{{album}}/{{filename}}',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
[QueueName.SEARCH]: { concurrency: 5 },
|
[QueueName.SEARCH]: { concurrency: 5 },
|
||||||
[QueueName.SIDECAR]: { concurrency: 5 },
|
[QueueName.SIDECAR]: { concurrency: 5 },
|
||||||
[QueueName.LIBRARY]: { concurrency: 5 },
|
[QueueName.LIBRARY]: { concurrency: 5 },
|
||||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
|
|
||||||
[QueueName.MIGRATION]: { concurrency: 5 },
|
[QueueName.MIGRATION]: { concurrency: 5 },
|
||||||
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
|
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
|
||||||
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
|
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
|
||||||
@@ -102,6 +101,8 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
storageTemplate: {
|
storageTemplate: {
|
||||||
|
enabled: false,
|
||||||
|
hashVerificationEnabled: true,
|
||||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||||
},
|
},
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||||||
[QueueName.SEARCH]: { concurrency: 5 },
|
[QueueName.SEARCH]: { concurrency: 5 },
|
||||||
[QueueName.SIDECAR]: { concurrency: 5 },
|
[QueueName.SIDECAR]: { concurrency: 5 },
|
||||||
[QueueName.LIBRARY]: { concurrency: 5 },
|
[QueueName.LIBRARY]: { concurrency: 5 },
|
||||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 },
|
|
||||||
[QueueName.MIGRATION]: { concurrency: 5 },
|
[QueueName.MIGRATION]: { concurrency: 5 },
|
||||||
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
|
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
|
||||||
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
|
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
|
||||||
@@ -102,6 +101,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
storageTemplate: {
|
storageTemplate: {
|
||||||
|
enabled: false,
|
||||||
|
hashVerificationEnabled: true,
|
||||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||||
},
|
},
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export class SystemConfigService {
|
|||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const config = await this.core.getConfig();
|
const config = await this.core.getConfig();
|
||||||
await this.setLogLevel(config);
|
this.config$.next(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
get config$() {
|
get config$() {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const databaseConfig: PostgresConnectionOptions = {
|
|||||||
subscribers: [__dirname + '/subscribers/*.{js,ts}'],
|
subscribers: [__dirname + '/subscribers/*.{js,ts}'],
|
||||||
migrationsRun: false,
|
migrationsRun: false,
|
||||||
connectTimeoutMS: 10000, // 10 seconds
|
connectTimeoutMS: 10000, // 10 seconds
|
||||||
|
parseInt8: true,
|
||||||
...urlOrParts,
|
...urlOrParts,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export enum SystemConfigKey {
|
|||||||
|
|
||||||
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
|
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
|
||||||
|
|
||||||
|
STORAGE_TEMPLATE_ENABLED = 'storageTemplate.enabled',
|
||||||
|
STORAGE_TEMPLATE_HASH_VERIFICATION_ENABLED = 'storageTemplate.hashVerificationEnabled',
|
||||||
STORAGE_TEMPLATE = 'storageTemplate.template',
|
STORAGE_TEMPLATE = 'storageTemplate.template',
|
||||||
|
|
||||||
THUMBNAIL_WEBP_SIZE = 'thumbnail.webpSize',
|
THUMBNAIL_WEBP_SIZE = 'thumbnail.webpSize',
|
||||||
@@ -171,7 +173,7 @@ export interface SystemConfig {
|
|||||||
accel: TranscodeHWAccel;
|
accel: TranscodeHWAccel;
|
||||||
tonemap: ToneMapping;
|
tonemap: ToneMapping;
|
||||||
};
|
};
|
||||||
job: Record<QueueName, { concurrency: number }>;
|
job: Record<Exclude<QueueName, QueueName.STORAGE_TEMPLATE_MIGRATION>, { concurrency: number }>;
|
||||||
logging: {
|
logging: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
level: LogLevel;
|
level: LogLevel;
|
||||||
@@ -216,6 +218,8 @@ export interface SystemConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
storageTemplate: {
|
storageTemplate: {
|
||||||
|
enabled: boolean;
|
||||||
|
hashVerificationEnabled: boolean;
|
||||||
template: string;
|
template: string;
|
||||||
};
|
};
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||||
|
|
||||||
|
export class DefaultStorageTemplateOnForExistingInstallations1703288449127 implements MigrationInterface {
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const adminCount = await queryRunner.query(`SELECT COUNT(*) FROM users WHERE "isAdmin" = true`)
|
||||||
|
if(adminCount[0].count > 0) {
|
||||||
|
await queryRunner.query(`INSERT INTO system_config (key, value) VALUES ('storageTemplate.enabled', 'true')`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DELETE FROM system_config WHERE key = 'storageTemplate.enabled'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { DatabaseExtension, DatabaseLock, IDatabaseRepository, Version } from '@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import AsyncLock from 'async-lock';
|
import AsyncLock from 'async-lock';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DatabaseRepository implements IDatabaseRepository {
|
export class DatabaseRepository implements IDatabaseRepository {
|
||||||
@@ -32,11 +32,16 @@ export class DatabaseRepository implements IDatabaseRepository {
|
|||||||
async withLock<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> {
|
async withLock<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> {
|
||||||
let res;
|
let res;
|
||||||
await this.asyncLock.acquire(DatabaseLock[lock], async () => {
|
await this.asyncLock.acquire(DatabaseLock[lock], async () => {
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
try {
|
try {
|
||||||
await this.acquireLock(lock);
|
await this.acquireLock(lock, queryRunner);
|
||||||
res = await callback();
|
res = await callback();
|
||||||
} finally {
|
} finally {
|
||||||
await this.releaseLock(lock);
|
try {
|
||||||
|
await this.releaseLock(lock, queryRunner);
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,11 +56,11 @@ export class DatabaseRepository implements IDatabaseRepository {
|
|||||||
await this.asyncLock.acquire(DatabaseLock[lock], () => {});
|
await this.asyncLock.acquire(DatabaseLock[lock], () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async acquireLock(lock: DatabaseLock): Promise<void> {
|
private async acquireLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise<void> {
|
||||||
return this.dataSource.query('SELECT pg_advisory_lock($1)', [lock]);
|
return queryRunner.query('SELECT pg_advisory_lock($1)', [lock]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async releaseLock(lock: DatabaseLock): Promise<void> {
|
private async releaseLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise<void> {
|
||||||
return this.dataSource.query('SELECT pg_advisory_unlock($1)', [lock]);
|
return queryRunner.query('SELECT pg_advisory_unlock($1)', [lock]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ import {
|
|||||||
import { ImmichLogger } from '@app/infra/logger';
|
import { ImmichLogger } from '@app/infra/logger';
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
|
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
|
||||||
import fs, { readdir, writeFile } from 'fs/promises';
|
import fs, { copyFile, readdir, rename, writeFile } from 'fs/promises';
|
||||||
import { glob } from 'glob';
|
import { glob } from 'glob';
|
||||||
import mv from 'mv';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const moveFile = promisify<string, string, mv.Options>(mv);
|
|
||||||
|
|
||||||
export class FilesystemProvider implements IStorageRepository {
|
export class FilesystemProvider implements IStorageRepository {
|
||||||
private logger = new ImmichLogger(FilesystemProvider.name);
|
private logger = new ImmichLogger(FilesystemProvider.name);
|
||||||
|
|
||||||
@@ -54,15 +50,9 @@ export class FilesystemProvider implements IStorageRepository {
|
|||||||
|
|
||||||
writeFile = writeFile;
|
writeFile = writeFile;
|
||||||
|
|
||||||
async moveFile(source: string, destination: string): Promise<void> {
|
rename = rename;
|
||||||
this.logger.verbose(`Moving ${source} to ${destination}`);
|
|
||||||
|
|
||||||
if (await this.checkFileExists(destination)) {
|
copyFile = copyFile;
|
||||||
throw new Error(`Destination file already exists: ${destination}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await moveFile(source, destination, { mkdirp: true, clobber: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
|
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export class PersonRepository implements IPersonRepository {
|
|||||||
.leftJoin('person.faces', 'face')
|
.leftJoin('person.faces', 'face')
|
||||||
.where('person.ownerId = :userId', { userId })
|
.where('person.ownerId = :userId', { userId })
|
||||||
.innerJoin('face.asset', 'asset')
|
.innerJoin('face.asset', 'asset')
|
||||||
|
.andWhere('asset.isArchived = false')
|
||||||
.orderBy('person.isHidden', 'ASC')
|
.orderBy('person.isHidden', 'ASC')
|
||||||
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
|
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
|
||||||
.addOrderBy('COUNT(face.assetId)', 'DESC')
|
.addOrderBy('COUNT(face.assetId)', 'DESC')
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ FROM
|
|||||||
AND ("asset"."deletedAt" IS NULL)
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
WHERE
|
WHERE
|
||||||
"person"."ownerId" = $1
|
"person"."ownerId" = $1
|
||||||
|
AND "asset"."isArchived" = false
|
||||||
AND "person"."isHidden" = false
|
AND "person"."isHidden" = false
|
||||||
GROUP BY
|
GROUP BY
|
||||||
"person"."id"
|
"person"."id"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class AppService {
|
|||||||
async init() {
|
async init() {
|
||||||
await this.databaseService.init();
|
await this.databaseService.init();
|
||||||
await this.configService.init();
|
await this.configService.init();
|
||||||
await this.jobService.registerHandlers({
|
await this.jobService.init({
|
||||||
[JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data),
|
[JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data),
|
||||||
[JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(),
|
[JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(),
|
||||||
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
|
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ export const newStorageRepositoryMock = (reset = true): jest.Mocked<IStorageRepo
|
|||||||
unlink: jest.fn(),
|
unlink: jest.fn(),
|
||||||
unlinkDir: jest.fn().mockResolvedValue(true),
|
unlinkDir: jest.fn().mockResolvedValue(true),
|
||||||
removeEmptyDirs: jest.fn(),
|
removeEmptyDirs: jest.fn(),
|
||||||
moveFile: jest.fn(),
|
|
||||||
checkFileExists: jest.fn(),
|
checkFileExists: jest.fn(),
|
||||||
mkdirSync: jest.fn(),
|
mkdirSync: jest.fn(),
|
||||||
checkDiskUsage: jest.fn(),
|
checkDiskUsage: jest.fn(),
|
||||||
readdir: jest.fn(),
|
readdir: jest.fn(),
|
||||||
stat: jest.fn(),
|
stat: jest.fn(),
|
||||||
crawl: jest.fn(),
|
crawl: jest.fn(),
|
||||||
|
rename: jest.fn(),
|
||||||
|
copyFile: jest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
|
|
||||||
};
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
/*
|
|
||||||
* For a detailed explanation regarding each configuration property, visit:
|
|
||||||
* https://jestjs.io/docs/configuration
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// All imported modules in your tests should be mocked automatically
|
|
||||||
// automock: false,
|
|
||||||
|
|
||||||
// Stop running tests after `n` failures
|
|
||||||
// bail: 0,
|
|
||||||
|
|
||||||
// The directory where Jest should store its cached dependency information
|
|
||||||
// cacheDirectory: "/private/var/folders/6n/31wm28711gzbt3gzsxhzxx500000gn/T/jest_dx",
|
|
||||||
|
|
||||||
// Automatically clear mock calls, instances, contexts and results before every test
|
|
||||||
clearMocks: true,
|
|
||||||
|
|
||||||
// Indicates whether the coverage information should be collected while executing the test
|
|
||||||
// collectCoverage: false,
|
|
||||||
|
|
||||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
|
||||||
collectCoverageFrom: ['src/**/*.*', '!src/api/open-api/**'],
|
|
||||||
|
|
||||||
// The directory where Jest should output its coverage files
|
|
||||||
// coverageDirectory: undefined,
|
|
||||||
coverageThreshold: {
|
|
||||||
global: {
|
|
||||||
lines: 4,
|
|
||||||
statements: 4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// An array of regexp pattern strings used to skip coverage collection
|
|
||||||
// coveragePathIgnorePatterns: [
|
|
||||||
// "/node_modules/"
|
|
||||||
// ],
|
|
||||||
|
|
||||||
// Indicates which provider should be used to instrument code for coverage
|
|
||||||
coverageProvider: 'v8',
|
|
||||||
|
|
||||||
// A list of reporter names that Jest uses when writing coverage reports
|
|
||||||
// coverageReporters: [
|
|
||||||
// "json",
|
|
||||||
// "text",
|
|
||||||
// "lcov",
|
|
||||||
// "clover"
|
|
||||||
// ],
|
|
||||||
|
|
||||||
// An object that configures minimum threshold enforcement for coverage results
|
|
||||||
// coverageThreshold: undefined,
|
|
||||||
|
|
||||||
// A path to a custom dependency extractor
|
|
||||||
// dependencyExtractor: undefined,
|
|
||||||
|
|
||||||
// Make calling deprecated APIs throw helpful error messages
|
|
||||||
// errorOnDeprecated: false,
|
|
||||||
|
|
||||||
// The default configuration for fake timers
|
|
||||||
// fakeTimers: {
|
|
||||||
// "enableGlobally": false
|
|
||||||
// },
|
|
||||||
|
|
||||||
// Force coverage collection from ignored files using an array of glob patterns
|
|
||||||
// forceCoverageMatch: [],
|
|
||||||
|
|
||||||
// A path to a module which exports an async function that is triggered once before all test suites
|
|
||||||
// globalSetup: undefined,
|
|
||||||
|
|
||||||
// A path to a module which exports an async function that is triggered once after all test suites
|
|
||||||
// globalTeardown: undefined,
|
|
||||||
|
|
||||||
// A set of global variables that need to be available in all test environments
|
|
||||||
// globals: {},
|
|
||||||
|
|
||||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
|
||||||
// maxWorkers: "50%",
|
|
||||||
|
|
||||||
// An array of directory names to be searched recursively up from the requiring module's location
|
|
||||||
// moduleDirectories: [
|
|
||||||
// "node_modules"
|
|
||||||
// ],
|
|
||||||
|
|
||||||
// An array of file extensions your modules use
|
|
||||||
moduleFileExtensions: ['svelte', 'js', 'ts'],
|
|
||||||
|
|
||||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
|
||||||
moduleNameMapper: {
|
|
||||||
'\\.(jpg|ico|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'identity-obj-proxy',
|
|
||||||
'^\\$lib(.*)$': '<rootDir>/src/lib$1',
|
|
||||||
'^\\@api(.*)$': '<rootDir>/src/api$1',
|
|
||||||
'^\\@test-data(.*)$': '<rootDir>/src/test-data$1',
|
|
||||||
},
|
|
||||||
|
|
||||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
|
||||||
// modulePathIgnorePatterns: [],
|
|
||||||
|
|
||||||
// Activates notifications for test results
|
|
||||||
// notify: false,
|
|
||||||
|
|
||||||
// An enum that specifies notification mode. Requires { notify: true }
|
|
||||||
// notifyMode: "failure-change",
|
|
||||||
|
|
||||||
// A preset that is used as a base for Jest's configuration
|
|
||||||
// preset: undefined,
|
|
||||||
|
|
||||||
// Run tests from one or more projects
|
|
||||||
// projects: undefined,
|
|
||||||
|
|
||||||
// Use this configuration option to add custom reporters to Jest
|
|
||||||
// reporters: undefined,
|
|
||||||
|
|
||||||
// Automatically reset mock state before every test
|
|
||||||
// resetMocks: false,
|
|
||||||
|
|
||||||
// Reset the module registry before running each individual test
|
|
||||||
// resetModules: false,
|
|
||||||
|
|
||||||
// A path to a custom resolver
|
|
||||||
// resolver: undefined,
|
|
||||||
|
|
||||||
// Automatically restore mock state and implementation before every test
|
|
||||||
// restoreMocks: false,
|
|
||||||
|
|
||||||
// The root directory that Jest should scan for tests and modules within
|
|
||||||
// rootDir: undefined,
|
|
||||||
|
|
||||||
// A list of paths to directories that Jest should use to search for files in
|
|
||||||
// roots: [
|
|
||||||
// "<rootDir>"
|
|
||||||
// ],
|
|
||||||
|
|
||||||
// Allows you to use a custom runner instead of Jest's default test runner
|
|
||||||
// runner: "jest-runner",
|
|
||||||
|
|
||||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
|
||||||
// setupFiles: [],
|
|
||||||
|
|
||||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
|
||||||
// setupFilesAfterEnv: [],
|
|
||||||
|
|
||||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
|
||||||
// slowTestThreshold: 5,
|
|
||||||
|
|
||||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
|
||||||
// snapshotSerializers: [],
|
|
||||||
|
|
||||||
// The test environment that will be used for testing
|
|
||||||
testEnvironment: 'jsdom',
|
|
||||||
|
|
||||||
// Options that will be passed to the testEnvironment
|
|
||||||
// testEnvironmentOptions: {},
|
|
||||||
|
|
||||||
// Adds a location field to test results
|
|
||||||
// testLocationInResults: false,
|
|
||||||
|
|
||||||
// The glob patterns Jest uses to detect test files
|
|
||||||
// testMatch: [
|
|
||||||
// "**/__tests__/**/*.[jt]s?(x)",
|
|
||||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
|
||||||
// ],
|
|
||||||
|
|
||||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
|
||||||
// testPathIgnorePatterns: [
|
|
||||||
// "/node_modules/"
|
|
||||||
// ],
|
|
||||||
|
|
||||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
|
||||||
// testRegex: [],
|
|
||||||
|
|
||||||
// This option allows the use of a custom results processor
|
|
||||||
// testResultsProcessor: undefined,
|
|
||||||
|
|
||||||
// This option allows use of a custom test runner
|
|
||||||
// testRunner: "jest-circus/runner",
|
|
||||||
|
|
||||||
// A map from regular expressions to paths to transformers
|
|
||||||
transform: {
|
|
||||||
'\\.[jt]sx?$': 'babel-jest',
|
|
||||||
'^.+\\.svelte$': [
|
|
||||||
'svelte-jester',
|
|
||||||
{
|
|
||||||
preprocess: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
|
||||||
transformIgnorePatterns: ['\\.pnp\\.[^\\/]+$'],
|
|
||||||
|
|
||||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
|
||||||
// unmockedModulePathPatterns: undefined,
|
|
||||||
|
|
||||||
// Indicates whether each individual test should be reported during the run
|
|
||||||
// verbose: undefined,
|
|
||||||
|
|
||||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
|
||||||
// watchPathIgnorePatterns: [],
|
|
||||||
|
|
||||||
// Whether to use watchman for file crawling
|
|
||||||
// watchman: true,
|
|
||||||
};
|
|
||||||
Generated
+2126
-5815
File diff suppressed because it is too large
Load Diff
+7
-11
@@ -15,19 +15,17 @@
|
|||||||
"lint:fix": "npm run lint -- --fix",
|
"lint:fix": "npm run lint -- --fix",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"format:fix": "prettier --write .",
|
"format:fix": "prettier --write .",
|
||||||
"test": "jest",
|
"test": "vitest --run",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "vitest --coverage",
|
||||||
"test:watch": "npm test -- --watch",
|
"test:watch": "vitest dev",
|
||||||
"prepare": "svelte-kit sync"
|
"prepare": "svelte-kit sync"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-env": "^7.20.2",
|
|
||||||
"@babel/preset-typescript": "^7.22.5",
|
|
||||||
"@faker-js/faker": "^8.0.0",
|
"@faker-js/faker": "^8.0.0",
|
||||||
"@floating-ui/dom": "^1.5.1",
|
"@floating-ui/dom": "^1.5.1",
|
||||||
"@sveltejs/adapter-static": "^2.0.3",
|
"@sveltejs/adapter-static": "^2.0.3",
|
||||||
"@sveltejs/kit": "^1.20.4",
|
"@sveltejs/kit": "^1.20.4",
|
||||||
"@testing-library/jest-dom": "^6.0.0",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@testing-library/svelte": "^4.0.3",
|
"@testing-library/svelte": "^4.0.3",
|
||||||
"@types/dom-to-image": "^2.6.4",
|
"@types/dom-to-image": "^2.6.4",
|
||||||
"@types/justified-layout": "^4.1.0",
|
"@types/justified-layout": "^4.1.0",
|
||||||
@@ -35,26 +33,24 @@
|
|||||||
"@types/luxon": "^3.2.0",
|
"@types/luxon": "^3.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^6.0.0",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"@vitest/coverage-v8": "^1.0.4",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"babel-jest": "^29.4.3",
|
|
||||||
"eslint": "^8.34.0",
|
"eslint": "^8.34.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-svelte": "^2.30.0",
|
"eslint-plugin-svelte": "^2.30.0",
|
||||||
"factory.ts": "^1.3.0",
|
"factory.ts": "^1.3.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^29.4.3",
|
|
||||||
"jest-environment-jsdom": "^29.4.3",
|
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.1.0",
|
||||||
"prettier-plugin-svelte": "^3.1.2",
|
"prettier-plugin-svelte": "^3.1.2",
|
||||||
"svelte": "^4.0.5",
|
"svelte": "^4.0.5",
|
||||||
"svelte-check": "^3.4.3",
|
"svelte-check": "^3.4.3",
|
||||||
"svelte-jester": "^3.0.0",
|
|
||||||
"svelte-preprocess": "^5.0.3",
|
"svelte-preprocess": "^5.0.3",
|
||||||
"tailwindcss": "^3.2.7",
|
"tailwindcss": "^3.2.7",
|
||||||
"tslib": "^2.5.0",
|
"tslib": "^2.5.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vite": "^4.1.4"
|
"vite": "^4.1.4",
|
||||||
|
"vitest": "^1.0.4"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+12
-6
@@ -3780,12 +3780,6 @@ export interface SystemConfigJobDto {
|
|||||||
* @memberof SystemConfigJobDto
|
* @memberof SystemConfigJobDto
|
||||||
*/
|
*/
|
||||||
'smartSearch': JobSettingsDto;
|
'smartSearch': JobSettingsDto;
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @type {JobSettingsDto}
|
|
||||||
* @memberof SystemConfigJobDto
|
|
||||||
*/
|
|
||||||
'storageTemplateMigration': JobSettingsDto;
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {JobSettingsDto}
|
* @type {JobSettingsDto}
|
||||||
@@ -4026,6 +4020,18 @@ export interface SystemConfigReverseGeocodingDto {
|
|||||||
* @interface SystemConfigStorageTemplateDto
|
* @interface SystemConfigStorageTemplateDto
|
||||||
*/
|
*/
|
||||||
export interface SystemConfigStorageTemplateDto {
|
export interface SystemConfigStorageTemplateDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof SystemConfigStorageTemplateDto
|
||||||
|
*/
|
||||||
|
'enabled': boolean;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof SystemConfigStorageTemplateDto
|
||||||
|
*/
|
||||||
|
'hashVerificationEnabled': boolean;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {string}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const createObjectURLMock = jest.fn();
|
const createObjectURLMock = vi.fn();
|
||||||
|
|
||||||
Object.defineProperty(URL, 'createObjectURL', {
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
JobName.SmartSearch,
|
JobName.SmartSearch,
|
||||||
JobName.RecognizeFaces,
|
JobName.RecognizeFaces,
|
||||||
JobName.VideoConversion,
|
JobName.VideoConversion,
|
||||||
JobName.StorageTemplateMigration,
|
|
||||||
JobName.Migration,
|
JobName.Migration,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+21
-5
@@ -15,6 +15,7 @@
|
|||||||
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import type { ResetOptions } from '$lib/utils/dipatch';
|
import type { ResetOptions } from '$lib/utils/dipatch';
|
||||||
|
import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte';
|
||||||
|
|
||||||
export let storageConfig: SystemConfigStorageTemplateDto;
|
export let storageConfig: SystemConfigStorageTemplateDto;
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
@@ -145,7 +146,23 @@
|
|||||||
|
|
||||||
<section class="dark:text-immich-dark-fg">
|
<section class="dark:text-immich-dark-fg">
|
||||||
{#await getConfigs() then}
|
{#await getConfigs() then}
|
||||||
<div id="directory-path-builder" class="m-4">
|
<div id="directory-path-builder" class="flex flex-col gap-4 m-4">
|
||||||
|
<SettingSwitch
|
||||||
|
title="ENABLED"
|
||||||
|
{disabled}
|
||||||
|
subtitle="Enable storage template engine"
|
||||||
|
bind:checked={storageConfig.enabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingSwitch
|
||||||
|
title="HASH VERIFICATION ENABLED"
|
||||||
|
{disabled}
|
||||||
|
subtitle="Enables hash verification, don't disable this unless you're certain of the implications"
|
||||||
|
bind:checked={storageConfig.hashVerificationEnabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Variables</h3>
|
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">Variables</h3>
|
||||||
|
|
||||||
<section class="support-date">
|
<section class="support-date">
|
||||||
@@ -191,8 +208,8 @@
|
|||||||
<div class="flex flex-col my-2">
|
<div class="flex flex-col my-2">
|
||||||
<label class="text-sm" for="preset-select">PRESET</label>
|
<label class="text-sm" for="preset-select">PRESET</label>
|
||||||
<select
|
<select
|
||||||
class="p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
|
class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
|
||||||
{disabled}
|
disabled={disabled || !storageConfig.enabled}
|
||||||
name="presets"
|
name="presets"
|
||||||
id="preset-select"
|
id="preset-select"
|
||||||
bind:value={selectedPreset}
|
bind:value={selectedPreset}
|
||||||
@@ -206,7 +223,7 @@
|
|||||||
<div class="flex gap-2 align-bottom">
|
<div class="flex gap-2 align-bottom">
|
||||||
<SettingInputField
|
<SettingInputField
|
||||||
label="TEMPLATE"
|
label="TEMPLATE"
|
||||||
{disabled}
|
disabled={disabled || !storageConfig.enabled}
|
||||||
required
|
required
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
bind:value={storageConfig.template}
|
bind:value={storageConfig.template}
|
||||||
@@ -239,7 +256,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingButtonsRow
|
<SettingButtonsRow
|
||||||
on:reset={({ detail }) => handleReset(detail)}
|
on:reset={({ detail }) => handleReset(detail)}
|
||||||
on:save={saveSetting}
|
on:save={saveSetting}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock';
|
import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock';
|
||||||
import { api, ThumbnailFormat } from '@api';
|
import { api, ThumbnailFormat } from '@api';
|
||||||
import { describe, it, jest } from '@jest/globals';
|
|
||||||
import { albumFactory } from '@test-data';
|
import { albumFactory } from '@test-data';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { fireEvent, render, RenderResult, waitFor } from '@testing-library/svelte';
|
import { fireEvent, render, RenderResult, waitFor } from '@testing-library/svelte';
|
||||||
import AlbumCard from '../album-card.svelte';
|
import AlbumCard from '../album-card.svelte';
|
||||||
|
import type { MockedObject } from 'vitest';
|
||||||
|
|
||||||
jest.mock('@api');
|
vi.mock('@api');
|
||||||
|
const apiMock: MockedObject<typeof api> = api as MockedObject<typeof api>;
|
||||||
const apiMock: jest.MockedObject<typeof api> = api as jest.MockedObject<typeof api>;
|
|
||||||
|
|
||||||
describe('AlbumCard component', () => {
|
describe('AlbumCard component', () => {
|
||||||
let sut: RenderResult<AlbumCard>;
|
let sut: RenderResult<AlbumCard>;
|
||||||
@@ -35,7 +34,7 @@ describe('AlbumCard component', () => {
|
|||||||
shared: true,
|
shared: true,
|
||||||
},
|
},
|
||||||
])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => {
|
])('shows album data without thumbnail with count $count - shared: $shared', async ({ album, count, shared }) => {
|
||||||
sut = render(AlbumCard, { album, user: album.owner });
|
sut = render(AlbumCard, { album });
|
||||||
|
|
||||||
const albumImgElement = sut.getByTestId('album-image');
|
const albumImgElement = sut.getByTestId('album-image');
|
||||||
const albumNameElement = sut.getByTestId('album-name');
|
const albumNameElement = sut.getByTestId('album-name');
|
||||||
@@ -54,9 +53,13 @@ describe('AlbumCard component', () => {
|
|||||||
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
|
expect(albumDetailsElement).toHaveTextContent(new RegExp(detailsText));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows album data and and loads the thumbnail image when available', async () => {
|
it('shows album data and loads the thumbnail image when available', async () => {
|
||||||
const thumbnailFile = new File([new Blob()], 'fileThumbnail');
|
const thumbnailFile = new File([new Blob()], 'fileThumbnail');
|
||||||
const thumbnailUrl = 'blob:thumbnailUrlOne';
|
const thumbnailUrl = 'blob:thumbnailUrlOne';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
// TODO: there needs to be a more robust mock of the @api to avoid mockResolvedValueOnce ts error
|
||||||
|
// this is a workaround to make ts checks not fail but the test will pass as expected
|
||||||
apiMock.assetApi.getAssetThumbnail.mockResolvedValue({
|
apiMock.assetApi.getAssetThumbnail.mockResolvedValue({
|
||||||
data: thumbnailFile,
|
data: thumbnailFile,
|
||||||
config: {},
|
config: {},
|
||||||
@@ -71,7 +74,7 @@ describe('AlbumCard component', () => {
|
|||||||
shared: false,
|
shared: false,
|
||||||
albumName: 'some album name',
|
albumName: 'some album name',
|
||||||
});
|
});
|
||||||
sut = render(AlbumCard, { album, user: album.owner });
|
sut = render(AlbumCard, { album });
|
||||||
|
|
||||||
const albumImgElement = sut.getByTestId('album-image');
|
const albumImgElement = sut.getByTestId('album-image');
|
||||||
const albumNameElement = sut.getByTestId('album-name');
|
const albumNameElement = sut.getByTestId('album-name');
|
||||||
@@ -99,14 +102,14 @@ describe('AlbumCard component', () => {
|
|||||||
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
|
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
sut = render(AlbumCard, { album, user: album.owner });
|
sut = render(AlbumCard, { album });
|
||||||
|
|
||||||
const albumImgElement = sut.getByTestId('album-image');
|
const albumImgElement = sut.getByTestId('album-image');
|
||||||
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
|
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches custom "click" event with the album in context', async () => {
|
it('dispatches custom "click" event with the album in context', async () => {
|
||||||
const onClickHandler = jest.fn();
|
const onClickHandler = vi.fn();
|
||||||
sut.component.$on('click', onClickHandler);
|
sut.component.$on('click', onClickHandler);
|
||||||
const albumCardElement = sut.getByTestId('album-card');
|
const albumCardElement = sut.getByTestId('album-card');
|
||||||
|
|
||||||
@@ -116,11 +119,24 @@ describe('AlbumCard component', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches custom "click" event on context menu click with mouse coordinates', async () => {
|
it('dispatches custom "click" event on context menu click with mouse coordinates', async () => {
|
||||||
const onClickHandler = jest.fn();
|
const onClickHandler = vi.fn();
|
||||||
sut.component.$on('showalbumcontextmenu', onClickHandler);
|
sut.component.$on('showalbumcontextmenu', onClickHandler);
|
||||||
|
|
||||||
const contextMenuBtnParent = sut.getByTestId('context-button-parent');
|
const contextMenuBtnParent = sut.getByTestId('context-button-parent');
|
||||||
|
|
||||||
|
// Mock getBoundingClientRect to return a bounding rectangle that will result in the expected position
|
||||||
|
contextMenuBtnParent.getBoundingClientRect = () => ({
|
||||||
|
x: 123,
|
||||||
|
y: 456,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
await fireEvent(
|
await fireEvent(
|
||||||
contextMenuBtnParent,
|
contextMenuBtnParent,
|
||||||
new MouseEvent('click', {
|
new MouseEvent('click', {
|
||||||
@@ -128,7 +144,6 @@ describe('AlbumCard component', () => {
|
|||||||
clientY: 456,
|
clientY: 456,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(onClickHandler).toHaveBeenCalledTimes(1);
|
expect(onClickHandler).toHaveBeenCalledTimes(1);
|
||||||
expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: { x: 123, y: 456 } }));
|
expect(onClickHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: { x: 123, y: 456 } }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||||
import { BucketPosition, type AssetStore } from '$lib/stores/assets.store';
|
import { BucketPosition, type AssetStore, isSelectAllCancelled } from '$lib/stores/assets.store';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { mdiTimerSand, mdiSelectAll } from '@mdi/js';
|
import { mdiTimerSand, mdiSelectAll } from '@mdi/js';
|
||||||
@@ -13,10 +13,14 @@
|
|||||||
|
|
||||||
const handleSelectAll = async () => {
|
const handleSelectAll = async () => {
|
||||||
try {
|
try {
|
||||||
|
$isSelectAllCancelled = false;
|
||||||
selecting = true;
|
selecting = true;
|
||||||
|
|
||||||
const assetGridState = get(assetStore);
|
const assetGridState = get(assetStore);
|
||||||
for (const bucket of assetGridState.buckets) {
|
for (const bucket of assetGridState.buckets) {
|
||||||
|
if ($isSelectAllCancelled) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
||||||
for (const asset of bucket.assets) {
|
for (const asset of bucket.assets) {
|
||||||
assetInteractionStore.selectAsset(asset);
|
assetInteractionStore.selectAsset(asset);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import { mdiClose } from '@mdi/js';
|
import { mdiClose } from '@mdi/js';
|
||||||
|
import { isSelectAllCancelled } from '$lib/stores/assets.store';
|
||||||
|
|
||||||
export let showBackButton = true;
|
export let showBackButton = true;
|
||||||
export let backIcon = mdiClose;
|
export let backIcon = mdiClose;
|
||||||
@@ -29,6 +30,11 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
$isSelectAllCancelled = true;
|
||||||
|
dispatch('close');
|
||||||
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
document.addEventListener('scroll', onScroll);
|
document.addEventListener('scroll', onScroll);
|
||||||
@@ -52,7 +58,7 @@
|
|||||||
<div class="flex place-items-center gap-6 justify-self-start dark:text-immich-dark-fg">
|
<div class="flex place-items-center gap-6 justify-self-start dark:text-immich-dark-fg">
|
||||||
{#if showBackButton}
|
{#if showBackButton}
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
on:click={() => dispatch('close')}
|
on:click={handleClose}
|
||||||
icon={backIcon}
|
icon={backIcon}
|
||||||
backgroundColor={'transparent'}
|
backgroundColor={'transparent'}
|
||||||
hoverColor={'#e2e7e9'}
|
hoverColor={'#e2e7e9'}
|
||||||
|
|||||||
+1
-2
@@ -1,4 +1,3 @@
|
|||||||
import { describe, it, jest } from '@jest/globals';
|
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { cleanup, render, RenderResult } from '@testing-library/svelte';
|
import { cleanup, render, RenderResult } from '@testing-library/svelte';
|
||||||
import { NotificationType } from '../notification';
|
import { NotificationType } from '../notification';
|
||||||
@@ -8,7 +7,7 @@ describe('NotificationCard component', () => {
|
|||||||
let sut: RenderResult<NotificationCard>;
|
let sut: RenderResult<NotificationCard>;
|
||||||
|
|
||||||
it('disposes timeout if already removed from the DOM', () => {
|
it('disposes timeout if already removed from the DOM', () => {
|
||||||
jest.spyOn(window, 'clearTimeout');
|
vi.spyOn(window, 'clearTimeout');
|
||||||
|
|
||||||
sut = render(NotificationCard, {
|
sut = render(NotificationCard, {
|
||||||
notificationInfo: {
|
notificationInfo: {
|
||||||
|
|||||||
+5
-5
@@ -1,4 +1,3 @@
|
|||||||
import { describe, it, jest } from '@jest/globals';
|
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { render, RenderResult, waitFor } from '@testing-library/svelte';
|
import { render, RenderResult, waitFor } from '@testing-library/svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@@ -13,11 +12,11 @@ describe('NotificationList component', () => {
|
|||||||
const sut: RenderResult<NotificationList> = render(NotificationList);
|
const sut: RenderResult<NotificationList> = render(NotificationList);
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
jest.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
jest.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a notification when added and closes it automatically after the delay timeout', async () => {
|
it('shows a notification when added and closes it automatically after the delay timeout', async () => {
|
||||||
@@ -33,10 +32,11 @@ describe('NotificationList component', () => {
|
|||||||
|
|
||||||
expect(_getNotificationListElement(sut)?.children).toHaveLength(1);
|
expect(_getNotificationListElement(sut)?.children).toHaveLength(1);
|
||||||
|
|
||||||
jest.advanceTimersByTime(3000);
|
vi.advanceTimersByTime(4000);
|
||||||
// due to some weirdness in svelte (or testing-library) need to check if it has been removed from the store to make sure it works.
|
// due to some weirdness in svelte (or testing-library) need to check if it has been removed from the store to make sure it works.
|
||||||
expect(get(notificationController.notificationList)).toHaveLength(0);
|
expect(get(notificationController.notificationList)).toHaveLength(0);
|
||||||
|
|
||||||
await waitFor(() => expect(_getNotificationListElement(sut)).not.toBeInTheDocument());
|
// TODO: investigate why this element is not removed from the DOM even notification list is in fact 0.
|
||||||
|
// await waitFor(() => expect(_getNotificationListElement(sut)).not.toBeInTheDocument());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -438,3 +438,5 @@ export class AssetStore {
|
|||||||
this.store$.update(() => this);
|
this.store$.update(() => this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isSelectAllCancelled = writable(false);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { AssetResponseDto } from '@api';
|
import type { AssetResponseDto } from '@api';
|
||||||
import { describe, expect, it } from '@jest/globals';
|
|
||||||
import { getAssetFilename, getFilenameExtension } from './asset-utils';
|
import { getAssetFilename, getFilenameExtension } from './asset-utils';
|
||||||
|
|
||||||
describe('get file extension from filename', () => {
|
describe('get file extension from filename', () => {
|
||||||
|
|||||||
@@ -12,20 +12,18 @@ describe('Executor Queue test', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should respect concurrency parameter', function () {
|
it('should respect concurrency parameter', function () {
|
||||||
jest.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const eq = new ExecutorQueue({ concurrency: 3 });
|
const eq = new ExecutorQueue({ concurrency: 3 });
|
||||||
|
|
||||||
const finished = jest.fn();
|
const finished = vi.fn();
|
||||||
const started = jest.fn();
|
const started = vi.fn();
|
||||||
|
|
||||||
const timeoutPromiseBuilder = (delay: number, id: string) =>
|
const timeoutPromiseBuilder = (delay: number, id: string) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
console.log('Task is running: ', id);
|
|
||||||
started();
|
started();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log('Finished ' + id + ' after', delay, 'ms');
|
|
||||||
finished();
|
finished();
|
||||||
resolve(undefined);
|
resolve(id);
|
||||||
}, delay);
|
}, delay);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,16 +37,16 @@ describe('Executor Queue test', function () {
|
|||||||
expect(finished).not.toBeCalled();
|
expect(finished).not.toBeCalled();
|
||||||
expect(started).toHaveBeenCalledTimes(3);
|
expect(started).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
jest.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
expect(finished).toHaveBeenCalledTimes(1);
|
expect(finished).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
jest.advanceTimersByTime(250);
|
vi.advanceTimersByTime(250);
|
||||||
expect(finished).toHaveBeenCalledTimes(3);
|
expect(finished).toHaveBeenCalledTimes(3);
|
||||||
// expect(started).toHaveBeenCalledTimes(4)
|
// expect(started).toHaveBeenCalledTimes(4)
|
||||||
|
|
||||||
//TODO : fix The test ...
|
//TODO : fix The test ...
|
||||||
|
|
||||||
jest.runAllTimers();
|
vi.runAllTimers();
|
||||||
jest.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { describe, expect, it } from '@jest/globals';
|
|
||||||
import { timeToSeconds } from './time-to-seconds';
|
import { timeToSeconds } from './time-to-seconds';
|
||||||
|
|
||||||
describe('converting time to seconds', () => {
|
describe('converting time to seconds', () => {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
||||||
import { api, CreateAlbumDto } from '@api';
|
import { api, CreateAlbumDto } from '@api';
|
||||||
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
|
|
||||||
import { albumFactory } from '@test-data';
|
import { albumFactory } from '@test-data';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { useAlbums } from '../albums.bloc';
|
import { useAlbums } from '../albums.bloc';
|
||||||
|
import type { MockedObject } from 'vitest';
|
||||||
|
|
||||||
jest.mock('@api');
|
vi.mock('@api');
|
||||||
|
|
||||||
const apiMock: jest.MockedObject<typeof api> = api as jest.MockedObject<typeof api>;
|
const apiMock: MockedObject<typeof api> = api as MockedObject<typeof api>;
|
||||||
|
|
||||||
describe('Albums BLoC', () => {
|
describe('Albums BLoC', () => {
|
||||||
let sut: ReturnType<typeof useAlbums>;
|
let sut: ReturnType<typeof useAlbums>;
|
||||||
@@ -33,6 +33,10 @@ describe('Albums BLoC', () => {
|
|||||||
// TODO: this method currently deletes albums with no assets and albumName === '' which might not be the best approach
|
// TODO: this method currently deletes albums with no assets and albumName === '' which might not be the best approach
|
||||||
const loadedAlbums = [..._albums, albumFactory.build({ id: 'new_loaded_uuid' })];
|
const loadedAlbums = [..._albums, albumFactory.build({ id: 'new_loaded_uuid' })];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
// TODO: there needs to be a more robust mock of the @api to avoid mockResolvedValueOnce ts error
|
||||||
|
// this is a workaround to make ts checks not fail but the test will pass as expected
|
||||||
apiMock.albumApi.getAllAlbums.mockResolvedValueOnce({
|
apiMock.albumApi.getAllAlbums.mockResolvedValueOnce({
|
||||||
data: loadedAlbums,
|
data: loadedAlbums,
|
||||||
config: {},
|
config: {},
|
||||||
@@ -49,14 +53,19 @@ describe('Albums BLoC', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows error message when it fails loading albums', async () => {
|
it('shows error message when it fails loading albums', async () => {
|
||||||
apiMock.albumApi.getAllAlbums.mockRejectedValueOnce({}); // TODO: implement APIProblem interface in the server
|
// TODO: implement APIProblem interface in the server
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
// TODO: there needs to be a more robust mock of the @api to avoid mockResolvedValueOnce ts error
|
||||||
|
// this is a workaround to make ts checks not fail but the test will pass as expected
|
||||||
|
apiMock.albumApi.getAllAlbums.mockRejectedValueOnce({});
|
||||||
|
|
||||||
expect(get(notificationController.notificationList)).toHaveLength(0);
|
expect(get(notificationController.notificationList)).toHaveLength(0);
|
||||||
await sut.loadAlbums();
|
await sut.loadAlbums();
|
||||||
const albums = get(sut.albums);
|
const albums = get(sut.albums);
|
||||||
const notifications = get(notificationController.notificationList);
|
const notifications = get(notificationController.notificationList);
|
||||||
|
|
||||||
expect(apiMock.albumApi.getAllAlbums).toHaveBeenCalledTimes(1);
|
expect(apiMock.albumApi.getAllAlbums).toHaveBeenCalledTimes(2);
|
||||||
expect(albums).toEqual(_albums);
|
expect(albums).toEqual(_albums);
|
||||||
expect(notifications).toHaveLength(1);
|
expect(notifications).toHaveLength(1);
|
||||||
expect(notifications[0].type).toEqual(NotificationType.Error);
|
expect(notifications[0].type).toEqual(NotificationType.Error);
|
||||||
@@ -68,7 +77,10 @@ describe('Albums BLoC', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const returnedAlbum = albumFactory.build();
|
const returnedAlbum = albumFactory.build();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
// TODO: there needs to be a more robust mock of the @api to avoid mockResolvedValueOnce ts error
|
||||||
|
// this is a workaround to make ts checks not fail but the test will pass as expected
|
||||||
apiMock.albumApi.createAlbum.mockResolvedValueOnce({
|
apiMock.albumApi.createAlbum.mockResolvedValueOnce({
|
||||||
data: returnedAlbum,
|
data: returnedAlbum,
|
||||||
config: {},
|
config: {},
|
||||||
@@ -85,18 +97,26 @@ describe('Albums BLoC', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows error message when it fails creating an album', async () => {
|
it('shows error message when it fails creating an album', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
// TODO: there needs to be a more robust mock of the @api to avoid mockResolvedValueOnce ts error
|
||||||
|
// this is a workaround to make ts checks not fail but the test will pass as expected
|
||||||
apiMock.albumApi.createAlbum.mockRejectedValueOnce({});
|
apiMock.albumApi.createAlbum.mockRejectedValueOnce({});
|
||||||
|
|
||||||
const newAlbum = await sut.createAlbum();
|
const newAlbum = await sut.createAlbum();
|
||||||
const notifications = get(notificationController.notificationList);
|
const notifications = get(notificationController.notificationList);
|
||||||
|
|
||||||
expect(apiMock.albumApi.createAlbum).toHaveBeenCalledTimes(1);
|
expect(apiMock.albumApi.createAlbum).toHaveBeenCalledTimes(2);
|
||||||
expect(newAlbum).not.toBeDefined();
|
expect(newAlbum).not.toBeDefined();
|
||||||
expect(notifications).toHaveLength(1);
|
expect(notifications).toHaveLength(1);
|
||||||
expect(notifications[0].type).toEqual(NotificationType.Error);
|
expect(notifications[0].type).toEqual(NotificationType.Error);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects an album and deletes it', async () => {
|
it('selects an album and deletes it', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
// TODO: there needs to be a more robust mock of the @api to avoid mockResolvedValueOnce ts error
|
||||||
|
// this is a workaround to make ts checks not fail but the test will pass as expected
|
||||||
apiMock.albumApi.deleteAlbum.mockResolvedValueOnce({
|
apiMock.albumApi.deleteAlbum.mockResolvedValueOnce({
|
||||||
data: undefined,
|
data: undefined,
|
||||||
config: {},
|
config: {},
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
|
|||||||
assets: [],
|
assets: [],
|
||||||
createdAt: Sync.each(() => faker.date.past().toISOString()),
|
createdAt: Sync.each(() => faker.date.past().toISOString()),
|
||||||
updatedAt: Sync.each(() => faker.date.past().toISOString()),
|
updatedAt: Sync.each(() => faker.date.past().toISOString()),
|
||||||
id: Sync.each(() => faker.datatype.uuid()),
|
id: Sync.each(() => faker.string.uuid()),
|
||||||
ownerId: Sync.each(() => faker.datatype.uuid()),
|
ownerId: Sync.each(() => faker.string.uuid()),
|
||||||
owner: userFactory.build(),
|
owner: userFactory.build(),
|
||||||
shared: false,
|
shared: false,
|
||||||
sharedUsers: [],
|
sharedUsers: [],
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { faker } from '@faker-js/faker';
|
|||||||
import { Sync } from 'factory.ts';
|
import { Sync } from 'factory.ts';
|
||||||
|
|
||||||
export const userFactory = Sync.makeFactory<UserResponseDto>({
|
export const userFactory = Sync.makeFactory<UserResponseDto>({
|
||||||
id: Sync.each(() => faker.datatype.uuid()),
|
id: Sync.each(() => faker.string.uuid()),
|
||||||
email: Sync.each(() => faker.internet.email()),
|
email: Sync.each(() => faker.internet.email()),
|
||||||
name: Sync.each(() => faker.name.fullName()),
|
name: Sync.each(() => faker.person.fullName()),
|
||||||
storageLabel: Sync.each(() => faker.random.alphaNumeric()),
|
storageLabel: Sync.each(() => faker.string.alphanumeric()),
|
||||||
externalPath: Sync.each(() => faker.random.alphaNumeric()),
|
externalPath: Sync.each(() => faker.string.alphanumeric()),
|
||||||
profileImagePath: '',
|
profileImagePath: '',
|
||||||
shouldChangePassword: Sync.each(() => faker.datatype.boolean()),
|
shouldChangePassword: Sync.each(() => faker.datatype.boolean()),
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "es2020",
|
"target": "es2020",
|
||||||
|
"types": ["vitest/globals"],
|
||||||
"preserveValueImports": false,
|
"preserveValueImports": false,
|
||||||
"paths": {
|
"paths": {
|
||||||
"$lib": [
|
"$lib": [
|
||||||
|
|||||||
+14
-1
@@ -14,6 +14,7 @@ const config = {
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js',
|
'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js',
|
||||||
|
'@test-data': path.resolve(__dirname, './src/test-data'),
|
||||||
'@api': path.resolve('./src/api'),
|
'@api': path.resolve('./src/api'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -31,4 +32,16 @@ const config = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
/** @type {import('vitest').UserConfig} */
|
||||||
|
const test = {
|
||||||
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test-data/setup.ts'],
|
||||||
|
sequence: {
|
||||||
|
hooks: 'list',
|
||||||
|
},
|
||||||
|
alias: [{ find: /^svelte$/, replacement: 'svelte/internal' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default { ...config, test };
|
||||||
|
|||||||
Reference in New Issue
Block a user