mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
feat(server, web): smart search filtering and pagination (#6525)
* initial pagination impl * use limit + offset instead of take + skip * wip web pagination * working infinite scroll * update api * formatting * fix rebase * search refactor * re-add runtime config for vector search * fix rebase * fixes * useless omitBy * unnecessary handling * add sql decorator for `searchAssets` * fixed search builder * fixed sql * remove mock method * linting * fixed pagination * fixed unit tests * formatting * fix e2e tests * re-flatten search builder * refactor endpoints * clean up dto * refinements * don't break everything just yet * update openapi spec & sql * update api * linting * update sql * fixes * optimize web code * fix typing * add page limit * make limit based on asset count * increase limit * simpler import
This commit is contained in:
parent
f1e4fdf175
commit
e334443919
54 changed files with 2908 additions and 775 deletions
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/AssetApi.md
generated
BIN
mobile/openapi/doc/AssetApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SearchApi.md
generated
BIN
mobile/openapi/doc/SearchApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SearchAssetResponseDto.md
generated
BIN
mobile/openapi/doc/SearchAssetResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/asset_api.dart
generated
BIN
mobile/openapi/lib/api/asset_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/search_api.dart
generated
BIN
mobile/openapi/lib/api/search_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/search_asset_response_dto.dart
generated
BIN
mobile/openapi/lib/model/search_asset_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/asset_api_test.dart
generated
BIN
mobile/openapi/test/asset_api_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/search_api_test.dart
generated
BIN
mobile/openapi/test/search_api_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/search_asset_response_dto_test.dart
generated
BIN
mobile/openapi/test/search_asset_response_dto_test.dart
generated
Binary file not shown.
|
|
@ -2130,6 +2130,7 @@
|
||||||
},
|
},
|
||||||
"/assets": {
|
"/assets": {
|
||||||
"get": {
|
"get": {
|
||||||
|
"deprecated": true,
|
||||||
"operationId": "searchAssets",
|
"operationId": "searchAssets",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -2430,6 +2431,14 @@
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "withArchived",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "withDeleted",
|
"name": "withDeleted",
|
||||||
"required": false,
|
"required": false,
|
||||||
|
|
@ -4354,6 +4363,7 @@
|
||||||
},
|
},
|
||||||
"/search": {
|
"/search": {
|
||||||
"get": {
|
"get": {
|
||||||
|
"deprecated": true,
|
||||||
"operationId": "search",
|
"operationId": "search",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|
@ -4374,6 +4384,14 @@
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "q",
|
"name": "q",
|
||||||
"required": false,
|
"required": false,
|
||||||
|
|
@ -4398,6 +4416,14 @@
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "size",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "smart",
|
"name": "smart",
|
||||||
"required": false,
|
"required": false,
|
||||||
|
|
@ -4492,6 +4518,377 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/search/metadata": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "searchMetadata",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "checksum",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "city",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "country",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createdAfter",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createdBefore",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deviceAssetId",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deviceId",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "encodedVideoPath",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isArchived",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isEncoded",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isExternal",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isFavorite",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isMotion",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isOffline",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isReadOnly",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isVisible",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lensModel",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "libraryId",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "make",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "order",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssetOrder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "originalFileName",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "originalPath",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resizePath",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "size",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "state",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "takenAfter",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "takenBefore",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trashedAfter",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trashedBefore",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssetTypeEnum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updatedAfter",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updatedBefore",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "webpPath",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withArchived",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withDeleted",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withExif",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withPeople",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withStacked",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SearchResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Search"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/search/person": {
|
"/search/person": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "searchPerson",
|
"operationId": "searchPerson",
|
||||||
|
|
@ -4544,6 +4941,296 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/search/smart": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "searchSmart",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "city",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "country",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createdAfter",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "createdBefore",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deviceId",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isArchived",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isEncoded",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isExternal",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isFavorite",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isMotion",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isOffline",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isReadOnly",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "isVisible",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "lensModel",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "libraryId",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "make",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "query",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "size",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "state",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "takenAfter",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "takenBefore",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trashedAfter",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trashedBefore",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AssetTypeEnum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updatedAfter",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updatedBefore",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withArchived",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withDeleted",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withExif",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SearchResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Search"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/server-info": {
|
"/server-info": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getServerInfo",
|
"operationId": "getServerInfo",
|
||||||
|
|
@ -8458,6 +9145,10 @@
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"nextPage": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"total": {
|
"total": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
|
|
@ -8466,6 +9157,7 @@
|
||||||
"count",
|
"count",
|
||||||
"facets",
|
"facets",
|
||||||
"items",
|
"items",
|
||||||
|
"nextPage",
|
||||||
"total"
|
"total"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
|
@ -169,7 +169,11 @@ describe(`${AssetController.name} (e2e)`, () => {
|
||||||
{
|
{
|
||||||
should: 'should reject size as a string',
|
should: 'should reject size as a string',
|
||||||
query: { size: 'abc' },
|
query: { size: 'abc' },
|
||||||
expected: ['size must not be less than 1', 'size must be an integer number'],
|
expected: [
|
||||||
|
'size must not be greater than 1000',
|
||||||
|
'size must not be less than 1',
|
||||||
|
'size must be an integer number',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
should: 'should reject an invalid size',
|
should: 'should reject an invalid size',
|
||||||
|
|
@ -478,7 +482,7 @@ describe(`${AssetController.name} (e2e)`, () => {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
should: 'sohuld search by make',
|
should: 'should search by make',
|
||||||
deferred: () => ({
|
deferred: () => ({
|
||||||
query: { make: 'Cannon' },
|
query: { make: 'Cannon' },
|
||||||
assets: [asset3],
|
assets: [asset3],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import {
|
import {
|
||||||
AssetResponseDto,
|
AssetResponseDto,
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
ISmartInfoRepository,
|
ISearchRepository,
|
||||||
LibraryResponseDto,
|
LibraryResponseDto,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
mapAsset,
|
mapAsset,
|
||||||
|
|
@ -20,14 +20,14 @@ describe(`${SearchController.name}`, () => {
|
||||||
let accessToken: string;
|
let accessToken: string;
|
||||||
let libraries: LibraryResponseDto[];
|
let libraries: LibraryResponseDto[];
|
||||||
let assetRepository: IAssetRepository;
|
let assetRepository: IAssetRepository;
|
||||||
let smartInfoRepository: ISmartInfoRepository;
|
let smartInfoRepository: ISearchRepository;
|
||||||
let asset1: AssetResponseDto;
|
let asset1: AssetResponseDto;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await testApp.create();
|
app = await testApp.create();
|
||||||
server = app.getHttpServer();
|
server = app.getHttpServer();
|
||||||
assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
assetRepository = app.get<IAssetRepository>(IAssetRepository);
|
||||||
smartInfoRepository = app.get<ISmartInfoRepository>(ISmartInfoRepository);
|
smartInfoRepository = app.get<ISearchRepository>(ISearchRepository);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,6 @@ import {
|
||||||
AssetBulkUpdateDto,
|
AssetBulkUpdateDto,
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
AssetJobsDto,
|
AssetJobsDto,
|
||||||
AssetOrder,
|
|
||||||
AssetSearchDto,
|
|
||||||
AssetStatsDto,
|
AssetStatsDto,
|
||||||
MapMarkerDto,
|
MapMarkerDto,
|
||||||
MemoryLaneDto,
|
MemoryLaneDto,
|
||||||
|
|
@ -92,34 +90,6 @@ export class AssetService {
|
||||||
this.configCore = SystemConfigCore.create(configRepository);
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
search(auth: AuthDto, dto: AssetSearchDto) {
|
|
||||||
let checksum: Buffer | undefined;
|
|
||||||
|
|
||||||
if (dto.checksum) {
|
|
||||||
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
|
|
||||||
checksum = Buffer.from(dto.checksum, encoding);
|
|
||||||
}
|
|
||||||
|
|
||||||
const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const;
|
|
||||||
const order = dto.order ? enumToOrder[dto.order] : undefined;
|
|
||||||
|
|
||||||
return this.assetRepository
|
|
||||||
.search({
|
|
||||||
...dto,
|
|
||||||
order,
|
|
||||||
checksum,
|
|
||||||
ownerId: auth.user.id,
|
|
||||||
})
|
|
||||||
.then((assets) =>
|
|
||||||
assets.map((asset) =>
|
|
||||||
mapAsset(asset, {
|
|
||||||
stripMetadata: false,
|
|
||||||
withStack: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
canUploadFile({ auth, fieldName, file }: UploadRequest): true {
|
canUploadFile({ auth, fieldName, file }: UploadRequest): true {
|
||||||
this.access.requireUploadAccess(auth);
|
this.access.requireUploadAccess(auth);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,16 @@
|
||||||
import { AssetType } from '@app/infra/entities';
|
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsEnum,
|
|
||||||
IsInt,
|
IsInt,
|
||||||
IsLatitude,
|
IsLatitude,
|
||||||
IsLongitude,
|
IsLongitude,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
Min,
|
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Optional, QueryBoolean, QueryDate, ValidateUUID } from '../../domain.util';
|
import { Optional, ValidateUUID } from '../../domain.util';
|
||||||
import { BulkIdsDto } from '../response-dto';
|
import { BulkIdsDto } from '../response-dto';
|
||||||
|
|
||||||
export class DeviceIdDto {
|
export class DeviceIdDto {
|
||||||
|
|
@ -32,152 +28,6 @@ const hasGPS = (o: { latitude: undefined; longitude: undefined }) =>
|
||||||
o.latitude !== undefined || o.longitude !== undefined;
|
o.latitude !== undefined || o.longitude !== undefined;
|
||||||
const ValidateGPS = () => ValidateIf(hasGPS);
|
const ValidateGPS = () => ValidateIf(hasGPS);
|
||||||
|
|
||||||
export class AssetSearchDto {
|
|
||||||
@ValidateUUID({ optional: true })
|
|
||||||
id?: string;
|
|
||||||
|
|
||||||
@ValidateUUID({ optional: true })
|
|
||||||
libraryId?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
deviceAssetId?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
deviceId?: string;
|
|
||||||
|
|
||||||
@IsEnum(AssetType)
|
|
||||||
@Optional()
|
|
||||||
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
|
|
||||||
type?: AssetType;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
checksum?: string;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
isArchived?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
isEncoded?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
isExternal?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
isFavorite?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
isMotion?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
isOffline?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
isReadOnly?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
isVisible?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
withDeleted?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
withStacked?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
withExif?: boolean;
|
|
||||||
|
|
||||||
@QueryBoolean({ optional: true })
|
|
||||||
withPeople?: boolean;
|
|
||||||
|
|
||||||
@QueryDate({ optional: true })
|
|
||||||
createdBefore?: Date;
|
|
||||||
|
|
||||||
@QueryDate({ optional: true })
|
|
||||||
createdAfter?: Date;
|
|
||||||
|
|
||||||
@QueryDate({ optional: true })
|
|
||||||
updatedBefore?: Date;
|
|
||||||
|
|
||||||
@QueryDate({ optional: true })
|
|
||||||
updatedAfter?: Date;
|
|
||||||
|
|
||||||
@QueryDate({ optional: true })
|
|
||||||
trashedBefore?: Date;
|
|
||||||
|
|
||||||
@QueryDate({ optional: true })
|
|
||||||
trashedAfter?: Date;
|
|
||||||
|
|
||||||
@QueryDate({ optional: true })
|
|
||||||
takenBefore?: Date;
|
|
||||||
|
|
||||||
@QueryDate({ optional: true })
|
|
||||||
takenAfter?: Date;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
originalFileName?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
originalPath?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
resizePath?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
webpPath?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
encodedVideoPath?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
city?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
state?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
country?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
make?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
model?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Optional()
|
|
||||||
lensModel?: string;
|
|
||||||
|
|
||||||
@IsEnum(AssetOrder)
|
|
||||||
@Optional()
|
|
||||||
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
|
|
||||||
order?: AssetOrder;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Type(() => Number)
|
|
||||||
@Optional()
|
|
||||||
page?: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Type(() => Number)
|
|
||||||
@Optional()
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AssetBulkUpdateDto extends BulkIdsDto {
|
export class AssetBulkUpdateDto extends BulkIdsDto {
|
||||||
@Optional()
|
@Optional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,17 @@ export interface PaginationOptions {
|
||||||
skip?: number;
|
skip?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PaginationMode {
|
||||||
|
LIMIT_OFFSET = 'limit-offset',
|
||||||
|
SKIP_TAKE = 'skip-take',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedBuilderOptions {
|
||||||
|
take: number;
|
||||||
|
skip?: number;
|
||||||
|
mode?: PaginationMode;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginationResult<T> {
|
export interface PaginationResult<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
newMediaRepositoryMock,
|
newMediaRepositoryMock,
|
||||||
newMoveRepositoryMock,
|
newMoveRepositoryMock,
|
||||||
newPersonRepositoryMock,
|
newPersonRepositoryMock,
|
||||||
newSmartInfoRepositoryMock,
|
newSearchRepositoryMock,
|
||||||
newStorageRepositoryMock,
|
newStorageRepositoryMock,
|
||||||
newSystemConfigRepositoryMock,
|
newSystemConfigRepositoryMock,
|
||||||
personStub,
|
personStub,
|
||||||
|
|
@ -31,7 +31,7 @@ import {
|
||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
IMoveRepository,
|
IMoveRepository,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
ISmartInfoRepository,
|
ISearchRepository,
|
||||||
IStorageRepository,
|
IStorageRepository,
|
||||||
ISystemConfigRepository,
|
ISystemConfigRepository,
|
||||||
WithoutProperty,
|
WithoutProperty,
|
||||||
|
|
@ -76,7 +76,7 @@ describe(PersonService.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 smartInfoMock: jest.Mocked<ISmartInfoRepository>;
|
let searchMock: jest.Mocked<ISearchRepository>;
|
||||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||||
let sut: PersonService;
|
let sut: PersonService;
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ describe(PersonService.name, () => {
|
||||||
mediaMock = newMediaRepositoryMock();
|
mediaMock = newMediaRepositoryMock();
|
||||||
personMock = newPersonRepositoryMock();
|
personMock = newPersonRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
smartInfoMock = newSmartInfoRepositoryMock();
|
searchMock = newSearchRepositoryMock();
|
||||||
cryptoMock = newCryptoRepositoryMock();
|
cryptoMock = newCryptoRepositoryMock();
|
||||||
sut = new PersonService(
|
sut = new PersonService(
|
||||||
accessMock,
|
accessMock,
|
||||||
|
|
@ -102,7 +102,7 @@ describe(PersonService.name, () => {
|
||||||
configMock,
|
configMock,
|
||||||
storageMock,
|
storageMock,
|
||||||
jobMock,
|
jobMock,
|
||||||
smartInfoMock,
|
searchMock,
|
||||||
cryptoMock,
|
cryptoMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -752,7 +752,7 @@ describe(PersonService.name, () => {
|
||||||
it('should create a face with no person and queue recognition job', async () => {
|
it('should create a face with no person and queue recognition job', async () => {
|
||||||
personMock.createFaces.mockResolvedValue([faceStub.face1.id]);
|
personMock.createFaces.mockResolvedValue([faceStub.face1.id]);
|
||||||
machineLearningMock.detectFaces.mockResolvedValue([detectFaceMock]);
|
machineLearningMock.detectFaces.mockResolvedValue([detectFaceMock]);
|
||||||
smartInfoMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]);
|
searchMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
const face = {
|
const face = {
|
||||||
assetId: 'asset-id',
|
assetId: 'asset-id',
|
||||||
|
|
@ -823,7 +823,7 @@ describe(PersonService.name, () => {
|
||||||
configMock.load.mockResolvedValue([
|
configMock.load.mockResolvedValue([
|
||||||
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 },
|
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 },
|
||||||
]);
|
]);
|
||||||
smartInfoMock.searchFaces.mockResolvedValue(faces);
|
searchMock.searchFaces.mockResolvedValue(faces);
|
||||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
personMock.create.mockResolvedValue(faceStub.primaryFace1.person);
|
personMock.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||||
|
|
||||||
|
|
@ -850,7 +850,7 @@ describe(PersonService.name, () => {
|
||||||
configMock.load.mockResolvedValue([
|
configMock.load.mockResolvedValue([
|
||||||
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 },
|
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 },
|
||||||
]);
|
]);
|
||||||
smartInfoMock.searchFaces.mockResolvedValue(faces);
|
searchMock.searchFaces.mockResolvedValue(faces);
|
||||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
personMock.create.mockResolvedValue(personStub.withName);
|
personMock.create.mockResolvedValue(personStub.withName);
|
||||||
|
|
||||||
|
|
@ -869,14 +869,14 @@ describe(PersonService.name, () => {
|
||||||
it('should not queue face with no matches', async () => {
|
it('should not queue face with no matches', async () => {
|
||||||
const faces = [{ face: faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
|
const faces = [{ face: faceStub.noPerson1, distance: 0 }] as FaceSearchResult[];
|
||||||
|
|
||||||
smartInfoMock.searchFaces.mockResolvedValue(faces);
|
searchMock.searchFaces.mockResolvedValue(faces);
|
||||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
personMock.create.mockResolvedValue(personStub.withName);
|
personMock.create.mockResolvedValue(personStub.withName);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||||
|
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
expect(smartInfoMock.searchFaces).toHaveBeenCalledTimes(1);
|
expect(searchMock.searchFaces).toHaveBeenCalledTimes(1);
|
||||||
expect(personMock.create).not.toHaveBeenCalled();
|
expect(personMock.create).not.toHaveBeenCalled();
|
||||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -890,7 +890,7 @@ describe(PersonService.name, () => {
|
||||||
configMock.load.mockResolvedValue([
|
configMock.load.mockResolvedValue([
|
||||||
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 },
|
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 },
|
||||||
]);
|
]);
|
||||||
smartInfoMock.searchFaces.mockResolvedValue(faces);
|
searchMock.searchFaces.mockResolvedValue(faces);
|
||||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
personMock.create.mockResolvedValue(personStub.withName);
|
personMock.create.mockResolvedValue(personStub.withName);
|
||||||
|
|
||||||
|
|
@ -900,7 +900,7 @@ describe(PersonService.name, () => {
|
||||||
name: JobName.FACIAL_RECOGNITION,
|
name: JobName.FACIAL_RECOGNITION,
|
||||||
data: { id: faceStub.noPerson1.id, deferred: true },
|
data: { id: faceStub.noPerson1.id, deferred: true },
|
||||||
});
|
});
|
||||||
expect(smartInfoMock.searchFaces).toHaveBeenCalledTimes(1);
|
expect(searchMock.searchFaces).toHaveBeenCalledTimes(1);
|
||||||
expect(personMock.create).not.toHaveBeenCalled();
|
expect(personMock.create).not.toHaveBeenCalled();
|
||||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -914,14 +914,14 @@ describe(PersonService.name, () => {
|
||||||
configMock.load.mockResolvedValue([
|
configMock.load.mockResolvedValue([
|
||||||
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 },
|
{ key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 },
|
||||||
]);
|
]);
|
||||||
smartInfoMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
||||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||||
personMock.create.mockResolvedValue(personStub.withName);
|
personMock.create.mockResolvedValue(personStub.withName);
|
||||||
|
|
||||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
|
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
|
||||||
|
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
expect(smartInfoMock.searchFaces).toHaveBeenCalledTimes(2);
|
expect(searchMock.searchFaces).toHaveBeenCalledTimes(2);
|
||||||
expect(personMock.create).not.toHaveBeenCalled();
|
expect(personMock.create).not.toHaveBeenCalled();
|
||||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import {
|
||||||
IMediaRepository,
|
IMediaRepository,
|
||||||
IMoveRepository,
|
IMoveRepository,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
ISmartInfoRepository,
|
ISearchRepository,
|
||||||
IStorageRepository,
|
IStorageRepository,
|
||||||
ISystemConfigRepository,
|
ISystemConfigRepository,
|
||||||
JobItem,
|
JobItem,
|
||||||
|
|
@ -61,7 +61,7 @@ export class PersonService {
|
||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
@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(ISearchRepository) private smartInfoRepository: ISearchRepository,
|
||||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||||
) {
|
) {
|
||||||
this.access = AccessCore.create(accessRepository);
|
this.access = AccessCore.create(accessRepository);
|
||||||
|
|
@ -285,15 +285,7 @@ export class PersonService {
|
||||||
|
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
return force
|
return force
|
||||||
? this.assetRepository.getAll(pagination, {
|
? this.assetRepository.getAll(pagination, { orderDirection: 'DESC', withFaces: true })
|
||||||
order: 'DESC',
|
|
||||||
withFaces: true,
|
|
||||||
withPeople: false,
|
|
||||||
withSmartInfo: false,
|
|
||||||
withSmartSearch: false,
|
|
||||||
withExif: false,
|
|
||||||
withStacked: false,
|
|
||||||
})
|
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
|
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { SearchExploreItem } from '@app/domain';
|
import { AssetSearchOptions, SearchExploreItem } from '@app/domain';
|
||||||
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
||||||
import { FindOptionsRelations, FindOptionsSelect } from 'typeorm';
|
import { FindOptionsRelations, FindOptionsSelect } from 'typeorm';
|
||||||
import { Paginated, PaginationOptions } from '../domain.util';
|
import { Paginated, PaginationOptions } from '../domain.util';
|
||||||
|
|
@ -11,64 +11,6 @@ export interface AssetStatsOptions {
|
||||||
isTrashed?: boolean;
|
isTrashed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetSearchOptions {
|
|
||||||
id?: string;
|
|
||||||
libraryId?: string;
|
|
||||||
deviceAssetId?: string;
|
|
||||||
deviceId?: string;
|
|
||||||
ownerId?: string;
|
|
||||||
type?: AssetType;
|
|
||||||
checksum?: Buffer;
|
|
||||||
|
|
||||||
isArchived?: boolean;
|
|
||||||
isEncoded?: boolean;
|
|
||||||
isExternal?: boolean;
|
|
||||||
isFavorite?: boolean;
|
|
||||||
isMotion?: boolean;
|
|
||||||
isOffline?: boolean;
|
|
||||||
isReadOnly?: boolean;
|
|
||||||
isVisible?: boolean;
|
|
||||||
|
|
||||||
withDeleted?: boolean;
|
|
||||||
withStacked?: boolean;
|
|
||||||
withExif?: boolean;
|
|
||||||
withPeople?: boolean;
|
|
||||||
withSmartInfo?: boolean;
|
|
||||||
withSmartSearch?: boolean;
|
|
||||||
withFaces?: boolean;
|
|
||||||
|
|
||||||
createdBefore?: Date;
|
|
||||||
createdAfter?: Date;
|
|
||||||
updatedBefore?: Date;
|
|
||||||
updatedAfter?: Date;
|
|
||||||
trashedBefore?: Date;
|
|
||||||
trashedAfter?: Date;
|
|
||||||
takenBefore?: Date;
|
|
||||||
takenAfter?: Date;
|
|
||||||
|
|
||||||
originalFileName?: string;
|
|
||||||
originalPath?: string;
|
|
||||||
resizePath?: string;
|
|
||||||
webpPath?: string;
|
|
||||||
encodedVideoPath?: string;
|
|
||||||
|
|
||||||
city?: string;
|
|
||||||
state?: string;
|
|
||||||
country?: string;
|
|
||||||
make?: string;
|
|
||||||
model?: string;
|
|
||||||
lensModel?: string;
|
|
||||||
|
|
||||||
/** defaults to 'DESC' */
|
|
||||||
order?: 'ASC' | 'DESC';
|
|
||||||
|
|
||||||
/** defaults to 1 */
|
|
||||||
page?: number;
|
|
||||||
|
|
||||||
/** defaults to 250 */
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LivePhotoSearchOptions {
|
export interface LivePhotoSearchOptions {
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
livePhotoCID: string;
|
livePhotoCID: string;
|
||||||
|
|
@ -204,7 +146,6 @@ export interface IAssetRepository {
|
||||||
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
|
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
|
||||||
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
|
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
|
||||||
upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity>): Promise<void>;
|
upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity>): Promise<void>;
|
||||||
search(options: AssetSearchOptions): Promise<AssetEntity[]>;
|
|
||||||
getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
||||||
getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
||||||
searchMetadata(query: string, userIds: string[], options: MetadataSearchOptions): Promise<AssetEntity[]>;
|
searchMetadata(query: string, userIds: string[], options: MetadataSearchOptions): Promise<AssetEntity[]>;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ export * from './person.repository';
|
||||||
export * from './search.repository';
|
export * from './search.repository';
|
||||||
export * from './server-info.repository';
|
export * from './server-info.repository';
|
||||||
export * from './shared-link.repository';
|
export * from './shared-link.repository';
|
||||||
export * from './smart-info.repository';
|
|
||||||
export * from './storage.repository';
|
export * from './storage.repository';
|
||||||
export * from './system-config.repository';
|
export * from './system-config.repository';
|
||||||
export * from './system-metadata.repository';
|
export * from './system-metadata.repository';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import { AssetType } from '@app/infra/entities';
|
import { AssetEntity, AssetFaceEntity, AssetType, SmartInfoEntity } from '@app/infra/entities';
|
||||||
|
import { Paginated } from '../domain.util';
|
||||||
|
|
||||||
|
export const ISearchRepository = 'ISearchRepository';
|
||||||
|
|
||||||
export enum SearchStrategy {
|
export enum SearchStrategy {
|
||||||
SMART = 'SMART',
|
SMART = 'SMART',
|
||||||
|
|
@ -54,3 +57,122 @@ export interface SearchExploreItem<T> {
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
items: SearchExploreItemSet<T>;
|
items: SearchExploreItemSet<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Embedding = number[];
|
||||||
|
|
||||||
|
export interface SearchAssetIDOptions {
|
||||||
|
checksum?: Buffer;
|
||||||
|
deviceAssetId?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchUserIDOptions {
|
||||||
|
deviceId?: string;
|
||||||
|
libraryId?: string;
|
||||||
|
ownerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchIDOptions = SearchAssetIDOptions & SearchUserIDOptions;
|
||||||
|
|
||||||
|
export interface SearchStatusOptions {
|
||||||
|
isArchived?: boolean;
|
||||||
|
isEncoded?: boolean;
|
||||||
|
isExternal?: boolean;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
isMotion?: boolean;
|
||||||
|
isOffline?: boolean;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
isVisible?: boolean;
|
||||||
|
type?: AssetType;
|
||||||
|
withArchived?: boolean;
|
||||||
|
withDeleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchOneToOneRelationOptions {
|
||||||
|
withExif?: boolean;
|
||||||
|
withSmartInfo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchRelationOptions extends SearchOneToOneRelationOptions {
|
||||||
|
withFaces?: boolean;
|
||||||
|
withPeople?: boolean;
|
||||||
|
withStacked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchDateOptions {
|
||||||
|
createdBefore?: Date;
|
||||||
|
createdAfter?: Date;
|
||||||
|
takenBefore?: Date;
|
||||||
|
takenAfter?: Date;
|
||||||
|
trashedBefore?: Date;
|
||||||
|
trashedAfter?: Date;
|
||||||
|
updatedBefore?: Date;
|
||||||
|
updatedAfter?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchPathOptions {
|
||||||
|
encodedVideoPath?: string;
|
||||||
|
originalFileName?: string;
|
||||||
|
originalPath?: string;
|
||||||
|
resizePath?: string;
|
||||||
|
webpPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchExifOptions {
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
|
lensModel?: string;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
state?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchEmbeddingOptions {
|
||||||
|
embedding: Embedding;
|
||||||
|
userIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchOrderOptions {
|
||||||
|
orderDirection?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchPaginationOptions {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssetSearchOptions = SearchDateOptions &
|
||||||
|
SearchIDOptions &
|
||||||
|
SearchExifOptions &
|
||||||
|
SearchOrderOptions &
|
||||||
|
SearchPathOptions &
|
||||||
|
SearchRelationOptions &
|
||||||
|
SearchStatusOptions;
|
||||||
|
|
||||||
|
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
||||||
|
|
||||||
|
export type SmartSearchOptions = SearchDateOptions &
|
||||||
|
SearchEmbeddingOptions &
|
||||||
|
SearchExifOptions &
|
||||||
|
SearchOneToOneRelationOptions &
|
||||||
|
SearchStatusOptions &
|
||||||
|
SearchUserIDOptions;
|
||||||
|
|
||||||
|
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
||||||
|
hasPerson?: boolean;
|
||||||
|
numResults: number;
|
||||||
|
maxDistance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaceSearchResult {
|
||||||
|
distance: number;
|
||||||
|
face: AssetFaceEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISearchRepository {
|
||||||
|
init(modelName: string): Promise<void>;
|
||||||
|
searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity>;
|
||||||
|
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
|
||||||
|
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
||||||
|
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { AssetEntity, AssetFaceEntity, SmartInfoEntity } from '@app/infra/entities';
|
|
||||||
|
|
||||||
export const ISmartInfoRepository = 'ISmartInfoRepository';
|
|
||||||
|
|
||||||
export type Embedding = number[];
|
|
||||||
|
|
||||||
export interface EmbeddingSearch {
|
|
||||||
userIds: string[];
|
|
||||||
embedding: Embedding;
|
|
||||||
numResults: number;
|
|
||||||
withArchived?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FaceEmbeddingSearch extends EmbeddingSearch {
|
|
||||||
maxDistance?: number;
|
|
||||||
hasPerson?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FaceSearchResult {
|
|
||||||
face: AssetFaceEntity;
|
|
||||||
distance: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ISmartInfoRepository {
|
|
||||||
init(modelName: string): Promise<void>;
|
|
||||||
searchCLIP(search: EmbeddingSearch): Promise<AssetEntity[]>;
|
|
||||||
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
|
||||||
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,184 @@
|
||||||
|
import { AssetOrder } from '@app/domain/asset/dto/asset.dto';
|
||||||
import { AssetType } from '@app/infra/entities';
|
import { AssetType } from '@app/infra/entities';
|
||||||
import { Transform } from 'class-transformer';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import { Optional, toBoolean } from '../../domain.util';
|
import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { Optional, QueryBoolean, QueryDate, ValidateUUID, toBoolean } from '../../domain.util';
|
||||||
|
|
||||||
|
class BaseSearchDto {
|
||||||
|
@ValidateUUID({ optional: true })
|
||||||
|
libraryId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
deviceId?: string;
|
||||||
|
|
||||||
|
@IsEnum(AssetType)
|
||||||
|
@Optional()
|
||||||
|
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
|
||||||
|
type?: AssetType;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
isArchived?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
withArchived?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
isEncoded?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
isExternal?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
isFavorite?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
isMotion?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
isOffline?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
isVisible?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
withDeleted?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
withExif?: boolean;
|
||||||
|
|
||||||
|
@QueryDate({ optional: true })
|
||||||
|
createdBefore?: Date;
|
||||||
|
|
||||||
|
@QueryDate({ optional: true })
|
||||||
|
createdAfter?: Date;
|
||||||
|
|
||||||
|
@QueryDate({ optional: true })
|
||||||
|
updatedBefore?: Date;
|
||||||
|
|
||||||
|
@QueryDate({ optional: true })
|
||||||
|
updatedAfter?: Date;
|
||||||
|
|
||||||
|
@QueryDate({ optional: true })
|
||||||
|
trashedBefore?: Date;
|
||||||
|
|
||||||
|
@QueryDate({ optional: true })
|
||||||
|
trashedAfter?: Date;
|
||||||
|
|
||||||
|
@QueryDate({ optional: true })
|
||||||
|
takenBefore?: Date;
|
||||||
|
|
||||||
|
@QueryDate({ optional: true })
|
||||||
|
takenAfter?: Date;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
city?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
make?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
model?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
lensModel?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@Optional()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(1000)
|
||||||
|
@Type(() => Number)
|
||||||
|
@Optional()
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MetadataSearchDto extends BaseSearchDto {
|
||||||
|
@ValidateUUID({ optional: true })
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
deviceAssetId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
checksum?: string;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
withStacked?: boolean;
|
||||||
|
|
||||||
|
@QueryBoolean({ optional: true })
|
||||||
|
withPeople?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
originalFileName?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
originalPath?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
resizePath?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
webpPath?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Optional()
|
||||||
|
encodedVideoPath?: string;
|
||||||
|
|
||||||
|
@IsEnum(AssetOrder)
|
||||||
|
@Optional()
|
||||||
|
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
|
||||||
|
order?: AssetOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SmartSearchDto extends BaseSearchDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
query!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: remove after implementing new search filters
|
||||||
|
/** @deprecated */
|
||||||
export class SearchDto {
|
export class SearchDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|
@ -43,6 +219,19 @@ export class SearchDto {
|
||||||
@Optional()
|
@Optional()
|
||||||
@Transform(toBoolean)
|
@Transform(toBoolean)
|
||||||
withArchived?: boolean;
|
withArchived?: boolean;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@Optional()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(1000)
|
||||||
|
@Type(() => Number)
|
||||||
|
@Optional()
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SearchPeopleDto {
|
export class SearchPeopleDto {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class SearchAssetResponseDto {
|
||||||
count!: number;
|
count!: number;
|
||||||
items!: AssetResponseDto[];
|
items!: AssetResponseDto[];
|
||||||
facets!: SearchFacetResponseDto[];
|
facets!: SearchFacetResponseDto[];
|
||||||
|
nextPage!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SearchResponseDto {
|
export class SearchResponseDto {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
newMachineLearningRepositoryMock,
|
newMachineLearningRepositoryMock,
|
||||||
newPartnerRepositoryMock,
|
newPartnerRepositoryMock,
|
||||||
newPersonRepositoryMock,
|
newPersonRepositoryMock,
|
||||||
newSmartInfoRepositoryMock,
|
newSearchRepositoryMock,
|
||||||
newSystemConfigRepositoryMock,
|
newSystemConfigRepositoryMock,
|
||||||
personStub,
|
personStub,
|
||||||
} from '@test';
|
} from '@test';
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
IMachineLearningRepository,
|
IMachineLearningRepository,
|
||||||
IPartnerRepository,
|
IPartnerRepository,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
ISmartInfoRepository,
|
ISearchRepository,
|
||||||
ISystemConfigRepository,
|
ISystemConfigRepository,
|
||||||
} from '../repositories';
|
} from '../repositories';
|
||||||
import { SearchDto } from './dto';
|
import { SearchDto } from './dto';
|
||||||
|
|
@ -30,7 +30,7 @@ describe(SearchService.name, () => {
|
||||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||||
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
||||||
let personMock: jest.Mocked<IPersonRepository>;
|
let personMock: jest.Mocked<IPersonRepository>;
|
||||||
let smartInfoMock: jest.Mocked<ISmartInfoRepository>;
|
let searchMock: jest.Mocked<ISearchRepository>;
|
||||||
let partnerMock: jest.Mocked<IPartnerRepository>;
|
let partnerMock: jest.Mocked<IPartnerRepository>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -38,9 +38,9 @@ describe(SearchService.name, () => {
|
||||||
configMock = newSystemConfigRepositoryMock();
|
configMock = newSystemConfigRepositoryMock();
|
||||||
machineMock = newMachineLearningRepositoryMock();
|
machineMock = newMachineLearningRepositoryMock();
|
||||||
personMock = newPersonRepositoryMock();
|
personMock = newPersonRepositoryMock();
|
||||||
smartInfoMock = newSmartInfoRepositoryMock();
|
searchMock = newSearchRepositoryMock();
|
||||||
partnerMock = newPartnerRepositoryMock();
|
partnerMock = newPartnerRepositoryMock();
|
||||||
sut = new SearchService(configMock, machineMock, personMock, smartInfoMock, assetMock, partnerMock);
|
sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
|
|
@ -104,6 +104,7 @@ describe(SearchService.name, () => {
|
||||||
count: 1,
|
count: 1,
|
||||||
items: [mapAsset(assetStub.image)],
|
items: [mapAsset(assetStub.image)],
|
||||||
facets: [],
|
facets: [],
|
||||||
|
nextPage: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -111,13 +112,13 @@ describe(SearchService.name, () => {
|
||||||
|
|
||||||
expect(result).toEqual(expectedResponse);
|
expect(result).toEqual(expectedResponse);
|
||||||
expect(assetMock.searchMetadata).toHaveBeenCalledWith(dto.q, [authStub.user1.user.id], { numResults: 250 });
|
expect(assetMock.searchMetadata).toHaveBeenCalledWith(dto.q, [authStub.user1.user.id], { numResults: 250 });
|
||||||
expect(smartInfoMock.searchCLIP).not.toHaveBeenCalled();
|
expect(searchMock.searchSmart).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should search archived photos if `withArchived` option is true', async () => {
|
it('should search archived photos if `withArchived` option is true', async () => {
|
||||||
const dto: SearchDto = { q: 'test query', clip: true, withArchived: true };
|
const dto: SearchDto = { q: 'test query', clip: true, withArchived: true };
|
||||||
const embedding = [1, 2, 3];
|
const embedding = [1, 2, 3];
|
||||||
smartInfoMock.searchCLIP.mockResolvedValueOnce([assetStub.image]);
|
searchMock.searchSmart.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false });
|
||||||
machineMock.encodeText.mockResolvedValueOnce(embedding);
|
machineMock.encodeText.mockResolvedValueOnce(embedding);
|
||||||
partnerMock.getAll.mockResolvedValueOnce([]);
|
partnerMock.getAll.mockResolvedValueOnce([]);
|
||||||
const expectedResponse = {
|
const expectedResponse = {
|
||||||
|
|
@ -132,25 +133,28 @@ describe(SearchService.name, () => {
|
||||||
count: 1,
|
count: 1,
|
||||||
items: [mapAsset(assetStub.image)],
|
items: [mapAsset(assetStub.image)],
|
||||||
facets: [],
|
facets: [],
|
||||||
|
nextPage: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await sut.search(authStub.user1, dto);
|
const result = await sut.search(authStub.user1, dto);
|
||||||
|
|
||||||
expect(result).toEqual(expectedResponse);
|
expect(result).toEqual(expectedResponse);
|
||||||
expect(smartInfoMock.searchCLIP).toHaveBeenCalledWith({
|
expect(searchMock.searchSmart).toHaveBeenCalledWith(
|
||||||
userIds: [authStub.user1.user.id],
|
{ page: 1, size: 100 },
|
||||||
embedding,
|
{
|
||||||
numResults: 100,
|
userIds: [authStub.user1.user.id],
|
||||||
withArchived: true,
|
embedding,
|
||||||
});
|
withArchived: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(assetMock.searchMetadata).not.toHaveBeenCalled();
|
expect(assetMock.searchMetadata).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should search by CLIP if `clip` option is true', async () => {
|
it('should search by CLIP if `clip` option is true', async () => {
|
||||||
const dto: SearchDto = { q: 'test query', clip: true };
|
const dto: SearchDto = { q: 'test query', clip: true };
|
||||||
const embedding = [1, 2, 3];
|
const embedding = [1, 2, 3];
|
||||||
smartInfoMock.searchCLIP.mockResolvedValueOnce([assetStub.image]);
|
searchMock.searchSmart.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false });
|
||||||
machineMock.encodeText.mockResolvedValueOnce(embedding);
|
machineMock.encodeText.mockResolvedValueOnce(embedding);
|
||||||
partnerMock.getAll.mockResolvedValueOnce([]);
|
partnerMock.getAll.mockResolvedValueOnce([]);
|
||||||
const expectedResponse = {
|
const expectedResponse = {
|
||||||
|
|
@ -165,18 +169,21 @@ describe(SearchService.name, () => {
|
||||||
count: 1,
|
count: 1,
|
||||||
items: [mapAsset(assetStub.image)],
|
items: [mapAsset(assetStub.image)],
|
||||||
facets: [],
|
facets: [],
|
||||||
|
nextPage: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await sut.search(authStub.user1, dto);
|
const result = await sut.search(authStub.user1, dto);
|
||||||
|
|
||||||
expect(result).toEqual(expectedResponse);
|
expect(result).toEqual(expectedResponse);
|
||||||
expect(smartInfoMock.searchCLIP).toHaveBeenCalledWith({
|
expect(searchMock.searchSmart).toHaveBeenCalledWith(
|
||||||
userIds: [authStub.user1.user.id],
|
{ page: 1, size: 100 },
|
||||||
embedding,
|
{
|
||||||
numResults: 100,
|
userIds: [authStub.user1.user.id],
|
||||||
withArchived: false,
|
embedding,
|
||||||
});
|
withArchived: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(assetMock.searchMetadata).not.toHaveBeenCalled();
|
expect(assetMock.searchMetadata).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { AssetEntity } from '@app/infra/entities';
|
import { AssetEntity } from '@app/infra/entities';
|
||||||
import { ImmichLogger } from '@app/infra/logger';
|
import { ImmichLogger } from '@app/infra/logger';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { AssetResponseDto, mapAsset } from '../asset';
|
import { AssetOrder, AssetResponseDto, mapAsset } from '../asset';
|
||||||
import { AuthDto } from '../auth';
|
import { AuthDto } from '../auth';
|
||||||
import { PersonResponseDto } from '../person';
|
import { PersonResponseDto } from '../person';
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,13 +9,13 @@ import {
|
||||||
IMachineLearningRepository,
|
IMachineLearningRepository,
|
||||||
IPartnerRepository,
|
IPartnerRepository,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
ISmartInfoRepository,
|
ISearchRepository,
|
||||||
ISystemConfigRepository,
|
ISystemConfigRepository,
|
||||||
SearchExploreItem,
|
SearchExploreItem,
|
||||||
SearchStrategy,
|
SearchStrategy,
|
||||||
} from '../repositories';
|
} from '../repositories';
|
||||||
import { FeatureFlag, SystemConfigCore } from '../system-config';
|
import { FeatureFlag, SystemConfigCore } from '../system-config';
|
||||||
import { SearchDto, SearchPeopleDto } from './dto';
|
import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto';
|
||||||
import { SearchResponseDto } from './response-dto';
|
import { SearchResponseDto } from './response-dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -27,7 +27,7 @@ export class SearchService {
|
||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
||||||
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
||||||
@Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository,
|
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
||||||
) {
|
) {
|
||||||
|
|
@ -55,6 +55,53 @@ export class SearchService {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
|
||||||
|
let checksum: Buffer | undefined;
|
||||||
|
|
||||||
|
if (dto.checksum) {
|
||||||
|
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
|
||||||
|
checksum = Buffer.from(dto.checksum, encoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = dto.page ?? 1;
|
||||||
|
const size = dto.size || 250;
|
||||||
|
const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const;
|
||||||
|
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
|
||||||
|
{ page, size },
|
||||||
|
{
|
||||||
|
...dto,
|
||||||
|
checksum,
|
||||||
|
ownerId: auth.user.id,
|
||||||
|
orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
|
||||||
|
await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH);
|
||||||
|
const { machineLearning } = await this.configCore.getConfig();
|
||||||
|
const userIds = await this.getUserIdsToSearch(auth);
|
||||||
|
|
||||||
|
const embedding = await this.machineLearning.encodeText(
|
||||||
|
machineLearning.url,
|
||||||
|
{ text: dto.query },
|
||||||
|
machineLearning.clip,
|
||||||
|
);
|
||||||
|
|
||||||
|
const page = dto.page ?? 1;
|
||||||
|
const size = dto.size || 100;
|
||||||
|
const { hasNextPage, items } = await this.searchRepository.searchSmart(
|
||||||
|
{ page, size },
|
||||||
|
{ ...dto, userIds, embedding },
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: remove after implementing new search filters
|
||||||
|
/** @deprecated */
|
||||||
async search(auth: AuthDto, dto: SearchDto): Promise<SearchResponseDto> {
|
async search(auth: AuthDto, dto: SearchDto): Promise<SearchResponseDto> {
|
||||||
await this.configCore.requireFeature(FeatureFlag.SEARCH);
|
await this.configCore.requireFeature(FeatureFlag.SEARCH);
|
||||||
const { machineLearning } = await this.configCore.getConfig();
|
const { machineLearning } = await this.configCore.getConfig();
|
||||||
|
|
@ -70,10 +117,10 @@ export class SearchService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const userIds = await this.getUserIdsToSearch(auth);
|
const userIds = await this.getUserIdsToSearch(auth);
|
||||||
const withArchived = dto.withArchived || false;
|
const page = dto.page ?? 1;
|
||||||
|
|
||||||
|
let nextPage: string | null = null;
|
||||||
let assets: AssetEntity[] = [];
|
let assets: AssetEntity[] = [];
|
||||||
|
|
||||||
switch (strategy) {
|
switch (strategy) {
|
||||||
case SearchStrategy.SMART: {
|
case SearchStrategy.SMART: {
|
||||||
const embedding = await this.machineLearning.encodeText(
|
const embedding = await this.machineLearning.encodeText(
|
||||||
|
|
@ -81,36 +128,30 @@ export class SearchService {
|
||||||
{ text: query },
|
{ text: query },
|
||||||
machineLearning.clip,
|
machineLearning.clip,
|
||||||
);
|
);
|
||||||
assets = await this.smartInfoRepository.searchCLIP({
|
|
||||||
userIds: userIds,
|
const { hasNextPage, items } = await this.searchRepository.searchSmart(
|
||||||
embedding,
|
{ page, size: dto.size || 100 },
|
||||||
numResults: 100,
|
{
|
||||||
withArchived,
|
userIds,
|
||||||
});
|
embedding,
|
||||||
|
withArchived: !!dto.withArchived,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (hasNextPage) {
|
||||||
|
nextPage = (page + 1).toString();
|
||||||
|
}
|
||||||
|
assets = items;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case SearchStrategy.TEXT: {
|
case SearchStrategy.TEXT: {
|
||||||
assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: 250 });
|
assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: dto.size || 250 });
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return this.mapResponse(assets, nextPage);
|
||||||
albums: {
|
|
||||||
total: 0,
|
|
||||||
count: 0,
|
|
||||||
items: [],
|
|
||||||
facets: [],
|
|
||||||
},
|
|
||||||
assets: {
|
|
||||||
total: assets.length,
|
|
||||||
count: assets.length,
|
|
||||||
items: assets.map((asset) => mapAsset(asset)),
|
|
||||||
facets: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
|
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
|
||||||
|
|
@ -122,4 +163,17 @@ export class SearchService {
|
||||||
userIds.push(...partnersIds);
|
userIds.push(...partnersIds);
|
||||||
return userIds;
|
return userIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async mapResponse(assets: AssetEntity[], nextPage: string | null): Promise<SearchResponseDto> {
|
||||||
|
return {
|
||||||
|
albums: { total: 0, count: 0, items: [], facets: [] },
|
||||||
|
assets: {
|
||||||
|
total: assets.length,
|
||||||
|
count: assets.length,
|
||||||
|
items: assets.map((asset) => mapAsset(asset)),
|
||||||
|
facets: [],
|
||||||
|
nextPage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
newDatabaseRepositoryMock,
|
newDatabaseRepositoryMock,
|
||||||
newJobRepositoryMock,
|
newJobRepositoryMock,
|
||||||
newMachineLearningRepositoryMock,
|
newMachineLearningRepositoryMock,
|
||||||
newSmartInfoRepositoryMock,
|
newSearchRepositoryMock,
|
||||||
newSystemConfigRepositoryMock,
|
newSystemConfigRepositoryMock,
|
||||||
} from '@test';
|
} from '@test';
|
||||||
import { JobName } from '../job';
|
import { JobName } from '../job';
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
IDatabaseRepository,
|
IDatabaseRepository,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IMachineLearningRepository,
|
IMachineLearningRepository,
|
||||||
ISmartInfoRepository,
|
ISearchRepository,
|
||||||
ISystemConfigRepository,
|
ISystemConfigRepository,
|
||||||
WithoutProperty,
|
WithoutProperty,
|
||||||
} from '../repositories';
|
} from '../repositories';
|
||||||
|
|
@ -31,18 +31,18 @@ describe(SmartInfoService.name, () => {
|
||||||
let assetMock: jest.Mocked<IAssetRepository>;
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
let smartMock: jest.Mocked<ISmartInfoRepository>;
|
let searchMock: jest.Mocked<ISearchRepository>;
|
||||||
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
let machineMock: jest.Mocked<IMachineLearningRepository>;
|
||||||
let databaseMock: jest.Mocked<IDatabaseRepository>;
|
let databaseMock: jest.Mocked<IDatabaseRepository>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
configMock = newSystemConfigRepositoryMock();
|
configMock = newSystemConfigRepositoryMock();
|
||||||
smartMock = newSmartInfoRepositoryMock();
|
searchMock = newSearchRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
machineMock = newMachineLearningRepositoryMock();
|
machineMock = newMachineLearningRepositoryMock();
|
||||||
databaseMock = newDatabaseRepositoryMock();
|
databaseMock = newDatabaseRepositoryMock();
|
||||||
sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, smartMock, configMock);
|
sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, configMock);
|
||||||
|
|
||||||
assetMock.getByIds.mockResolvedValue([asset]);
|
assetMock.getByIds.mockResolvedValue([asset]);
|
||||||
});
|
});
|
||||||
|
|
@ -102,12 +102,12 @@ describe(SmartInfoService.name, () => {
|
||||||
|
|
||||||
await sut.handleEncodeClip({ id: asset.id });
|
await sut.handleEncodeClip({ id: asset.id });
|
||||||
|
|
||||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
expect(searchMock.upsert).not.toHaveBeenCalled();
|
||||||
expect(machineMock.encodeImage).not.toHaveBeenCalled();
|
expect(machineMock.encodeImage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should save the returned objects', async () => {
|
it('should save the returned objects', async () => {
|
||||||
smartMock.upsert.mockResolvedValue();
|
searchMock.upsert.mockResolvedValue();
|
||||||
machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
|
machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
|
||||||
|
|
||||||
await sut.handleEncodeClip({ id: asset.id });
|
await sut.handleEncodeClip({ id: asset.id });
|
||||||
|
|
@ -117,7 +117,7 @@ describe(SmartInfoService.name, () => {
|
||||||
{ imagePath: 'path/to/resize.ext' },
|
{ imagePath: 'path/to/resize.ext' },
|
||||||
{ enabled: true, modelName: 'ViT-B-32__openai' },
|
{ enabled: true, modelName: 'ViT-B-32__openai' },
|
||||||
);
|
);
|
||||||
expect(smartMock.upsert).toHaveBeenCalledWith(
|
expect(searchMock.upsert).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
assetId: 'asset-1',
|
assetId: 'asset-1',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
IDatabaseRepository,
|
IDatabaseRepository,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
IMachineLearningRepository,
|
IMachineLearningRepository,
|
||||||
ISmartInfoRepository,
|
ISearchRepository,
|
||||||
ISystemConfigRepository,
|
ISystemConfigRepository,
|
||||||
WithoutProperty,
|
WithoutProperty,
|
||||||
} from '../repositories';
|
} from '../repositories';
|
||||||
|
|
@ -24,7 +24,7 @@ export class SmartInfoService {
|
||||||
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
|
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
|
||||||
@Inject(ISmartInfoRepository) private repository: ISmartInfoRepository,
|
@Inject(ISearchRepository) private repository: ISearchRepository,
|
||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
) {
|
) {
|
||||||
this.configCore = SystemConfigCore.create(configRepository);
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { ImmichLogger } from '@app/infra/logger';
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { newCommunicationRepositoryMock, newSystemConfigRepositoryMock } from '@test';
|
import { newCommunicationRepositoryMock, newSystemConfigRepositoryMock } from '@test';
|
||||||
import { QueueName } from '../job';
|
import { QueueName } from '../job';
|
||||||
import { ICommunicationRepository, ISmartInfoRepository, ISystemConfigRepository, ServerEvent } from '../repositories';
|
import { ICommunicationRepository, ISearchRepository, ISystemConfigRepository, ServerEvent } from '../repositories';
|
||||||
import { defaults, SystemConfigValidator } from './system-config.core';
|
import { defaults, SystemConfigValidator } from './system-config.core';
|
||||||
import { SystemConfigService } from './system-config.service';
|
import { SystemConfigService } from './system-config.service';
|
||||||
|
|
||||||
|
|
@ -146,7 +146,7 @@ describe(SystemConfigService.name, () => {
|
||||||
let sut: SystemConfigService;
|
let sut: SystemConfigService;
|
||||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||||
let smartInfoMock: jest.Mocked<ISmartInfoRepository>;
|
let smartInfoMock: jest.Mocked<ISearchRepository>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
delete process.env.IMMICH_CONFIG_FILE;
|
delete process.env.IMMICH_CONFIG_FILE;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import _ from 'lodash';
|
||||||
import {
|
import {
|
||||||
ClientEvent,
|
ClientEvent,
|
||||||
ICommunicationRepository,
|
ICommunicationRepository,
|
||||||
ISmartInfoRepository,
|
ISearchRepository,
|
||||||
ISystemConfigRepository,
|
ISystemConfigRepository,
|
||||||
ServerEvent,
|
ServerEvent,
|
||||||
} from '../repositories';
|
} from '../repositories';
|
||||||
|
|
@ -32,7 +32,7 @@ export class SystemConfigService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ISystemConfigRepository) private repository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) private repository: ISystemConfigRepository,
|
||||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||||
@Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository,
|
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,
|
||||||
) {
|
) {
|
||||||
this.core = SystemConfigCore.create(repository);
|
this.core = SystemConfigCore.create(repository);
|
||||||
this.communicationRepository.on(ServerEvent.CONFIG_UPDATE, () => this.handleConfigUpdate());
|
this.communicationRepository.on(ServerEvent.CONFIG_UPDATE, () => this.handleConfigUpdate());
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ export class TrashService {
|
||||||
|
|
||||||
async restore(auth: AuthDto): Promise<void> {
|
async restore(auth: AuthDto): Promise<void> {
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||||
this.assetRepository.getByUserId(pagination, auth.user.id, { trashedBefore: DateTime.now().toJSDate() }),
|
this.assetRepository.getByUserId(pagination, auth.user.id, {
|
||||||
|
trashedBefore: DateTime.now().toJSDate(),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
|
|
@ -44,7 +46,9 @@ export class TrashService {
|
||||||
|
|
||||||
async empty(auth: AuthDto): Promise<void> {
|
async empty(auth: AuthDto): Promise<void> {
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||||
this.assetRepository.getByUserId(pagination, auth.user.id, { trashedBefore: DateTime.now().toJSDate() }),
|
this.assetRepository.getByUserId(pagination, auth.user.id, {
|
||||||
|
trashedBefore: DateTime.now().toJSDate(),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
for await (const assets of assetPagination) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import {
|
||||||
AssetBulkUpdateDto,
|
AssetBulkUpdateDto,
|
||||||
AssetJobsDto,
|
AssetJobsDto,
|
||||||
AssetResponseDto,
|
AssetResponseDto,
|
||||||
AssetSearchDto,
|
|
||||||
AssetService,
|
AssetService,
|
||||||
AssetStatsDto,
|
AssetStatsDto,
|
||||||
AssetStatsResponseDto,
|
AssetStatsResponseDto,
|
||||||
|
|
@ -14,7 +13,9 @@ import {
|
||||||
MapMarkerResponseDto,
|
MapMarkerResponseDto,
|
||||||
MemoryLaneDto,
|
MemoryLaneDto,
|
||||||
MemoryLaneResponseDto,
|
MemoryLaneResponseDto,
|
||||||
|
MetadataSearchDto,
|
||||||
RandomAssetsDto,
|
RandomAssetsDto,
|
||||||
|
SearchService,
|
||||||
TimeBucketAssetDto,
|
TimeBucketAssetDto,
|
||||||
TimeBucketDto,
|
TimeBucketDto,
|
||||||
TimeBucketResponseDto,
|
TimeBucketResponseDto,
|
||||||
|
|
@ -23,7 +24,7 @@ import {
|
||||||
UpdateStackParentDto,
|
UpdateStackParentDto,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { Auth, Authenticated, SharedLinkRoute } from '../app.guard';
|
import { Auth, Authenticated, SharedLinkRoute } from '../app.guard';
|
||||||
import { UseValidation } from '../app.utils';
|
import { UseValidation } from '../app.utils';
|
||||||
import { Route } from '../interceptors';
|
import { Route } from '../interceptors';
|
||||||
|
|
@ -34,11 +35,15 @@ import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
@UseValidation()
|
@UseValidation()
|
||||||
export class AssetsController {
|
export class AssetsController {
|
||||||
constructor(private service: AssetService) {}
|
constructor(private searchService: SearchService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
searchAssets(@Auth() auth: AuthDto, @Query() dto: AssetSearchDto): Promise<AssetResponseDto[]> {
|
@ApiOperation({ deprecated: true })
|
||||||
return this.service.search(auth, dto);
|
async searchAssets(@Auth() auth: AuthDto, @Query() dto: MetadataSearchDto): Promise<AssetResponseDto[]> {
|
||||||
|
const {
|
||||||
|
assets: { items },
|
||||||
|
} = await this.searchService.searchMetadata(auth, dto);
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import {
|
import {
|
||||||
AuthDto,
|
AuthDto,
|
||||||
|
MetadataSearchDto,
|
||||||
PersonResponseDto,
|
PersonResponseDto,
|
||||||
SearchDto,
|
SearchDto,
|
||||||
SearchExploreResponseDto,
|
SearchExploreResponseDto,
|
||||||
SearchPeopleDto,
|
SearchPeopleDto,
|
||||||
SearchResponseDto,
|
SearchResponseDto,
|
||||||
SearchService,
|
SearchService,
|
||||||
|
SmartSearchDto,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { Auth, Authenticated } from '../app.guard';
|
import { Auth, Authenticated } from '../app.guard';
|
||||||
import { UseValidation } from '../app.utils';
|
import { UseValidation } from '../app.utils';
|
||||||
|
|
||||||
|
|
@ -19,7 +21,18 @@ import { UseValidation } from '../app.utils';
|
||||||
export class SearchController {
|
export class SearchController {
|
||||||
constructor(private service: SearchService) {}
|
constructor(private service: SearchService) {}
|
||||||
|
|
||||||
|
@Get('metadata')
|
||||||
|
searchMetadata(@Auth() auth: AuthDto, @Query() dto: MetadataSearchDto): Promise<SearchResponseDto> {
|
||||||
|
return this.service.searchMetadata(auth, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('smart')
|
||||||
|
searchSmart(@Auth() auth: AuthDto, @Query() dto: SmartSearchDto): Promise<SearchResponseDto> {
|
||||||
|
return this.service.searchSmart(auth, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOperation({ deprecated: true })
|
||||||
search(@Auth() auth: AuthDto, @Query() dto: SearchDto): Promise<SearchResponseDto> {
|
search(@Auth() auth: AuthDto, @Query() dto: SearchDto): Promise<SearchResponseDto> {
|
||||||
return this.service.search(auth, dto);
|
return this.service.search(auth, dto);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ import {
|
||||||
IMoveRepository,
|
IMoveRepository,
|
||||||
IPartnerRepository,
|
IPartnerRepository,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
|
ISearchRepository,
|
||||||
IServerInfoRepository,
|
IServerInfoRepository,
|
||||||
ISharedLinkRepository,
|
ISharedLinkRepository,
|
||||||
ISmartInfoRepository,
|
|
||||||
IStorageRepository,
|
IStorageRepository,
|
||||||
ISystemConfigRepository,
|
ISystemConfigRepository,
|
||||||
ISystemMetadataRepository,
|
ISystemMetadataRepository,
|
||||||
|
|
@ -56,9 +56,9 @@ import {
|
||||||
MoveRepository,
|
MoveRepository,
|
||||||
PartnerRepository,
|
PartnerRepository,
|
||||||
PersonRepository,
|
PersonRepository,
|
||||||
|
SearchRepository,
|
||||||
ServerInfoRepository,
|
ServerInfoRepository,
|
||||||
SharedLinkRepository,
|
SharedLinkRepository,
|
||||||
SmartInfoRepository,
|
|
||||||
SystemConfigRepository,
|
SystemConfigRepository,
|
||||||
SystemMetadataRepository,
|
SystemMetadataRepository,
|
||||||
TagRepository,
|
TagRepository,
|
||||||
|
|
@ -86,7 +86,7 @@ const providers: Provider[] = [
|
||||||
{ provide: IPersonRepository, useClass: PersonRepository },
|
{ provide: IPersonRepository, useClass: PersonRepository },
|
||||||
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
|
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
|
||||||
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
||||||
{ provide: ISmartInfoRepository, useClass: SmartInfoRepository },
|
{ provide: ISearchRepository, useClass: SearchRepository },
|
||||||
{ provide: IStorageRepository, useClass: FilesystemProvider },
|
{ provide: IStorageRepository, useClass: FilesystemProvider },
|
||||||
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
|
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
|
||||||
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
|
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,19 @@
|
||||||
import { Paginated, PaginationOptions } from '@app/domain';
|
import { AssetSearchBuilderOptions, Paginated, PaginationOptions } from '@app/domain';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { Between, FindManyOptions, LessThanOrEqual, MoreThanOrEqual, ObjectLiteral, Repository } from 'typeorm';
|
import {
|
||||||
import { chunks, setUnion } from '../domain/domain.util';
|
Between,
|
||||||
|
Brackets,
|
||||||
|
FindManyOptions,
|
||||||
|
IsNull,
|
||||||
|
LessThanOrEqual,
|
||||||
|
MoreThanOrEqual,
|
||||||
|
Not,
|
||||||
|
ObjectLiteral,
|
||||||
|
Repository,
|
||||||
|
SelectQueryBuilder,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { PaginatedBuilderOptions, PaginationMode, PaginationResult, chunks, setUnion } from '../domain/domain.util';
|
||||||
|
import { AssetEntity } from './entities';
|
||||||
import { DATABASE_PARAMETER_CHUNK_SIZE } from './infra.util';
|
import { DATABASE_PARAMETER_CHUNK_SIZE } from './infra.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -18,9 +30,21 @@ export function OptionalBetween<T>(from?: T, to?: T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => {
|
||||||
|
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options;
|
||||||
|
return Number.isInteger(value) && value >= min && value <= max;
|
||||||
|
};
|
||||||
|
|
||||||
|
function paginationHelper<Entity extends ObjectLiteral>(items: Entity[], take: number): PaginationResult<Entity> {
|
||||||
|
const hasNextPage = items.length > take;
|
||||||
|
items.splice(take);
|
||||||
|
|
||||||
|
return { items, hasNextPage };
|
||||||
|
}
|
||||||
|
|
||||||
export async function paginate<Entity extends ObjectLiteral>(
|
export async function paginate<Entity extends ObjectLiteral>(
|
||||||
repository: Repository<Entity>,
|
repository: Repository<Entity>,
|
||||||
paginationOptions: PaginationOptions,
|
{ take, skip }: PaginationOptions,
|
||||||
searchOptions?: FindManyOptions<Entity>,
|
searchOptions?: FindManyOptions<Entity>,
|
||||||
): Paginated<Entity> {
|
): Paginated<Entity> {
|
||||||
const items = await repository.find(
|
const items = await repository.find(
|
||||||
|
|
@ -28,27 +52,33 @@ export async function paginate<Entity extends ObjectLiteral>(
|
||||||
{
|
{
|
||||||
...searchOptions,
|
...searchOptions,
|
||||||
// Take one more item to check if there's a next page
|
// Take one more item to check if there's a next page
|
||||||
take: paginationOptions.take + 1,
|
take: take + 1,
|
||||||
skip: paginationOptions.skip,
|
skip,
|
||||||
},
|
},
|
||||||
_.isUndefined,
|
_.isUndefined,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasNextPage = items.length > paginationOptions.take;
|
return paginationHelper(items, take);
|
||||||
items.splice(paginationOptions.take);
|
}
|
||||||
|
|
||||||
return { items, hasNextPage };
|
export async function paginatedBuilder<Entity extends ObjectLiteral>(
|
||||||
|
qb: SelectQueryBuilder<Entity>,
|
||||||
|
{ take, skip, mode }: PaginatedBuilderOptions,
|
||||||
|
): Paginated<Entity> {
|
||||||
|
if (mode === PaginationMode.LIMIT_OFFSET) {
|
||||||
|
qb.limit(take + 1).offset(skip);
|
||||||
|
} else {
|
||||||
|
qb.take(take + 1).skip(skip);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await qb.getMany();
|
||||||
|
return paginationHelper(items, take);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const asVector = (embedding: number[], quote = false) =>
|
export const asVector = (embedding: number[], quote = false) =>
|
||||||
quote ? `'[${embedding.join(',')}]'` : `[${embedding.join(',')}]`;
|
quote ? `'[${embedding.join(',')}]'` : `[${embedding.join(',')}]`;
|
||||||
|
|
||||||
export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => {
|
|
||||||
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options;
|
|
||||||
return Number.isInteger(value) && value >= min && value <= max;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection,
|
* Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection,
|
||||||
* to overcome the maximum number of parameters allowed by the database driver.
|
* to overcome the maximum number of parameters allowed by the database driver.
|
||||||
|
|
@ -91,3 +121,79 @@ export function ChunkedArray(options?: { paramIndex?: number }): MethodDecorator
|
||||||
export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator {
|
export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator {
|
||||||
return Chunked({ ...options, mergeFn: setUnion });
|
return Chunked({ ...options, mergeFn: setUnion });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchAssetBuilder(
|
||||||
|
builder: SelectQueryBuilder<AssetEntity>,
|
||||||
|
options: AssetSearchBuilderOptions,
|
||||||
|
): SelectQueryBuilder<AssetEntity> {
|
||||||
|
builder.andWhere(
|
||||||
|
_.omitBy(
|
||||||
|
{
|
||||||
|
createdAt: OptionalBetween(options.createdAfter, options.createdBefore),
|
||||||
|
updatedAt: OptionalBetween(options.updatedAfter, options.updatedBefore),
|
||||||
|
deletedAt: OptionalBetween(options.trashedAfter, options.trashedBefore),
|
||||||
|
fileCreatedAt: OptionalBetween(options.takenAfter, options.takenBefore),
|
||||||
|
},
|
||||||
|
_.isUndefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const exifInfo = _.omitBy(_.pick(options, ['city', 'country', 'lensModel', 'make', 'model', 'state']), _.isUndefined);
|
||||||
|
if (Object.keys(exifInfo).length > 0) {
|
||||||
|
builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo');
|
||||||
|
builder.andWhere({ exifInfo });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId', 'ownerId']);
|
||||||
|
builder.andWhere(_.omitBy(id, _.isUndefined));
|
||||||
|
|
||||||
|
const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'originalPath', 'resizePath', 'webpPath']);
|
||||||
|
builder.andWhere(_.omitBy(path, _.isUndefined));
|
||||||
|
|
||||||
|
const status = _.pick(options, ['isExternal', 'isFavorite', 'isOffline', 'isReadOnly', 'isVisible', 'type']);
|
||||||
|
const { isArchived, isEncoded, isMotion, withArchived } = options;
|
||||||
|
builder.andWhere(
|
||||||
|
_.omitBy(
|
||||||
|
{
|
||||||
|
...status,
|
||||||
|
isArchived: isArchived ?? withArchived,
|
||||||
|
encodedVideoPath: isEncoded ? Not(IsNull()) : undefined,
|
||||||
|
livePhotoVideoId: isMotion ? Not(IsNull()) : undefined,
|
||||||
|
},
|
||||||
|
_.isUndefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.withExif) {
|
||||||
|
builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.withFaces || options.withPeople) {
|
||||||
|
builder.leftJoinAndSelect(`${builder.alias}.faces`, 'faces');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.withPeople) {
|
||||||
|
builder.leftJoinAndSelect(`${builder.alias}.person`, 'person');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.withSmartInfo) {
|
||||||
|
builder.leftJoinAndSelect(`${builder.alias}.smartInfo`, 'smartInfo');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.withStacked) {
|
||||||
|
builder
|
||||||
|
.leftJoinAndSelect(`${builder.alias}.stack`, 'stack')
|
||||||
|
.leftJoinAndSelect('stack.assets', 'stackedAssets')
|
||||||
|
.andWhere(
|
||||||
|
new Brackets((qb) => qb.where(`stack.primaryAssetId = ${builder.alias}.id`).orWhere('asset.stackId IS NULL')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const withDeleted =
|
||||||
|
options.withDeleted ?? (options.trashedAfter !== undefined || options.trashedBefore !== undefined);
|
||||||
|
if (withDeleted) {
|
||||||
|
builder.withDeleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export class ApiKeyRepository implements IKeyRepository {
|
||||||
return this.repository.findOne({ where: { userId, id } });
|
return this.repository.findOne({ where: { userId, id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getByUserId(userId: string): Promise<APIKeyEntity[]> {
|
getByUserId(userId: string): Promise<APIKeyEntity[]> {
|
||||||
return this.repository.find({ where: { userId }, order: { createdAt: 'DESC' } });
|
return this.repository.find({ where: { userId }, order: { createdAt: 'DESC' } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
MetadataSearchOptions,
|
MetadataSearchOptions,
|
||||||
MonthDay,
|
MonthDay,
|
||||||
Paginated,
|
Paginated,
|
||||||
|
PaginationMode,
|
||||||
PaginationOptions,
|
PaginationOptions,
|
||||||
SearchExploreItem,
|
SearchExploreItem,
|
||||||
TimeBucketItem,
|
TimeBucketItem,
|
||||||
|
|
@ -22,26 +23,21 @@ import {
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import _ from 'lodash';
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
And,
|
|
||||||
Brackets,
|
Brackets,
|
||||||
FindOptionsRelations,
|
FindOptionsRelations,
|
||||||
FindOptionsSelect,
|
FindOptionsSelect,
|
||||||
FindOptionsWhere,
|
FindOptionsWhere,
|
||||||
In,
|
In,
|
||||||
IsNull,
|
IsNull,
|
||||||
LessThan,
|
|
||||||
Not,
|
Not,
|
||||||
Repository,
|
Repository,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity, SmartInfoEntity } from '../entities';
|
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity, SmartInfoEntity } from '../entities';
|
||||||
import { DummyValue, GenerateSql } from '../infra.util';
|
import { DummyValue, GenerateSql } from '../infra.util';
|
||||||
import { Chunked, ChunkedArray, OptionalBetween, paginate } from '../infra.utils';
|
import { Chunked, ChunkedArray, OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from '../infra.utils';
|
||||||
|
|
||||||
const DEFAULT_SEARCH_SIZE = 250;
|
|
||||||
|
|
||||||
const truncateMap: Record<TimeBucketSize, string> = {
|
const truncateMap: Record<TimeBucketSize, string> = {
|
||||||
[TimeBucketSize.DAY]: 'day',
|
[TimeBucketSize.DAY]: 'day',
|
||||||
|
|
@ -70,142 +66,6 @@ export class AssetRepository implements IAssetRepository {
|
||||||
await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] });
|
await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
search(options: AssetSearchOptions): Promise<AssetEntity[]> {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
libraryId,
|
|
||||||
deviceAssetId,
|
|
||||||
type,
|
|
||||||
checksum,
|
|
||||||
ownerId,
|
|
||||||
|
|
||||||
isVisible,
|
|
||||||
isFavorite,
|
|
||||||
isExternal,
|
|
||||||
isReadOnly,
|
|
||||||
isOffline,
|
|
||||||
isArchived,
|
|
||||||
isMotion,
|
|
||||||
isEncoded,
|
|
||||||
|
|
||||||
createdBefore,
|
|
||||||
createdAfter,
|
|
||||||
updatedBefore,
|
|
||||||
updatedAfter,
|
|
||||||
trashedBefore,
|
|
||||||
trashedAfter,
|
|
||||||
takenBefore,
|
|
||||||
takenAfter,
|
|
||||||
|
|
||||||
originalFileName,
|
|
||||||
originalPath,
|
|
||||||
resizePath,
|
|
||||||
webpPath,
|
|
||||||
encodedVideoPath,
|
|
||||||
|
|
||||||
city,
|
|
||||||
state,
|
|
||||||
country,
|
|
||||||
make,
|
|
||||||
model,
|
|
||||||
lensModel,
|
|
||||||
|
|
||||||
withDeleted: _withDeleted,
|
|
||||||
withExif: _withExif,
|
|
||||||
withStacked,
|
|
||||||
withPeople,
|
|
||||||
withSmartInfo,
|
|
||||||
|
|
||||||
order,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const withDeleted = _withDeleted ?? (trashedAfter !== undefined || trashedBefore !== undefined);
|
|
||||||
|
|
||||||
const page = Math.max(options.page || 1, 1);
|
|
||||||
const size = Math.min(options.size || DEFAULT_SEARCH_SIZE, DEFAULT_SEARCH_SIZE);
|
|
||||||
|
|
||||||
const exifWhere = _.omitBy(
|
|
||||||
{
|
|
||||||
city,
|
|
||||||
state,
|
|
||||||
country,
|
|
||||||
make,
|
|
||||||
model,
|
|
||||||
lensModel,
|
|
||||||
},
|
|
||||||
_.isUndefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const withExif = Object.keys(exifWhere).length > 0 || _withExif;
|
|
||||||
|
|
||||||
const where: FindOptionsWhere<AssetEntity> = _.omitBy(
|
|
||||||
{
|
|
||||||
ownerId,
|
|
||||||
id,
|
|
||||||
libraryId,
|
|
||||||
deviceAssetId,
|
|
||||||
type,
|
|
||||||
checksum,
|
|
||||||
isVisible,
|
|
||||||
isFavorite,
|
|
||||||
isExternal,
|
|
||||||
isReadOnly,
|
|
||||||
isOffline,
|
|
||||||
isArchived,
|
|
||||||
livePhotoVideoId: isMotion && Not(IsNull()),
|
|
||||||
originalFileName,
|
|
||||||
originalPath,
|
|
||||||
resizePath,
|
|
||||||
webpPath,
|
|
||||||
encodedVideoPath: encodedVideoPath ?? (isEncoded && Not(IsNull())),
|
|
||||||
createdAt: OptionalBetween(createdAfter, createdBefore),
|
|
||||||
updatedAt: OptionalBetween(updatedAfter, updatedBefore),
|
|
||||||
deletedAt: OptionalBetween(trashedAfter, trashedBefore),
|
|
||||||
fileCreatedAt: OptionalBetween(takenAfter, takenBefore),
|
|
||||||
exifInfo: Object.keys(exifWhere).length > 0 ? exifWhere : undefined,
|
|
||||||
},
|
|
||||||
_.isUndefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const builder = this.repository.createQueryBuilder('asset');
|
|
||||||
|
|
||||||
if (withExif) {
|
|
||||||
if (_withExif) {
|
|
||||||
builder.leftJoinAndSelect('asset.exifInfo', 'exifInfo');
|
|
||||||
} else {
|
|
||||||
builder.leftJoin('asset.exifInfo', 'exifInfo');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (withPeople) {
|
|
||||||
builder.leftJoinAndSelect('asset.faces', 'faces');
|
|
||||||
builder.leftJoinAndSelect('faces.person', 'person');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (withSmartInfo) {
|
|
||||||
builder.leftJoinAndSelect('asset.smartInfo', 'smartInfo');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (withDeleted) {
|
|
||||||
builder.withDeleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.where(where);
|
|
||||||
|
|
||||||
if (withStacked) {
|
|
||||||
builder
|
|
||||||
.leftJoinAndSelect('asset.stack', 'stack')
|
|
||||||
.leftJoinAndSelect('stack.assets', 'stackedAssets')
|
|
||||||
.andWhere(new Brackets((qb) => qb.where('stack.primaryAssetId = asset.id').orWhere('asset.stackId IS NULL')));
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder
|
|
||||||
.skip(size * (page - 1))
|
|
||||||
.take(size)
|
|
||||||
.orderBy('asset.fileCreatedAt', order ?? 'DESC')
|
|
||||||
.getMany();
|
|
||||||
}
|
|
||||||
|
|
||||||
create(asset: AssetCreate): Promise<AssetEntity> {
|
create(asset: AssetCreate): Promise<AssetEntity> {
|
||||||
return this.repository.save(asset);
|
return this.repository.save(asset);
|
||||||
}
|
}
|
||||||
|
|
@ -316,17 +176,7 @@ export class AssetRepository implements IAssetRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
getByUserId(pagination: PaginationOptions, userId: string, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
|
getByUserId(pagination: PaginationOptions, userId: string, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
|
||||||
return paginate(this.repository, pagination, {
|
return this.getAll(pagination, { ...options, id: userId });
|
||||||
where: {
|
|
||||||
ownerId: userId,
|
|
||||||
isVisible: options.isVisible,
|
|
||||||
deletedAt: options.trashedBefore ? And(Not(IsNull()), LessThan(options.trashedBefore)) : undefined,
|
|
||||||
},
|
|
||||||
relations: {
|
|
||||||
exifInfo: true,
|
|
||||||
},
|
|
||||||
withDeleted: !!options.trashedBefore,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||||
|
|
@ -345,24 +195,13 @@ export class AssetRepository implements IAssetRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
|
getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
|
||||||
return paginate(this.repository, pagination, {
|
let builder = this.repository.createQueryBuilder('asset');
|
||||||
where: {
|
builder = searchAssetBuilder(builder, options);
|
||||||
isVisible: options.isVisible,
|
builder.orderBy('asset.createdAt', options.orderDirection ?? 'ASC');
|
||||||
type: options.type,
|
return paginatedBuilder<AssetEntity>(builder, {
|
||||||
deletedAt: options.trashedBefore ? And(Not(IsNull()), LessThan(options.trashedBefore)) : undefined,
|
mode: PaginationMode.SKIP_TAKE,
|
||||||
},
|
skip: pagination.skip,
|
||||||
relations: {
|
take: pagination.take,
|
||||||
exifInfo: options.withExif !== false,
|
|
||||||
smartInfo: options.withSmartInfo !== false,
|
|
||||||
tags: options.withSmartInfo !== false,
|
|
||||||
faces: options.withFaces !== false,
|
|
||||||
smartSearch: options.withSmartInfo === true,
|
|
||||||
},
|
|
||||||
withDeleted: options.withDeleted ?? !!options.trashedBefore,
|
|
||||||
order: {
|
|
||||||
// Ensures correct order when paginating
|
|
||||||
createdAt: options.order ?? 'ASC',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,7 +274,7 @@ export class AssetRepository implements IAssetRepository {
|
||||||
await this.repository.remove(asset);
|
await this.repository.remove(asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.BUFFER] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
|
||||||
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null> {
|
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null> {
|
||||||
return this.repository.findOne({ where: { ownerId: userId, checksum } });
|
return this.repository.findOne({ where: { ownerId: userId, checksum } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ export * from './metadata.repository';
|
||||||
export * from './move.repository';
|
export * from './move.repository';
|
||||||
export * from './partner.repository';
|
export * from './partner.repository';
|
||||||
export * from './person.repository';
|
export * from './person.repository';
|
||||||
|
export * from './search.repository';
|
||||||
export * from './server-info.repository';
|
export * from './server-info.repository';
|
||||||
export * from './shared-link.repository';
|
export * from './shared-link.repository';
|
||||||
export * from './smart-info.repository';
|
|
||||||
export * from './system-config.repository';
|
export * from './system-config.repository';
|
||||||
export * from './system-metadata.repository';
|
export * from './system-metadata.repository';
|
||||||
export * from './tag.repository';
|
export * from './tag.repository';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
import {
|
import {
|
||||||
|
AssetSearchOptions,
|
||||||
DatabaseExtension,
|
DatabaseExtension,
|
||||||
Embedding,
|
Embedding,
|
||||||
EmbeddingSearch,
|
|
||||||
FaceEmbeddingSearch,
|
FaceEmbeddingSearch,
|
||||||
FaceSearchResult,
|
FaceSearchResult,
|
||||||
ISmartInfoRepository,
|
ISearchRepository,
|
||||||
|
Paginated,
|
||||||
|
PaginationMode,
|
||||||
|
PaginationResult,
|
||||||
|
SearchPaginationOptions,
|
||||||
|
SmartSearchOptions,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
|
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
|
||||||
import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities';
|
import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities';
|
||||||
|
|
@ -14,11 +19,11 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { vectorExt } from '../database.config';
|
import { vectorExt } from '../database.config';
|
||||||
import { DummyValue, GenerateSql } from '../infra.util';
|
import { DummyValue, GenerateSql } from '../infra.util';
|
||||||
import { asVector, isValidInteger } from '../infra.utils';
|
import { asVector, isValidInteger, paginatedBuilder, searchAssetBuilder } from '../infra.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmartInfoRepository implements ISmartInfoRepository {
|
export class SearchRepository implements ISearchRepository {
|
||||||
private logger = new ImmichLogger(SmartInfoRepository.name);
|
private logger = new ImmichLogger(SearchRepository.name);
|
||||||
private faceColumns: string[];
|
private faceColumns: string[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -35,48 +40,74 @@ export class SmartInfoRepository implements ISmartInfoRepository {
|
||||||
|
|
||||||
async init(modelName: string): Promise<void> {
|
async init(modelName: string): Promise<void> {
|
||||||
const { dimSize } = getCLIPModelInfo(modelName);
|
const { dimSize } = getCLIPModelInfo(modelName);
|
||||||
if (dimSize == null) {
|
const curDimSize = await this.getDimSize();
|
||||||
throw new Error(`Invalid CLIP model name: ${modelName}`);
|
this.logger.verbose(`Current database CLIP dimension size is ${curDimSize}`);
|
||||||
}
|
|
||||||
|
|
||||||
const currentDimSize = await this.getDimSize();
|
if (dimSize != curDimSize) {
|
||||||
this.logger.verbose(`Current database CLIP dimension size is ${currentDimSize}`);
|
this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${curDimSize}.`);
|
||||||
|
|
||||||
if (dimSize != currentDimSize) {
|
|
||||||
this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${currentDimSize}.`);
|
|
||||||
await this.updateDimSize(dimSize);
|
await this.updateDimSize(dimSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
params: [{ userIds: [DummyValue.UUID], embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }],
|
params: [
|
||||||
|
{ page: 1, size: 100 },
|
||||||
|
{
|
||||||
|
takenAfter: DummyValue.DATE,
|
||||||
|
lensModel: DummyValue.STRING,
|
||||||
|
ownerId: DummyValue.UUID,
|
||||||
|
withStacked: true,
|
||||||
|
isFavorite: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
async searchCLIP({ userIds, embedding, numResults, withArchived }: EmbeddingSearch): Promise<AssetEntity[]> {
|
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
|
||||||
if (!isValidInteger(numResults, { min: 1 })) {
|
let builder = this.assetRepository.createQueryBuilder('asset');
|
||||||
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
builder = searchAssetBuilder(builder, options);
|
||||||
}
|
|
||||||
|
|
||||||
// setting this too low messes with prefilter recall
|
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
|
||||||
numResults = Math.max(numResults, 64);
|
|
||||||
|
return paginatedBuilder<AssetEntity>(builder, {
|
||||||
|
mode: PaginationMode.SKIP_TAKE,
|
||||||
|
skip: (pagination.page - 1) * pagination.size,
|
||||||
|
take: pagination.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({
|
||||||
|
params: [
|
||||||
|
{ page: 1, size: 100 },
|
||||||
|
{
|
||||||
|
takenAfter: DummyValue.DATE,
|
||||||
|
embedding: Array.from({ length: 512 }, Math.random),
|
||||||
|
lensModel: DummyValue.STRING,
|
||||||
|
withStacked: true,
|
||||||
|
isFavorite: true,
|
||||||
|
userIds: [DummyValue.UUID],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
async searchSmart(
|
||||||
|
pagination: SearchPaginationOptions,
|
||||||
|
{ embedding, userIds, ...options }: SmartSearchOptions,
|
||||||
|
): Paginated<AssetEntity> {
|
||||||
|
let results: PaginationResult<AssetEntity> = { items: [], hasNextPage: false };
|
||||||
|
|
||||||
let results: AssetEntity[] = [];
|
|
||||||
await this.assetRepository.manager.transaction(async (manager) => {
|
await this.assetRepository.manager.transaction(async (manager) => {
|
||||||
const query = manager
|
let builder = manager.createQueryBuilder(AssetEntity, 'asset');
|
||||||
.createQueryBuilder(AssetEntity, 'a')
|
builder = searchAssetBuilder(builder, options);
|
||||||
.innerJoin('a.smartSearch', 's')
|
builder
|
||||||
.leftJoinAndSelect('a.exifInfo', 'e')
|
.innerJoin('asset.smartSearch', 'search')
|
||||||
.where('a.ownerId IN (:...userIds )')
|
.andWhere('asset.ownerId IN (:...userIds )')
|
||||||
.orderBy('s.embedding <=> :embedding')
|
.orderBy('search.embedding <=> :embedding')
|
||||||
.setParameters({ userIds, embedding: asVector(embedding) });
|
.setParameters({ userIds, embedding: asVector(embedding) });
|
||||||
|
|
||||||
if (!withArchived) {
|
await manager.query(this.getRuntimeConfig(pagination.size));
|
||||||
query.andWhere('a.isArchived = false');
|
results = await paginatedBuilder<AssetEntity>(builder, {
|
||||||
}
|
mode: PaginationMode.LIMIT_OFFSET,
|
||||||
query.andWhere('a.isVisible = true').andWhere('a.fileCreatedAt < NOW()');
|
skip: (pagination.page - 1) * pagination.size,
|
||||||
query.limit(numResults);
|
take: pagination.size,
|
||||||
|
});
|
||||||
await manager.query(this.getRuntimeConfig(numResults));
|
|
||||||
results = await query.getMany();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|
@ -135,7 +166,6 @@ export class SmartInfoRepository implements ISmartInfoRepository {
|
||||||
.where('res.distance <= :maxDistance', { maxDistance })
|
.where('res.distance <= :maxDistance', { maxDistance })
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
});
|
});
|
||||||
|
|
||||||
return results.map((row) => ({
|
return results.map((row) => ({
|
||||||
face: this.assetFaceRepository.create(row),
|
face: this.assetFaceRepository.create(row),
|
||||||
distance: row.distance,
|
distance: row.distance,
|
||||||
|
|
@ -163,17 +193,14 @@ export class SmartInfoRepository implements ISmartInfoRepository {
|
||||||
throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
|
throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentDimSize = await this.getDimSize();
|
const curDimSize = await this.getDimSize();
|
||||||
if (currentDimSize === dimSize) {
|
if (curDimSize === dimSize) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
|
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
|
||||||
|
|
||||||
await this.smartSearchRepository.manager.transaction(async (manager) => {
|
await this.smartSearchRepository.manager.transaction(async (manager) => {
|
||||||
if (vectorExt === DatabaseExtension.VECTORS) {
|
|
||||||
await manager.query(`SET vectors.pgvector_compatibility=on`);
|
|
||||||
}
|
|
||||||
await manager.query(`DROP TABLE smart_search`);
|
await manager.query(`DROP TABLE smart_search`);
|
||||||
|
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
|
|
@ -182,12 +209,15 @@ export class SmartInfoRepository implements ISmartInfoRepository {
|
||||||
embedding vector(${dimSize}) NOT NULL )`);
|
embedding vector(${dimSize}) NOT NULL )`);
|
||||||
|
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
CREATE INDEX IF NOT EXISTS clip_index ON smart_search
|
CREATE INDEX clip_index ON smart_search
|
||||||
USING hnsw (embedding vector_cosine_ops)
|
USING vectors (embedding vector_cos_ops) WITH (options = $$
|
||||||
WITH (ef_construction = 300, m = 16)`);
|
[indexing.hnsw]
|
||||||
|
m = 16
|
||||||
|
ef_construction = 300
|
||||||
|
$$)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Successfully updated database CLIP dimension size from ${currentDimSize} to ${dimSize}.`);
|
this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDimSize(): Promise<number> {
|
private async getDimSize(): Promise<number> {
|
||||||
|
|
@ -19,8 +19,8 @@ import {
|
||||||
MoveRepository,
|
MoveRepository,
|
||||||
PartnerRepository,
|
PartnerRepository,
|
||||||
PersonRepository,
|
PersonRepository,
|
||||||
|
SearchRepository,
|
||||||
SharedLinkRepository,
|
SharedLinkRepository,
|
||||||
SmartInfoRepository,
|
|
||||||
SystemConfigRepository,
|
SystemConfigRepository,
|
||||||
SystemMetadataRepository,
|
SystemMetadataRepository,
|
||||||
TagRepository,
|
TagRepository,
|
||||||
|
|
@ -41,7 +41,7 @@ const repositories = [
|
||||||
PartnerRepository,
|
PartnerRepository,
|
||||||
PersonRepository,
|
PersonRepository,
|
||||||
SharedLinkRepository,
|
SharedLinkRepository,
|
||||||
SmartInfoRepository,
|
SearchRepository,
|
||||||
SystemConfigRepository,
|
SystemConfigRepository,
|
||||||
SystemMetadataRepository,
|
SystemMetadataRepository,
|
||||||
TagRepository,
|
TagRepository,
|
||||||
|
|
@ -142,7 +142,7 @@ class SqlGenerator {
|
||||||
this.sqlLogger.clear();
|
this.sqlLogger.clear();
|
||||||
|
|
||||||
// errors still generate sql, which is all we care about
|
// errors still generate sql, which is all we care about
|
||||||
await target.apply(instance, params).catch(() => null);
|
await target.apply(instance, params).catch((error: Error) => console.error(`${queryLabel} error: ${error}`));
|
||||||
|
|
||||||
if (this.sqlLogger.queries.length === 0) {
|
if (this.sqlLogger.queries.length === 0) {
|
||||||
console.warn(`No queries recorded for ${queryLabel}`);
|
console.warn(`No queries recorded for ${queryLabel}`);
|
||||||
|
|
|
||||||
234
server/src/infra/sql/search.repository.sql
Normal file
234
server/src/infra/sql/search.repository.sql
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
-- NOTE: This file is auto generated by ./sql-generator
|
||||||
|
|
||||||
|
-- SearchRepository.searchMetadata
|
||||||
|
SELECT DISTINCT
|
||||||
|
"distinctAlias"."asset_id" AS "ids_asset_id",
|
||||||
|
"distinctAlias"."asset_fileCreatedAt"
|
||||||
|
FROM
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
"asset"."id" AS "asset_id",
|
||||||
|
"asset"."deviceAssetId" AS "asset_deviceAssetId",
|
||||||
|
"asset"."ownerId" AS "asset_ownerId",
|
||||||
|
"asset"."libraryId" AS "asset_libraryId",
|
||||||
|
"asset"."deviceId" AS "asset_deviceId",
|
||||||
|
"asset"."type" AS "asset_type",
|
||||||
|
"asset"."originalPath" AS "asset_originalPath",
|
||||||
|
"asset"."resizePath" AS "asset_resizePath",
|
||||||
|
"asset"."webpPath" AS "asset_webpPath",
|
||||||
|
"asset"."thumbhash" AS "asset_thumbhash",
|
||||||
|
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||||
|
"asset"."createdAt" AS "asset_createdAt",
|
||||||
|
"asset"."updatedAt" AS "asset_updatedAt",
|
||||||
|
"asset"."deletedAt" AS "asset_deletedAt",
|
||||||
|
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
|
||||||
|
"asset"."localDateTime" AS "asset_localDateTime",
|
||||||
|
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
|
||||||
|
"asset"."isFavorite" AS "asset_isFavorite",
|
||||||
|
"asset"."isArchived" AS "asset_isArchived",
|
||||||
|
"asset"."isExternal" AS "asset_isExternal",
|
||||||
|
"asset"."isReadOnly" AS "asset_isReadOnly",
|
||||||
|
"asset"."isOffline" AS "asset_isOffline",
|
||||||
|
"asset"."checksum" AS "asset_checksum",
|
||||||
|
"asset"."duration" AS "asset_duration",
|
||||||
|
"asset"."isVisible" AS "asset_isVisible",
|
||||||
|
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
|
||||||
|
"asset"."originalFileName" AS "asset_originalFileName",
|
||||||
|
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||||
|
"asset"."stackId" AS "asset_stackId",
|
||||||
|
"stack"."id" AS "stack_id",
|
||||||
|
"stack"."primaryAssetId" AS "stack_primaryAssetId",
|
||||||
|
"stackedAssets"."id" AS "stackedAssets_id",
|
||||||
|
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
|
||||||
|
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
|
||||||
|
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||||
|
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||||
|
"stackedAssets"."type" AS "stackedAssets_type",
|
||||||
|
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||||
|
"stackedAssets"."resizePath" AS "stackedAssets_resizePath",
|
||||||
|
"stackedAssets"."webpPath" AS "stackedAssets_webpPath",
|
||||||
|
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||||
|
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||||
|
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
|
||||||
|
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
|
||||||
|
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
|
||||||
|
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
|
||||||
|
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
|
||||||
|
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
|
||||||
|
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
|
||||||
|
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
|
||||||
|
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
|
||||||
|
"stackedAssets"."isReadOnly" AS "stackedAssets_isReadOnly",
|
||||||
|
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
|
||||||
|
"stackedAssets"."checksum" AS "stackedAssets_checksum",
|
||||||
|
"stackedAssets"."duration" AS "stackedAssets_duration",
|
||||||
|
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
|
||||||
|
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
||||||
|
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
||||||
|
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
||||||
|
"stackedAssets"."stackId" AS "stackedAssets_stackId"
|
||||||
|
FROM
|
||||||
|
"assets" "asset"
|
||||||
|
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
||||||
|
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
|
||||||
|
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
|
||||||
|
AND ("stackedAssets"."deletedAt" IS NULL)
|
||||||
|
WHERE
|
||||||
|
(
|
||||||
|
"asset"."fileCreatedAt" >= $1
|
||||||
|
AND "exifInfo"."lensModel" = $2
|
||||||
|
AND "asset"."ownerId" = $3
|
||||||
|
AND 1 = 1
|
||||||
|
AND "asset"."isFavorite" = $4
|
||||||
|
AND (
|
||||||
|
"stack"."primaryAssetId" = "asset"."id"
|
||||||
|
OR "asset"."stackId" IS NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
|
) "distinctAlias"
|
||||||
|
ORDER BY
|
||||||
|
"distinctAlias"."asset_fileCreatedAt" DESC,
|
||||||
|
"asset_id" ASC
|
||||||
|
LIMIT
|
||||||
|
101
|
||||||
|
|
||||||
|
-- SearchRepository.searchSmart
|
||||||
|
START TRANSACTION
|
||||||
|
SET
|
||||||
|
LOCAL vectors.enable_prefilter = on;
|
||||||
|
|
||||||
|
SET
|
||||||
|
LOCAL vectors.search_mode = vbase;
|
||||||
|
|
||||||
|
SET
|
||||||
|
LOCAL vectors.hnsw_ef_search = 100;
|
||||||
|
SELECT
|
||||||
|
"asset"."id" AS "asset_id",
|
||||||
|
"asset"."deviceAssetId" AS "asset_deviceAssetId",
|
||||||
|
"asset"."ownerId" AS "asset_ownerId",
|
||||||
|
"asset"."libraryId" AS "asset_libraryId",
|
||||||
|
"asset"."deviceId" AS "asset_deviceId",
|
||||||
|
"asset"."type" AS "asset_type",
|
||||||
|
"asset"."originalPath" AS "asset_originalPath",
|
||||||
|
"asset"."resizePath" AS "asset_resizePath",
|
||||||
|
"asset"."webpPath" AS "asset_webpPath",
|
||||||
|
"asset"."thumbhash" AS "asset_thumbhash",
|
||||||
|
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||||
|
"asset"."createdAt" AS "asset_createdAt",
|
||||||
|
"asset"."updatedAt" AS "asset_updatedAt",
|
||||||
|
"asset"."deletedAt" AS "asset_deletedAt",
|
||||||
|
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
|
||||||
|
"asset"."localDateTime" AS "asset_localDateTime",
|
||||||
|
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
|
||||||
|
"asset"."isFavorite" AS "asset_isFavorite",
|
||||||
|
"asset"."isArchived" AS "asset_isArchived",
|
||||||
|
"asset"."isExternal" AS "asset_isExternal",
|
||||||
|
"asset"."isReadOnly" AS "asset_isReadOnly",
|
||||||
|
"asset"."isOffline" AS "asset_isOffline",
|
||||||
|
"asset"."checksum" AS "asset_checksum",
|
||||||
|
"asset"."duration" AS "asset_duration",
|
||||||
|
"asset"."isVisible" AS "asset_isVisible",
|
||||||
|
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
|
||||||
|
"asset"."originalFileName" AS "asset_originalFileName",
|
||||||
|
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||||
|
"asset"."stackId" AS "asset_stackId",
|
||||||
|
"stack"."id" AS "stack_id",
|
||||||
|
"stack"."primaryAssetId" AS "stack_primaryAssetId",
|
||||||
|
"stackedAssets"."id" AS "stackedAssets_id",
|
||||||
|
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
|
||||||
|
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
|
||||||
|
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||||
|
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||||
|
"stackedAssets"."type" AS "stackedAssets_type",
|
||||||
|
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||||
|
"stackedAssets"."resizePath" AS "stackedAssets_resizePath",
|
||||||
|
"stackedAssets"."webpPath" AS "stackedAssets_webpPath",
|
||||||
|
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||||
|
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||||
|
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
|
||||||
|
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
|
||||||
|
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
|
||||||
|
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
|
||||||
|
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
|
||||||
|
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
|
||||||
|
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
|
||||||
|
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
|
||||||
|
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
|
||||||
|
"stackedAssets"."isReadOnly" AS "stackedAssets_isReadOnly",
|
||||||
|
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
|
||||||
|
"stackedAssets"."checksum" AS "stackedAssets_checksum",
|
||||||
|
"stackedAssets"."duration" AS "stackedAssets_duration",
|
||||||
|
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
|
||||||
|
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
||||||
|
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
||||||
|
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
||||||
|
"stackedAssets"."stackId" AS "stackedAssets_stackId"
|
||||||
|
FROM
|
||||||
|
"assets" "asset"
|
||||||
|
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
||||||
|
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
|
||||||
|
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
|
||||||
|
AND ("stackedAssets"."deletedAt" IS NULL)
|
||||||
|
INNER JOIN "smart_search" "search" ON "search"."assetId" = "asset"."id"
|
||||||
|
WHERE
|
||||||
|
(
|
||||||
|
"asset"."fileCreatedAt" >= $1
|
||||||
|
AND "exifInfo"."lensModel" = $2
|
||||||
|
AND 1 = 1
|
||||||
|
AND 1 = 1
|
||||||
|
AND "asset"."isFavorite" = $3
|
||||||
|
AND (
|
||||||
|
"stack"."primaryAssetId" = "asset"."id"
|
||||||
|
OR "asset"."stackId" IS NULL
|
||||||
|
)
|
||||||
|
AND "asset"."ownerId" IN ($4)
|
||||||
|
)
|
||||||
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
|
ORDER BY
|
||||||
|
"search"."embedding" <= > $5 ASC
|
||||||
|
LIMIT
|
||||||
|
101
|
||||||
|
COMMIT
|
||||||
|
|
||||||
|
-- SearchRepository.searchFaces
|
||||||
|
START TRANSACTION
|
||||||
|
SET
|
||||||
|
LOCAL vectors.enable_prefilter = on;
|
||||||
|
|
||||||
|
SET
|
||||||
|
LOCAL vectors.search_mode = vbase;
|
||||||
|
|
||||||
|
SET
|
||||||
|
LOCAL vectors.hnsw_ef_search = 100;
|
||||||
|
WITH
|
||||||
|
"cte" AS (
|
||||||
|
SELECT
|
||||||
|
"faces"."id" AS "id",
|
||||||
|
"faces"."assetId" AS "assetId",
|
||||||
|
"faces"."personId" AS "personId",
|
||||||
|
"faces"."imageWidth" AS "imageWidth",
|
||||||
|
"faces"."imageHeight" AS "imageHeight",
|
||||||
|
"faces"."boundingBoxX1" AS "boundingBoxX1",
|
||||||
|
"faces"."boundingBoxY1" AS "boundingBoxY1",
|
||||||
|
"faces"."boundingBoxX2" AS "boundingBoxX2",
|
||||||
|
"faces"."boundingBoxY2" AS "boundingBoxY2",
|
||||||
|
"faces"."embedding" <= > $1 AS "distance"
|
||||||
|
FROM
|
||||||
|
"asset_faces" "faces"
|
||||||
|
INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId"
|
||||||
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
|
WHERE
|
||||||
|
"asset"."ownerId" IN ($2)
|
||||||
|
ORDER BY
|
||||||
|
"faces"."embedding" <= > $1 ASC
|
||||||
|
LIMIT
|
||||||
|
100
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
res.*
|
||||||
|
FROM
|
||||||
|
"cte" "res"
|
||||||
|
WHERE
|
||||||
|
res.distance <= $3
|
||||||
|
COMMIT
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
-- NOTE: This file is auto generated by ./sql-generator
|
|
||||||
|
|
||||||
-- SmartInfoRepository.searchCLIP
|
|
||||||
START TRANSACTION
|
|
||||||
SET
|
|
||||||
LOCAL vectors.enable_prefilter = on;
|
|
||||||
|
|
||||||
SET
|
|
||||||
LOCAL vectors.search_mode = vbase;
|
|
||||||
|
|
||||||
SET
|
|
||||||
LOCAL vectors.hnsw_ef_search = 100;
|
|
||||||
SELECT
|
|
||||||
"a"."id" AS "a_id",
|
|
||||||
"a"."deviceAssetId" AS "a_deviceAssetId",
|
|
||||||
"a"."ownerId" AS "a_ownerId",
|
|
||||||
"a"."libraryId" AS "a_libraryId",
|
|
||||||
"a"."deviceId" AS "a_deviceId",
|
|
||||||
"a"."type" AS "a_type",
|
|
||||||
"a"."originalPath" AS "a_originalPath",
|
|
||||||
"a"."resizePath" AS "a_resizePath",
|
|
||||||
"a"."webpPath" AS "a_webpPath",
|
|
||||||
"a"."thumbhash" AS "a_thumbhash",
|
|
||||||
"a"."encodedVideoPath" AS "a_encodedVideoPath",
|
|
||||||
"a"."createdAt" AS "a_createdAt",
|
|
||||||
"a"."updatedAt" AS "a_updatedAt",
|
|
||||||
"a"."deletedAt" AS "a_deletedAt",
|
|
||||||
"a"."fileCreatedAt" AS "a_fileCreatedAt",
|
|
||||||
"a"."localDateTime" AS "a_localDateTime",
|
|
||||||
"a"."fileModifiedAt" AS "a_fileModifiedAt",
|
|
||||||
"a"."isFavorite" AS "a_isFavorite",
|
|
||||||
"a"."isArchived" AS "a_isArchived",
|
|
||||||
"a"."isExternal" AS "a_isExternal",
|
|
||||||
"a"."isReadOnly" AS "a_isReadOnly",
|
|
||||||
"a"."isOffline" AS "a_isOffline",
|
|
||||||
"a"."checksum" AS "a_checksum",
|
|
||||||
"a"."duration" AS "a_duration",
|
|
||||||
"a"."isVisible" AS "a_isVisible",
|
|
||||||
"a"."livePhotoVideoId" AS "a_livePhotoVideoId",
|
|
||||||
"a"."originalFileName" AS "a_originalFileName",
|
|
||||||
"a"."sidecarPath" AS "a_sidecarPath",
|
|
||||||
"a"."stackId" AS "a_stackId",
|
|
||||||
"e"."assetId" AS "e_assetId",
|
|
||||||
"e"."description" AS "e_description",
|
|
||||||
"e"."exifImageWidth" AS "e_exifImageWidth",
|
|
||||||
"e"."exifImageHeight" AS "e_exifImageHeight",
|
|
||||||
"e"."fileSizeInByte" AS "e_fileSizeInByte",
|
|
||||||
"e"."orientation" AS "e_orientation",
|
|
||||||
"e"."dateTimeOriginal" AS "e_dateTimeOriginal",
|
|
||||||
"e"."modifyDate" AS "e_modifyDate",
|
|
||||||
"e"."timeZone" AS "e_timeZone",
|
|
||||||
"e"."latitude" AS "e_latitude",
|
|
||||||
"e"."longitude" AS "e_longitude",
|
|
||||||
"e"."projectionType" AS "e_projectionType",
|
|
||||||
"e"."city" AS "e_city",
|
|
||||||
"e"."livePhotoCID" AS "e_livePhotoCID",
|
|
||||||
"e"."autoStackId" AS "e_autoStackId",
|
|
||||||
"e"."state" AS "e_state",
|
|
||||||
"e"."country" AS "e_country",
|
|
||||||
"e"."make" AS "e_make",
|
|
||||||
"e"."model" AS "e_model",
|
|
||||||
"e"."lensModel" AS "e_lensModel",
|
|
||||||
"e"."fNumber" AS "e_fNumber",
|
|
||||||
"e"."focalLength" AS "e_focalLength",
|
|
||||||
"e"."iso" AS "e_iso",
|
|
||||||
"e"."exposureTime" AS "e_exposureTime",
|
|
||||||
"e"."profileDescription" AS "e_profileDescription",
|
|
||||||
"e"."colorspace" AS "e_colorspace",
|
|
||||||
"e"."bitsPerSample" AS "e_bitsPerSample",
|
|
||||||
"e"."fps" AS "e_fps"
|
|
||||||
FROM
|
|
||||||
"assets" "a"
|
|
||||||
INNER JOIN "smart_search" "s" ON "s"."assetId" = "a"."id"
|
|
||||||
LEFT JOIN "exif" "e" ON "e"."assetId" = "a"."id"
|
|
||||||
WHERE
|
|
||||||
(
|
|
||||||
"a"."ownerId" IN ($1)
|
|
||||||
AND "a"."isArchived" = false
|
|
||||||
AND "a"."isVisible" = true
|
|
||||||
AND "a"."fileCreatedAt" < NOW()
|
|
||||||
)
|
|
||||||
AND ("a"."deletedAt" IS NULL)
|
|
||||||
ORDER BY
|
|
||||||
"s"."embedding" <= > $2 ASC
|
|
||||||
LIMIT
|
|
||||||
100
|
|
||||||
COMMIT
|
|
||||||
|
|
||||||
-- SmartInfoRepository.searchFaces
|
|
||||||
START TRANSACTION
|
|
||||||
SET
|
|
||||||
LOCAL vectors.enable_prefilter = on;
|
|
||||||
|
|
||||||
SET
|
|
||||||
LOCAL vectors.search_mode = vbase;
|
|
||||||
|
|
||||||
SET
|
|
||||||
LOCAL vectors.hnsw_ef_search = 100;
|
|
||||||
WITH
|
|
||||||
"cte" AS (
|
|
||||||
SELECT
|
|
||||||
"faces"."id" AS "id",
|
|
||||||
"faces"."assetId" AS "assetId",
|
|
||||||
"faces"."personId" AS "personId",
|
|
||||||
"faces"."imageWidth" AS "imageWidth",
|
|
||||||
"faces"."imageHeight" AS "imageHeight",
|
|
||||||
"faces"."boundingBoxX1" AS "boundingBoxX1",
|
|
||||||
"faces"."boundingBoxY1" AS "boundingBoxY1",
|
|
||||||
"faces"."boundingBoxX2" AS "boundingBoxX2",
|
|
||||||
"faces"."boundingBoxY2" AS "boundingBoxY2",
|
|
||||||
"faces"."embedding" <= > $1 AS "distance"
|
|
||||||
FROM
|
|
||||||
"asset_faces" "faces"
|
|
||||||
INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId"
|
|
||||||
AND ("asset"."deletedAt" IS NULL)
|
|
||||||
WHERE
|
|
||||||
"asset"."ownerId" IN ($2)
|
|
||||||
ORDER BY
|
|
||||||
"faces"."embedding" <= > $1 ASC
|
|
||||||
LIMIT
|
|
||||||
100
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
res.*
|
|
||||||
FROM
|
|
||||||
"cte" "res"
|
|
||||||
WHERE
|
|
||||||
res.distance <= $3
|
|
||||||
COMMIT
|
|
||||||
|
|
@ -32,7 +32,6 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
|
||||||
getTimeBuckets: jest.fn(),
|
getTimeBuckets: jest.fn(),
|
||||||
restoreAll: jest.fn(),
|
restoreAll: jest.fn(),
|
||||||
softDeleteAll: jest.fn(),
|
softDeleteAll: jest.fn(),
|
||||||
search: jest.fn(),
|
|
||||||
getAssetIdByCity: jest.fn(),
|
getAssetIdByCity: jest.fn(),
|
||||||
getAssetIdByTag: jest.fn(),
|
getAssetIdByTag: jest.fn(),
|
||||||
searchMetadata: jest.fn(),
|
searchMetadata: jest.fn(),
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ export * from './metadata.repository.mock';
|
||||||
export * from './move.repository.mock';
|
export * from './move.repository.mock';
|
||||||
export * from './partner.repository.mock';
|
export * from './partner.repository.mock';
|
||||||
export * from './person.repository.mock';
|
export * from './person.repository.mock';
|
||||||
|
export * from './search.repository.mock';
|
||||||
export * from './shared-link.repository.mock';
|
export * from './shared-link.repository.mock';
|
||||||
export * from './smart-info.repository.mock';
|
|
||||||
export * from './storage.repository.mock';
|
export * from './storage.repository.mock';
|
||||||
export * from './system-config.repository.mock';
|
export * from './system-config.repository.mock';
|
||||||
export * from './system-info.repository.mock';
|
export * from './system-info.repository.mock';
|
||||||
|
|
|
||||||
11
server/test/repositories/search.repository.mock.ts
Normal file
11
server/test/repositories/search.repository.mock.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { ISearchRepository } from '@app/domain';
|
||||||
|
|
||||||
|
export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
|
||||||
|
return {
|
||||||
|
init: jest.fn(),
|
||||||
|
searchMetadata: jest.fn(),
|
||||||
|
searchSmart: jest.fn(),
|
||||||
|
searchFaces: jest.fn(),
|
||||||
|
upsert: jest.fn(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { ISmartInfoRepository } from '@app/domain';
|
|
||||||
|
|
||||||
export const newSmartInfoRepositoryMock = (): jest.Mocked<ISmartInfoRepository> => {
|
|
||||||
return {
|
|
||||||
init: jest.fn(),
|
|
||||||
searchCLIP: jest.fn(),
|
|
||||||
searchFaces: jest.fn(),
|
|
||||||
upsert: jest.fn(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
export let right = 0;
|
export let right = 0;
|
||||||
export let root: HTMLElement | null = null;
|
export let root: HTMLElement | null = null;
|
||||||
|
|
||||||
let intersecting = false;
|
export let intersecting = false;
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
hidden: HTMLDivElement;
|
hidden: HTMLDivElement;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
export let readonly = false;
|
export let readonly = false;
|
||||||
export let showArchiveIcon = false;
|
export let showArchiveIcon = false;
|
||||||
export let showStackedIcon = true;
|
export let showStackedIcon = true;
|
||||||
|
export let intersecting = false;
|
||||||
|
|
||||||
let className = '';
|
let className = '';
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
|
@ -85,7 +86,7 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<IntersectionObserver once={false} let:intersecting>
|
<IntersectionObserver once={false} on:intersected bind:intersecting>
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div
|
<div
|
||||||
style:width="{width}px"
|
style:width="{width}px"
|
||||||
|
|
@ -95,8 +96,8 @@
|
||||||
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
|
: 'bg-immich-primary/20 dark:bg-immich-dark-primary/20'}"
|
||||||
class:cursor-not-allowed={disabled}
|
class:cursor-not-allowed={disabled}
|
||||||
class:hover:cursor-pointer={!disabled}
|
class:hover:cursor-pointer={!disabled}
|
||||||
on:mouseenter={() => onMouseEnter()}
|
on:mouseenter={onMouseEnter}
|
||||||
on:mouseleave={() => onMouseLeave()}
|
on:mouseleave={onMouseLeave}
|
||||||
on:click={thumbnailClickedHandler}
|
on:click={thumbnailClickedHandler}
|
||||||
on:keydown={thumbnailKeyDownHandler}
|
on:keydown={thumbnailKeyDownHandler}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@
|
||||||
import { getThumbnailSize } from '$lib/utils/thumbnail-util';
|
import { getThumbnailSize } from '$lib/utils/thumbnail-util';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import type { BucketPosition } from '$lib/stores/assets.store';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>();
|
||||||
|
|
||||||
export let assets: AssetResponseDto[];
|
export let assets: AssetResponseDto[];
|
||||||
export let selectedAssets: Set<AssetResponseDto> = new Set();
|
export let selectedAssets: Set<AssetResponseDto> = new Set();
|
||||||
|
|
@ -18,7 +22,6 @@
|
||||||
|
|
||||||
let selectedAsset: AssetResponseDto;
|
let selectedAsset: AssetResponseDto;
|
||||||
let currentViewAssetIndex = 0;
|
let currentViewAssetIndex = 0;
|
||||||
|
|
||||||
let viewWidth: number;
|
let viewWidth: number;
|
||||||
$: thumbnailSize = getThumbnailSize(assets.length, viewWidth);
|
$: thumbnailSize = getThumbnailSize(assets.length, viewWidth);
|
||||||
|
|
||||||
|
|
@ -88,7 +91,7 @@
|
||||||
|
|
||||||
{#if assets.length > 0}
|
{#if assets.length > 0}
|
||||||
<div class="flex w-full flex-wrap gap-1 pb-20" bind:clientWidth={viewWidth}>
|
<div class="flex w-full flex-wrap gap-1 pb-20" bind:clientWidth={viewWidth}>
|
||||||
{#each assets as asset (asset.id)}
|
{#each assets as asset, i (asset.id)}
|
||||||
<div animate:flip={{ duration: 500 }}>
|
<div animate:flip={{ duration: 500 }}>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
{asset}
|
{asset}
|
||||||
|
|
@ -97,6 +100,8 @@
|
||||||
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
|
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
|
||||||
on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))}
|
on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))}
|
||||||
on:select={selectAssetHandler}
|
on:select={selectAssetHandler}
|
||||||
|
on:intersected={(event) =>
|
||||||
|
i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined}
|
||||||
selected={selectedAssets.has(asset)}
|
selected={selectedAssets.has(asset)}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
const parameters = new URLSearchParams({
|
const parameters = new URLSearchParams({
|
||||||
q: searchValue,
|
q: searchValue,
|
||||||
smart: smartSearch,
|
smart: smartSearch,
|
||||||
|
take: '100',
|
||||||
});
|
});
|
||||||
|
|
||||||
showHistory = false;
|
showHistory = false;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||||
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
||||||
import type { AssetResponseDto } from '@api';
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
|
|
@ -27,15 +26,20 @@
|
||||||
import { preventRaceConditionSearchBar } from '$lib/stores/search.store';
|
import { preventRaceConditionSearchBar } from '$lib/stores/search.store';
|
||||||
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
||||||
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||||
|
import type { AssetResponseDto, SearchResponseDto } from '@immich/sdk';
|
||||||
|
import { authenticate } from '$lib/utils/auth';
|
||||||
|
import { api } from '@api';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
|
const MAX_ASSET_COUNT = 5000;
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
|
||||||
// The GalleryViewer pushes it's own history state, which causes weird
|
// The GalleryViewer pushes it's own history state, which causes weird
|
||||||
// behavior for history.back(). To prevent that we store the previous page
|
// behavior for history.back(). To prevent that we store the previous page
|
||||||
// manually and navigate back to that.
|
// manually and navigate back to that.
|
||||||
let previousRoute = AppRoute.EXPLORE as string;
|
let previousRoute = AppRoute.EXPLORE as string;
|
||||||
|
$: curPage = data.results?.assets.nextPage;
|
||||||
$: albums = data.results?.albums.items;
|
$: albums = data.results?.albums.items;
|
||||||
|
|
||||||
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
|
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
|
||||||
|
|
@ -107,6 +111,33 @@
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
selectedAssets = new Set(searchResultAssets);
|
selectedAssets = new Set(searchResultAssets);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadNextPage = async () => {
|
||||||
|
if (curPage == null || !term || (searchResultAssets && searchResultAssets.length >= MAX_ASSET_COUNT)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await authenticate();
|
||||||
|
let results: SearchResponseDto | null = null;
|
||||||
|
$page.url.searchParams.set('page', curPage.toString());
|
||||||
|
const res = await api.searchApi.search({}, { params: $page.url.searchParams });
|
||||||
|
if (searchResultAssets) {
|
||||||
|
searchResultAssets.push(...res.data.assets.items);
|
||||||
|
} else {
|
||||||
|
searchResultAssets = res.data.assets.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = {
|
||||||
|
...res.data.assets,
|
||||||
|
items: searchResultAssets,
|
||||||
|
};
|
||||||
|
results = {
|
||||||
|
assets,
|
||||||
|
albums: res.data.albums,
|
||||||
|
};
|
||||||
|
|
||||||
|
data.results = results;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -164,7 +195,12 @@
|
||||||
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
|
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
|
||||||
{#if searchResultAssets && searchResultAssets.length > 0}
|
{#if searchResultAssets && searchResultAssets.length > 0}
|
||||||
<div class="pl-4">
|
<div class="pl-4">
|
||||||
<GalleryViewer assets={searchResultAssets} bind:selectedAssets showArchiveIcon={true} />
|
<GalleryViewer
|
||||||
|
assets={searchResultAssets}
|
||||||
|
bind:selectedAssets
|
||||||
|
on:intersected={loadNextPage}
|
||||||
|
showArchiveIcon={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
|
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { authenticate } from '$lib/utils/auth';
|
import { authenticate } from '$lib/utils/auth';
|
||||||
import { type SearchResponseDto, api } from '@api';
|
import { type AssetResponseDto, type SearchResponseDto, api } from '@api';
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
import { QueryParameter } from '$lib/constants';
|
import { QueryParameter } from '$lib/constants';
|
||||||
|
|
||||||
|
|
@ -10,8 +10,18 @@ export const load = (async (data) => {
|
||||||
url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined;
|
url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined;
|
||||||
let results: SearchResponseDto | null = null;
|
let results: SearchResponseDto | null = null;
|
||||||
if (term) {
|
if (term) {
|
||||||
const { data } = await api.searchApi.search({}, { params: url.searchParams });
|
const res = await api.searchApi.search({}, { params: data.url.searchParams });
|
||||||
results = data;
|
let items: AssetResponseDto[] = (data as unknown as { results: SearchResponseDto }).results?.assets.items;
|
||||||
|
if (items) {
|
||||||
|
items.push(...res.data.assets.items);
|
||||||
|
} else {
|
||||||
|
items = res.data.assets.items;
|
||||||
|
}
|
||||||
|
const assets = { ...res.data.assets, items };
|
||||||
|
results = {
|
||||||
|
assets,
|
||||||
|
albums: res.data.albums,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue