From 753dab8b3c24a43a0cdd1736d67b7aa493bbd8c9 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 14 Nov 2023 17:47:15 -0500 Subject: [PATCH] feat(server): asset search endpoint (#4931) * feat(server): GET /assets endpoint * chore: open api * chore: use dumb name * feat: search by make, model, lens, city, state, country * chore: open api * chore: pagination validation and tests * chore: pr feedback --- cli/src/api/open-api/api.ts | 624 ++++++++++++++++++ mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 23360 -> 23480 bytes mobile/openapi/doc/AssetApi.md | Bin 67085 -> 73537 bytes mobile/openapi/doc/AssetOrder.md | Bin 0 -> 376 bytes mobile/openapi/lib/api.dart | Bin 7609 -> 7640 bytes mobile/openapi/lib/api/asset_api.dart | Bin 59715 -> 70467 bytes mobile/openapi/lib/api_client.dart | Bin 22360 -> 22447 bytes mobile/openapi/lib/api_helper.dart | Bin 5736 -> 5834 bytes mobile/openapi/lib/model/asset_order.dart | Bin 0 -> 2529 bytes mobile/openapi/test/asset_api_test.dart | Bin 5968 -> 6813 bytes mobile/openapi/test/asset_order_test.dart | Bin 0 -> 417 bytes server/immich-openapi-specs.json | 373 +++++++++++ server/src/domain/asset/asset.service.ts | 30 + server/src/domain/asset/dto/asset.dto.ts | 157 ++++- server/src/domain/domain.util.ts | 29 +- .../domain/repositories/asset.repository.ts | 54 +- server/src/immich/app.module.ts | 2 + .../immich/controllers/asset.controller.ts | 14 + .../infra/repositories/asset.repository.ts | 131 ++++ server/test/e2e/asset.e2e-spec.ts | 585 +++++++++++++--- .../repositories/asset.repository.mock.ts | 1 + server/test/test-utils.ts | 24 +- web/src/api/open-api/api.ts | 624 ++++++++++++++++++ 24 files changed, 2557 insertions(+), 94 deletions(-) create mode 100644 mobile/openapi/doc/AssetOrder.md create mode 100644 mobile/openapi/lib/model/asset_order.dart create mode 100644 mobile/openapi/test/asset_order_test.dart diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index ecb0fb39e8..3828babd7d 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -670,6 +670,20 @@ export interface AssetJobsDto { } +/** + * + * @export + * @enum {string} + */ + +export const AssetOrder = { + Asc: 'asc', + Desc: 'desc' +} as const; + +export type AssetOrder = typeof AssetOrder[keyof typeof AssetOrder]; + + /** * * @export @@ -7822,6 +7836,260 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {string} [id] + * @param {string} [libraryId] + * @param {AssetTypeEnum} [type] + * @param {AssetOrder} [order] + * @param {string} [deviceAssetId] + * @param {string} [deviceId] + * @param {string} [checksum] + * @param {boolean} [isArchived] + * @param {boolean} [isEncoded] + * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] + * @param {boolean} [isMotion] + * @param {boolean} [isOffline] + * @param {boolean} [isReadOnly] + * @param {boolean} [isVisible] + * @param {boolean} [withDeleted] + * @param {boolean} [withStacked] + * @param {boolean} [withExif] + * @param {boolean} [withPeople] + * @param {string} [createdBefore] + * @param {string} [createdAfter] + * @param {string} [updatedBefore] + * @param {string} [updatedAfter] + * @param {string} [trashedBefore] + * @param {string} [trashedAfter] + * @param {string} [takenBefore] + * @param {string} [takenAfter] + * @param {string} [originalFileName] + * @param {string} [originalPath] + * @param {string} [resizePath] + * @param {string} [webpPath] + * @param {string} [encodedVideoPath] + * @param {string} [city] + * @param {string} [state] + * @param {string} [country] + * @param {string} [make] + * @param {string} [model] + * @param {string} [lensModel] + * @param {number} [page] + * @param {number} [size] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchAssets: async (id?: string, libraryId?: string, type?: AssetTypeEnum, order?: AssetOrder, deviceAssetId?: string, deviceId?: string, checksum?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, withDeleted?: boolean, withStacked?: boolean, withExif?: boolean, withPeople?: boolean, createdBefore?: string, createdAfter?: string, updatedBefore?: string, updatedAfter?: string, trashedBefore?: string, trashedAfter?: string, takenBefore?: string, takenAfter?: string, originalFileName?: string, originalPath?: string, resizePath?: string, webpPath?: string, encodedVideoPath?: string, city?: string, state?: string, country?: string, make?: string, model?: string, lensModel?: string, page?: number, size?: number, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/assets`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (id !== undefined) { + localVarQueryParameter['id'] = id; + } + + if (libraryId !== undefined) { + localVarQueryParameter['libraryId'] = libraryId; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + if (order !== undefined) { + localVarQueryParameter['order'] = order; + } + + if (deviceAssetId !== undefined) { + localVarQueryParameter['deviceAssetId'] = deviceAssetId; + } + + if (deviceId !== undefined) { + localVarQueryParameter['deviceId'] = deviceId; + } + + if (checksum !== undefined) { + localVarQueryParameter['checksum'] = checksum; + } + + if (isArchived !== undefined) { + localVarQueryParameter['isArchived'] = isArchived; + } + + if (isEncoded !== undefined) { + localVarQueryParameter['isEncoded'] = isEncoded; + } + + if (isExternal !== undefined) { + localVarQueryParameter['isExternal'] = isExternal; + } + + if (isFavorite !== undefined) { + localVarQueryParameter['isFavorite'] = isFavorite; + } + + if (isMotion !== undefined) { + localVarQueryParameter['isMotion'] = isMotion; + } + + if (isOffline !== undefined) { + localVarQueryParameter['isOffline'] = isOffline; + } + + if (isReadOnly !== undefined) { + localVarQueryParameter['isReadOnly'] = isReadOnly; + } + + if (isVisible !== undefined) { + localVarQueryParameter['isVisible'] = isVisible; + } + + if (withDeleted !== undefined) { + localVarQueryParameter['withDeleted'] = withDeleted; + } + + if (withStacked !== undefined) { + localVarQueryParameter['withStacked'] = withStacked; + } + + if (withExif !== undefined) { + localVarQueryParameter['withExif'] = withExif; + } + + if (withPeople !== undefined) { + localVarQueryParameter['withPeople'] = withPeople; + } + + if (createdBefore !== undefined) { + localVarQueryParameter['createdBefore'] = (createdBefore as any instanceof Date) ? + (createdBefore as any).toISOString() : + createdBefore; + } + + if (createdAfter !== undefined) { + localVarQueryParameter['createdAfter'] = (createdAfter as any instanceof Date) ? + (createdAfter as any).toISOString() : + createdAfter; + } + + if (updatedBefore !== undefined) { + localVarQueryParameter['updatedBefore'] = (updatedBefore as any instanceof Date) ? + (updatedBefore as any).toISOString() : + updatedBefore; + } + + if (updatedAfter !== undefined) { + localVarQueryParameter['updatedAfter'] = (updatedAfter as any instanceof Date) ? + (updatedAfter as any).toISOString() : + updatedAfter; + } + + if (trashedBefore !== undefined) { + localVarQueryParameter['trashedBefore'] = (trashedBefore as any instanceof Date) ? + (trashedBefore as any).toISOString() : + trashedBefore; + } + + if (trashedAfter !== undefined) { + localVarQueryParameter['trashedAfter'] = (trashedAfter as any instanceof Date) ? + (trashedAfter as any).toISOString() : + trashedAfter; + } + + if (takenBefore !== undefined) { + localVarQueryParameter['takenBefore'] = (takenBefore as any instanceof Date) ? + (takenBefore as any).toISOString() : + takenBefore; + } + + if (takenAfter !== undefined) { + localVarQueryParameter['takenAfter'] = (takenAfter as any instanceof Date) ? + (takenAfter as any).toISOString() : + takenAfter; + } + + if (originalFileName !== undefined) { + localVarQueryParameter['originalFileName'] = originalFileName; + } + + if (originalPath !== undefined) { + localVarQueryParameter['originalPath'] = originalPath; + } + + if (resizePath !== undefined) { + localVarQueryParameter['resizePath'] = resizePath; + } + + if (webpPath !== undefined) { + localVarQueryParameter['webpPath'] = webpPath; + } + + if (encodedVideoPath !== undefined) { + localVarQueryParameter['encodedVideoPath'] = encodedVideoPath; + } + + if (city !== undefined) { + localVarQueryParameter['city'] = city; + } + + if (state !== undefined) { + localVarQueryParameter['state'] = state; + } + + if (country !== undefined) { + localVarQueryParameter['country'] = country; + } + + if (make !== undefined) { + localVarQueryParameter['make'] = make; + } + + if (model !== undefined) { + localVarQueryParameter['model'] = model; + } + + if (lensModel !== undefined) { + localVarQueryParameter['lensModel'] = lensModel; + } + + if (page !== undefined) { + localVarQueryParameter['page'] = page; + } + + if (size !== undefined) { + localVarQueryParameter['size'] = size; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} id @@ -8440,6 +8708,55 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.searchAsset(searchAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} [id] + * @param {string} [libraryId] + * @param {AssetTypeEnum} [type] + * @param {AssetOrder} [order] + * @param {string} [deviceAssetId] + * @param {string} [deviceId] + * @param {string} [checksum] + * @param {boolean} [isArchived] + * @param {boolean} [isEncoded] + * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] + * @param {boolean} [isMotion] + * @param {boolean} [isOffline] + * @param {boolean} [isReadOnly] + * @param {boolean} [isVisible] + * @param {boolean} [withDeleted] + * @param {boolean} [withStacked] + * @param {boolean} [withExif] + * @param {boolean} [withPeople] + * @param {string} [createdBefore] + * @param {string} [createdAfter] + * @param {string} [updatedBefore] + * @param {string} [updatedAfter] + * @param {string} [trashedBefore] + * @param {string} [trashedAfter] + * @param {string} [takenBefore] + * @param {string} [takenAfter] + * @param {string} [originalFileName] + * @param {string} [originalPath] + * @param {string} [resizePath] + * @param {string} [webpPath] + * @param {string} [encodedVideoPath] + * @param {string} [city] + * @param {string} [state] + * @param {string} [country] + * @param {string} [make] + * @param {string} [model] + * @param {string} [lensModel] + * @param {number} [page] + * @param {number} [size] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async searchAssets(id?: string, libraryId?: string, type?: AssetTypeEnum, order?: AssetOrder, deviceAssetId?: string, deviceId?: string, checksum?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, withDeleted?: boolean, withStacked?: boolean, withExif?: boolean, withPeople?: boolean, createdBefore?: string, createdAfter?: string, updatedBefore?: string, updatedAfter?: string, trashedBefore?: string, trashedAfter?: string, takenBefore?: string, takenAfter?: string, originalFileName?: string, originalPath?: string, resizePath?: string, webpPath?: string, encodedVideoPath?: string, city?: string, state?: string, country?: string, make?: string, model?: string, lensModel?: string, page?: number, size?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchAssets(id, libraryId, type, order, deviceAssetId, deviceId, checksum, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, withDeleted, withStacked, withExif, withPeople, createdBefore, createdAfter, updatedBefore, updatedAfter, trashedBefore, trashedAfter, takenBefore, takenAfter, originalFileName, originalPath, resizePath, webpPath, encodedVideoPath, city, state, country, make, model, lensModel, page, size, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -8739,6 +9056,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath searchAsset(requestParameters: AssetApiSearchAssetRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.searchAsset(requestParameters.searchAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiSearchAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchAssets(requestParameters: AssetApiSearchAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.searchAssets(requestParameters.id, requestParameters.libraryId, requestParameters.type, requestParameters.order, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.checksum, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.withDeleted, requestParameters.withStacked, requestParameters.withExif, requestParameters.withPeople, requestParameters.createdBefore, requestParameters.createdAfter, requestParameters.updatedBefore, requestParameters.updatedAfter, requestParameters.trashedBefore, requestParameters.trashedAfter, requestParameters.takenBefore, requestParameters.takenAfter, requestParameters.originalFileName, requestParameters.originalPath, requestParameters.resizePath, requestParameters.webpPath, requestParameters.encodedVideoPath, requestParameters.city, requestParameters.state, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.lensModel, requestParameters.page, requestParameters.size, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiServeFileRequest} requestParameters Request parameters. @@ -9333,6 +9659,293 @@ export interface AssetApiSearchAssetRequest { readonly searchAssetDto: SearchAssetDto } +/** + * Request parameters for searchAssets operation in AssetApi. + * @export + * @interface AssetApiSearchAssetsRequest + */ +export interface AssetApiSearchAssetsRequest { + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly id?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly libraryId?: string + + /** + * + * @type {AssetTypeEnum} + * @memberof AssetApiSearchAssets + */ + readonly type?: AssetTypeEnum + + /** + * + * @type {AssetOrder} + * @memberof AssetApiSearchAssets + */ + readonly order?: AssetOrder + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly deviceAssetId?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly deviceId?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly checksum?: string + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isArchived?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isEncoded?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isExternal?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isFavorite?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isMotion?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isOffline?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isReadOnly?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isVisible?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly withDeleted?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly withStacked?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly withExif?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly withPeople?: boolean + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly createdBefore?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly createdAfter?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly updatedBefore?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly updatedAfter?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly trashedBefore?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly trashedAfter?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly takenBefore?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly takenAfter?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly originalFileName?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly originalPath?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly resizePath?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly webpPath?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly encodedVideoPath?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly city?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly state?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly country?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly make?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly model?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly lensModel?: string + + /** + * + * @type {number} + * @memberof AssetApiSearchAssets + */ + readonly page?: number + + /** + * + * @type {number} + * @memberof AssetApiSearchAssets + */ + readonly size?: number +} + /** * Request parameters for serveFile operation in AssetApi. * @export @@ -9813,6 +10426,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).searchAsset(requestParameters.searchAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiSearchAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public searchAssets(requestParameters: AssetApiSearchAssetsRequest = {}, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).searchAssets(requestParameters.id, requestParameters.libraryId, requestParameters.type, requestParameters.order, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.checksum, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.withDeleted, requestParameters.withStacked, requestParameters.withExif, requestParameters.withPeople, requestParameters.createdBefore, requestParameters.createdAfter, requestParameters.updatedBefore, requestParameters.updatedAfter, requestParameters.trashedBefore, requestParameters.trashedAfter, requestParameters.takenBefore, requestParameters.takenAfter, requestParameters.originalFileName, requestParameters.originalPath, requestParameters.resizePath, requestParameters.webpPath, requestParameters.encodedVideoPath, requestParameters.city, requestParameters.state, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.lensModel, requestParameters.page, requestParameters.size, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiServeFileRequest} requestParameters Request parameters. diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 9ca8335169..8545c35ad4 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -29,6 +29,7 @@ doc/AssetIdsDto.md doc/AssetIdsResponseDto.md doc/AssetJobName.md doc/AssetJobsDto.md +doc/AssetOrder.md doc/AssetResponseDto.md doc/AssetStatsResponseDto.md doc/AssetTypeEnum.md @@ -220,6 +221,7 @@ lib/model/asset_ids_dto.dart lib/model/asset_ids_response_dto.dart lib/model/asset_job_name.dart lib/model/asset_jobs_dto.dart +lib/model/asset_order.dart lib/model/asset_response_dto.dart lib/model/asset_stats_response_dto.dart lib/model/asset_type_enum.dart @@ -376,6 +378,7 @@ test/asset_ids_dto_test.dart test/asset_ids_response_dto_test.dart test/asset_job_name_test.dart test/asset_jobs_dto_test.dart +test/asset_order_test.dart test/asset_response_dto_test.dart test/asset_stats_response_dto_test.dart test/asset_type_enum_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 02dc534b04ec850c1a9b6ceecc0a82afb797d390..272c1742e9cdde98b96eabb25b480accf12fc8b0 100644 GIT binary patch delta 76 zcmX@Gjd90z#tqXYC)-JKPA(J{n;av_Em*9nP@|xw*pKz_|^?(XYGEw;x|oVnN6DN6T{k&AsZ8? zFtjFO#3$;8N(}hDRa2{kh%#!9MSP-W`f+RRIzg6Z2BV6uJ~%DZIs0@=)1%P;tI<8K zS)t=3Oe~mvRNff8p$+>)iVYAw7RGHAgUA+917uh?>=a&iR^J(msTB4PTg1Q<7_u2k zlTs-!%A+L8|8LPGRnQG>M2pJd6Iz(`%Mo@58yS$TkWVy-sof-YJWhsUc0}kcf<(Qb zsYJISMYAS+qG{oY@WA?5)G{!S;HU6cS4kMBPvXrE+69m|s|(VNpsAqDPj$NO(_mX*t7j56dV2Gi+;FV7 zI~57NU3pAra84a8vH5A;p=he5nO`Zp0eJkKJ>l1=;RULA29KtkQ<-iUGSUgJm+>ZI zm5036z}lM8Rpji1IJbcI8|%2hTw4`YxtKK{F}8AINop1-mrVty7t+~4lrEfH%qE3` zbWAu}zCAct*^X1yd=WwQcOuL<3w+wAiLzNahH)xTBMU4GW%J;dX|l*ZmE!mT0#v-SKR%(|%VWIp`G> zsF`VXh?1nh=jnDh95?`uKu5~N^)r$SOSZSaPWw`h_RciH6@R`Eg>TbR2{$EwK>^QJ z#6f0F1PU^oh^cGadYF{_3`qQVl2@eCF0B{q?DL(k zL5>tmblS6}vu<-HStm;gplNWy#-e-@4qJ?-Kba#z*LBt;0p}wp!O>$reP~UpUL}lP zD|JxXq_RxnD8tHl!W-_cv9=lIKDj84LqRK+!cb3DhEGVTzc9dl|5pA)VO-Wscy?J1 zhZn6U+j%=%&b7CV<^JL+h^fBngOeH(<}rR>x10BW-j=+If{pBg{E+xE{AE4{fIGVW BbKw90 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 92bfa3f81dc49868c99f727405aaf5fc4414fe46..73a83263baa42772aeceef1f5a9052e841fc7641 100644 GIT binary patch delta 22 dcmdmKeZzV~9S3WEQA%pjBql~p%8QQ%gE4^@n(*XN#r!()X^(T3}n60q{?!KTAS zj~5F*7>^+`g!eHF46$d3MbtddP-tFh@lShLpXLla$-ZWDz z=F-L~?#;=pUB0}1KV{4`v_>S3nZ`l9hod~t(gr3FY5M@1EjrZ-C?4;GJIPJ(d9r7l z$mV~U$uwImRu+k^k0Zn*vmSHw`BX|(p6fM~J1x~2*D{rMB`K$k+T<%0o)TYx=EO75 ztp66YfF4AEqlci`oO&CkffblG=yede9%=DF9wsMXDLC9)?IOFVl#9J3Nm|_%aF>{RXBb71Ftl{ee{s>#eNoXfm^ha!`^USZ9q{^3 zVvA1hj-AF-V+~^j&44v}PQ6vyhGThAvk^vq-?2vH@Y(}$qBwUaW@mG}Kv$t?ypPRr z>w((m2(#>oLT*7=Crte?Ds@Nh2!^e4lp7`d)&eFThs$S9Ua3ReNYd&2-s} z!{3X|Xi?4{X!k8d`S=Dq%@4@{R{KkSSN$iO-cnVcXc^?iLy~p6*8ghUSE-m%F=!_I z&fGBf%Cmt|rdZUYMPbWGqGkWmzrS6Vwz7hsvz_%_9517%w&c;HMnu7@n?3sNQAX4d z0zSmnCqVZJO2Ck2(Ni<^eyW#{*RW#HN0Id&wXKFc_tpwALanGi61FB-I={DvS`<3a zQg0-I3-{P4D73>B9xuk5Wb>j3&-;Chf=G0$kCTVV9jOTQH?+biKBH(z8kotig3>sSYGf9QGAsBl3QpDJP1&ga&82afT9MWh7evui08uqk;#K)S$AZ{b zmOsW)b#N&04)00K=K|YO1Z^Ik!3Qx^N;DIIg$I?>xqv8Scl&ZaO6n*Dc+(^}iNYu3ZRAtMuNFi;iZK%5Bp+d+S>~WZ`nQ>>vL1>l#-g9Sc z;}Dug3i#v9{W$mDa~Y2Z<1x)1mW%IyncvU_Mxj`qrL z$!{f9);uAooK{bwtmQhvxe#s7SdtF?R=Fe~uHY`3UgCJI;jBC;{Yc?0hsN0GWhZwV zY1w3i-!{gS^k~gqFa;ATMCs({`6_fxp|u8k#?ml$4tz4#3_ZSYJ;u&^QSy!_91LY==h1yc zUrb2XW!X1I6hlu$o_AEmb41S}{PsKES?O!5+f&|fLc^Ax>yD`4|9SAtS~RAvFz8tD z5C<1S?kN+R&_`UUHQ`!?(aj{!RKK&xn`T%>!J^QZ-dN!^wSAEbFK8pOuZ-@WPF`un zNxOt%79#V;9^Psy?zyy!aXOv6(v1_tkdZi4r~2|=xMt)dCBtKX6DJqsfiI#gb<=ti zTHQhHRxKBy;_DTI*Gz}#rN!cevcyli1Jf9Dv%g1gthq_Ots$IXn8otEMbu3jPa8?G z2sybr%cOG!j2w8Jx$ZcxL(8$%*f}y!=vT9s(7+K-#)<1NU2pqtZ2wOL(KmP3gT{`F zTTkY5YmJRbZZRhMB^9jhCjlYO17RKI2ikKsusHkUjaCbKXGj*~g#Ch9 zwsHr(&s#(~hus}KX}l?pg4#%vqk_O&4sPEy<=*G=_$Q6v!XsBbJ64K{3%b6|dPMCY;)(93J2 z)&6mK7hUlEFc{&vSTwVbHUD@CMI2r}=&u?jjB7VCk4BIEcun7NvT^;jKHktX)IXJ2<;(PwOe_()|1Tzx>~O?R@X9{?M-67B|e(S|QN2D{_j8i6LQ;|0Ox=E2^e# zOBz}O_NFNWVw@1P!m$$-fg&So?Ze=L4SbRtU63iaeOeTu#}Xi+0xP2?45V7%=A}?I_g>lDcw~l( z>iC~3#~T9#^&=GN2*jVr59&(YN>Z^Q6Of4r|@JcPX5hH#eOgY05z@#3jhEB diff --git a/mobile/openapi/test/asset_order_test.dart b/mobile/openapi/test/asset_order_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..4a1490810043841d619401621717d2cccd6d6d0d GIT binary patch literal 417 zcmZvYK}*Ci5QXpg72~P9P`A1#*+sC>t}N&lq#itlp`CVvZ4xJ`BC`M8L|G8*VcziI zdwEH+Ok^44&vkWqUmnV*ye*Ej~X$YFb87Y=7PA8(9y z9<`UM4TY*x#6NL2TQ(?7P7X%*0rN-H^5#l5M`S0&?;Rnr*t}v-8)HeMRaux-e08$@ zhj?;6i*g2HY4i@lmq6|u`HEX&@orQLBPzXE!j}*LJ)6lck_i9dk`&IsX}J~RB)$Pm Cm5luW literal 0 HcmV?d00001 diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 4dbc2c2825..ad53f7e1b6 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2464,6 +2464,372 @@ ] } }, + "/assets": { + "get": { + "operationId": "searchAssets", + "parameters": [ + { + "name": "id", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "libraryId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetTypeEnum" + } + }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetOrder" + } + }, + { + "name": "deviceAssetId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "checksum", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isEncoded", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isExternal", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMotion", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isOffline", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isReadOnly", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isVisible", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withDeleted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withStacked", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withExif", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withPeople", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "createdBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "createdAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "updatedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "updatedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "trashedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "trashedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "takenBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "takenAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "originalFileName", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "originalPath", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "resizePath", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "webpPath", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "encodedVideoPath", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "city", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "country", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "make", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "model", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "lensModel", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "size", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] + } + }, "/audit/deletes": { "get": { "operationId": "getAuditDeletes", @@ -6304,6 +6670,13 @@ ], "type": "object" }, + "AssetOrder": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, "AssetResponseDto": { "properties": { "checksum": { diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 3b9b56412f..dbba670e73 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -30,6 +30,8 @@ import { AssetIdsDto, AssetJobName, AssetJobsDto, + AssetOrder, + AssetSearchDto, AssetStatsDto, DownloadArchiveInfo, DownloadInfoDto, @@ -91,6 +93,34 @@ export class AssetService { this.configCore = SystemConfigCore.create(configRepository); } + search(authUser: AuthUserDto, dto: AssetSearchDto) { + let checksum: Buffer | undefined = undefined; + + if (dto.checksum) { + const encoding = dto.checksum.length === 28 ? 'base64' : 'hex'; + checksum = Buffer.from(dto.checksum, encoding); + } + + const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; + const order = dto.order ? enumToOrder[dto.order] : undefined; + + return this.assetRepository + .search({ + ...dto, + order, + checksum, + ownerId: authUser.id, + }) + .then((assets) => + assets.map((asset) => + mapAsset(asset, { + stripMetadata: false, + withStack: true, + }), + ), + ); + } + canUploadFile({ authUser, fieldName, file }: UploadRequest): true { this.access.requireUploadAccess(authUser); diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index 0b3ce68d5c..c7c371706e 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -1,8 +1,161 @@ +import { AssetType } from '@app/infra/entities'; +import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator'; -import { Optional, ValidateUUID } from '../../domain.util'; +import { IsBoolean, IsEnum, IsInt, IsPositive, IsString, Min } from 'class-validator'; +import { Optional, QueryBoolean, QueryDate, ValidateUUID } from '../../domain.util'; import { BulkIdsDto } from '../response-dto'; +export enum AssetOrder { + ASC = 'asc', + DESC = 'desc', +} + +export class AssetSearchDto { + @ValidateUUID({ optional: true }) + id?: string; + + @ValidateUUID({ optional: true }) + libraryId?: string; + + @IsString() + @Optional() + deviceAssetId?: string; + + @IsString() + @Optional() + deviceId?: string; + + @IsEnum(AssetType) + @Optional() + @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) + type?: AssetType; + + @IsString() + @Optional() + checksum?: string; + + @QueryBoolean({ optional: true }) + isArchived?: boolean; + + @QueryBoolean({ optional: true }) + isEncoded?: boolean; + + @QueryBoolean({ optional: true }) + isExternal?: boolean; + + @QueryBoolean({ optional: true }) + isFavorite?: boolean; + + @QueryBoolean({ optional: true }) + isMotion?: boolean; + + @QueryBoolean({ optional: true }) + isOffline?: boolean; + + @QueryBoolean({ optional: true }) + isReadOnly?: boolean; + + @QueryBoolean({ optional: true }) + isVisible?: boolean; + + @QueryBoolean({ optional: true }) + withDeleted?: boolean; + + @QueryBoolean({ optional: true }) + withStacked?: boolean; + + @QueryBoolean({ optional: true }) + withExif?: boolean; + + @QueryBoolean({ optional: true }) + withPeople?: boolean; + + @QueryDate({ optional: true }) + createdBefore?: Date; + + @QueryDate({ optional: true }) + createdAfter?: Date; + + @QueryDate({ optional: true }) + updatedBefore?: Date; + + @QueryDate({ optional: true }) + updatedAfter?: Date; + + @QueryDate({ optional: true }) + trashedBefore?: Date; + + @QueryDate({ optional: true }) + trashedAfter?: Date; + + @QueryDate({ optional: true }) + takenBefore?: Date; + + @QueryDate({ optional: true }) + takenAfter?: Date; + + @IsString() + @Optional() + originalFileName?: string; + + @IsString() + @Optional() + originalPath?: string; + + @IsString() + @Optional() + resizePath?: string; + + @IsString() + @Optional() + webpPath?: string; + + @IsString() + @Optional() + encodedVideoPath?: string; + + @IsString() + @Optional() + city?: string; + + @IsString() + @Optional() + state?: string; + + @IsString() + @Optional() + country?: string; + + @IsString() + @Optional() + make?: string; + + @IsString() + @Optional() + model?: string; + + @IsString() + @Optional() + lensModel?: string; + + @IsEnum(AssetOrder) + @Optional() + @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) + order?: AssetOrder; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + size?: number; +} + export class AssetBulkUpdateDto extends BulkIdsDto { @Optional() @IsBoolean() diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts index 04ec4f430d..53e674bf30 100644 --- a/server/src/domain/domain.util.ts +++ b/server/src/domain/domain.util.ts @@ -1,6 +1,17 @@ import { applyDecorators } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID, ValidateIf, ValidationOptions } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsDate, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + ValidateIf, + ValidationOptions, +} from 'class-validator'; import { CronJob } from 'cron'; import { basename, extname } from 'node:path'; import sanitize from 'sanitize-filename'; @@ -33,6 +44,22 @@ interface IValue { value?: string; } +export const QueryBoolean = ({ optional }: { optional?: boolean }) => { + const decorators = [IsBoolean(), Transform(toBoolean)]; + if (optional) { + decorators.push(Optional()); + } + return applyDecorators(...decorators); +}; + +export const QueryDate = ({ optional }: { optional?: boolean }) => { + const decorators = [IsDate(), Type(() => Date)]; + if (optional) { + decorators.push(Optional()); + } + return applyDecorators(...decorators); +}; + export const toBoolean = ({ value }: IValue) => { if (value == 'true') { return true; diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index 2ceabad351..12ae49000e 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -11,11 +11,58 @@ export interface AssetStatsOptions { } export interface AssetSearchOptions { - isVisible?: boolean; - trashedBefore?: Date; + id?: string; + libraryId?: string; + deviceAssetId?: string; + deviceId?: string; + ownerId?: string; type?: AssetType; - order?: 'ASC' | 'DESC'; + checksum?: Buffer; + + isArchived?: boolean; + isEncoded?: boolean; + isExternal?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isOffline?: boolean; + isReadOnly?: boolean; + isVisible?: boolean; + withDeleted?: boolean; + withStacked?: boolean; + withExif?: boolean; + withPeople?: boolean; + + createdBefore?: Date; + createdAfter?: Date; + updatedBefore?: Date; + updatedAfter?: Date; + trashedBefore?: Date; + trashedAfter?: Date; + takenBefore?: Date; + takenAfter?: Date; + + originalFileName?: string; + originalPath?: string; + resizePath?: string; + webpPath?: string; + encodedVideoPath?: string; + + city?: string; + state?: string; + country?: string; + make?: string; + model?: string; + lensModel?: string; + + /** defaults to 'DESC' */ + order?: 'ASC' | 'DESC'; + + /** defaults to 1 */ + page?: number; + + /** defaults to 250 */ + size?: number; } export interface LivePhotoSearchOptions { @@ -127,4 +174,5 @@ export interface IAssetRepository { getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; upsertExif(exif: Partial): Promise; upsertJobStatus(jobStatus: Partial): Promise; + search(options: AssetSearchOptions): Promise; } diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index cf95eb2368..7f9edfd426 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -16,6 +16,7 @@ import { AlbumController, AppController, AssetController, + AssetsController, AuditController, AuthController, JobController, @@ -41,6 +42,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors'; ], controllers: [ ActivityController, + AssetsController, AssetController, AssetControllerV1, AppController, diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index d0f294d1ee..105760e502 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -4,6 +4,7 @@ import { AssetIdsDto, AssetJobsDto, AssetResponseDto, + AssetSearchDto, AssetService, AssetStatsDto, AssetStatsResponseDto, @@ -42,6 +43,19 @@ import { UseValidation, asStreamableFile } from '../app.utils'; import { Route } from '../interceptors'; import { UUIDParamDto } from './dto/uuid-param.dto'; +@ApiTags('Asset') +@Controller('assets') +@Authenticated() +@UseValidation() +export class AssetsController { + constructor(private service: AssetService) {} + + @Get() + searchAssets(@AuthUser() authUser: AuthUserDto, @Query() dto: AssetSearchDto): Promise { + return this.service.search(authUser, dto); + } +} + @ApiTags('Asset') @Controller(Route.ASSET) @Authenticated() diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 3f0b439a37..b6fa10c0d7 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -18,12 +18,15 @@ import { } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import _ from 'lodash'; import { DateTime } from 'luxon'; import { And, FindOptionsRelations, FindOptionsWhere, In, IsNull, LessThan, Not, Repository } from 'typeorm'; import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '../entities'; import OptionalBetween from '../utils/optional-between.util'; import { paginate } from '../utils/pagination.util'; +const DEFAULT_SEARCH_SIZE = 250; + const truncateMap: Record = { [TimeBucketSize.DAY]: 'day', [TimeBucketSize.MONTH]: 'month', @@ -50,6 +53,134 @@ export class AssetRepository implements IAssetRepository { await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] }); } + search(options: AssetSearchOptions): Promise { + const { + id, + libraryId, + deviceAssetId, + type, + checksum, + ownerId, + + isVisible, + isFavorite, + isExternal, + isReadOnly, + isOffline, + isArchived, + isMotion, + isEncoded, + + createdBefore, + createdAfter, + updatedBefore, + updatedAfter, + trashedBefore, + trashedAfter, + takenBefore, + takenAfter, + + originalFileName, + originalPath, + resizePath, + webpPath, + encodedVideoPath, + + city, + state, + country, + make, + model, + lensModel, + + withDeleted: _withDeleted, + withExif: _withExif, + withStacked, + withPeople, + + order, + } = options; + + const withDeleted = _withDeleted ?? (trashedAfter !== undefined || trashedBefore !== undefined); + + const page = Math.max(options.page || 1, 1); + const size = Math.min(options.size || DEFAULT_SEARCH_SIZE, DEFAULT_SEARCH_SIZE); + + const exifWhere = _.omitBy( + { + city, + state, + country, + make, + model, + lensModel, + }, + _.isUndefined, + ); + + const withExif = Object.keys(exifWhere).length > 0 || _withExif; + + const where = _.omitBy( + { + ownerId, + id, + libraryId, + deviceAssetId, + type, + checksum, + isVisible, + isFavorite, + isExternal, + isReadOnly, + isOffline, + isArchived, + livePhotoVideoId: isMotion && Not(IsNull()), + originalFileName, + originalPath, + resizePath, + webpPath, + encodedVideoPath: encodedVideoPath ?? (isEncoded && Not(IsNull())), + createdAt: OptionalBetween(createdAfter, createdBefore), + updatedAt: OptionalBetween(updatedAfter, updatedBefore), + deletedAt: OptionalBetween(trashedAfter, trashedBefore), + fileCreatedAt: OptionalBetween(takenAfter, takenBefore), + exifInfo: Object.keys(exifWhere).length > 0 ? exifWhere : undefined, + }, + _.isUndefined, + ); + + const builder = this.repository.createQueryBuilder('asset'); + + if (withExif) { + if (_withExif) { + builder.leftJoinAndSelect('asset.exifInfo', 'exifInfo'); + } else { + builder.leftJoin('asset.exifInfo', 'exifInfo'); + } + } + + if (withPeople) { + builder.leftJoinAndSelect('asset.faces', 'faces'); + builder.leftJoinAndSelect('faces.person', 'person'); + } + + if (withStacked) { + builder.leftJoinAndSelect('asset.stack', 'stack'); + } + + if (withDeleted) { + builder.withDeleted(); + } + + builder + .where(where) + .skip(size * (page - 1)) + .take(size) + .orderBy('asset.fileCreatedAt', order ?? 'DESC'); + + return builder.getMany(); + } + create(asset: AssetCreate): Promise { return this.repository.save(asset); } diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index e15e27d5a4..197f40d54d 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -4,19 +4,20 @@ import { IPersonRepository, LibraryResponseDto, LoginResponseDto, - SharedLinkResponseDto, TimeBucketSize, WithoutProperty, + mapAsset, usePagination, } from '@app/domain'; import { AssetController } from '@app/immich'; -import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities'; +import { AssetEntity, AssetType, LibraryType, SharedLinkType } from '@app/infra/entities'; import { AssetRepository } from '@app/infra/repositories'; import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { errorStub, uuidStub } from '@test/fixtures'; import { db, testApp } from '@test/test-utils'; import { randomBytes } from 'crypto'; +import { DateTime } from 'luxon'; import request from 'supertest'; const user1Dto = { @@ -31,6 +32,9 @@ const user2Dto = { name: 'User 2', }; +const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 }); +const yesterday = today.minus({ days: 1 }); + const makeUploadDto = (options?: { omit: string }): Record => { const dto: Record = { deviceAssetId: 'example-image', @@ -49,83 +53,498 @@ const makeUploadDto = (options?: { omit: string }): Record => { return dto; }; -let assetCount = 0; -const createAsset = ( - repository: IAssetRepository, - loginResponse: LoginResponseDto, - libraryId: string, - createdAt: Date, -): Promise => { - const id = assetCount++; - return repository.create({ - ownerId: loginResponse.userId, - checksum: randomBytes(20), - originalPath: `/tests/test_${id}`, - deviceAssetId: `test_${id}`, - deviceId: 'e2e-test', - libraryId, - isVisible: true, - fileCreatedAt: createdAt, - fileModifiedAt: new Date(), - localDateTime: createdAt, - type: AssetType.IMAGE, - originalFileName: `test_${id}`, - }); -}; - describe(`${AssetController.name} (e2e)`, () => { let app: INestApplication; let server: any; let assetRepository: IAssetRepository; - let defaultLibrary: LibraryResponseDto; - let sharedLink: SharedLinkResponseDto; let user1: LoginResponseDto; let user2: LoginResponseDto; - let asset1: AssetEntity; - let asset2: AssetEntity; - let asset3: AssetEntity; - let asset4: AssetEntity; + let libraries: LibraryResponseDto[]; + let asset1: AssetResponseDto; + let asset2: AssetResponseDto; + let asset3: AssetResponseDto; + let asset4: AssetResponseDto; + let asset5: AssetResponseDto; + + let assetCount = 0; + const createAsset = async (loginResponse: LoginResponseDto, createdAt: Date, other: Partial = {}) => { + const id = assetCount++; + const asset = await assetRepository.create({ + createdAt: today.toJSDate(), + updatedAt: today.toJSDate(), + ownerId: loginResponse.userId, + checksum: randomBytes(20), + originalPath: `/tests/test_${id}`, + deviceAssetId: `test_${id}`, + deviceId: 'e2e-test', + libraryId: ( + libraries.find( + ({ ownerId, type }) => ownerId === loginResponse.userId && type === LibraryType.UPLOAD, + ) as LibraryResponseDto + ).id, + isVisible: true, + fileCreatedAt: createdAt, + fileModifiedAt: new Date(), + localDateTime: createdAt, + type: AssetType.IMAGE, + originalFileName: `test_${id}`, + ...other, + }); + + return mapAsset(asset); + }; beforeAll(async () => { [server, app] = await testApp.create(); assetRepository = app.get(IAssetRepository); - }); - afterAll(async () => { - await testApp.teardown(); - }); - - beforeEach(async () => { await db.reset(); + await api.authApi.adminSignUp(server); const admin = await api.authApi.adminLogin(server); - const [libraries] = await Promise.all([ - api.libraryApi.getAll(server, admin.accessToken), + await Promise.all([ api.userApi.create(server, admin.accessToken, user1Dto), api.userApi.create(server, admin.accessToken, user2Dto), ]); - defaultLibrary = libraries[0]; - [user1, user2] = await Promise.all([ api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }), api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password }), ]); - [asset1, asset2, asset3, asset4] = await Promise.all([ - createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')), - createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-02')), - createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-02-01')), - createAsset(assetRepository, user2, defaultLibrary.id, new Date('1970-01-01')), + const [user1Libraries, user2Libraries] = await Promise.all([ + api.libraryApi.getAll(server, user1.accessToken), + api.libraryApi.getAll(server, user2.accessToken), ]); - sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, { - type: SharedLinkType.INDIVIDUAL, - assetIds: [asset1.id, asset2.id], + libraries = [...user1Libraries, ...user2Libraries]; + }); + + beforeEach(async () => { + await db.reset({ entities: [AssetEntity] }); + + [asset1, asset2, asset3, asset4, asset5] = await Promise.all([ + createAsset(user1, new Date('1970-01-01')), + createAsset(user1, new Date('1970-02-10')), + createAsset(user1, new Date('1970-02-11'), { + isFavorite: true, + isArchived: true, + isExternal: true, + isReadOnly: true, + type: AssetType.VIDEO, + fileCreatedAt: yesterday.toJSDate(), + fileModifiedAt: yesterday.toJSDate(), + createdAt: yesterday.toJSDate(), + updatedAt: yesterday.toJSDate(), + localDateTime: yesterday.toJSDate(), + encodedVideoPath: '/path/to/encoded-video.mp4', + webpPath: '/path/to/thumb.webp', + resizePath: '/path/to/thumb.jpg', + }), + createAsset(user2, new Date('1970-01-01')), + createAsset(user1, new Date('1970-01-01'), { + deletedAt: yesterday.toJSDate(), + }), + ]); + + await assetRepository.upsertExif({ + assetId: asset3.id, + latitude: 90, + longitude: 90, + city: 'Immich', + state: 'Nebraska', + country: 'United States', + make: 'Cannon', + model: 'EOS Rebel T7', + lensModel: 'Fancy lens', }); }); + afterAll(async () => { + await testApp.teardown(); + }); + + describe('GET /assets', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get('/assets'); + expect(body).toEqual(errorStub.unauthorized); + expect(status).toBe(401); + }); + + const badTests = [ + // + { + should: 'should reject page as a string', + query: { page: 'abc' }, + expected: ['page must not be less than 1', 'page must be an integer number'], + }, + { + should: 'should reject page as a decimal', + query: { page: 1.5 }, + expected: ['page must be an integer number'], + }, + { + should: 'should reject page as a negative number', + query: { page: -10 }, + expected: ['page must not be less than 1'], + }, + { + should: 'should reject page as 0', + query: { page: 0 }, + expected: ['page must not be less than 1'], + }, + { + should: 'should reject size as a string', + query: { size: 'abc' }, + expected: ['size must not be less than 1', 'size must be an integer number'], + }, + { + should: 'should reject an invalid size', + query: { size: -1.5 }, + expected: ['size must not be less than 1', 'size must be an integer number'], + }, + ...[ + 'isArchived', + 'isFavorite', + 'isReadOnly', + 'isExternal', + 'isEncoded', + 'isMotion', + 'isOffline', + 'isVisible', + ].map((value) => ({ + should: `should reject ${value} not a boolean`, + query: { [value]: 'immich' }, + expected: [`${value} must be a boolean value`], + })), + ]; + + for (const { should, query, expected } of badTests) { + it(should, async () => { + const { status, body } = await request(server) + .get('/assets') + .set('Authorization', `Bearer ${user1.accessToken}`) + .query(query); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(expected)); + }); + } + + const searchTests = [ + { + should: 'should only return my own assets', + deferred: () => ({ + query: {}, + assets: [asset3, asset2, asset1], + }), + }, + { + should: 'should sort my assets in reverse', + deferred: () => ({ + query: { order: 'asc' }, + assets: [asset1, asset2, asset3], + }), + }, + { + should: 'should support custom page sizes', + deferred: () => ({ + query: { size: 1 }, + assets: [asset3], + }), + }, + { + should: 'should support pagination', + deferred: () => ({ + query: { size: 1, page: 2 }, + assets: [asset2], + }), + }, + { + should: 'should search by checksum (base64)', + deferred: () => ({ + query: { checksum: asset1.checksum }, + assets: [asset1], + }), + }, + { + should: 'should search by checksum (hex)', + deferred: () => ({ + query: { checksum: Buffer.from(asset1.checksum, 'base64').toString('hex') }, + assets: [asset1], + }), + }, + { + should: 'should search by id', + deferred: () => ({ + query: { id: asset1.id }, + assets: [asset1], + }), + }, + { + should: 'should search by isFavorite (true)', + deferred: () => ({ + query: { isFavorite: true }, + assets: [asset3], + }), + }, + { + should: 'should search by isFavorite (false)', + deferred: () => ({ + query: { isFavorite: false }, + assets: [asset2, asset1], + }), + }, + { + should: 'should search by isArchived (true)', + deferred: () => ({ + query: { isArchived: true }, + assets: [asset3], + }), + }, + { + should: 'should search by isArchived (false)', + deferred: () => ({ + query: { isArchived: false }, + assets: [asset2, asset1], + }), + }, + { + should: 'should search by isReadOnly (true)', + deferred: () => ({ + query: { isReadOnly: true }, + assets: [asset3], + }), + }, + { + should: 'should search by isReadOnly (false)', + deferred: () => ({ + query: { isReadOnly: false }, + assets: [asset2, asset1], + }), + }, + { + should: 'should search by type (image)', + deferred: () => ({ + query: { type: 'IMAGE' }, + assets: [asset2, asset1], + }), + }, + { + should: 'should search by type (video)', + deferred: () => ({ + query: { type: 'VIDEO' }, + assets: [asset3], + }), + }, + { + should: 'should search by createdBefore', + deferred: () => ({ + query: { createdBefore: yesterday.plus({ hour: 1 }).toJSDate() }, + assets: [asset3], + }), + }, + { + should: 'should search by createdBefore (no results)', + deferred: () => ({ + query: { createdBefore: yesterday.minus({ hour: 1 }).toJSDate() }, + assets: [], + }), + }, + { + should: 'should search by createdAfter', + deferred: () => ({ + query: { createdAfter: yesterday.minus({ hour: 1 }).toJSDate() }, + assets: [asset3, asset2, asset1], + }), + }, + { + should: 'should search by createdAfter (no results)', + deferred: () => ({ + query: { createdAfter: today.plus({ hour: 1 }).toJSDate() }, + assets: [], + }), + }, + { + should: 'should search by updatedBefore', + deferred: () => ({ + query: { updatedBefore: yesterday.plus({ hour: 1 }).toJSDate() }, + assets: [asset3], + }), + }, + { + should: 'should search by updatedBefore (no results)', + deferred: () => ({ + query: { updatedBefore: yesterday.minus({ hour: 1 }).toJSDate() }, + assets: [], + }), + }, + { + should: 'should search by updatedAfter', + deferred: () => ({ + query: { updatedAfter: yesterday.minus({ hour: 1 }).toJSDate() }, + assets: [asset3, asset2, asset1], + }), + }, + { + should: 'should search by updatedAfter (no results)', + deferred: () => ({ + query: { updatedAfter: today.plus({ hour: 1 }).toJSDate() }, + assets: [], + }), + }, + { + should: 'should search by trashedBefore', + deferred: () => ({ + query: { trashedBefore: yesterday.plus({ hour: 1 }).toJSDate() }, + assets: [asset5], + }), + }, + { + should: 'should search by trashedBefore (no results)', + deferred: () => ({ + query: { trashedBefore: yesterday.minus({ hour: 1 }).toJSDate() }, + assets: [], + }), + }, + { + should: 'should search by trashedAfter', + deferred: () => ({ + query: { trashedAfter: yesterday.minus({ hour: 1 }).toJSDate() }, + assets: [asset5], + }), + }, + { + should: 'should search by trashedAfter (no results)', + deferred: () => ({ + query: { trashedAfter: today.plus({ hour: 1 }).toJSDate() }, + assets: [], + }), + }, + { + should: 'should search by takenBefore', + deferred: () => ({ + query: { takenBefore: yesterday.plus({ hour: 1 }).toJSDate() }, + assets: [asset3, asset2, asset1], + }), + }, + { + should: 'should search by takenBefore (no results)', + deferred: () => ({ + query: { takenBefore: yesterday.minus({ years: 100 }).toJSDate() }, + assets: [], + }), + }, + { + should: 'should search by takenAfter', + deferred: () => ({ + query: { takenAfter: yesterday.minus({ hour: 1 }).toJSDate() }, + assets: [asset3], + }), + }, + { + should: 'should search by takenAfter (no results)', + deferred: () => ({ + query: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, + assets: [], + }), + }, + { + should: 'should search by originalPath', + deferred: () => ({ + query: { originalPath: asset1.originalPath }, + assets: [asset1], + }), + }, + { + should: 'should search by originalFilename', + deferred: () => ({ + query: { originalFileName: asset1.originalFileName }, + assets: [asset1], + }), + }, + { + should: 'should search by encodedVideoPath', + deferred: () => ({ + query: { encodedVideoPath: '/path/to/encoded-video.mp4' }, + assets: [asset3], + }), + }, + { + should: 'should search by resizePath', + deferred: () => ({ + query: { resizePath: '/path/to/thumb.jpg' }, + assets: [asset3], + }), + }, + { + should: 'should search by webpPath', + deferred: () => ({ + query: { webpPath: '/path/to/thumb.webp' }, + assets: [asset3], + }), + }, + { + should: 'should search by city', + deferred: () => ({ + query: { city: 'Immich' }, + assets: [asset3], + }), + }, + { + should: 'should search by state', + deferred: () => ({ + query: { state: 'Nebraska' }, + assets: [asset3], + }), + }, + { + should: 'should search by country', + deferred: () => ({ + query: { country: 'United States' }, + assets: [asset3], + }), + }, + { + should: 'sohuld search by make', + deferred: () => ({ + query: { make: 'Cannon' }, + assets: [asset3], + }), + }, + { + should: 'should search by country', + deferred: () => ({ + query: { model: 'EOS Rebel T7' }, + assets: [asset3], + }), + }, + { + should: 'should search by lensModel', + deferred: () => ({ + query: { lensModel: 'Fancy lens' }, + assets: [asset3], + }), + }, + ]; + + for (const { should, deferred } of searchTests) { + it(should, async () => { + const { assets, query } = deferred(); + const { status, body } = await request(server) + .get('/assets') + .query(query) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body.length).toBe(assets.length); + for (let i = 0; i < assets.length; i++) { + expect(body[i]).toEqual(expect.objectContaining({ id: assets[i].id })); + } + }); + } + }); + describe('POST /asset/upload', () => { it('should require authentication', async () => { const { status, body } = await request(server) @@ -369,8 +788,8 @@ describe(`${AssetController.name} (e2e)`, () => { .get('/asset/statistics') .set('Authorization', `Bearer ${user1.accessToken}`); + expect(body).toEqual({ images: 5, videos: 1, total: 6 }); expect(status).toBe(200); - expect(body).toEqual({ images: 6, videos: 0, total: 6 }); }); it('should return stats of all favored assets', async () => { @@ -380,7 +799,7 @@ describe(`${AssetController.name} (e2e)`, () => { .query({ isFavorite: true }); expect(status).toBe(200); - expect(body).toEqual({ images: 2, videos: 0, total: 2 }); + expect(body).toEqual({ images: 2, videos: 1, total: 3 }); }); it('should return stats of all archived assets', async () => { @@ -390,7 +809,7 @@ describe(`${AssetController.name} (e2e)`, () => { .query({ isArchived: true }); expect(status).toBe(200); - expect(body).toEqual({ images: 2, videos: 0, total: 2 }); + expect(body).toEqual({ images: 2, videos: 1, total: 3 }); }); it('should return stats of all favored and archived assets', async () => { @@ -400,7 +819,7 @@ describe(`${AssetController.name} (e2e)`, () => { .query({ isFavorite: true, isArchived: true }); expect(status).toBe(200); - expect(body).toEqual({ images: 1, videos: 0, total: 1 }); + expect(body).toEqual({ images: 1, videos: 1, total: 2 }); }); it('should return stats of all assets neither favored nor archived', async () => { @@ -410,19 +829,19 @@ describe(`${AssetController.name} (e2e)`, () => { .query({ isFavorite: false, isArchived: false }); expect(status).toBe(200); - expect(body).toEqual({ images: 3, videos: 0, total: 3 }); + expect(body).toEqual({ images: 2, videos: 0, total: 2 }); }); }); describe('GET /asset/random', () => { beforeAll(async () => { await Promise.all([ - createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-02-01')), - createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-02-01')), - createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-02-01')), - createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-02-01')), - createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-02-01')), - createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-02-01')), + createAsset(user1, new Date('1970-02-01')), + createAsset(user1, new Date('1970-02-01')), + createAsset(user1, new Date('1970-02-01')), + createAsset(user1, new Date('1970-02-01')), + createAsset(user1, new Date('1970-02-01')), + createAsset(user1, new Date('1970-02-01')), ]); }); it('should require authentication', async () => { @@ -432,7 +851,7 @@ describe(`${AssetController.name} (e2e)`, () => { expect(body).toEqual(errorStub.unauthorized); }); - it('should return 1 random assets', async () => { + it.each(Array(10))('should return 1 random assets', async () => { const { status, body } = await request(server) .get('/asset/random') .set('Authorization', `Bearer ${user1.accessToken}`); @@ -442,13 +861,14 @@ describe(`${AssetController.name} (e2e)`, () => { const assets: AssetResponseDto[] = body; expect(assets.length).toBe(1); expect(assets[0].ownerId).toBe(user1.userId); - // assets owned by user1 - expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id); + // // assets owned by user2 expect(assets[0].id).not.toBe(asset4.id); + // assets owned by user1 + expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id); }); - it('should return 2 random assets', async () => { + it.each(Array(10))('should return 2 random assets', async () => { const { status, body } = await request(server) .get('/asset/random?count=2') .set('Authorization', `Bearer ${user1.accessToken}`); @@ -505,13 +925,19 @@ describe(`${AssetController.name} (e2e)`, () => { expect(status).toBe(200); expect(body).toEqual( expect.arrayContaining([ - { count: 1, timeBucket: asset3.fileCreatedAt.toISOString() }, - { count: 2, timeBucket: asset1.fileCreatedAt.toISOString() }, + { count: 1, timeBucket: '2023-11-01T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-02-01T00:00:00.000Z' }, ]), ); }); it('should not allow access for unrelated shared links', async () => { + const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, { + type: SharedLinkType.INDIVIDUAL, + assetIds: [asset1.id, asset2.id], + }); + const { status, body } = await request(server) .get('/asset/time-buckets') .query({ key: sharedLink.key, size: TimeBucketSize.MONTH }); @@ -575,12 +1001,7 @@ describe(`${AssetController.name} (e2e)`, () => { .query({ size: TimeBucketSize.MONTH, timeBucket }); expect(status).toBe(200); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset1.id }), - expect.objectContaining({ id: asset2.id }), - ]), - ); + expect(body).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset2.id })])); }); it('should return error if time bucket is requested with partners asset and archived', async () => { @@ -649,16 +1070,12 @@ describe(`${AssetController.name} (e2e)`, () => { it('should get map markers for all non-archived assets', async () => { const { status, body } = await request(server) .get('/asset/map-marker') + .query({ isArchived: false }) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset1.id }), - expect.objectContaining({ id: asset2.id }), - ]), - ); + expect(body).toHaveLength(1); + expect(body).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset2.id })])); }); it('should get all map markers', async () => { @@ -711,8 +1128,10 @@ describe(`${AssetController.name} (e2e)`, () => { }); it('should add stack children', async () => { - const parent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); - const child = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + const [parent, child] = await Promise.all([ + createAsset(user1, new Date('1970-01-01')), + createAsset(user1, new Date('1970-01-01')), + ]); const { status } = await request(server) .put('/asset') @@ -752,7 +1171,7 @@ describe(`${AssetController.name} (e2e)`, () => { }); it('should merge stack children', async () => { - const newParent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + const newParent = await createAsset(user1, new Date('1970-01-01')); const { status } = await request(server) .put('/asset') .set('Authorization', `Bearer ${user1.accessToken}`) diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 3b584e09c8..566b7733a1 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -31,5 +31,6 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getTimeBuckets: jest.fn(), restoreAll: jest.fn(), softDeleteAll: jest.fn(), + search: jest.fn(), }; }; diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts index 4ac0cf0bfa..819d04556f 100644 --- a/server/test/test-utils.ts +++ b/server/test/test-utils.ts @@ -5,25 +5,39 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import * as fs from 'fs'; import path from 'path'; +import { EntityTarget, ObjectLiteral } from 'typeorm'; import { AppService } from '../src/microservices/app.service'; export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH; export const IMMICH_TEST_ASSET_TEMP_PATH = path.normalize(`${IMMICH_TEST_ASSET_PATH}/temp/`); +export interface ResetOptions { + entities?: EntityTarget[]; +} export const db = { - reset: async () => { + reset: async (options?: ResetOptions) => { if (!dataSource.isInitialized) { await dataSource.initialize(); } await dataSource.transaction(async (em) => { - for (const entity of dataSource.entityMetadatas) { - if (entity.tableName === 'users') { + const entities = options?.entities || []; + const tableNames = + entities.length > 0 + ? entities.map((entity) => em.getRepository(entity).metadata.tableName) + : dataSource.entityMetadatas.map((entity) => entity.tableName); + + let deleteUsers = false; + for (const tableName of tableNames) { + if (tableName === 'users') { + deleteUsers = true; continue; } - await em.query(`DELETE FROM ${entity.tableName} CASCADE;`); + await em.query(`DELETE FROM ${tableName} CASCADE;`); + } + if (deleteUsers) { + await em.query(`DELETE FROM "users" CASCADE;`); } - await em.query(`DELETE FROM "users" CASCADE;`); }); }, disconnect: async () => { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index ecb0fb39e8..3828babd7d 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -670,6 +670,20 @@ export interface AssetJobsDto { } +/** + * + * @export + * @enum {string} + */ + +export const AssetOrder = { + Asc: 'asc', + Desc: 'desc' +} as const; + +export type AssetOrder = typeof AssetOrder[keyof typeof AssetOrder]; + + /** * * @export @@ -7822,6 +7836,260 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {string} [id] + * @param {string} [libraryId] + * @param {AssetTypeEnum} [type] + * @param {AssetOrder} [order] + * @param {string} [deviceAssetId] + * @param {string} [deviceId] + * @param {string} [checksum] + * @param {boolean} [isArchived] + * @param {boolean} [isEncoded] + * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] + * @param {boolean} [isMotion] + * @param {boolean} [isOffline] + * @param {boolean} [isReadOnly] + * @param {boolean} [isVisible] + * @param {boolean} [withDeleted] + * @param {boolean} [withStacked] + * @param {boolean} [withExif] + * @param {boolean} [withPeople] + * @param {string} [createdBefore] + * @param {string} [createdAfter] + * @param {string} [updatedBefore] + * @param {string} [updatedAfter] + * @param {string} [trashedBefore] + * @param {string} [trashedAfter] + * @param {string} [takenBefore] + * @param {string} [takenAfter] + * @param {string} [originalFileName] + * @param {string} [originalPath] + * @param {string} [resizePath] + * @param {string} [webpPath] + * @param {string} [encodedVideoPath] + * @param {string} [city] + * @param {string} [state] + * @param {string} [country] + * @param {string} [make] + * @param {string} [model] + * @param {string} [lensModel] + * @param {number} [page] + * @param {number} [size] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchAssets: async (id?: string, libraryId?: string, type?: AssetTypeEnum, order?: AssetOrder, deviceAssetId?: string, deviceId?: string, checksum?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, withDeleted?: boolean, withStacked?: boolean, withExif?: boolean, withPeople?: boolean, createdBefore?: string, createdAfter?: string, updatedBefore?: string, updatedAfter?: string, trashedBefore?: string, trashedAfter?: string, takenBefore?: string, takenAfter?: string, originalFileName?: string, originalPath?: string, resizePath?: string, webpPath?: string, encodedVideoPath?: string, city?: string, state?: string, country?: string, make?: string, model?: string, lensModel?: string, page?: number, size?: number, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/assets`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (id !== undefined) { + localVarQueryParameter['id'] = id; + } + + if (libraryId !== undefined) { + localVarQueryParameter['libraryId'] = libraryId; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + if (order !== undefined) { + localVarQueryParameter['order'] = order; + } + + if (deviceAssetId !== undefined) { + localVarQueryParameter['deviceAssetId'] = deviceAssetId; + } + + if (deviceId !== undefined) { + localVarQueryParameter['deviceId'] = deviceId; + } + + if (checksum !== undefined) { + localVarQueryParameter['checksum'] = checksum; + } + + if (isArchived !== undefined) { + localVarQueryParameter['isArchived'] = isArchived; + } + + if (isEncoded !== undefined) { + localVarQueryParameter['isEncoded'] = isEncoded; + } + + if (isExternal !== undefined) { + localVarQueryParameter['isExternal'] = isExternal; + } + + if (isFavorite !== undefined) { + localVarQueryParameter['isFavorite'] = isFavorite; + } + + if (isMotion !== undefined) { + localVarQueryParameter['isMotion'] = isMotion; + } + + if (isOffline !== undefined) { + localVarQueryParameter['isOffline'] = isOffline; + } + + if (isReadOnly !== undefined) { + localVarQueryParameter['isReadOnly'] = isReadOnly; + } + + if (isVisible !== undefined) { + localVarQueryParameter['isVisible'] = isVisible; + } + + if (withDeleted !== undefined) { + localVarQueryParameter['withDeleted'] = withDeleted; + } + + if (withStacked !== undefined) { + localVarQueryParameter['withStacked'] = withStacked; + } + + if (withExif !== undefined) { + localVarQueryParameter['withExif'] = withExif; + } + + if (withPeople !== undefined) { + localVarQueryParameter['withPeople'] = withPeople; + } + + if (createdBefore !== undefined) { + localVarQueryParameter['createdBefore'] = (createdBefore as any instanceof Date) ? + (createdBefore as any).toISOString() : + createdBefore; + } + + if (createdAfter !== undefined) { + localVarQueryParameter['createdAfter'] = (createdAfter as any instanceof Date) ? + (createdAfter as any).toISOString() : + createdAfter; + } + + if (updatedBefore !== undefined) { + localVarQueryParameter['updatedBefore'] = (updatedBefore as any instanceof Date) ? + (updatedBefore as any).toISOString() : + updatedBefore; + } + + if (updatedAfter !== undefined) { + localVarQueryParameter['updatedAfter'] = (updatedAfter as any instanceof Date) ? + (updatedAfter as any).toISOString() : + updatedAfter; + } + + if (trashedBefore !== undefined) { + localVarQueryParameter['trashedBefore'] = (trashedBefore as any instanceof Date) ? + (trashedBefore as any).toISOString() : + trashedBefore; + } + + if (trashedAfter !== undefined) { + localVarQueryParameter['trashedAfter'] = (trashedAfter as any instanceof Date) ? + (trashedAfter as any).toISOString() : + trashedAfter; + } + + if (takenBefore !== undefined) { + localVarQueryParameter['takenBefore'] = (takenBefore as any instanceof Date) ? + (takenBefore as any).toISOString() : + takenBefore; + } + + if (takenAfter !== undefined) { + localVarQueryParameter['takenAfter'] = (takenAfter as any instanceof Date) ? + (takenAfter as any).toISOString() : + takenAfter; + } + + if (originalFileName !== undefined) { + localVarQueryParameter['originalFileName'] = originalFileName; + } + + if (originalPath !== undefined) { + localVarQueryParameter['originalPath'] = originalPath; + } + + if (resizePath !== undefined) { + localVarQueryParameter['resizePath'] = resizePath; + } + + if (webpPath !== undefined) { + localVarQueryParameter['webpPath'] = webpPath; + } + + if (encodedVideoPath !== undefined) { + localVarQueryParameter['encodedVideoPath'] = encodedVideoPath; + } + + if (city !== undefined) { + localVarQueryParameter['city'] = city; + } + + if (state !== undefined) { + localVarQueryParameter['state'] = state; + } + + if (country !== undefined) { + localVarQueryParameter['country'] = country; + } + + if (make !== undefined) { + localVarQueryParameter['make'] = make; + } + + if (model !== undefined) { + localVarQueryParameter['model'] = model; + } + + if (lensModel !== undefined) { + localVarQueryParameter['lensModel'] = lensModel; + } + + if (page !== undefined) { + localVarQueryParameter['page'] = page; + } + + if (size !== undefined) { + localVarQueryParameter['size'] = size; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} id @@ -8440,6 +8708,55 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.searchAsset(searchAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} [id] + * @param {string} [libraryId] + * @param {AssetTypeEnum} [type] + * @param {AssetOrder} [order] + * @param {string} [deviceAssetId] + * @param {string} [deviceId] + * @param {string} [checksum] + * @param {boolean} [isArchived] + * @param {boolean} [isEncoded] + * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] + * @param {boolean} [isMotion] + * @param {boolean} [isOffline] + * @param {boolean} [isReadOnly] + * @param {boolean} [isVisible] + * @param {boolean} [withDeleted] + * @param {boolean} [withStacked] + * @param {boolean} [withExif] + * @param {boolean} [withPeople] + * @param {string} [createdBefore] + * @param {string} [createdAfter] + * @param {string} [updatedBefore] + * @param {string} [updatedAfter] + * @param {string} [trashedBefore] + * @param {string} [trashedAfter] + * @param {string} [takenBefore] + * @param {string} [takenAfter] + * @param {string} [originalFileName] + * @param {string} [originalPath] + * @param {string} [resizePath] + * @param {string} [webpPath] + * @param {string} [encodedVideoPath] + * @param {string} [city] + * @param {string} [state] + * @param {string} [country] + * @param {string} [make] + * @param {string} [model] + * @param {string} [lensModel] + * @param {number} [page] + * @param {number} [size] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async searchAssets(id?: string, libraryId?: string, type?: AssetTypeEnum, order?: AssetOrder, deviceAssetId?: string, deviceId?: string, checksum?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, withDeleted?: boolean, withStacked?: boolean, withExif?: boolean, withPeople?: boolean, createdBefore?: string, createdAfter?: string, updatedBefore?: string, updatedAfter?: string, trashedBefore?: string, trashedAfter?: string, takenBefore?: string, takenAfter?: string, originalFileName?: string, originalPath?: string, resizePath?: string, webpPath?: string, encodedVideoPath?: string, city?: string, state?: string, country?: string, make?: string, model?: string, lensModel?: string, page?: number, size?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchAssets(id, libraryId, type, order, deviceAssetId, deviceId, checksum, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, withDeleted, withStacked, withExif, withPeople, createdBefore, createdAfter, updatedBefore, updatedAfter, trashedBefore, trashedAfter, takenBefore, takenAfter, originalFileName, originalPath, resizePath, webpPath, encodedVideoPath, city, state, country, make, model, lensModel, page, size, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -8739,6 +9056,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath searchAsset(requestParameters: AssetApiSearchAssetRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.searchAsset(requestParameters.searchAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiSearchAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchAssets(requestParameters: AssetApiSearchAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.searchAssets(requestParameters.id, requestParameters.libraryId, requestParameters.type, requestParameters.order, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.checksum, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.withDeleted, requestParameters.withStacked, requestParameters.withExif, requestParameters.withPeople, requestParameters.createdBefore, requestParameters.createdAfter, requestParameters.updatedBefore, requestParameters.updatedAfter, requestParameters.trashedBefore, requestParameters.trashedAfter, requestParameters.takenBefore, requestParameters.takenAfter, requestParameters.originalFileName, requestParameters.originalPath, requestParameters.resizePath, requestParameters.webpPath, requestParameters.encodedVideoPath, requestParameters.city, requestParameters.state, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.lensModel, requestParameters.page, requestParameters.size, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiServeFileRequest} requestParameters Request parameters. @@ -9333,6 +9659,293 @@ export interface AssetApiSearchAssetRequest { readonly searchAssetDto: SearchAssetDto } +/** + * Request parameters for searchAssets operation in AssetApi. + * @export + * @interface AssetApiSearchAssetsRequest + */ +export interface AssetApiSearchAssetsRequest { + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly id?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly libraryId?: string + + /** + * + * @type {AssetTypeEnum} + * @memberof AssetApiSearchAssets + */ + readonly type?: AssetTypeEnum + + /** + * + * @type {AssetOrder} + * @memberof AssetApiSearchAssets + */ + readonly order?: AssetOrder + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly deviceAssetId?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly deviceId?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly checksum?: string + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isArchived?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isEncoded?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isExternal?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isFavorite?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isMotion?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isOffline?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isReadOnly?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly isVisible?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly withDeleted?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly withStacked?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly withExif?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly withPeople?: boolean + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly createdBefore?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly createdAfter?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly updatedBefore?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly updatedAfter?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly trashedBefore?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly trashedAfter?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly takenBefore?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly takenAfter?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly originalFileName?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly originalPath?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly resizePath?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly webpPath?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly encodedVideoPath?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly city?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly state?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly country?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly make?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly model?: string + + /** + * + * @type {string} + * @memberof AssetApiSearchAssets + */ + readonly lensModel?: string + + /** + * + * @type {number} + * @memberof AssetApiSearchAssets + */ + readonly page?: number + + /** + * + * @type {number} + * @memberof AssetApiSearchAssets + */ + readonly size?: number +} + /** * Request parameters for serveFile operation in AssetApi. * @export @@ -9813,6 +10426,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).searchAsset(requestParameters.searchAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiSearchAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public searchAssets(requestParameters: AssetApiSearchAssetsRequest = {}, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).searchAssets(requestParameters.id, requestParameters.libraryId, requestParameters.type, requestParameters.order, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.checksum, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.withDeleted, requestParameters.withStacked, requestParameters.withExif, requestParameters.withPeople, requestParameters.createdBefore, requestParameters.createdAfter, requestParameters.updatedBefore, requestParameters.updatedAfter, requestParameters.trashedBefore, requestParameters.trashedAfter, requestParameters.takenBefore, requestParameters.takenAfter, requestParameters.originalFileName, requestParameters.originalPath, requestParameters.resizePath, requestParameters.webpPath, requestParameters.encodedVideoPath, requestParameters.city, requestParameters.state, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.lensModel, requestParameters.page, requestParameters.size, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiServeFileRequest} requestParameters Request parameters.