diff --git a/mobile/lib/domain/services/search.service.dart b/mobile/lib/domain/services/search.service.dart index 8b93e9c8cc..745455cdf5 100644 --- a/mobile/lib/domain/services/search.service.dart +++ b/mobile/lib/domain/services/search.service.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart' hide AssetVisibility; +import 'package:openapi/api.dart' hide AssetVisibility, SearchFilter; class SearchService { final _log = Logger("SearchService"); diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index 402b26c9e7..1f513f5c4e 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -2,7 +2,7 @@ import 'package:immich_mobile/data/server/api_repository.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/utils/option.dart'; -import 'package:openapi/api.dart'; +import 'package:openapi/api.dart' hide SearchFilter; class SearchApiRepository extends ApiRepository { final SearchApi _api; diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index a196e2ad90..63a1f24604 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -40,6 +40,7 @@ final Map> openApiPatches = { 'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false}, 'SyncAssetV1': {'isEdited': false}, 'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false}, + 'SearchAssetResponseDto': {'nextCursor': null}, 'MemoriesResponse': {'duration': 5, 'sidebarWeb': false}, 'WorkflowResponseDto': {'logging': false}, }; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fea598e604..e8725295df 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11236,6 +11236,7 @@ }, "/search/large-assets": { "post": { + "deprecated": true, "description": "Search for assets that are considered large based on specified criteria.", "operationId": "searchLargeAssets", "parameters": [ @@ -11244,6 +11245,22 @@ "required": false, "in": "query", "description": "Filter by album IDs", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "array", "items": { @@ -11258,6 +11275,22 @@ "required": false, "in": "query", "description": "Filter by city name", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "string", "nullable": true @@ -11268,6 +11301,22 @@ "required": false, "in": "query", "description": "Filter by country name", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "string", "nullable": true @@ -11278,6 +11327,22 @@ "required": false, "in": "query", "description": "Filter by creation date (after)", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", @@ -11290,6 +11355,22 @@ "required": false, "in": "query", "description": "Filter by creation date (before)", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", @@ -11302,6 +11383,22 @@ "required": false, "in": "query", "description": "Filter by encoded status", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "boolean" } @@ -11311,6 +11408,22 @@ "required": false, "in": "query", "description": "Filter by favorite status", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "boolean" } @@ -11320,6 +11433,22 @@ "required": false, "in": "query", "description": "Filter by motion photo status", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "boolean" } @@ -11329,6 +11458,22 @@ "required": false, "in": "query", "description": "Filter assets not in any album", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "boolean" } @@ -11338,6 +11483,22 @@ "required": false, "in": "query", "description": "Filter by offline status", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "boolean" } @@ -11347,6 +11508,22 @@ "required": false, "in": "query", "description": "Filter by lens model", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "string", "nullable": true @@ -11357,6 +11534,22 @@ "required": false, "in": "query", "description": "Library ID to filter by", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "string", "format": "uuid", @@ -11369,6 +11562,22 @@ "required": false, "in": "query", "description": "Filter by camera make", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "string", "nullable": true @@ -11390,6 +11599,22 @@ "required": false, "in": "query", "description": "Filter by camera model", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "string", "nullable": true @@ -11400,6 +11625,22 @@ "required": false, "in": "query", "description": "Filter by OCR text content", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "string" } @@ -11409,6 +11650,22 @@ "required": false, "in": "query", "description": "Filter by person IDs", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "array", "items": { @@ -11441,9 +11698,14 @@ "version": "v3", "state": "Updated", "description": "Using -1 as a rating is no longer valid." + }, + { + "version": "v3.2.0", + "state": "Deprecated" } ], - "x-immich-state": "Stable", + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "integer", "minimum": 1, @@ -11459,6 +11721,7 @@ "schema": { "minimum": 1, "maximum": 1000, + "default": 250, "type": "integer" } }, @@ -11467,6 +11730,22 @@ "required": false, "in": "query", "description": "Filter by state/province name", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "string", "nullable": true @@ -11477,6 +11756,22 @@ "required": false, "in": "query", "description": "Filter by tag IDs", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "array", "items": { @@ -11492,6 +11787,22 @@ "required": false, "in": "query", "description": "Filter by taken date (after)", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", @@ -11504,6 +11815,22 @@ "required": false, "in": "query", "description": "Filter by taken date (before)", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", @@ -11516,6 +11843,22 @@ "required": false, "in": "query", "description": "Filter by trash date (after)", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", @@ -11528,6 +11871,22 @@ "required": false, "in": "query", "description": "Filter by trash date (before)", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", @@ -11539,6 +11898,22 @@ "name": "type", "required": false, "in": "query", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "$ref": "#/components/schemas/AssetTypeEnum" } @@ -11548,6 +11923,22 @@ "required": false, "in": "query", "description": "Filter by update date (after)", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", @@ -11560,6 +11951,22 @@ "required": false, "in": "query", "description": "Filter by update date (before)", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", @@ -11571,6 +11978,22 @@ "name": "visibility", "required": false, "in": "query", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "$ref": "#/components/schemas/AssetVisibility" } @@ -11580,6 +12003,22 @@ "required": false, "in": "query", "description": "Include deleted assets", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated", + "deprecated": true, "schema": { "type": "boolean" } @@ -11622,7 +12061,8 @@ ], "summary": "Search large assets", "tags": [ - "Search" + "Search", + "Deprecated" ], "x-immich-history": [ { @@ -11636,10 +12076,15 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "searchAssets" } ], "x-immich-permission": "asset.read", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/search/metadata": { @@ -20768,6 +21213,17 @@ }, "type": "object" }, + "BoolFilter": { + "properties": { + "eq": { + "type": "boolean" + } + }, + "required": [ + "eq" + ], + "type": "object" + }, "BulkIdErrorReason": { "description": "Error reason", "enum": [ @@ -21231,6 +21687,90 @@ }, "type": "object" }, + "DateFilter": { + "properties": { + "eq": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "gt": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "gte": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "lt": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "lte": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "ne": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + } + }, + "type": "object" + }, + "DateFilterNullable": { + "properties": { + "eq": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "gt": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "gte": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "lt": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "lte": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "ne": { + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + } + }, + "type": "object" + }, "DownloadArchiveDto": { "properties": { "archiveName": { @@ -21488,6 +22028,56 @@ }, "type": "object" }, + "EnumFilterAssetType": { + "properties": { + "eq": { + "$ref": "#/components/schemas/AssetTypeEnum" + }, + "in": { + "items": { + "$ref": "#/components/schemas/AssetTypeEnum" + }, + "minItems": 1, + "type": "array" + }, + "ne": { + "$ref": "#/components/schemas/AssetTypeEnum" + }, + "notIn": { + "items": { + "$ref": "#/components/schemas/AssetTypeEnum" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, + "EnumFilterAssetVisibility": { + "properties": { + "eq": { + "$ref": "#/components/schemas/AssetVisibility" + }, + "in": { + "items": { + "$ref": "#/components/schemas/AssetVisibility" + }, + "minItems": 1, + "type": "array" + }, + "ne": { + "$ref": "#/components/schemas/AssetVisibility" + }, + "notIn": { + "items": { + "$ref": "#/components/schemas/AssetVisibility" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, "ExifResponseDto": { "description": "EXIF response", "properties": { @@ -21693,6 +22283,70 @@ ], "type": "integer" }, + "IdFilter": { + "properties": { + "eq": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "ne": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + "type": "object" + }, + "IdFilterNullable": { + "properties": { + "eq": { + "format": "uuid", + "nullable": true, + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "ne": { + "format": "uuid", + "nullable": true, + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + "type": "object" + }, + "IdsFilter": { + "properties": { + "all": { + "items": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "any": { + "items": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "none": { + "items": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, "ImageFormat": { "description": "Image format", "enum": [ @@ -22563,135 +23217,564 @@ "MetadataSearchDto": { "properties": { "albumIds": { + "deprecated": true, "description": "Filter by album IDs", "items": { "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "type": "string" }, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "checksum": { + "deprecated": true, "description": "Filter by file checksum", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "city": { + "deprecated": true, "description": "Filter by city name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "country": { + "deprecated": true, "description": "Filter by country name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "createdAfter": { + "deprecated": true, "description": "Filter by creation date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "createdBefore": { + "deprecated": true, "description": "Filter by creation date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" + }, + "cursor": { + "description": "Cursor for the next page of results", + "minLength": 1, + "type": "string", + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ] }, "description": { + "deprecated": true, "description": "Filter by description text", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "encodedVideoPath": { + "deprecated": true, "description": "Filter by encoded video file path", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" + }, + "filter": { + "$ref": "#/components/schemas/SearchFilter", + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ] }, "id": { + "deprecated": true, "description": "Filter by asset ID", "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isEncoded": { + "deprecated": true, "description": "Filter by encoded status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isFavorite": { + "deprecated": true, "description": "Filter by favorite status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isMotion": { + "deprecated": true, "description": "Filter by motion photo status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isNotInAlbum": { + "deprecated": true, "description": "Filter assets not in any album", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isOffline": { + "deprecated": true, "description": "Filter by offline status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "lensModel": { + "deprecated": true, "description": "Filter by lens model", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "libraryId": { + "deprecated": true, "description": "Library ID to filter by", "format": "uuid", "nullable": true, "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "make": { + "deprecated": true, "description": "Filter by camera make", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "model": { + "deprecated": true, "description": "Filter by camera model", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "ocr": { + "deprecated": true, "description": "Filter by OCR text content", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "order": { "$ref": "#/components/schemas/AssetOrder", - "default": "desc", - "description": "Sort order" + "deprecated": true, + "description": "Sort order", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" + }, + "orderBy": { + "$ref": "#/components/schemas/SearchOrder", + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ] }, "originalFileName": { + "deprecated": true, "description": "Filter by original file name", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "originalPath": { + "deprecated": true, "description": "Filter by original file path", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "page": { + "deprecated": true, "description": "Page number", "maximum": 9007199254740991, "minimum": 1, - "type": "integer" + "type": "integer", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "personIds": { + "deprecated": true, "description": "Filter by person IDs", "items": { "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "type": "string" }, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "previewPath": { + "deprecated": true, "description": "Filter by preview file path", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "rating": { + "deprecated": true, "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": 1, @@ -22715,22 +23798,44 @@ "version": "v3", "state": "Updated", "description": "Using -1 as a rating is no longer valid." + }, + { + "version": "v3.2.0", + "state": "Deprecated" } ], - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" }, "size": { + "default": 250, "description": "Number of results to return", "maximum": 1000, "minimum": 1, "type": "integer" }, "state": { + "deprecated": true, "description": "Filter by state/province name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "tagIds": { + "deprecated": true, "description": "Filter by tag IDs", "items": { "format": "uuid", @@ -22738,63 +23843,238 @@ "type": "string" }, "nullable": true, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "takenAfter": { + "deprecated": true, "description": "Filter by taken date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "takenBefore": { + "deprecated": true, "description": "Filter by taken date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "thumbnailPath": { + "deprecated": true, "description": "Filter by thumbnail file path", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "trashedAfter": { + "deprecated": true, "description": "Filter by trash date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "trashedBefore": { + "deprecated": true, "description": "Filter by trash date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "type": { - "$ref": "#/components/schemas/AssetTypeEnum" + "$ref": "#/components/schemas/AssetTypeEnum", + "deprecated": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "updatedAfter": { + "deprecated": true, "description": "Filter by update date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "updatedBefore": { + "deprecated": true, "description": "Filter by update date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "visibility": { - "$ref": "#/components/schemas/AssetVisibility" + "$ref": "#/components/schemas/AssetVisibility", + "deprecated": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "withDeleted": { + "deprecated": true, "description": "Include deleted assets", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "withExif": { "description": "Include EXIF data in response", @@ -23004,6 +24284,82 @@ }, "type": "object" }, + "NumberFilter": { + "properties": { + "eq": { + "type": "number" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "in": { + "items": { + "type": "number" + }, + "minItems": 1, + "type": "array" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "ne": { + "type": "number" + }, + "notIn": { + "items": { + "type": "number" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, + "NumberFilterNullable": { + "properties": { + "eq": { + "nullable": true, + "type": "number" + }, + "gt": { + "type": "number" + }, + "gte": { + "type": "number" + }, + "in": { + "items": { + "type": "number" + }, + "minItems": 1, + "type": "array" + }, + "lt": { + "type": "number" + }, + "lte": { + "type": "number" + }, + "ne": { + "nullable": true, + "type": "number" + }, + "notIn": { + "items": { + "type": "number" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, "OAuthAuthorizeResponseDto": { "properties": { "url": { @@ -24351,94 +25707,360 @@ "RandomSearchDto": { "properties": { "albumIds": { + "deprecated": true, "description": "Filter by album IDs", "items": { "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "type": "string" }, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "city": { + "deprecated": true, "description": "Filter by city name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "country": { + "deprecated": true, "description": "Filter by country name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "createdAfter": { + "deprecated": true, "description": "Filter by creation date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "createdBefore": { + "deprecated": true, "description": "Filter by creation date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" + }, + "filter": { + "$ref": "#/components/schemas/SearchFilter", + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ] }, "isEncoded": { + "deprecated": true, "description": "Filter by encoded status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isFavorite": { + "deprecated": true, "description": "Filter by favorite status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isMotion": { + "deprecated": true, "description": "Filter by motion photo status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isNotInAlbum": { + "deprecated": true, "description": "Filter assets not in any album", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isOffline": { + "deprecated": true, "description": "Filter by offline status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "lensModel": { + "deprecated": true, "description": "Filter by lens model", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "libraryId": { + "deprecated": true, "description": "Library ID to filter by", "format": "uuid", "nullable": true, "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "make": { + "deprecated": true, "description": "Filter by camera make", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "model": { + "deprecated": true, "description": "Filter by camera model", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "ocr": { + "deprecated": true, "description": "Filter by OCR text content", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "personIds": { + "deprecated": true, "description": "Filter by person IDs", "items": { "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "type": "string" }, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "rating": { + "deprecated": true, "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": 1, @@ -24462,22 +26084,44 @@ "version": "v3", "state": "Updated", "description": "Using -1 as a rating is no longer valid." + }, + { + "version": "v3.2.0", + "state": "Deprecated" } ], - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" }, "size": { + "default": 250, "description": "Number of results to return", "maximum": 1000, "minimum": 1, "type": "integer" }, "state": { + "deprecated": true, "description": "Filter by state/province name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "tagIds": { + "deprecated": true, "description": "Filter by tag IDs", "items": { "format": "uuid", @@ -24485,59 +26129,218 @@ "type": "string" }, "nullable": true, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "takenAfter": { + "deprecated": true, "description": "Filter by taken date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "takenBefore": { + "deprecated": true, "description": "Filter by taken date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "trashedAfter": { + "deprecated": true, "description": "Filter by trash date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "trashedBefore": { + "deprecated": true, "description": "Filter by trash date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "type": { - "$ref": "#/components/schemas/AssetTypeEnum" + "$ref": "#/components/schemas/AssetTypeEnum", + "deprecated": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "updatedAfter": { + "deprecated": true, "description": "Filter by update date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "updatedBefore": { + "deprecated": true, "description": "Filter by update date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "visibility": { - "$ref": "#/components/schemas/AssetVisibility" + "$ref": "#/components/schemas/AssetVisibility", + "deprecated": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "withDeleted": { + "deprecated": true, "description": "Include deleted assets", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "withExif": { "description": "Include EXIF data in response", @@ -24749,10 +26552,37 @@ }, "type": "array" }, + "nextCursor": { + "description": "Cursor for the next page of results", + "nullable": true, + "type": "string", + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ] + }, "nextPage": { + "deprecated": true, "description": "Next page token", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "total": { "description": "Total number of matching assets", @@ -24772,6 +26602,7 @@ "count", "facets", "items", + "nextCursor", "nextPage", "total" ], @@ -24850,6 +26681,239 @@ ], "type": "object" }, + "SearchFilter": { + "additionalProperties": false, + "properties": { + "albumIds": { + "$ref": "#/components/schemas/IdsFilter" + }, + "checksum": { + "$ref": "#/components/schemas/StringFilter" + }, + "city": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "country": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "createdAt": { + "$ref": "#/components/schemas/DateFilter" + }, + "description": { + "$ref": "#/components/schemas/StringPatternFilter" + }, + "encodedVideoPath": { + "$ref": "#/components/schemas/StringFilter" + }, + "fileSizeInBytes": { + "$ref": "#/components/schemas/NumberFilter" + }, + "hasAlbums": { + "$ref": "#/components/schemas/BoolFilter" + }, + "hasPeople": { + "$ref": "#/components/schemas/BoolFilter" + }, + "hasTags": { + "$ref": "#/components/schemas/BoolFilter" + }, + "id": { + "$ref": "#/components/schemas/IdFilter" + }, + "isEncoded": { + "$ref": "#/components/schemas/BoolFilter" + }, + "isFavorite": { + "$ref": "#/components/schemas/BoolFilter" + }, + "isMotion": { + "$ref": "#/components/schemas/BoolFilter" + }, + "isOffline": { + "$ref": "#/components/schemas/BoolFilter" + }, + "lensModel": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "libraryId": { + "$ref": "#/components/schemas/IdFilterNullable" + }, + "make": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "model": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "ocr": { + "$ref": "#/components/schemas/StringSimilarityFilter" + }, + "or": { + "items": { + "$ref": "#/components/schemas/SearchFilterBranch" + }, + "minItems": 1, + "type": "array" + }, + "originalFileName": { + "$ref": "#/components/schemas/StringPatternFilter" + }, + "originalPath": { + "$ref": "#/components/schemas/StringPatternFilter" + }, + "personIds": { + "$ref": "#/components/schemas/IdsFilter" + }, + "rating": { + "$ref": "#/components/schemas/NumberFilterNullable" + }, + "state": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "tagIds": { + "$ref": "#/components/schemas/IdsFilter" + }, + "takenAt": { + "$ref": "#/components/schemas/DateFilter" + }, + "trashedAt": { + "$ref": "#/components/schemas/DateFilterNullable" + }, + "type": { + "$ref": "#/components/schemas/EnumFilterAssetType" + }, + "updatedAt": { + "$ref": "#/components/schemas/DateFilter" + }, + "visibility": { + "$ref": "#/components/schemas/EnumFilterAssetVisibility" + } + }, + "type": "object" + }, + "SearchFilterBranch": { + "additionalProperties": false, + "properties": { + "albumIds": { + "$ref": "#/components/schemas/IdsFilter" + }, + "checksum": { + "$ref": "#/components/schemas/StringFilter" + }, + "city": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "country": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "createdAt": { + "$ref": "#/components/schemas/DateFilter" + }, + "description": { + "$ref": "#/components/schemas/StringPatternFilter" + }, + "encodedVideoPath": { + "$ref": "#/components/schemas/StringFilter" + }, + "fileSizeInBytes": { + "$ref": "#/components/schemas/NumberFilter" + }, + "hasAlbums": { + "$ref": "#/components/schemas/BoolFilter" + }, + "hasPeople": { + "$ref": "#/components/schemas/BoolFilter" + }, + "hasTags": { + "$ref": "#/components/schemas/BoolFilter" + }, + "id": { + "$ref": "#/components/schemas/IdFilter" + }, + "isEncoded": { + "$ref": "#/components/schemas/BoolFilter" + }, + "isFavorite": { + "$ref": "#/components/schemas/BoolFilter" + }, + "isMotion": { + "$ref": "#/components/schemas/BoolFilter" + }, + "isOffline": { + "$ref": "#/components/schemas/BoolFilter" + }, + "lensModel": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "libraryId": { + "$ref": "#/components/schemas/IdFilterNullable" + }, + "make": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "model": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "ocr": { + "$ref": "#/components/schemas/StringSimilarityFilter" + }, + "originalFileName": { + "$ref": "#/components/schemas/StringPatternFilter" + }, + "originalPath": { + "$ref": "#/components/schemas/StringPatternFilter" + }, + "personIds": { + "$ref": "#/components/schemas/IdsFilter" + }, + "rating": { + "$ref": "#/components/schemas/NumberFilterNullable" + }, + "state": { + "$ref": "#/components/schemas/StringFilterNullable" + }, + "tagIds": { + "$ref": "#/components/schemas/IdsFilter" + }, + "takenAt": { + "$ref": "#/components/schemas/DateFilter" + }, + "trashedAt": { + "$ref": "#/components/schemas/DateFilterNullable" + }, + "type": { + "$ref": "#/components/schemas/EnumFilterAssetType" + }, + "updatedAt": { + "$ref": "#/components/schemas/DateFilter" + }, + "visibility": { + "$ref": "#/components/schemas/EnumFilterAssetVisibility" + } + }, + "type": "object" + }, + "SearchOrder": { + "properties": { + "direction": { + "$ref": "#/components/schemas/AssetOrder", + "default": "desc" + }, + "field": { + "$ref": "#/components/schemas/SearchOrderField", + "default": "fileCreatedAt" + } + }, + "type": "object" + }, + "SearchOrderField": { + "enum": [ + "fileCreatedAt", + "localDateTime", + "fileSizeInBytes", + "rating" + ], + "type": "string" + }, "SearchResponseDto": { "properties": { "albums": { @@ -25835,102 +27899,383 @@ "SmartSearchDto": { "properties": { "albumIds": { + "deprecated": true, "description": "Filter by album IDs", "items": { "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "type": "string" }, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "city": { + "deprecated": true, "description": "Filter by city name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "country": { + "deprecated": true, "description": "Filter by country name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "createdAfter": { + "deprecated": true, "description": "Filter by creation date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "createdBefore": { + "deprecated": true, "description": "Filter by creation date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" + }, + "filter": { + "$ref": "#/components/schemas/SearchFilter", + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ] }, "isEncoded": { + "deprecated": true, "description": "Filter by encoded status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isFavorite": { + "deprecated": true, "description": "Filter by favorite status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isMotion": { + "deprecated": true, "description": "Filter by motion photo status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isNotInAlbum": { + "deprecated": true, "description": "Filter assets not in any album", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isOffline": { + "deprecated": true, "description": "Filter by offline status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "language": { "description": "Search language code", "type": "string" }, "lensModel": { + "deprecated": true, "description": "Filter by lens model", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "libraryId": { + "deprecated": true, "description": "Library ID to filter by", "format": "uuid", "nullable": true, "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "make": { + "deprecated": true, "description": "Filter by camera make", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "model": { + "deprecated": true, "description": "Filter by camera model", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "ocr": { + "deprecated": true, "description": "Filter by OCR text content", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "page": { + "deprecated": true, "description": "Page number", "maximum": 9007199254740991, "minimum": 1, - "type": "integer" + "type": "integer", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "personIds": { + "deprecated": true, "description": "Filter by person IDs", "items": { "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "type": "string" }, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "query": { "description": "Natural language search query", @@ -25943,6 +28288,7 @@ "type": "string" }, "rating": { + "deprecated": true, "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": 1, @@ -25966,22 +28312,44 @@ "version": "v3", "state": "Updated", "description": "Using -1 as a rating is no longer valid." + }, + { + "version": "v3.2.0", + "state": "Deprecated" } ], - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" }, "size": { + "default": 100, "description": "Number of results to return", "maximum": 1000, "minimum": 1, "type": "integer" }, "state": { + "deprecated": true, "description": "Filter by state/province name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "tagIds": { + "deprecated": true, "description": "Filter by tag IDs", "items": { "format": "uuid", @@ -25989,59 +28357,218 @@ "type": "string" }, "nullable": true, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "takenAfter": { + "deprecated": true, "description": "Filter by taken date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "takenBefore": { + "deprecated": true, "description": "Filter by taken date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "trashedAfter": { + "deprecated": true, "description": "Filter by trash date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "trashedBefore": { + "deprecated": true, "description": "Filter by trash date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "type": { - "$ref": "#/components/schemas/AssetTypeEnum" + "$ref": "#/components/schemas/AssetTypeEnum", + "deprecated": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "updatedAfter": { + "deprecated": true, "description": "Filter by update date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "updatedBefore": { + "deprecated": true, "description": "Filter by update date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "visibility": { - "$ref": "#/components/schemas/AssetVisibility" + "$ref": "#/components/schemas/AssetVisibility", + "deprecated": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "withDeleted": { + "deprecated": true, "description": "Include deleted assets", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "withExif": { "description": "Include EXIF data in response", @@ -26120,98 +28647,380 @@ "StatisticsSearchDto": { "properties": { "albumIds": { + "deprecated": true, "description": "Filter by album IDs", "items": { "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "type": "string" }, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "city": { + "deprecated": true, "description": "Filter by city name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "country": { + "deprecated": true, "description": "Filter by country name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "createdAfter": { + "deprecated": true, "description": "Filter by creation date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "createdBefore": { + "deprecated": true, "description": "Filter by creation date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "description": { + "deprecated": true, "description": "Filter by description text", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" + }, + "filter": { + "$ref": "#/components/schemas/SearchFilter", + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ] }, "isEncoded": { + "deprecated": true, "description": "Filter by encoded status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isFavorite": { + "deprecated": true, "description": "Filter by favorite status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isMotion": { + "deprecated": true, "description": "Filter by motion photo status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isNotInAlbum": { + "deprecated": true, "description": "Filter assets not in any album", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isOffline": { + "deprecated": true, "description": "Filter by offline status", - "type": "boolean" + "type": "boolean", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "lensModel": { + "deprecated": true, "description": "Filter by lens model", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "libraryId": { + "deprecated": true, "description": "Library ID to filter by", "format": "uuid", "nullable": true, "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "make": { + "deprecated": true, "description": "Filter by camera make", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "model": { + "deprecated": true, "description": "Filter by camera model", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "ocr": { + "deprecated": true, "description": "Filter by OCR text content", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "personIds": { + "deprecated": true, "description": "Filter by person IDs", "items": { "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "type": "string" }, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "rating": { + "deprecated": true, "description": "Filter by rating [1-5], or null for unrated", "maximum": 5, "minimum": 1, @@ -26235,16 +29044,37 @@ "version": "v3", "state": "Updated", "description": "Using -1 as a rating is no longer valid." + }, + { + "version": "v3.2.0", + "state": "Deprecated" } ], - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" }, "state": { + "deprecated": true, "description": "Filter by state/province name", "nullable": true, - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "tagIds": { + "deprecated": true, "description": "Filter by tag IDs", "items": { "format": "uuid", @@ -26252,55 +29082,198 @@ "type": "string" }, "nullable": true, - "type": "array" + "type": "array", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "takenAfter": { + "deprecated": true, "description": "Filter by taken date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "takenBefore": { + "deprecated": true, "description": "Filter by taken date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "trashedAfter": { + "deprecated": true, "description": "Filter by trash date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "trashedBefore": { + "deprecated": true, "description": "Filter by trash date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "type": { - "$ref": "#/components/schemas/AssetTypeEnum" + "$ref": "#/components/schemas/AssetTypeEnum", + "deprecated": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "updatedAfter": { + "deprecated": true, "description": "Filter by update date (after)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "updatedBefore": { + "deprecated": true, "description": "Filter by update date (before)", "example": "2024-01-01T00:00:00.000Z", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "visibility": { - "$ref": "#/components/schemas/AssetVisibility" + "$ref": "#/components/schemas/AssetVisibility", + "deprecated": true, + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2", + "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" } }, "type": "object" @@ -26317,6 +29290,113 @@ ], "type": "string" }, + "StringFilter": { + "properties": { + "eq": { + "type": "string" + }, + "in": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "ne": { + "type": "string" + }, + "notIn": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, + "StringFilterNullable": { + "properties": { + "eq": { + "nullable": true, + "type": "string" + }, + "in": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "ne": { + "nullable": true, + "type": "string" + }, + "notIn": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, + "StringPatternFilter": { + "properties": { + "endsWith": { + "minLength": 1, + "type": "string" + }, + "eq": { + "nullable": true, + "type": "string" + }, + "in": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "like": { + "minLength": 1, + "type": "string" + }, + "ne": { + "nullable": true, + "type": "string" + }, + "notIn": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "notLike": { + "minLength": 1, + "type": "string" + }, + "startsWith": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "StringSimilarityFilter": { + "properties": { + "matches": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "matches" + ], + "type": "object" + }, "SyncAckDeleteDto": { "properties": { "types": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 99166a7b66..6177811aa4 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2154,6 +2154,168 @@ export type SearchExploreResponseDto = { fieldName: string; items: SearchExploreItem[]; }; +export type IdsFilter = { + all?: string[]; + "any"?: string[]; + none?: string[]; +}; +export type StringFilter = { + eq?: string; + "in"?: string[]; + ne?: string; + notIn?: string[]; +}; +export type StringFilterNullable = { + eq?: string | null; + "in"?: string[]; + ne?: string | null; + notIn?: string[]; +}; +export type DateFilter = { + eq?: string; + gt?: string; + gte?: string; + lt?: string; + lte?: string; + ne?: string; +}; +export type StringPatternFilter = { + endsWith?: string; + eq?: string | null; + "in"?: string[]; + like?: string; + ne?: string | null; + notIn?: string[]; + notLike?: string; + startsWith?: string; +}; +export type NumberFilter = { + eq?: number; + gt?: number; + gte?: number; + "in"?: number[]; + lt?: number; + lte?: number; + ne?: number; + notIn?: number[]; +}; +export type BoolFilter = { + eq: boolean; +}; +export type IdFilter = { + eq?: string; + ne?: string; +}; +export type IdFilterNullable = { + eq?: string | null; + ne?: string | null; +}; +export type StringSimilarityFilter = { + matches: string; +}; +export type NumberFilterNullable = { + eq?: number | null; + gt?: number; + gte?: number; + "in"?: number[]; + lt?: number; + lte?: number; + ne?: number | null; + notIn?: number[]; +}; +export type DateFilterNullable = { + eq?: string | null; + gt?: string; + gte?: string; + lt?: string; + lte?: string; + ne?: string | null; +}; +export type EnumFilterAssetType = { + eq?: AssetTypeEnum; + "in"?: AssetTypeEnum[]; + ne?: AssetTypeEnum; + notIn?: AssetTypeEnum[]; +}; +export type EnumFilterAssetVisibility = { + eq?: AssetVisibility; + "in"?: AssetVisibility[]; + ne?: AssetVisibility; + notIn?: AssetVisibility[]; +}; +export type SearchFilterBranch = { + albumIds?: IdsFilter; + checksum?: StringFilter; + city?: StringFilterNullable; + country?: StringFilterNullable; + createdAt?: DateFilter; + description?: StringPatternFilter; + encodedVideoPath?: StringFilter; + fileSizeInBytes?: NumberFilter; + hasAlbums?: BoolFilter; + hasPeople?: BoolFilter; + hasTags?: BoolFilter; + id?: IdFilter; + isEncoded?: BoolFilter; + isFavorite?: BoolFilter; + isMotion?: BoolFilter; + isOffline?: BoolFilter; + lensModel?: StringFilterNullable; + libraryId?: IdFilterNullable; + make?: StringFilterNullable; + model?: StringFilterNullable; + ocr?: StringSimilarityFilter; + originalFileName?: StringPatternFilter; + originalPath?: StringPatternFilter; + personIds?: IdsFilter; + rating?: NumberFilterNullable; + state?: StringFilterNullable; + tagIds?: IdsFilter; + takenAt?: DateFilter; + trashedAt?: DateFilterNullable; + "type"?: EnumFilterAssetType; + updatedAt?: DateFilter; + visibility?: EnumFilterAssetVisibility; +}; +export type SearchFilter = { + albumIds?: IdsFilter; + checksum?: StringFilter; + city?: StringFilterNullable; + country?: StringFilterNullable; + createdAt?: DateFilter; + description?: StringPatternFilter; + encodedVideoPath?: StringFilter; + fileSizeInBytes?: NumberFilter; + hasAlbums?: BoolFilter; + hasPeople?: BoolFilter; + hasTags?: BoolFilter; + id?: IdFilter; + isEncoded?: BoolFilter; + isFavorite?: BoolFilter; + isMotion?: BoolFilter; + isOffline?: BoolFilter; + lensModel?: StringFilterNullable; + libraryId?: IdFilterNullable; + make?: StringFilterNullable; + model?: StringFilterNullable; + ocr?: StringSimilarityFilter; + or?: SearchFilterBranch[]; + originalFileName?: StringPatternFilter; + originalPath?: StringPatternFilter; + personIds?: IdsFilter; + rating?: NumberFilterNullable; + state?: StringFilterNullable; + tagIds?: IdsFilter; + takenAt?: DateFilter; + trashedAt?: DateFilterNullable; + "type"?: EnumFilterAssetType; + updatedAt?: DateFilter; + visibility?: EnumFilterAssetVisibility; +}; +export type SearchOrder = { + direction?: AssetOrder; + field?: SearchOrderField; +}; export type MetadataSearchDto = { /** Filter by album IDs */ albumIds?: string[]; @@ -2167,10 +2329,13 @@ export type MetadataSearchDto = { createdAfter?: string; /** Filter by creation date (before) */ createdBefore?: string; + /** Cursor for the next page of results */ + cursor?: string; /** Filter by description text */ description?: string; /** Filter by encoded video file path */ encodedVideoPath?: string; + filter?: SearchFilter; /** Filter by asset ID */ id?: string; /** Filter by encoded status */ @@ -2195,6 +2360,7 @@ export type MetadataSearchDto = { ocr?: string; /** Sort order */ order?: AssetOrder; + orderBy?: SearchOrder; /** Filter by original file name */ originalFileName?: string; /** Filter by original file path */ @@ -2262,6 +2428,8 @@ export type SearchAssetResponseDto = { count: number; facets: SearchFacetResponseDto[]; items: AssetResponseDto[]; + /** Cursor for the next page of results */ + nextCursor: string | null; /** Next page token */ nextPage: string | null; /** Total number of matching assets */ @@ -2294,6 +2462,7 @@ export type RandomSearchDto = { createdAfter?: string; /** Filter by creation date (before) */ createdBefore?: string; + filter?: SearchFilter; /** Filter by encoded status */ isEncoded?: boolean; /** Filter by favorite status */ @@ -2358,6 +2527,7 @@ export type SmartSearchDto = { createdAfter?: string; /** Filter by creation date (before) */ createdBefore?: string; + filter?: SearchFilter; /** Filter by encoded status */ isEncoded?: boolean; /** Filter by favorite status */ @@ -2428,6 +2598,7 @@ export type StatisticsSearchDto = { createdBefore?: string; /** Filter by description text */ description?: string; + filter?: SearchFilter; /** Filter by encoded status */ isEncoded?: boolean; /** Filter by favorite status */ @@ -8095,6 +8266,12 @@ export enum JobName { IntegrityDeleteReportType = "IntegrityDeleteReportType", IntegrityDeleteReports = "IntegrityDeleteReports" } +export enum SearchOrderField { + FileCreatedAt = "fileCreatedAt", + LocalDateTime = "localDateTime", + FileSizeInBytes = "fileSizeInBytes", + Rating = "rating" +} export enum SearchSuggestionType { Country = "country", State = "state", diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index f1ac01ed41..6754be8af4 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -115,6 +115,46 @@ describe(SearchController.name, () => { ); }); + it('should reject a deprecated field combined with a new structure field', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/metadata') + .send({ filter: {}, city: 'Oslo' }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: ['city'], message: 'Deprecated field city cannot be combined with filter' }]), + ); + }); + + it('should reject an unknown key in the filter', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/metadata') + .send({ filter: { previewPath: { eq: 'preview.webp' } } }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: ['filter'], message: 'Unrecognized key: "previewPath"' }]), + ); + }); + + it('should reject a nested or', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/metadata') + .send({ filter: { or: [{ or: [{ city: { eq: 'Oslo' } }] }] } }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: ['filter', 'or', 0], message: 'Unrecognized key: "or"' }]), + ); + }); + + it('should reject an empty or branch', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/search/metadata') + .send({ filter: { or: [{}] } }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: ['filter', 'or', 0], message: 'At least one filter condition is required' }]), + ); + }); + describe('POST /search/random', () => { it('should reject if withStacked is not a boolean', async () => { const { status, body } = await request(ctx.getHttpServer()) diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 30feecbe10..2dea0cf7a1 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -69,7 +69,11 @@ export class SearchController { @Endpoint({ summary: 'Search large assets', description: 'Search for assets that are considered large based on specified criteria.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'searchAssets' }), }) searchLargeAssets(@Auth() auth: AuthDto, @Query() dto: LargeAssetSearchDto): Promise { return this.service.searchLargeAssets(auth, dto); diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 7911f92f44..1f65c3e434 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -14,32 +14,40 @@ import { import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation'; import z from 'zod'; +const ADDED_V3_2 = new HistoryBuilder().added('v3.2.0').getExtensions(); + +// fields deprecated in favor of the structured filter tree +const DEPRECATED_FLAT_FIELD = { + ...new HistoryBuilder().added('v1').stable('v2').deprecated('v3.2.0').getExtensions(), + deprecated: true, +}; + const BaseSearchSchema = z.object({ - libraryId: z.uuidv4().nullish().describe('Library ID to filter by'), - type: AssetTypeSchema.optional(), - isEncoded: z.boolean().optional().describe('Filter by encoded status'), - isFavorite: z.boolean().optional().describe('Filter by favorite status'), - isMotion: z.boolean().optional().describe('Filter by motion photo status'), - isOffline: z.boolean().optional().describe('Filter by offline status'), - visibility: AssetVisibilitySchema.optional(), - createdBefore: isoDatetimeToDate.optional().describe('Filter by creation date (before)'), - createdAfter: isoDatetimeToDate.optional().describe('Filter by creation date (after)'), - updatedBefore: isoDatetimeToDate.optional().describe('Filter by update date (before)'), - updatedAfter: isoDatetimeToDate.optional().describe('Filter by update date (after)'), - trashedBefore: isoDatetimeToDate.optional().describe('Filter by trash date (before)'), - trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)'), - takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)'), - takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)'), - city: z.string().nullable().optional().describe('Filter by city name'), - state: z.string().nullable().optional().describe('Filter by state/province name'), - country: z.string().nullable().optional().describe('Filter by country name'), - make: z.string().nullable().optional().describe('Filter by camera make'), - model: z.string().nullable().optional().describe('Filter by camera model'), - lensModel: z.string().nullable().optional().describe('Filter by lens model'), - isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album'), - personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs'), - tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'), - albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'), + libraryId: z.uuidv4().nullish().describe('Library ID to filter by').meta(DEPRECATED_FLAT_FIELD), + type: AssetTypeSchema.optional().meta(DEPRECATED_FLAT_FIELD), + isEncoded: z.boolean().optional().describe('Filter by encoded status').meta(DEPRECATED_FLAT_FIELD), + isFavorite: z.boolean().optional().describe('Filter by favorite status').meta(DEPRECATED_FLAT_FIELD), + isMotion: z.boolean().optional().describe('Filter by motion photo status').meta(DEPRECATED_FLAT_FIELD), + isOffline: z.boolean().optional().describe('Filter by offline status').meta(DEPRECATED_FLAT_FIELD), + visibility: AssetVisibilitySchema.optional().meta(DEPRECATED_FLAT_FIELD), + createdBefore: isoDatetimeToDate.optional().describe('Filter by creation date (before)').meta(DEPRECATED_FLAT_FIELD), + createdAfter: isoDatetimeToDate.optional().describe('Filter by creation date (after)').meta(DEPRECATED_FLAT_FIELD), + updatedBefore: isoDatetimeToDate.optional().describe('Filter by update date (before)').meta(DEPRECATED_FLAT_FIELD), + updatedAfter: isoDatetimeToDate.optional().describe('Filter by update date (after)').meta(DEPRECATED_FLAT_FIELD), + trashedBefore: isoDatetimeToDate.optional().describe('Filter by trash date (before)').meta(DEPRECATED_FLAT_FIELD), + trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)').meta(DEPRECATED_FLAT_FIELD), + takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)').meta(DEPRECATED_FLAT_FIELD), + takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)').meta(DEPRECATED_FLAT_FIELD), + city: z.string().nullable().optional().describe('Filter by city name').meta(DEPRECATED_FLAT_FIELD), + state: z.string().nullable().optional().describe('Filter by state/province name').meta(DEPRECATED_FLAT_FIELD), + country: z.string().nullable().optional().describe('Filter by country name').meta(DEPRECATED_FLAT_FIELD), + make: z.string().nullable().optional().describe('Filter by camera make').meta(DEPRECATED_FLAT_FIELD), + model: z.string().nullable().optional().describe('Filter by camera model').meta(DEPRECATED_FLAT_FIELD), + lensModel: z.string().nullable().optional().describe('Filter by lens model').meta(DEPRECATED_FLAT_FIELD), + isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album').meta(DEPRECATED_FLAT_FIELD), + personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs').meta(DEPRECATED_FLAT_FIELD), + tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs').meta(DEPRECATED_FLAT_FIELD), + albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs').meta(DEPRECATED_FLAT_FIELD), rating: z .int() .min(1) @@ -52,51 +60,24 @@ const BaseSearchSchema = z.object({ .stable('v2') .updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.') .updated('v3', 'Using -1 as a rating is no longer valid.') + .deprecated('v3.2.0') .getExtensions(), + deprecated: true, }), - ocr: z.string().optional().describe('Filter by OCR text content'), + ocr: z.string().optional().describe('Filter by OCR text content').meta(DEPRECATED_FLAT_FIELD), }); const BaseSearchWithResultsSchema = BaseSearchSchema.extend({ - withDeleted: z.boolean().optional().describe('Include deleted assets'), + withDeleted: z.boolean().optional().describe('Include deleted assets').meta(DEPRECATED_FLAT_FIELD), withExif: z.boolean().optional().describe('Include EXIF data in response'), - size: z.int().min(1).max(1000).optional().describe('Number of results to return'), + size: z.int().min(1).max(1000).default(250).describe('Number of results to return'), }); -const RandomSearchSchema = BaseSearchWithResultsSchema.extend({ - withStacked: z.boolean().optional().describe('Include stacked assets'), - withPeople: z.boolean().optional().describe('Include people data in response'), -}).meta({ id: 'RandomSearchDto' }); - const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({ minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'), - size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'), + size: z.coerce.number().int().min(1).max(1000).default(250).describe('Number of results to return'), }).meta({ id: 'LargeAssetSearchDto' }); -const MetadataSearchSchema = RandomSearchSchema.extend({ - id: z.uuidv4().optional().describe('Filter by asset ID'), - description: z.string().trim().optional().describe('Filter by description text'), - checksum: z.string().optional().describe('Filter by file checksum'), - originalFileName: z.string().trim().optional().describe('Filter by original file name'), - originalPath: z.string().optional().describe('Filter by original file path'), - previewPath: z.string().optional().describe('Filter by preview file path'), - thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'), - encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'), - order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'), - page: z.int().min(1).optional().describe('Page number'), -}).meta({ id: 'MetadataSearchDto' }); - -const StatisticsSearchSchema = BaseSearchSchema.extend({ - description: z.string().trim().optional().describe('Filter by description text'), -}).meta({ id: 'StatisticsSearchDto' }); - -const SmartSearchSchema = BaseSearchWithResultsSchema.extend({ - query: z.string().trim().optional().describe('Natural language search query'), - queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'), - language: z.string().optional().describe('Search language code'), - page: z.int().min(1).optional().describe('Page number'), -}).meta({ id: 'SmartSearchDto' }); - const SearchPlacesSchema = z .object({ name: z.string().describe('Place name to search for'), @@ -263,47 +244,56 @@ export const SearchOrderSchema = z }) .meta({ id: 'SearchOrder' }); +const searchFilterBranchShape = { + id: IdFilterSchema, + libraryId: IdFilterNullableSchema, + type: EnumFilterAssetTypeSchema, + visibility: EnumFilterAssetVisibilitySchema, + isFavorite: BoolFilterSchema, + isMotion: BoolFilterSchema, + isOffline: BoolFilterSchema, + isEncoded: BoolFilterSchema, + hasAlbums: BoolFilterSchema, + hasPeople: BoolFilterSchema, + hasTags: BoolFilterSchema, + city: StringFilterNullableSchema, + state: StringFilterNullableSchema, + country: StringFilterNullableSchema, + make: StringFilterNullableSchema, + model: StringFilterNullableSchema, + lensModel: StringFilterNullableSchema, + description: StringPatternFilterSchema, + originalFileName: StringPatternFilterSchema, + originalPath: StringPatternFilterSchema, + ocr: StringSimilarityFilterSchema, + rating: NumberFilterNullableSchema, + fileSizeInBytes: NumberFilterSchema, + takenAt: DateFilterSchema, + createdAt: DateFilterSchema, + updatedAt: DateFilterSchema, + trashedAt: DateFilterNullableSchema, + personIds: IdsFilterSchema, + tagIds: IdsFilterSchema, + albumIds: IdsFilterSchema, + checksum: StringFilterSchema, + encodedVideoPath: StringFilterSchema, +}; + const SearchFilterBranchSchema = z - .object({ - id: IdFilterSchema, - libraryId: IdFilterNullableSchema, - type: EnumFilterAssetTypeSchema, - visibility: EnumFilterAssetVisibilitySchema, - isFavorite: BoolFilterSchema, - isMotion: BoolFilterSchema, - isOffline: BoolFilterSchema, - isEncoded: BoolFilterSchema, - hasAlbums: BoolFilterSchema, - hasPeople: BoolFilterSchema, - hasTags: BoolFilterSchema, - city: StringFilterNullableSchema, - state: StringFilterNullableSchema, - country: StringFilterNullableSchema, - make: StringFilterNullableSchema, - model: StringFilterNullableSchema, - lensModel: StringFilterNullableSchema, - description: StringPatternFilterSchema, - originalFileName: StringPatternFilterSchema, - originalPath: StringPatternFilterSchema, - ocr: StringSimilarityFilterSchema, - rating: NumberFilterNullableSchema, - fileSizeInBytes: NumberFilterSchema, - takenAt: DateFilterSchema, - createdAt: DateFilterSchema, - updatedAt: DateFilterSchema, - trashedAt: DateFilterNullableSchema, - personIds: IdsFilterSchema, - tagIds: IdsFilterSchema, - albumIds: IdsFilterSchema, - checksum: StringFilterSchema, - encodedVideoPath: StringFilterSchema, - }) + .strictObject(searchFilterBranchShape) .partial() + .refine((branch) => Object.values(branch).some((value) => value !== undefined), { + message: 'At least one filter condition is required', + }) .meta({ id: 'SearchFilterBranch' }); -export const SearchFilterSchema = SearchFilterBranchSchema.extend({ - or: z.array(SearchFilterBranchSchema).min(1).optional(), -}).meta({ id: 'SearchFilter' }); +export const SearchFilterSchema = z + .strictObject(searchFilterBranchShape) + .partial() + .extend({ + or: z.array(SearchFilterBranchSchema).min(1).optional(), + }) + .meta({ id: 'SearchFilter' }); export type IdFilter = z.infer; export type IdFilterNullable = z.infer; @@ -319,6 +309,95 @@ export type SearchOrder = z.infer; export type SearchFilter = z.infer; export type SearchFilterBranch = z.infer; +const NEW_SHAPE_FIELDS = ['filter', 'orderBy', 'cursor'] as const; + +export const isNewShapeRequest = (dto: Partial>): boolean => + NEW_SHAPE_FIELDS.some((field) => dto[field] !== undefined); + +/** Whether every asset the branch can match is provably inside an (access-checked) album */ +export const isAlbumConfined = (branch: SearchFilterBranch): boolean => + branch.albumIds?.any !== undefined || branch.albumIds?.all !== undefined; + +/** Whether every result of the whole filter is album-confined */ +export const isFullyAlbumConfined = (filter: SearchFilter): boolean => + isAlbumConfined(filter) || (!!filter.or?.length && filter.or.every((branch) => isAlbumConfined(branch))); + +/** + * The structured shape and the deprecated flat search fields are mutually exclusive + * TODO(v4): remove together with the deprecated flat fields. + */ +const withShapeExclusivity = >(schema: T) => { + const deprecatedFields = Object.keys(schema.shape).filter( + (field) => (schema.shape[field] as z.ZodType).meta()?.deprecated, + ); + + return schema.superRefine((dto, ctx) => { + const values = dto as Record; + const newShapeFields = NEW_SHAPE_FIELDS.filter((field) => values[field] !== undefined); + if (newShapeFields.length === 0) { + return; + } + + for (const field of deprecatedFields) { + if (values[field] === undefined) { + continue; + } + + ctx.addIssue({ + code: 'custom', + path: [field], + message: `Deprecated field ${field} cannot be combined with ${newShapeFields.join('/')}`, + }); + } + }); +}; + +const filterField = SearchFilterSchema.optional().meta(ADDED_V3_2); +const cursorField = z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_2); + +const RandomSearchBaseSchema = BaseSearchWithResultsSchema.extend({ + withStacked: z.boolean().optional().describe('Include stacked assets'), + withPeople: z.boolean().optional().describe('Include people data in response'), + filter: filterField, +}); + +const RandomSearchSchema = withShapeExclusivity(RandomSearchBaseSchema).meta({ id: 'RandomSearchDto' }); + +const MetadataSearchSchema = withShapeExclusivity( + RandomSearchBaseSchema.extend({ + id: z.uuidv4().optional().describe('Filter by asset ID').meta(DEPRECATED_FLAT_FIELD), + description: z.string().trim().optional().describe('Filter by description text').meta(DEPRECATED_FLAT_FIELD), + checksum: z.string().optional().describe('Filter by file checksum').meta(DEPRECATED_FLAT_FIELD), + originalFileName: z.string().trim().optional().describe('Filter by original file name').meta(DEPRECATED_FLAT_FIELD), + originalPath: z.string().optional().describe('Filter by original file path').meta(DEPRECATED_FLAT_FIELD), + previewPath: z.string().optional().describe('Filter by preview file path').meta(DEPRECATED_FLAT_FIELD), + thumbnailPath: z.string().optional().describe('Filter by thumbnail file path').meta(DEPRECATED_FLAT_FIELD), + encodedVideoPath: z.string().optional().describe('Filter by encoded video file path').meta(DEPRECATED_FLAT_FIELD), + order: AssetOrderSchema.optional().describe('Sort order').meta(DEPRECATED_FLAT_FIELD), + page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD), + orderBy: SearchOrderSchema.optional().meta(ADDED_V3_2), + cursor: cursorField, + }), +).meta({ id: 'MetadataSearchDto' }); + +const StatisticsSearchSchema = withShapeExclusivity( + BaseSearchSchema.extend({ + description: z.string().trim().optional().describe('Filter by description text').meta(DEPRECATED_FLAT_FIELD), + filter: filterField, + }), +).meta({ id: 'StatisticsSearchDto' }); + +const SmartSearchSchema = withShapeExclusivity( + BaseSearchWithResultsSchema.extend({ + size: z.int().min(1).max(1000).default(100).describe('Number of results to return'), + query: z.string().trim().optional().describe('Natural language search query'), + queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'), + language: z.string().optional().describe('Search language code'), + page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD), + filter: filterField, + }), +).meta({ id: 'SmartSearchDto' }); + export class RandomSearchDto extends createZodDto(RandomSearchSchema) {} export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {} export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {} @@ -372,7 +451,8 @@ const SearchAssetResponseSchema = z count: z.int().min(0).describe('Number of assets in this page'), items: z.array(AssetResponseSchema), facets: z.array(SearchFacetResponseSchema), - nextPage: z.string().nullable().describe('Next page token'), + nextPage: z.string().nullable().describe('Next page token').meta(DEPRECATED_FLAT_FIELD), + nextCursor: z.string().nullable().describe('Cursor for the next page of results').meta(ADDED_V3_2), }) .meta({ id: 'SearchAssetResponseDto' }); diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index abd10e6221..819d7cf8ef 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -455,14 +455,20 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $2 + $4 +offset + $5 --- SearchRepository.searchMetadataV3 (empty) +-- SearchRepository.searchMetadataV3 (or-mixed-scope) select "asset"."id", "asset"."updateId", @@ -496,12 +502,31 @@ from "asset" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - true + ( + "asset"."visibility" != $1 + or "asset"."ownerId" = $2 + ) + and ( + exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + and "album_asset"."albumId" = any ($3::uuid[]) + ) + or ( + "asset_exif"."city" = $4 + and "asset"."ownerId" = any ($5::uuid[]) + ) + ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $1 + $6 +offset + $7 -- SearchRepository.searchMetadataV3 (or-exif-only) select @@ -538,12 +563,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and "asset_exif"."city" = $2 + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and "asset_exif"."city" = $4 order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 +offset + $6 -- SearchRepository.searchMetadataV3 (string-eq-null) select @@ -580,12 +611,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and "asset_exif"."city" is null order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $2 + $4 +offset + $5 -- SearchRepository.searchMetadataV3 (string-pattern-like) select @@ -622,12 +659,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($2) || '%') + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($4) || '%') order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 +offset + $6 -- SearchRepository.searchMetadataV3 (string-pattern-notLike) select @@ -664,12 +707,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($2) || '%') + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($4) || '%') order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 +offset + $6 -- SearchRepository.searchMetadataV3 (string-pattern-startsWith) select @@ -706,12 +755,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($2) || '%') + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($4) || '%') order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 +offset + $6 -- SearchRepository.searchMetadataV3 (string-similarity-ocr) select @@ -748,19 +803,25 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and exists ( select from "ocr_search" where "ocr_search"."assetId" = "asset"."id" - and f_unaccent (ocr_search.text) %>> f_unaccent ($2) + and f_unaccent (ocr_search.text) %>> f_unaccent ($4) ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 +offset + $6 -- SearchRepository.searchMetadataV3 (ids-any) select @@ -796,20 +857,25 @@ from "asset" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - "asset"."ownerId" = any ($1::uuid[]) + ( + "asset"."visibility" != $1 + or "asset"."ownerId" = $2 + ) and exists ( select from "album_asset" where "album_asset"."assetId" = "asset"."id" - and "album_asset"."albumId" = any ($2::uuid[]) + and "album_asset"."albumId" = any ($3::uuid[]) ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $4 +offset + $5 -- SearchRepository.searchMetadataV3 (ids-all) select @@ -846,6 +912,10 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and exists ( select "asset_face"."assetId" @@ -854,18 +924,20 @@ where where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $2 - and "asset_face"."personGroupId" = any ($3::uuid[]) + and "asset_face"."isVisible" = $4 + and "asset_face"."personGroupId" = any ($5::uuid[]) group by "asset_face"."assetId" having - count(distinct "asset_face"."personGroupId") = $4 + count(distinct "asset_face"."personGroupId") = $6 ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $5 + $7 +offset + $8 -- SearchRepository.searchMetadataV3 (ids-all-single) select @@ -901,20 +973,25 @@ from "asset" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - "asset"."ownerId" = any ($1::uuid[]) + ( + "asset"."visibility" != $1 + or "asset"."ownerId" = $2 + ) and exists ( select from "album_asset" where "album_asset"."assetId" = "asset"."id" - and "album_asset"."albumId" = any ($2::uuid[]) + and "album_asset"."albumId" = any ($3::uuid[]) ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $4 +offset + $5 -- SearchRepository.searchMetadataV3 (ids-none) select @@ -951,6 +1028,10 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and not exists ( select from @@ -958,13 +1039,15 @@ where inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" where "tag_asset"."assetId" = "asset"."id" - and "tag_closure"."id_ancestor" = any ($2::uuid[]) + and "tag_closure"."id_ancestor" = any ($4::uuid[]) ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 +offset + $6 -- SearchRepository.searchMetadataV3 (ids-tags-all) select @@ -1001,6 +1084,10 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and exists ( select "tag_asset"."assetId" @@ -1009,17 +1096,19 @@ where inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" where "tag_asset"."assetId" = "asset"."id" - and "tag_closure"."id_ancestor" = any ($2::uuid[]) + and "tag_closure"."id_ancestor" = any ($4::uuid[]) group by "tag_asset"."assetId" having - count(distinct "tag_closure"."id_ancestor") = $3 + count(distinct "tag_closure"."id_ancestor") = $5 ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $4 + $6 +offset + $7 -- SearchRepository.searchMetadataV3 (has-albums-false) select @@ -1056,6 +1145,10 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and not exists ( select from @@ -1067,7 +1160,9 @@ order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $2 + $4 +offset + $5 -- SearchRepository.searchMetadataV3 (is-encoded) select @@ -1104,19 +1199,25 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and exists ( select from "asset_file" where "asset_file"."assetId" = "asset"."id" - and "asset_file"."type" = $2 + and "asset_file"."type" = $4 ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 +offset + $6 -- SearchRepository.searchMetadataV3 (number-range) select @@ -1154,14 +1255,20 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset_exif"."fileSizeInByte" <= $2 - and "asset_exif"."fileSizeInByte" >= $3 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset_exif"."fileSizeInByte" <= $4 + and "asset_exif"."fileSizeInByte" >= $5 ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $4 + $6 +offset + $7 -- SearchRepository.searchMetadataV3 (date-eq) select @@ -1198,12 +1305,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) - and "asset"."fileCreatedAt" = $2 + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and "asset"."fileCreatedAt" = $4 order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $3 + $5 +offset + $6 -- SearchRepository.searchMetadataV3 (date-range) select @@ -1241,14 +1354,20 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset"."fileCreatedAt" < $2 - and "asset"."fileCreatedAt" >= $3 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset"."fileCreatedAt" < $4 + and "asset"."fileCreatedAt" >= $5 ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $4 + $6 +offset + $7 -- SearchRepository.searchMetadataV3 (order-fileSize-noExif) select @@ -1285,12 +1404,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true order by "asset_exif"."fileSizeInByte" desc nulls last, "asset"."id" desc limit - $2 + $4 +offset + $5 -- SearchRepository.searchMetadataV3 (order-rating-withExif) select @@ -1328,12 +1453,18 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true order by "asset_exif"."rating" asc nulls last, "asset"."id" asc limit - $2 + $4 +offset + $5 -- SearchRepository.searchMetadataV3 (or-branches) select @@ -1371,7 +1502,11 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset"."isFavorite" = $2 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset"."isFavorite" = $4 or exists ( select from @@ -1379,15 +1514,17 @@ where where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $3 - and "asset_face"."personGroupId" = any ($4::uuid[]) + and "asset_face"."isVisible" = $5 + and "asset_face"."personGroupId" = any ($6::uuid[]) ) ) order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit - $5 + $7 +offset + $8 -- SearchRepository.searchMetadataV3 (or-with-top-level) select @@ -1423,19 +1560,25 @@ from "asset" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - "asset"."ownerId" = any ($1::uuid[]) + ( + "asset"."visibility" != $1 + or "asset"."ownerId" = $2 + ) and ( - "asset"."fileCreatedAt" < $2 - and "asset"."fileCreatedAt" >= $3 + "asset"."fileCreatedAt" < $3 + and "asset"."fileCreatedAt" >= $4 and ( - "asset"."isFavorite" = $4 + ( + "asset"."isFavorite" = $5 + and "asset"."ownerId" = any ($6::uuid[]) + ) or exists ( select from "album_asset" where "album_asset"."assetId" = "asset"."id" - and "album_asset"."albumId" = any ($5::uuid[]) + and "album_asset"."albumId" = any ($7::uuid[]) ) ) ) @@ -1443,8 +1586,310 @@ order by "asset"."fileCreatedAt" desc, "asset"."id" desc limit + $8 +offset + $9 + +-- SearchRepository.searchMetadataV3 (cursor-offset) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and "asset"."isFavorite" = $4 +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $5 +offset $6 +-- SearchRepository.searchRandomV3 (baseline) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and true +order by + random() +limit + $4 + +-- SearchRepository.searchRandomV3 (with-filter) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and "asset"."isFavorite" = $4 +order by + random() +limit + $5 + +-- SearchRepository.searchSmartV3 (baseline) +begin +set + local vchordrq.probes = 1 +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" + inner join "smart_search" on "asset"."id" = "smart_search"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and true +order by + smart_search.embedding <=> $4, + "asset"."id" asc +limit + $5 +offset + $6 +commit + +-- SearchRepository.searchSmartV3 (with-filter) +begin +set + local vchordrq.probes = 1 +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" + inner join "smart_search" on "asset"."id" = "smart_search"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset"."fileCreatedAt" < $4 + and "asset"."fileCreatedAt" >= $5 + ) +order by + smart_search.embedding <=> $6, + "asset"."id" asc +limit + $7 +offset + $8 +commit + +-- SearchRepository.searchSmartV3 (cursor-offset) +begin +set + local vchordrq.probes = 1 +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" + inner join "smart_search" on "asset"."id" = "smart_search"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and true +order by + smart_search.embedding <=> $4, + "asset"."id" asc +limit + $5 +offset + $6 +commit + -- SearchRepository.searchStatisticsV3 (baseline) select count(*) as "total" @@ -1453,6 +1898,10 @@ from left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) and true -- SearchRepository.searchStatisticsV3 (with-filter) @@ -1464,9 +1913,13 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset_exif"."fileSizeInByte" >= $2 - and "asset"."fileCreatedAt" < $3 - and "asset"."fileCreatedAt" >= $4 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset_exif"."fileSizeInByte" >= $4 + and "asset"."fileCreatedAt" < $5 + and "asset"."fileCreatedAt" >= $6 ) -- SearchRepository.searchStatisticsV3 (with-or) @@ -1478,7 +1931,11 @@ from where "asset"."ownerId" = any ($1::uuid[]) and ( - "asset"."isFavorite" = $2 + "asset"."visibility" != $2 + or "asset"."ownerId" = $3 + ) + and ( + "asset"."isFavorite" = $4 or not exists ( select from diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index ce055a0044..0956e34d41 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -14,11 +14,13 @@ import { searchAssetBuilder, searchAssetBuilderLegacy, searchMetadataV3Examples, + searchRandomV3Examples, + searchSmartV3Examples, searchStatisticsV3Examples, withExifInner, withSearchOrder, } from 'src/utils/database'; -import { paginationHelper } from 'src/utils/pagination'; +import { paginationHelper, PaginationOptions } from 'src/utils/pagination'; import z from 'zod'; export interface SearchAssetIdOptions { @@ -135,12 +137,15 @@ export type AssetSearchOptions = Omit & export type AssetSearchBuilderOptions = Omit; -export interface AssetSearchBuilderV3Options { - filter?: SearchFilter; - /** Server-derived ownership scope. Never client-controlled. */ - userIds?: string[]; +export interface AssetSearchScope { + userIds: string[]; + lockedOwnerId: string; /** whose version of the people to select, required when selecting faces or people */ viewingUserId?: string; +} + +export interface AssetSearchBuilderV3Options { + filter?: SearchFilter; withExif?: boolean; withFaces?: boolean; withPeople?: boolean; @@ -148,10 +153,6 @@ export interface AssetSearchBuilderV3Options { order?: SearchOrder; } -export interface AssetSearchPaginationV3Options { - size: number; -} - export type SmartSearchOptions = SearchDateOptions & SearchEmbeddingOptions & SearchExifOptions & @@ -211,6 +212,7 @@ export interface GetCameraLensModelsOptions { export class SearchRepository { constructor(@InjectKysely() private db: Kysely) {} + // TODO(v4): remove with the deprecated flat-field search API @GenerateSql({ params: [ { page: 1, size: 100 }, @@ -236,6 +238,7 @@ export class SearchRepository { return paginationHelper(items, pagination.size); } + // TODO(v4): remove with the deprecated flat-field search API @GenerateSql({ params: [ { @@ -252,6 +255,7 @@ export class SearchRepository { .executeTakeFirstOrThrow(); } + // TODO(v4): remove with the deprecated flat-field search API @GenerateSql({ params: [ 100, @@ -272,6 +276,7 @@ export class SearchRepository { .execute(); } + // TODO(v4): remove with the deprecated flat-field search API @GenerateSql({ params: [ 100, @@ -295,6 +300,7 @@ export class SearchRepository { .execute(); } + // TODO(v4): remove with the deprecated flat-field search API @GenerateSql({ params: [ { page: 1, size: 200 }, @@ -530,20 +536,56 @@ export class SearchRepository { return res.map((row) => row.lensModel!); } + // TODO(v4): drop the V3 suffix once the legacy methods are removed @GenerateSql(...searchMetadataV3Examples) - searchMetadataV3( - pagination: AssetSearchPaginationV3Options, - options: AssetSearchBuilderV3Options, - ): Promise { - return withSearchOrder(searchAssetBuilder(this.db, options), options.order) + async searchMetadataV3(pagination: PaginationOptions, options: AssetSearchBuilderV3Options, scope: AssetSearchScope) { + const items = await withSearchOrder(searchAssetBuilder(this.db, options, scope), options.order) .select(columns.searchAsset) - .limit(pagination.size) + .limit(pagination.take + 1) + .offset(pagination.skip ?? 0) + .execute(); + return paginationHelper(items, pagination.take); + } + + // TODO(v4): drop the V3 suffix once the legacy methods are removed + @GenerateSql(...searchRandomV3Examples) + searchRandomV3( + size: number, + options: Omit, + scope: AssetSearchScope, + ): Promise { + return searchAssetBuilder(this.db, options, scope) + .select(columns.searchAsset) + .orderBy(sql`random()`) + .limit(size) .execute(); } + // TODO(v4): drop the V3 suffix once the legacy methods are removed + @GenerateSql(...searchSmartV3Examples) + searchSmartV3( + pagination: PaginationOptions, + options: Omit & { embedding: string }, + scope: AssetSearchScope, + ) { + return this.db.transaction().execute(async (trx) => { + await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx); + const items = await searchAssetBuilder(trx, options, scope) + .select(columns.searchAsset) + .innerJoin('smart_search', 'asset.id', 'smart_search.assetId') + .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) + .orderBy('asset.id', 'asc') + .limit(pagination.take + 1) + .offset(pagination.skip ?? 0) + .execute(); + return paginationHelper(items, pagination.take); + }); + } + + // TODO(v4): drop the V3 suffix once the legacy methods are removed @GenerateSql(...searchStatisticsV3Examples) - searchStatisticsV3(options: AssetSearchBuilderV3Options) { - return searchAssetBuilder(this.db, options) + searchStatisticsV3(options: AssetSearchBuilderV3Options, scope: AssetSearchScope) { + return searchAssetBuilder(this.db, options, scope) .select((qb) => qb.fn.countAll().as('total')) .executeTakeFirstOrThrow(); } diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index bc59a4ad72..1496acd92f 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,11 +1,13 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto'; +import { AssetVisibility } from 'src/enum'; import { SearchService } from 'src/services/search.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { getForAsset } from 'test/mappers'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { beforeEach, vitest } from 'vitest'; @@ -215,6 +217,84 @@ describe(SearchService.name, () => { }); }); + describe('new shape routing', () => { + it('should route a filter request to the V3 search and a flat request to the legacy search', async () => { + const auth = AuthFactory.create(); + + mocks.search.searchMetadataV3.mockResolvedValue({ hasNextPage: false, items: [] }); + await sut.searchMetadata(auth, { size: 250, filter: {} }); + expect(mocks.search.searchMetadataV3).toHaveBeenCalled(); + expect(mocks.search.searchMetadata).not.toHaveBeenCalled(); + + mocks.search.searchMetadata.mockResolvedValue({ hasNextPage: false, items: [] }); + await sut.searchMetadata(auth, { size: 250, city: 'Oslo' }); + expect(mocks.search.searchMetadata).toHaveBeenCalled(); + }); + + it('should route statistics, random, and smart filter requests to their V3 search', async () => { + const auth = AuthFactory.create(); + + mocks.search.searchStatisticsV3.mockResolvedValue({ total: 0 }); + await expect(sut.searchStatistics(auth, { filter: {} })).resolves.toEqual({ total: 0 }); + + mocks.search.searchRandomV3.mockResolvedValue([]); + await expect(sut.searchRandom(auth, { size: 250, filter: {} })).resolves.toEqual([]); + + mocks.search.searchSmartV3.mockResolvedValue({ hasNextPage: false, items: [] }); + mocks.machineLearning.encodeText.mockResolvedValue('[1, 2, 3]'); + await sut.searchSmart(auth, { size: 100, filter: {}, query: 'test' }); + expect(mocks.search.searchSmartV3).toHaveBeenCalledWith( + { take: 100 }, + expect.objectContaining({ embedding: '[1, 2, 3]' }), + expect.objectContaining({ lockedOwnerId: expect.any(String) }), + ); + }); + + it('should reject an invalid cursor', async () => { + await expect(sut.searchMetadata(AuthFactory.create(), { size: 250, cursor: '???' })).rejects.toThrowError( + new BadRequestException('Invalid cursor'), + ); + }); + + it('should reject an unelevated session whose filter could match locked assets', async () => { + const filter = { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } }; + await expect(sut.searchMetadata(AuthFactory.create(), { size: 250, filter })).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('should reject a shared link whose filter is not confined to albums everywhere', async () => { + const auth = AuthFactory.from().sharedLink().build(); + const albumId = newUuid(); + + await expect(sut.searchMetadata(auth, { size: 250, filter: {} })).rejects.toThrowError( + new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'), + ); + + await expect( + sut.searchMetadata(auth, { + size: 250, + filter: { or: [{ albumIds: { any: [albumId] } }, { city: { eq: 'Oslo' } }] }, + }), + ).rejects.toThrowError( + new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'), + ); + }); + + it('should allow a shared link when every branch is confined to a covered album', async () => { + const auth = AuthFactory.from().sharedLink().build(); + const albumId = newUuid(); + + mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([albumId])); + mocks.search.searchMetadataV3.mockResolvedValue({ hasNextPage: false, items: [] }); + + await expect( + sut.searchMetadata(auth, { size: 250, filter: { or: [{ albumIds: { any: [albumId] } }] } }), + ).resolves.toBeDefined(); + expect(mocks.search.searchMetadataV3).toHaveBeenCalled(); + }); + }); + describe('searchSmart', () => { beforeEach(() => { mocks.search.searchSmart.mockResolvedValue({ hasNextPage: false, items: [] }); @@ -226,7 +306,7 @@ describe(SearchService.name, () => { machineLearning: { enabled: false }, }); - await expect(sut.searchSmart(authStub.user1, { query: 'test' })).rejects.toThrowError( + await expect(sut.searchSmart(authStub.user1, { size: 100, query: 'test' })).rejects.toThrowError( new BadRequestException('Smart search is not enabled'), ); }); @@ -236,13 +316,13 @@ describe(SearchService.name, () => { machineLearning: { clip: { enabled: false } }, }); - await expect(sut.searchSmart(authStub.user1, { query: 'test' })).rejects.toThrowError( + await expect(sut.searchSmart(authStub.user1, { size: 100, query: 'test' })).rejects.toThrowError( new BadRequestException('Smart search is not enabled'), ); }); it('should work', async () => { - await sut.searchSmart(authStub.user1, { query: 'test' }); + await sut.searchSmart(authStub.user1, { size: 100, query: 'test' }); expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( 'test', @@ -252,6 +332,7 @@ describe(SearchService.name, () => { { page: 1, size: 100 }, { query: 'test', + size: 100, embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id], viewingUserId: authStub.user1.user.id, @@ -278,7 +359,7 @@ describe(SearchService.name, () => { machineLearning: { clip: { modelName: 'ViT-B-16-SigLIP__webli' } }, }); - await sut.searchSmart(authStub.user1, { query: 'test' }); + await sut.searchSmart(authStub.user1, { size: 100, query: 'test' }); expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( 'test', @@ -287,7 +368,7 @@ describe(SearchService.name, () => { }); it('should use language specified in request', async () => { - await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' }); + await sut.searchSmart(authStub.user1, { size: 100, query: 'test', language: 'de' }); expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( 'test', diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 1612b0e29c..d1791f05b1 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -2,13 +2,17 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { LRUMap } from 'mnemonist'; import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfig } from 'src/dtos/config.dto'; import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { + isFullyAlbumConfined, + isNewShapeRequest, LargeAssetSearchDto, mapPlaces, MetadataSearchDto, PlacesResponseDto, RandomSearchDto, + SearchFilter, SearchPeopleDto, SearchPlacesDto, SearchResponseDto, @@ -19,10 +23,13 @@ import { StatisticsSearchDto, } from 'src/dtos/search.dto'; import { AssetOrder, AssetVisibility, Permission } from 'src/enum'; +import { AssetSearchScope } from 'src/repositories/search.repository'; import { BaseService } from 'src/services/base.service'; import { requireElevatedPermission } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { isSmartSearchEnabled } from 'src/utils/misc'; +import { decodeSearchCursor, encodeSearchCursor } from 'src/utils/search-cursor'; +import { applyLockedVisibilityPolicy, collectFilterIds } from 'src/utils/search-filter'; @Injectable() export class SearchService extends BaseService { @@ -65,6 +72,10 @@ export class SearchService extends BaseService { } async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise { + if (isNewShapeRequest(dto)) { + return this.searchMetadataV3(auth, dto); + } + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } @@ -86,7 +97,7 @@ export class SearchService extends BaseService { } const page = dto.page ?? 1; - const size = dto.size || 250; + const size = dto.size; const { hasNextPage, items } = await this.searchRepository.searchMetadata( { page, size }, { @@ -99,10 +110,14 @@ export class SearchService extends BaseService { }, ); - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + return this.mapResponse(items, { auth }, { nextPage: hasNextPage ? (page + 1).toString() : null }); } async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise { + if (isNewShapeRequest(dto)) { + return this.searchStatisticsV3(auth, dto); + } + const userIds = await this.getUserIdsToSearch(auth, dto.visibility); if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); @@ -117,12 +132,16 @@ export class SearchService extends BaseService { } async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + if (isNewShapeRequest(dto)) { + return this.searchRandomV3(auth, dto); + } + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } const userIds = await this.getUserIdsToSearch(auth, dto.visibility); - const items = await this.searchRepository.searchRandom(dto.size || 250, { + const items = await this.searchRepository.searchRandom(dto.size, { ...dto, visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), userIds, @@ -137,7 +156,7 @@ export class SearchService extends BaseService { } const userIds = await this.getUserIdsToSearch(auth, dto.visibility); - const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { + const items = await this.searchRepository.searchLargeAssets(dto.size, { ...dto, visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), userIds, @@ -147,6 +166,10 @@ export class SearchService extends BaseService { } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { + if (isNewShapeRequest(dto)) { + return this.searchSmartV3(auth, dto); + } + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } @@ -157,30 +180,9 @@ export class SearchService extends BaseService { } const userIds = this.getUserIdsToSearch(auth, dto.visibility); - let embedding; - if (dto.query) { - const key = machineLearning.clip.modelName + dto.query + dto.language; - embedding = this.embeddingCache.get(key); - if (!embedding) { - embedding = await this.machineLearningRepository.encodeText(dto.query, { - modelName: machineLearning.clip.modelName, - language: dto.language, - }); - this.embeddingCache.set(key, embedding); - } - } else if (dto.queryAssetId) { - await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.queryAssetId] }); - const getEmbeddingResponse = await this.searchRepository.getEmbedding(dto.queryAssetId); - const assetEmbedding = getEmbeddingResponse?.embedding; - if (!assetEmbedding) { - throw new BadRequestException(`Asset ${dto.queryAssetId} has no embedding`); - } - embedding = assetEmbedding; - } else { - throw new BadRequestException('Either `query` or `queryAssetId` must be set'); - } + const embedding = await this.resolveEmbedding(auth, dto, machineLearning); const page = dto.page ?? 1; - const size = dto.size || 100; + const size = dto.size; const { hasNextPage, items } = await this.searchRepository.searchSmart( { page, size }, { @@ -192,7 +194,7 @@ export class SearchService extends BaseService { }, ); - return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); + return this.mapResponse(items, { auth }, { nextPage: hasNextPage ? (page + 1).toString() : null }); } async getAssetsByCity(auth: AuthDto): Promise { @@ -236,6 +238,121 @@ export class SearchService extends BaseService { } } + private async searchMetadataV3(auth: AuthDto, dto: MetadataSearchDto): Promise { + const { filter, scope } = await this.resolveSearchScopeV3(auth, dto); + + const { offset } = decodeSearchCursor(dto.cursor); + const size = dto.size; + const { hasNextPage, items } = await this.searchRepository.searchMetadataV3( + { take: size, skip: offset }, + { + filter, + withExif: dto.withExif, + withPeople: dto.withPeople, + withStacked: dto.withStacked, + order: dto.orderBy, + }, + scope, + ); + + return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null }); + } + + private async searchStatisticsV3(auth: AuthDto, dto: StatisticsSearchDto): Promise { + const { filter, scope } = await this.resolveSearchScopeV3(auth, dto); + return this.searchRepository.searchStatisticsV3({ filter }, scope); + } + + private async searchRandomV3(auth: AuthDto, dto: RandomSearchDto): Promise { + const { filter, scope } = await this.resolveSearchScopeV3(auth, dto); + const items = await this.searchRepository.searchRandomV3( + dto.size, + { + filter, + withExif: dto.withExif, + withPeople: dto.withPeople, + withStacked: dto.withStacked, + }, + scope, + ); + return items.map((item) => mapAsset(item, { auth })); + } + + private async searchSmartV3(auth: AuthDto, dto: SmartSearchDto): Promise { + const { machineLearning } = await this.getConfig({ withCache: false }); + if (!isSmartSearchEnabled(machineLearning)) { + throw new BadRequestException('Smart search is not enabled'); + } + + const [{ filter, scope }, embedding] = await Promise.all([ + this.resolveSearchScopeV3(auth, dto), + this.resolveEmbedding(auth, dto, machineLearning), + ]); + + // no cursor until a rank-aware pagination strategy for smart search is decided + const { items } = await this.searchRepository.searchSmartV3( + { take: dto.size }, + { filter, withExif: dto.withExif, embedding }, + scope, + ); + + return this.mapResponse(items, { auth }); + } + + private async resolveSearchScopeV3( + auth: AuthDto, + dto: { filter?: SearchFilter }, + ): Promise<{ filter: SearchFilter; scope: AssetSearchScope }> { + const filter = dto.filter ?? {}; + const effectiveFilter = applyLockedVisibilityPolicy(auth, filter); + + const fullyConfined = isFullyAlbumConfined(filter); + // a shared link visitor does not have a universe, so there every branch must be confined + if (auth.sharedLink && !fullyConfined) { + throw new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'); + } + + const albumIds = collectFilterIds(filter, 'albumIds'); + const [userIds] = await Promise.all([ + // a fully confined filter searches albums only, so the unused universe can skip the partner lookup + fullyConfined ? [auth.user.id] : this.getUserIdsToSearch(auth), + albumIds.length > 0 ? this.requireAccess({ auth, ids: albumIds, permission: Permission.AlbumRead }) : undefined, + ]); + + return { filter: effectiveFilter, scope: { userIds, lockedOwnerId: auth.user.id, viewingUserId: auth.user.id } }; + } + + private async resolveEmbedding( + auth: AuthDto, + dto: SmartSearchDto, + machineLearning: SystemConfig['machineLearning'], + ): Promise { + if (dto.query) { + const key = machineLearning.clip.modelName + dto.query + dto.language; + let embedding = this.embeddingCache.get(key); + if (!embedding) { + embedding = await this.machineLearningRepository.encodeText(dto.query, { + modelName: machineLearning.clip.modelName, + language: dto.language, + }); + this.embeddingCache.set(key, embedding); + } + return embedding; + } + + if (dto.queryAssetId) { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.queryAssetId] }); + const getEmbeddingResponse = await this.searchRepository.getEmbedding(dto.queryAssetId); + const assetEmbedding = getEmbeddingResponse?.embedding; + if (!assetEmbedding) { + throw new BadRequestException(`Asset ${dto.queryAssetId} has no embedding`); + } + return assetEmbedding; + } + + throw new BadRequestException('Either `query` or `queryAssetId` must be set'); + } + private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise { // Locked assets are personal. Never include partner IDs, regardless of A's elevated session. if (visibility === AssetVisibility.Locked) { @@ -249,7 +366,11 @@ export class SearchService extends BaseService { return [auth.user.id, ...partnerIds]; } - private mapResponse(assets: MapAsset[], nextPage: string | null, options: AssetMapOptions): SearchResponseDto { + private mapResponse( + assets: MapAsset[], + options: AssetMapOptions, + page: { nextPage?: string | null; nextCursor?: string | null } = {}, + ): SearchResponseDto { return { albums: { total: 0, count: 0, items: [], facets: [] }, assets: { @@ -257,7 +378,8 @@ export class SearchService extends BaseService { count: assets.length, items: assets.map((asset) => mapAsset(asset, options)), facets: [], - nextPage, + nextPage: page.nextPage ?? null, + nextCursor: page.nextCursor ?? null, }, }; } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 275b354c16..7b1cd7bb25 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -24,6 +24,7 @@ import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { DEFAULT_SEARCH_ORDER, IdsFilter, + isAlbumConfined, SearchFilterBranch, SearchOrder, StringFilter, @@ -38,7 +39,11 @@ import { ExifOrientation, SearchOrderField, } from 'src/enum'; -import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository'; +import { + AssetSearchBuilderOptions, + AssetSearchBuilderV3Options, + AssetSearchScope, +} from 'src/repositories/search.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types'; @@ -787,8 +792,16 @@ function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch // ordering is deliberately left to the caller so aggregate-only consumers (counts, stats) // can compose the same filters without stripping an order by -export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderV3Options) { +export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderV3Options, scope: AssetSearchScope) { const filter = options.filter ?? {}; + const branches = filter.or ?? []; + const ownershipPredicate = (eb: AssetExpressionBuilder) => eb('asset.ownerId', '=', anyUuid(scope.userIds)); + // search universe: own+partner assets unless album-confined, which searches the albums instead; + // ownership lands nowhere (top level confined), per unconfined branch, or hoisted globally + const topConfined = isAlbumConfined(filter); + const anyBranchConfined = branches.some((branch) => isAlbumConfined(branch)); + const scopePerBranch = !topConfined && anyBranchConfined; + const scopeGlobally = !topConfined && !anyBranchConfined; return ( kysely @@ -797,17 +810,27 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild // postgres eliminates the left join when no exif column is referenced, so unused joins are free .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .$if(!!options.withExif, (qb) => qb.select(selectExifInfo)) - .$if(!!options.userIds && options.userIds.length > 0, (qb) => - qb.where('asset.ownerId', '=', anyUuid(options.userIds!)), + .$if(scopeGlobally, (qb) => qb.where(ownershipPredicate)) + .where((eb) => + eb.or([eb('asset.visibility', '!=', AssetVisibility.Locked), eb('asset.ownerId', '=', scope.lockedOwnerId)]), ) .$if(!!(options.withFaces || options.withPeople), (qb) => - qb.select(withFacesAndPeople({ viewingUserId: options.viewingUserId! })), + qb.select(withFacesAndPeople({ viewingUserId: scope.viewingUserId! })), ) .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) .where((eb) => { const predicates = branchPredicates(eb, filter); - if (filter.or && filter.or.length > 0) { - predicates.push(eb.or(filter.or.map((branch) => eb.and(branchPredicates(eb, branch))))); + if (branches.length > 0) { + predicates.push( + eb.or( + branches.map((branch) => + eb.and([ + ...branchPredicates(eb, branch), + ...(scopePerBranch && !isAlbumConfined(branch) ? [ownershipPredicate(eb)] : []), + ]), + ), + ), + ); } return predicates.length > 0 ? eb.and(predicates) : eb.lit(true); }) @@ -836,160 +859,238 @@ export function withSearchOrder(qb: ReturnType, order ); } +const scopeExample: AssetSearchScope = { userIds: [DummyValue.UUID], lockedOwnerId: DummyValue.UUID }; + export const searchMetadataV3Examples: GenerateSqlQueries[] = [ - { name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] }, - { name: 'empty', params: [{ size: 100 }, {}] }, + { name: 'baseline', params: [{ take: 100 }, {}, scopeExample] }, + { + name: 'or-mixed-scope', + params: [ + { take: 100 }, + { + filter: { or: [{ albumIds: { any: [DummyValue.UUID] } }, { city: { eq: DummyValue.STRING } }] }, + }, + scopeExample, + ], + }, { name: 'or-exif-only', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }], + params: [ + { take: 100 }, + { + filter: { or: [{ city: { eq: DummyValue.STRING } }] }, + }, + scopeExample, + ], }, { name: 'string-eq-null', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }], + params: [{ take: 100 }, { filter: { city: { eq: null } } }, scopeExample], }, { name: 'string-pattern-like', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }], + params: [ + { take: 100 }, + { + filter: { description: { like: DummyValue.STRING } }, + }, + scopeExample, + ], }, { name: 'string-pattern-notLike', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }], + params: [ + { take: 100 }, + { + filter: { description: { notLike: DummyValue.STRING } }, + }, + scopeExample, + ], }, { name: 'string-pattern-startsWith', params: [ - { size: 100 }, - { userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } }, + { take: 100 }, + { + filter: { originalFileName: { startsWith: DummyValue.STRING } }, + }, + scopeExample, ], }, { name: 'string-similarity-ocr', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }], + params: [{ take: 100 }, { filter: { ocr: { matches: DummyValue.STRING } } }, scopeExample], }, { name: 'ids-any', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }], + params: [{ take: 100 }, { filter: { albumIds: { any: [DummyValue.UUID] } } }, scopeExample], }, { name: 'ids-all', params: [ - { size: 100 }, - { userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } }, + { take: 100 }, + { + filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } }, + }, + scopeExample, ], }, { name: 'ids-all-single', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { all: [DummyValue.UUID] } } }], + params: [{ take: 100 }, { filter: { albumIds: { all: [DummyValue.UUID] } } }, scopeExample], }, { name: 'ids-none', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }], + params: [{ take: 100 }, { filter: { tagIds: { none: [DummyValue.UUID] } } }, scopeExample], }, { name: 'ids-tags-all', params: [ - { size: 100 }, - { userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } }, + { take: 100 }, + { + filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } }, + }, + scopeExample, ], }, { name: 'has-albums-false', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }], + params: [{ take: 100 }, { filter: { hasAlbums: { eq: false } } }, scopeExample], }, { name: 'is-encoded', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }], + params: [{ take: 100 }, { filter: { isEncoded: { eq: true } } }, scopeExample], }, { name: 'number-range', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }], + params: [ + { take: 100 }, + { + filter: { fileSizeInBytes: { gte: 100, lte: 1000 } }, + }, + scopeExample, + ], }, { name: 'date-eq', - params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }], + params: [{ take: 100 }, { filter: { takenAt: { eq: DummyValue.DATE } } }, scopeExample], }, { name: 'date-range', params: [ - { size: 100 }, + { take: 100 }, { - userIds: [DummyValue.UUID], filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } }, }, + scopeExample, ], }, { name: 'order-fileSize-noExif', params: [ - { size: 100 }, + { take: 100 }, { - userIds: [DummyValue.UUID], order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc }, withExif: false, }, + scopeExample, ], }, { name: 'order-rating-withExif', params: [ - { size: 100 }, + { take: 100 }, { - userIds: [DummyValue.UUID], order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc }, withExif: true, }, + scopeExample, ], }, { name: 'or-branches', params: [ - { size: 100 }, + { take: 100 }, { - userIds: [DummyValue.UUID], filter: { or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }], }, }, + scopeExample, ], }, { name: 'or-with-top-level', params: [ - { size: 100 }, + { take: 100 }, { - userIds: [DummyValue.UUID], filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }], }, }, + scopeExample, ], }, + { + name: 'cursor-offset', + params: [{ take: 100, skip: 100 }, { filter: { isFavorite: { eq: true } } }, scopeExample], + }, +]; + +export const searchRandomV3Examples: GenerateSqlQueries[] = [ + { name: 'baseline', params: [100, {}, scopeExample] }, + { + name: 'with-filter', + params: [100, { filter: { isFavorite: { eq: true } } }, scopeExample], + }, +]; + +export const searchSmartV3Examples: GenerateSqlQueries[] = [ + { + name: 'baseline', + params: [{ take: 100 }, { embedding: DummyValue.VECTOR }, scopeExample], + }, + { + name: 'with-filter', + params: [ + { take: 100 }, + { + embedding: DummyValue.VECTOR, + filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } }, + }, + scopeExample, + ], + }, + { + name: 'cursor-offset', + params: [{ take: 100, skip: 100 }, { embedding: DummyValue.VECTOR }, scopeExample], + }, ]; export const searchStatisticsV3Examples: GenerateSqlQueries[] = [ - { name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] }, + { name: 'baseline', params: [{}, scopeExample] }, { name: 'with-filter', params: [ { - userIds: [DummyValue.UUID], filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, fileSizeInBytes: { gte: 100 }, }, }, + scopeExample, ], }, { name: 'with-or', params: [ { - userIds: [DummyValue.UUID], filter: { or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }], }, }, + scopeExample, ], }, ]; diff --git a/server/src/utils/search-cursor.spec.ts b/server/src/utils/search-cursor.spec.ts new file mode 100644 index 0000000000..53574f0b31 --- /dev/null +++ b/server/src/utils/search-cursor.spec.ts @@ -0,0 +1,38 @@ +import { BadRequestException } from '@nestjs/common'; +import { decodeSearchCursor, encodeSearchCursor } from 'src/utils/search-cursor'; +import { describe, expect, it } from 'vitest'; + +describe('encodeSearchCursor', () => { + it('should produce an opaque base64url string', () => { + const cursor = encodeSearchCursor(250); + expect(cursor).toMatch(/^[\w-]+$/); + }); + + it('should round-trip an offset', () => { + for (const offset of [0, 1, 250, 1_000_000]) { + expect(decodeSearchCursor(encodeSearchCursor(offset))).toEqual({ offset }); + } + }); +}); + +describe('decodeSearchCursor', () => { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + + it('should treat a missing cursor as the first page', () => { + expect(decodeSearchCursor(undefined)).toEqual({ offset: 0 }); + }); + + it.each([ + ['empty string', ''], + ['garbage that is not base64url', '!!!not-base64!!!'], + ['base64 of non-JSON', Buffer.from('not json').toString('base64url')], + ['JSON without an offset', encode({})], + ['JSON with a non-object payload', encode(42)], + ['null payload', encode(null)], + ['negative offset', encode({ offset: -1 })], + ['fractional offset', encode({ offset: 1.5 })], + ['string offset', encode({ offset: '5' })], + ])('should reject %s', (_, cursor) => { + expect(() => decodeSearchCursor(cursor)).toThrowError(new BadRequestException('Invalid cursor')); + }); +}); diff --git a/server/src/utils/search-cursor.ts b/server/src/utils/search-cursor.ts new file mode 100644 index 0000000000..c2d19ad799 --- /dev/null +++ b/server/src/utils/search-cursor.ts @@ -0,0 +1,21 @@ +import { BadRequestException } from '@nestjs/common'; +import z from 'zod'; + +const SearchCursorPayloadSchema = z.object({ + offset: z.int().min(0), +}); + +export const encodeSearchCursor = (offset: number): string => + Buffer.from(JSON.stringify({ offset } satisfies z.infer)).toString('base64url'); + +export const decodeSearchCursor = (cursor?: string): { offset: number } => { + if (cursor === undefined) { + return { offset: 0 }; + } + + try { + return SearchCursorPayloadSchema.parse(JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))); + } catch { + throw new BadRequestException('Invalid cursor'); + } +}; diff --git a/server/src/utils/search-filter.spec.ts b/server/src/utils/search-filter.spec.ts new file mode 100644 index 0000000000..c4abe94366 --- /dev/null +++ b/server/src/utils/search-filter.spec.ts @@ -0,0 +1,116 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { isAlbumConfined, isFullyAlbumConfined, SearchFilter } from 'src/dtos/search.dto'; +import { AssetVisibility } from 'src/enum'; +import { applyLockedVisibilityPolicy, collectFilterIds } from 'src/utils/search-filter'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { describe, expect, it } from 'vitest'; + +const elevatedAuth = () => AuthFactory.from().session({ hasElevatedPermission: true }).build(); +const unelevatedAuth = () => AuthFactory.from().session().build(); + +describe(applyLockedVisibilityPolicy.name, () => { + it('should let an elevated session query locked assets', () => { + const filter = { visibility: { eq: AssetVisibility.Locked } }; + expect(applyLockedVisibilityPolicy(elevatedAuth(), filter)).toBe(filter); + }); + + it('should reject an unelevated session when any operator permits locked', () => { + for (const visibility of [ + { eq: AssetVisibility.Locked }, + { ne: AssetVisibility.Timeline }, + { in: [AssetVisibility.Locked, AssetVisibility.Timeline] }, + { notIn: [AssetVisibility.Timeline] }, + ]) { + expect(() => applyLockedVisibilityPolicy(unelevatedAuth(), { visibility })).toThrow(UnauthorizedException); + } + }); + + it('should keep a filter whose top-level visibility excludes locked', () => { + for (const visibility of [ + { eq: AssetVisibility.Timeline }, + { ne: AssetVisibility.Locked }, + { in: [AssetVisibility.Timeline, AssetVisibility.Archive] }, + { notIn: [AssetVisibility.Locked] }, + ]) { + const filter = { visibility }; + expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toBe(filter); + } + }); + + it('should let a safe top-level visibility decide over could-match branches', () => { + const filter = { visibility: { ne: AssetVisibility.Locked }, or: [{ visibility: { eq: AssetVisibility.Locked } }] }; + expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toBe(filter); + }); + + it('should reject a branch that permits locked when the top level has no visibility', () => { + const filter = { + or: [{ city: { eq: 'Oslo' } }, { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } }], + }; + expect(() => applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toThrow(UnauthorizedException); + }); + + it('should otherwise inject visibility != locked without mutating the input', () => { + const filter = { city: { eq: 'Oslo' }, visibility: undefined }; + expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toEqual({ + city: { eq: 'Oslo' }, + visibility: { ne: AssetVisibility.Locked }, + }); + expect(filter.visibility).toBeUndefined(); + + const branched = { or: [{ isFavorite: { eq: true } }, { visibility: { eq: AssetVisibility.Timeline } }] }; + expect(applyLockedVisibilityPolicy(unelevatedAuth(), branched).visibility).toEqual({ ne: AssetVisibility.Locked }); + }); +}); + +describe(collectFilterIds.name, () => { + it('should union and dedupe ids across operators and branches', () => { + const [albumA, albumB, albumC] = [ + '00000000-0000-4000-8000-00000000000a', + '00000000-0000-4000-8000-00000000000b', + '00000000-0000-4000-8000-00000000000c', + ]; + const filter: SearchFilter = { + albumIds: { any: [albumA], none: [albumB] }, + or: [{ albumIds: { all: [albumC, albumA] } }, { city: { eq: 'Oslo' } }], + }; + expect(collectFilterIds(filter, 'albumIds').toSorted()).toEqual([albumA, albumB, albumC]); + expect(collectFilterIds({}, 'albumIds')).toEqual([]); + }); + + it('should only collect ids of the requested field', () => { + const albumId = '00000000-0000-4000-8000-00000000000a'; + const personId = '00000000-0000-4000-8000-00000000000b'; + const filter = { albumIds: { any: [albumId] }, personIds: { any: [personId] } }; + expect(collectFilterIds(filter, 'personIds')).toEqual([personId]); + }); +}); + +describe(isAlbumConfined.name, () => { + it('should require a positive albumIds constraint', () => { + const albumId = '00000000-0000-4000-8000-00000000000a'; + expect(isAlbumConfined({ albumIds: { any: [albumId] } })).toBe(true); + expect(isAlbumConfined({ albumIds: { all: [albumId] } })).toBe(true); + expect(isAlbumConfined({ albumIds: { none: [albumId] } })).toBe(false); + expect(isAlbumConfined({ city: { eq: 'Oslo' } })).toBe(false); + expect(isAlbumConfined({})).toBe(false); + }); +}); + +describe(isFullyAlbumConfined.name, () => { + const albumId = '00000000-0000-4000-8000-00000000000a'; + + it('should be confined by the top level or by every branch', () => { + expect(isFullyAlbumConfined({ albumIds: { any: [albumId] } })).toBe(true); + expect(isFullyAlbumConfined({ albumIds: { any: [albumId] }, or: [{ city: { eq: 'Oslo' } }] })).toBe(true); + expect(isFullyAlbumConfined({ or: [{ albumIds: { any: [albumId] } }, { albumIds: { all: [albumId] } }] })).toBe( + true, + ); + }); + + it('should not be confined when any result can escape the albums', () => { + expect(isFullyAlbumConfined({})).toBe(false); + expect(isFullyAlbumConfined({ albumIds: { none: [albumId] } })).toBe(false); + expect(isFullyAlbumConfined({ or: [{ albumIds: { any: [albumId] } }, { city: { eq: 'Oslo' } }] })).toBe(false); + expect(isFullyAlbumConfined({ or: [] })).toBe(false); + }); +}); diff --git a/server/src/utils/search-filter.ts b/server/src/utils/search-filter.ts new file mode 100644 index 0000000000..83ea63d314 --- /dev/null +++ b/server/src/utils/search-filter.ts @@ -0,0 +1,73 @@ +import { AuthDto } from 'src/dtos/auth.dto'; +import { SearchFilter, SearchFilterBranch } from 'src/dtos/search.dto'; +import { AssetVisibility } from 'src/enum'; +import { requireElevatedPermission } from 'src/utils/access'; + +type EnumField = 'type' | 'visibility'; +type EnumOperator = keyof NonNullable; +type EnumOperandMap = { eq: T; ne: T; in: T[]; notIn: T[] }; +type EnumCondition = { [K in EnumOperator]?: EnumOperandMap[K] }; +type IdsFilterField = 'albumIds' | 'personIds' | 'tagIds'; + +const filterBranches = (filter: SearchFilter): SearchFilterBranch[] => [filter, ...(filter.or ?? [])]; + +/** Whether a row with `value` can satisfy the condition. A missing operator allows any value. */ +const canMatch = (condition: EnumCondition, value: T): boolean => { + const { eq, ne, in: anyOf, notIn, ...unhandled } = condition; + // fails to compile when EnumFilter gains an operator this check does not consider + void (unhandled satisfies Record); + + return ( + (eq === undefined || eq === value) && + (ne === undefined || ne !== value) && + (anyOf === undefined || anyOf.includes(value)) && + (notIn === undefined || !notIn.includes(value)) + ); +}; + +/** + * The conditions that decide which `field` values the filter can return. A top-level condition decides alone, otherwise each branch itself. + */ +const decidingConditions = (filter: SearchFilter, field: F) => { + if (filter[field] !== undefined) { + return [filter[field]]; + } + + return filterBranches(filter) + .map((branch) => branch[field]) + .filter((condition) => condition !== undefined); +}; + +/** + * Keeps locked assets out of search results unless the session is elevated: a filter that asks for + * them is rejected with 401, and any other filter gets `visibility != locked` ANDed in. + */ +export const applyLockedVisibilityPolicy = (auth: AuthDto, filter: SearchFilter): SearchFilter => { + if (auth.session?.hasElevatedPermission) { + return filter; + } + + if (decidingConditions(filter, 'visibility').some((condition) => canMatch(condition, AssetVisibility.Locked))) { + requireElevatedPermission(auth); + } + + if (filter.visibility !== undefined) { + return filter; + } + + return { ...filter, visibility: { ne: AssetVisibility.Locked } }; +}; + +export const collectFilterIds = (filter: SearchFilter, field: IdsFilterField): string[] => { + const ids = new Set(); + + for (const branch of filterBranches(filter)) { + for (const operator of ['any', 'all', 'none'] as const) { + for (const id of branch[field]?.[operator] ?? []) { + ids.add(id); + } + } + } + + return [...ids]; +}; diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index c612f45e47..edc8910c7e 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -1,6 +1,6 @@ import { Kysely } from 'kysely'; import { SearchSuggestionType } from 'src/dtos/search.dto'; -import { AlbumUserRole, AssetVisibility } from 'src/enum'; +import { AlbumUserRole, AssetOrder, AssetVisibility, SearchOrderField } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; @@ -16,6 +16,8 @@ import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; +const unitVector = (index: number) => JSON.stringify(Array.from({ length: 512 }, (_, i) => (i === index ? 1 : 0))); + const setup = (db?: Kysely) => { return newMediumService(SearchService, { database: db || defaultDatabase, @@ -56,7 +58,7 @@ describe(SearchService.name, () => { const auth = factory.auth({ user: { id: user.id } }); - await expect(sut.searchLargeAssets(auth, {})).resolves.toEqual([ + await expect(sut.searchLargeAssets(auth, { size: 250 })).resolves.toEqual([ expect.objectContaining({ id: assets[2].id }), expect.objectContaining({ id: assets[0].id }), expect.objectContaining({ id: assets[1].id }), @@ -120,7 +122,7 @@ describe(SearchService.name, () => { const auth = factory.auth({ user: { id: user.id } }); - const response = await sut.searchMetadata(auth, { withStacked: false }); + const response = await sut.searchMetadata(auth, { size: 250, withStacked: false }); expect(response.assets.items.length).toBe(1); expect(response.assets.items[0].id).toBe(unstackedAsset.id); @@ -135,7 +137,7 @@ describe(SearchService.name, () => { const auth = factory.auth({ user: { id: user.id } }); - const response = await sut.searchMetadata(auth, { withStacked: false }); + const response = await sut.searchMetadata(auth, { size: 250, withStacked: false }); expect(response.assets.items.length).toBe(0); }); @@ -148,7 +150,7 @@ describe(SearchService.name, () => { const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } }); - const response = await sut.searchMetadata(auth, { withStacked: false }); + const response = await sut.searchMetadata(auth, { size: 250, withStacked: false }); expect(response.assets.items.length).toBe(1); }); @@ -168,7 +170,7 @@ describe(SearchService.name, () => { const auth = factory.auth({ user: { id: user.id } }); - const response = await sut.searchMetadata(auth, { albumIds: [album.id] }); + const response = await sut.searchMetadata(auth, { size: 250, albumIds: [album.id] }); expect(response.assets.items.length).toBe(1); }); @@ -186,7 +188,7 @@ describe(SearchService.name, () => { const auth = factory.auth({ user: { id: user.id } }); - await expect(sut.searchMetadata(auth, { albumIds: [album.id] })).rejects.toThrow( + await expect(sut.searchMetadata(auth, { size: 250, albumIds: [album.id] })).rejects.toThrow( 'Not found or no album.read access', ); }); @@ -222,9 +224,229 @@ describe(SearchService.name, () => { const auth = factory.auth({ user: { id: user.id } }); - const response = await sut.searchRandom(auth, {}); + const response = await sut.searchRandom(auth, { size: 250 }); expect(response.length).toBe(0); }); }); + + describe('new search shape', () => { + it('should filter by an exif field and return a cursor-less single page', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, city: 'Oslo' }); + const { asset: other } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: other.id, city: 'Bergen' }); + + const auth = factory.auth({ user: { id: user.id } }); + const response = await sut.searchMetadata(auth, { size: 250, filter: { city: { eq: 'Oslo' } } }); + + expect(response.assets.items).toEqual([expect.objectContaining({ id: asset.id })]); + expect(response.assets.nextPage).toBeNull(); + expect(response.assets.nextCursor).toBeNull(); + }); + + it('should combine OR branches', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { asset: oslo } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: oslo.id, city: 'Oslo' }); + const { asset: favorite } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + await ctx.newAsset({ ownerId: user.id }); + + const auth = factory.auth({ user: { id: user.id } }); + const response = await sut.searchMetadata(auth, { + size: 250, + filter: { or: [{ city: { eq: 'Oslo' } }, { isFavorite: { eq: true } }] }, + }); + + expect(response.assets.items.map(({ id }) => id).toSorted()).toEqual([oslo.id, favorite.id].toSorted()); + }); + + it('should scope a top-level album constraint to the album', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: otherUser.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + + const auth = factory.auth({ user: { id: user.id } }); + const response = await sut.searchMetadata(auth, { size: 250, filter: { albumIds: { any: [album.id] } } }); + + expect(response.assets.items).toEqual([expect.objectContaining({ id: asset.id })]); + }); + + it('should scope an album-constrained branch to the album', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: otherUser.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + + const auth = factory.auth({ user: { id: user.id } }); + const response = await sut.searchMetadata(auth, { + size: 250, + filter: { or: [{ albumIds: { any: [album.id] } }] }, + }); + + expect(response.assets.items).toEqual([expect.objectContaining({ id: asset.id })]); + }); + + it('should keep the ownership scope for branches without an album constraint', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset: ownOslo } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: ownOslo.id, city: 'Oslo' }); + const { asset: foreignOslo } = await ctx.newAsset({ ownerId: otherUser.id }); + await ctx.newExif({ assetId: foreignOslo.id, city: 'Oslo' }); + const { asset: albumAsset } = await ctx.newAsset({ ownerId: otherUser.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + await ctx.newAlbumAsset({ albumId: album.id, assetId: albumAsset.id }); + + const auth = factory.auth({ user: { id: user.id } }); + const response = await sut.searchMetadata(auth, { + size: 250, + filter: { or: [{ albumIds: { any: [album.id] } }, { city: { eq: 'Oslo' } }] }, + }); + + // the album branch searches the album, the city branch stays confined to the caller's own assets + expect(response.assets.items.map(({ id }) => id).toSorted()).toEqual([ownOslo.id, albumAsset.id].toSorted()); + }); + + it('should reject an inaccessible album anywhere in the filter', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { album } = await ctx.newAlbum({ ownerId: otherUser.id }); + + const auth = factory.auth({ user: { id: user.id } }); + + await expect( + sut.searchMetadata(auth, { size: 250, filter: { or: [{ albumIds: { none: [album.id] } }] } }), + ).rejects.toThrow('Not found or no album.read access'); + }); + + it('should return locked assets only to an elevated session that asks for them', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { asset: timeline } = await ctx.newAsset({ ownerId: user.id }); + const { asset: locked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked }); + + const unelevated = await sut.searchMetadata(factory.auth({ user: { id: user.id } }), { size: 250, filter: {} }); + expect(unelevated.assets.items).toEqual([expect.objectContaining({ id: timeline.id })]); + + const elevatedAuth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } }); + const elevated = await sut.searchMetadata(elevatedAuth, { + size: 250, + filter: { visibility: { eq: AssetVisibility.Locked } }, + }); + expect(elevated.assets.items).toEqual([expect.objectContaining({ id: locked.id })]); + }); + + it('should exclude partner assets from a locked-only search', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id }); + const { asset: ownLocked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked }); + await ctx.newAsset({ ownerId: partner.id, visibility: AssetVisibility.Locked }); + + const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } }); + const response = await sut.searchMetadata(auth, { + size: 250, + filter: { visibility: { eq: AssetVisibility.Locked } }, + }); + + expect(response.assets.items).toEqual([expect.objectContaining({ id: ownLocked.id })]); + }); + + it('should never return partner locked assets, even for locked-matching mixed filters', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id }); + const { asset: ownLocked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked }); + const { asset: partnerTimeline } = await ctx.newAsset({ ownerId: partner.id }); + await ctx.newAsset({ ownerId: partner.id, visibility: AssetVisibility.Locked }); + + const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } }); + const response = await sut.searchMetadata(auth, { + size: 250, + filter: { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } }, + }); + + const ids = response.assets.items.map(({ id }) => id); + expect(ids.toSorted()).toEqual([ownLocked.id, partnerTimeline.id].toSorted()); + }); + + it('should paginate with an opaque cursor', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + for (let i = 0; i < 3; i++) { + await ctx.newAsset({ ownerId: user.id }); + } + + const auth = factory.auth({ user: { id: user.id } }); + + const firstPage = await sut.searchMetadata(auth, { filter: {}, size: 2 }); + expect(firstPage.assets.items.length).toBe(2); + expect(firstPage.assets.nextPage).toBeNull(); + expect(firstPage.assets.nextCursor).toEqual(expect.any(String)); + + const secondPage = await sut.searchMetadata(auth, { cursor: firstPage.assets.nextCursor!, size: 2 }); + expect(secondPage.assets.items.length).toBe(1); + expect(secondPage.assets.nextCursor).toBeNull(); + + const ids = [...firstPage.assets.items, ...secondPage.assets.items].map(({ id }) => id); + expect(new Set(ids).size).toBe(3); + }); + + it('should order by fileSizeInBytes', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const sizes = [12_334, 599, 123_456]; + const assetIds: string[] = []; + for (const fileSizeInByte of sizes) { + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte }); + assetIds.push(asset.id); + } + + const auth = factory.auth({ user: { id: user.id } }); + const response = await sut.searchMetadata(auth, { + size: 250, + orderBy: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Asc }, + }); + + expect(response.assets.items.map(({ id }) => id)).toEqual([assetIds[1], assetIds[0], assetIds[2]]); + }); + + it('should order smart search results by embedding distance with cursor offsets', async () => { + const { ctx } = setup(); + const { user } = await ctx.newUser(); + const searchRepository = ctx.get(SearchRepository); + + const assetIds: string[] = []; + for (let i = 0; i < 3; i++) { + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await searchRepository.upsert(asset.id, unitVector(i)); + assetIds.push(asset.id); + } + + const options = { filter: {}, embedding: unitVector(0) }; + const scope = { userIds: [user.id], lockedOwnerId: user.id }; + const firstPage = await searchRepository.searchSmartV3({ take: 2 }, options, scope); + expect(firstPage.items.length).toBe(2); + expect(firstPage.items[0].id).toBe(assetIds[0]); + expect(firstPage.hasNextPage).toBe(true); + + const secondPage = await searchRepository.searchSmartV3({ take: 2, skip: 2 }, options, scope); + expect(secondPage.items.length).toBe(1); + expect(secondPage.hasNextPage).toBe(false); + }); + }); });