From 5e901e4d2104ab82f89a56e2cce814683c65049d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 18 Aug 2023 10:31:48 -0400 Subject: [PATCH] feat(web,server): run jobs for specific assets (#3712) * feat(web,server): manually queue asset job * chore: open api * chore: tests --- cli/src/api/open-api/api.ts | 124 ++++++++++++++++++ mobile/openapi/.openapi-generator/FILES | 6 + mobile/openapi/README.md | Bin 18497 -> 18666 bytes mobile/openapi/doc/AssetApi.md | Bin 56743 -> 58716 bytes mobile/openapi/doc/AssetJobName.md | Bin 0 -> 378 bytes mobile/openapi/doc/AssetJobsDto.md | Bin 0 -> 490 bytes mobile/openapi/lib/api.dart | Bin 5969 -> 6037 bytes mobile/openapi/lib/api/asset_api.dart | Bin 52530 -> 53647 bytes mobile/openapi/lib/api_client.dart | Bin 18731 -> 18898 bytes mobile/openapi/lib/api_helper.dart | Bin 4464 -> 4566 bytes mobile/openapi/lib/model/asset_job_name.dart | Bin 0 -> 2873 bytes mobile/openapi/lib/model/asset_jobs_dto.dart | Bin 0 -> 3018 bytes mobile/openapi/test/asset_api_test.dart | Bin 5352 -> 5475 bytes mobile/openapi/test/asset_job_name_test.dart | Bin 0 -> 421 bytes mobile/openapi/test/asset_jobs_dto_test.dart | Bin 0 -> 691 bytes server/immich-openapi-specs.json | 62 +++++++++ server/src/domain/asset/asset.service.spec.ts | 28 +++- server/src/domain/asset/asset.service.ts | 24 ++++ server/src/domain/asset/dto/asset-ids.dto.ts | 14 ++ .../immich/controllers/asset.controller.ts | 7 + web/src/api/api.ts | 21 +++ web/src/api/open-api/api.ts | 124 ++++++++++++++++++ .../asset-viewer/asset-viewer-nav-bar.svelte | 63 ++++++--- .../asset-viewer/asset-viewer.svelte | 12 +- .../actions/asset-job-actions.svelte | 37 ++++++ web/src/routes/(user)/photos/+page.svelte | 2 + 26 files changed, 506 insertions(+), 18 deletions(-) create mode 100644 mobile/openapi/doc/AssetJobName.md create mode 100644 mobile/openapi/doc/AssetJobsDto.md create mode 100644 mobile/openapi/lib/model/asset_job_name.dart create mode 100644 mobile/openapi/lib/model/asset_jobs_dto.dart create mode 100644 mobile/openapi/test/asset_job_name_test.dart create mode 100644 mobile/openapi/test/asset_jobs_dto_test.dart create mode 100644 web/src/lib/components/photos-page/actions/asset-job-actions.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 850aeae7f..c4d4707f3 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -525,6 +525,42 @@ export const AssetIdsResponseDtoErrorEnum = { export type AssetIdsResponseDtoErrorEnum = typeof AssetIdsResponseDtoErrorEnum[keyof typeof AssetIdsResponseDtoErrorEnum]; +/** + * + * @export + * @enum {string} + */ + +export const AssetJobName = { + RegenerateThumbnail: 'regenerate-thumbnail', + RefreshMetadata: 'refresh-metadata', + TranscodeVideo: 'transcode-video' +} as const; + +export type AssetJobName = typeof AssetJobName[keyof typeof AssetJobName]; + + +/** + * + * @export + * @interface AssetJobsDto + */ +export interface AssetJobsDto { + /** + * + * @type {Array} + * @memberof AssetJobsDto + */ + 'assetIds': Array; + /** + * + * @type {AssetJobName} + * @memberof AssetJobsDto + */ + 'name': AssetJobName; +} + + /** * * @export @@ -5784,6 +5820,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {AssetJobsDto} assetJobsDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + runAssetJobs: async (assetJobsDto: AssetJobsDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'assetJobsDto' is not null or undefined + assertParamExists('runAssetJobs', 'assetJobsDto', assetJobsDto) + const localVarPath = `/asset/jobs`; + // 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: 'POST', ...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) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(assetJobsDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {SearchAssetDto} searchAssetDto @@ -6331,6 +6411,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {AssetJobsDto} assetJobsDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async runAssetJobs(assetJobsDto: AssetJobsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.runAssetJobs(assetJobsDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {SearchAssetDto} searchAssetDto @@ -6584,6 +6674,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiSearchAssetRequest} requestParameters Request parameters. @@ -7066,6 +7165,20 @@ export interface AssetApiImportFileRequest { readonly importAssetDto: ImportAssetDto } +/** + * Request parameters for runAssetJobs operation in AssetApi. + * @export + * @interface AssetApiRunAssetJobsRequest + */ +export interface AssetApiRunAssetJobsRequest { + /** + * + * @type {AssetJobsDto} + * @memberof AssetApiRunAssetJobs + */ + readonly assetJobsDto: AssetJobsDto +} + /** * Request parameters for searchAsset operation in AssetApi. * @export @@ -7472,6 +7585,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiSearchAssetRequest} requestParameters Request parameters. diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index c51b8a3e0..93b704976 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -23,6 +23,8 @@ doc/AssetBulkUploadCheckResult.md doc/AssetFileUploadResponseDto.md doc/AssetIdsDto.md doc/AssetIdsResponseDto.md +doc/AssetJobName.md +doc/AssetJobsDto.md doc/AssetResponseDto.md doc/AssetStatsResponseDto.md doc/AssetTypeEnum.md @@ -168,6 +170,8 @@ lib/model/asset_bulk_upload_check_result.dart lib/model/asset_file_upload_response_dto.dart 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_response_dto.dart lib/model/asset_stats_response_dto.dart lib/model/asset_type_enum.dart @@ -282,6 +286,8 @@ test/asset_bulk_upload_check_result_test.dart test/asset_file_upload_response_dto_test.dart 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_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 6c6108a9fe07fe912a9b475d93c1e6561f0eb271..976c1aa0942dee34b9df5f9352a026d77d712265 100644 GIT binary patch delta 125 zcmX>&f$`Nu#tr;pJVm8>j>W~PC0_YS#giL_L_q?HAb~89z(Y|HW*}qoL_yKb{9@X2 t_CRHRiMgq<8Y%h7`uZ@PAR)cn6iqG#U4>|vV6jU{KAO7X&6*|>A^-_cD`fxx delta 19 bcmaDgk@4UJ#tr;pn={0;W~PC0_YS#addi8eooNL8e}AiZW0x5hRxdl+#qGQP9!~ z@DC2r(o)a|3+sc#HwUmQtW-xeh09I>SwI7<8f3xdbsN-JwBcNrl6(bQ1xJ|mK(2!4BY({mfk=a5_UR01Oo^Vs=6Vh7hc>FLEa-Uoj$DyDtOJS&+D#IJ3)L$52zn_&qQ5ZKh6<%DH z!{JHm$;+ahZx-6y#&ZARDTt}Q>Wz~c66P`fUbnmDKW|H3MZrdPLGC1e3?IzL0Pqc| CaCCtH literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/AssetJobsDto.md b/mobile/openapi/doc/AssetJobsDto.md new file mode 100644 index 0000000000000000000000000000000000000000..e1d04388a1b62a4456e527b96c329651f22084f5 GIT binary patch literal 490 zcma)2!Ait15WVLs271_SpxwKQQ0X34c9%uHl|o~aU4v~VB(n#>k9Sg6tX?z(!h3o1 zX69vp-Zz(ho?86p4!AdSWgxmqdgKUirHauhKk}ZETmLvjg5{{ zvRKNkjdb%!!MfA!L^0cFb5!SnfDn3gk)UZ?E_FQHq-mu5N+ZoHWxJf?a@0B7@D-wI z?l_m^v`Z<%!2Q~GS(dAZbye@ytj#p`=g~M)Y0u(_p_melIeb@Fhs{50Ip4xK?a?~= NVe#4USMyv5@c})bm?HoH literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 064a265175fd91984af5d7b743e2c5eb3560162f..7797555bfa32d50dc91d06c1016625436f9bb6b8 100644 GIT binary patch delta 33 ncmcbpH&uT_3j5^u+%l6lu(Ppb~Hx2BUlAJ diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 2c73a4ba8ccf7503101030e19f9f17c7a5b8dd16..0f8b69a1b6a902ebc8fea7a6474756e7f94906b9 100644 GIT binary patch delta 246 zcmdlqi@AR>^M+6JShDhyiYGsm@SJR*%qrvsHQ=H^O-On+&JTo`J8`BR2h6C+hKfC#=Ls`0PX4DUjoUWK$?`kc kl#p%2Zp-8|qFN}*P#gxcw0N>V+ln+a delta 14 WcmeBQ%)Dt9^M+6JHv2Dj&;tN9GzQoJ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 830aab93695e865c257d2824ab67b50c662a68de..2a3ebeb648f7b22fc1c515eece188ccd3f9682a6 100644 GIT binary patch delta 54 zcmZ2IiSg28#ton4C(CF_PZp46W%J5U@=MH}e8J3M@&W}`W*~QRpu8*#kS?CwD66sg Iv%G~S02N~r%>V!Z delta 14 WcmcaKnQ`?b#ton4H_uYA&;$T8;s!MU diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index bc1dfd7bc73342592a8db6001355e4b13f80dbba..a80daaf76c4a5083db1d95244560caf735f35b1e 100644 GIT binary patch delta 51 zcmeyMbWM4K8k>?=ev)5eZmOn2HJ1Vq6s4Aw7Ud~878j?MK&2)JaM^EGWb@|+0K#<; AumAu6 delta 12 Tcmcbn{6T4h8r$X`HZN`fBQ*qq diff --git a/mobile/openapi/lib/model/asset_job_name.dart b/mobile/openapi/lib/model/asset_job_name.dart new file mode 100644 index 0000000000000000000000000000000000000000..61334df089336300bc63cd0b6aabe322b11b7b1a GIT binary patch literal 2873 zcmai0VQri?-Xr-)K59B0O{Z}ExO}+$ZE?T&^Ln|sf!n*!iwWF3!2QD#zC7IA-Tr+> zW^DN}6~@ipM!&or@ul1Se9zBP?%+pR88>heWWGNfo-mEh;pW z^Kw@f8zp28vwlM6{A70NYP)mF4xopY8MM1E=tL@<7)J6Ym(&X0W&p(*p=_d4{EL#+ zLm-XCC09fSD>pd?Vk}4%=w5MM*Q<6D_+oZa@fEV<{MWYKhZXq& zIMLCtHmM$T2naWuXnj6>Z|&o){I&7tkQrV0s-;Jo8>&-iB(87UaCzM0l6j(GEzpcF zq7<33ajWkte5RzE#2j+EciZ>$4>ijKzHhY7VQch3Fn2S^L~e2NeBlSB?oKLmnY)+t zP-aY!%wWuLK;7+2^5zZjbtLV*c8g-~jx`=3Y6IiY^yHPx#HOd`wa+j#z3y!IqNBbc z*?kBg*q_Znm3iKV;{tN{kx0{quy79G*(Xqchbx1wG^#%3G^aGKDY|ON^8TMk&n%`> zXv#z5Moa|dA(e8F2nE!HT(33IZ|RjMK^BB=TY}TotWP|qP$WO0(rc(Y#E;rWB;VM& zdp;Tvyk6`0vSuQ2+8keRG48>*iFH1o4ai>)oYXMP=1Zack!7V_fu3=@=YbRTO2>Ia+F^}jx-dqYkmQcWMs=y+Rn-fK;yeQF*YpSn&V~@L|F{EMwX}MPb7HBrF~i(Z zvNT?_E@`?A7PP$5ni00A7Yjoc25Eyx66@0Pwj*Gj6WD%#+Pi#%M}?zox5K<*eO%X@ z3tj71Q%jO4u4E%$tW0bOFLc4urcZX^X#J?6G)1gARW9SX6rxZ=d59`%>!FF?iaW=+mc@heIJd_AJsQS zT@+qpLh)(Z!bn`fPt11M^0kjwB$lO`<0B0X)ANTVuvmNc L^OKOhXTAOp<-@xW literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_jobs_dto.dart b/mobile/openapi/lib/model/asset_jobs_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..3c88438b979120e65ea574eef6744714cef256d9 GIT binary patch literal 3018 zcmbVOU2oeq6n*!vxB-ehF4})O{v_xC%WYHt38b<2>zI#bg zmQpPZ79fd4-Y=eW?iI)5!FUW;U+x#L|D4TdpRVp_*Kl+DVHU#m0_KZ*__(;fz4_|` z&B*dy#*~TQM8Ccn(5u*%Qt@OXRk9HUpFmYsnr8_w_>Pw*^zUM~m&){du;RduI@wny zQT(4uD0DB`4*%|%!vBUVjlq@fcTZ)h4J%C|GE68of-CE;dy^HS)GI*G9s_0AP%V|HMtDN(~H3et>1$ zW(C5QmGTIU+dBby1DIUs2qSZIn`wx_9s`!E(C&I5oEf1(>!%Uc1Mop;^ZRF`M7f=U z4`FwFo;f-IdD+!>@*7tQ;X&WElm#77F(Vbkaj;l_qK!=8gxylF3j9tpC}D%xq+PN{12I-;naq2q(%xWdCIkB>PQMf z(sQnbwq}8cBk}=8@XGB_+VkoUz&Jd?NcRv}EQTIXqJ8OO$1nU-Xb%y;pxCRSjro!7 zYi>fwj<_MxDMr21vc#B<`;91B0bg+miT}a+;dJQ`wH8Y?^x1|hH1Fu|z33h%iGvd`Pbh_xR&2LeyQFe8Ch_RHwRz-Tn zV*ENl2YhQ1rL1YFGiiZqHtN+HzqS|xEX)0Pb|yV^VQy;H#+E$ZLO{%Sd1cOU)GlQj zj5>~!M>B$g5yz7Tm^r>h#EZqH&f-Kdx^RO90FOnUC;PWP8S&I8qek0Knf8&VP~cf^ ze0UE#r3vA|4wkKw2evM-Yhl9*&BK$&-J!0yt_nkE(9h@H44!E_Kk#wZMLo@;ZWTV)gtdVbzkPc3;WBXqGeVGmpv}(_lP6$6-+E8P@ zCy}<4#EyyoY89L(^<7{B&1XAdTH^(QE&^^mcUZ$!b@aSN^d`9WCm}Z6X`bfK28paq zi;bV~hyX2}V+ht0|wA`zO=k_ATXse_r&HvHLGiC4`N!7!z)#pD|44$b= zY#aTEC}|*T2`6k?@!XVQqb4VYPDf*!mI%ghpYw+A=H`ad()hJzNNt5uSaZM+p2oB! rc?Nf%7jwEA){MLBpLc#wzRlHO`e30r-vitzAKJ-o?}e8GoFD%JQ4qpJ literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 2a4847e02917ff483c1495891bdac638a5a65986..ef8ae195a7d39404bb2f55f285578d1aa33b27e5 100644 GIT binary patch delta 69 zcmaE%`B-biR{<4;qS8Fa;^Nd2ul%H94LHlCBwryBCOmn8fD|{9y2%H5EjIrac*_g` D(PSIN delta 12 TcmaE?^+I#QSAoq1f}fcID(waS diff --git a/mobile/openapi/test/asset_job_name_test.dart b/mobile/openapi/test/asset_job_name_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..dc6313c9274eff4623459bb29cb332397b1f3096 GIT binary patch literal 421 zcmZvYK}*Ci5QXpg72~P9P&c|K*+sC>uB>PaQV*WO*iO5_Hi=29i0pqiQT8DAkO>dI zH*aP*XPiU!R9EM>MOi#%b&A(G?It6id!e! ze~2gNc`PR&hDPlmoCGrG$lvLgGOWSgwQONTsTNE4Vho_SdNPY7;(y2_g%fgIZW%kW EZ;M-wJpcdz literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/asset_jobs_dto_test.dart b/mobile/openapi/test/asset_jobs_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..e114d9fb2c64aae40f15e1f761cc4147fda0448a GIT binary patch literal 691 zcma)3-AltT5P$DqaXxK>Y1Jq33kRJt(G_$)h)6u!YZvU2np~A3{_kEg_aF}Rp=mDp zeO)e1lQf0V-7Md}7*EGHquDrv^U2kC2w4u(dqToFZ)9Nej!UoFj+LX=PCKC+T*%s@lRuO5BJTWV zdSQ)cG^}(fhPu+^BHeE^YqTM+8f$8S@f?jh*j2?GollZgM(DLzykJd-wk3nDPUYJP zUH6WDL6|(JVOax$X{bHOlfcM(;!FGNGq%Z_)g>%YoBjYk5&%I~+F%1ZHc$|p{2=rP zM~T2{XS=rF3!*=+dqemZic(kF^YJY^ZR%6N;5#Wj^h>I+YZ%_J>0~ { let accessMock: IAccessRepositoryMock; let assetMock: jest.Mocked; let cryptoMock: jest.Mocked; + let jobMock: jest.Mocked; let storageMock: jest.Mocked; it('should work', () => { @@ -155,8 +158,9 @@ describe(AssetService.name, () => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); + jobMock = newJobRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new AssetService(accessMock, assetMock, cryptoMock, storageMock); + sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, storageMock); }); describe('canUpload', () => { @@ -532,4 +536,24 @@ describe(AssetService.name, () => { expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); }); + + describe('run', () => { + it('should run the refresh metadata job', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }), + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }); + }); + + it('should run the refresh thumbnails job', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }), + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } }); + }); + + it('should run the transcode video', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }), + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }); + }); + }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 7d00aa6b0..c7dc79046 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -8,11 +8,14 @@ import { AuthUserDto } from '../auth'; import { ICryptoRepository } from '../crypto'; import { mimeTypes } from '../domain.constant'; import { HumanReadableSize, usePagination } from '../domain.util'; +import { IJobRepository, JobName } from '../job'; import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage'; import { IAssetRepository } from './asset.repository'; import { AssetBulkUpdateDto, AssetIdsDto, + AssetJobName, + AssetJobsDto, DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto, @@ -54,6 +57,7 @@ export class AssetService { @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { this.access = new AccessCore(accessRepository); @@ -275,4 +279,24 @@ export class AssetService { await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); await this.assetRepository.updateAll(ids, options); } + + async run(authUser: AuthUserDto, dto: AssetJobsDto) { + await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds); + + for (const id of dto.assetIds) { + switch (dto.name) { + case AssetJobName.REFRESH_METADATA: + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id } }); + break; + + case AssetJobName.REGENERATE_THUMBNAIL: + await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id } }); + break; + + case AssetJobName.TRANSCODE_VIDEO: + await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id } }); + break; + } + } + } } diff --git a/server/src/domain/asset/dto/asset-ids.dto.ts b/server/src/domain/asset/dto/asset-ids.dto.ts index 6d2c58528..5ee988bb4 100644 --- a/server/src/domain/asset/dto/asset-ids.dto.ts +++ b/server/src/domain/asset/dto/asset-ids.dto.ts @@ -1,6 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum } from 'class-validator'; import { ValidateUUID } from '../../domain.util'; export class AssetIdsDto { @ValidateUUID({ each: true }) assetIds!: string[]; } + +export enum AssetJobName { + REGENERATE_THUMBNAIL = 'regenerate-thumbnail', + REFRESH_METADATA = 'refresh-metadata', + TRANSCODE_VIDEO = 'transcode-video', +} + +export class AssetJobsDto extends AssetIdsDto { + @ApiProperty({ enumName: 'AssetJobName', enum: AssetJobName }) + @IsEnum(AssetJobName) + name!: AssetJobName; +} diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index 70168cb81..652238a1c 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -1,6 +1,7 @@ import { AssetBulkUpdateDto, AssetIdsDto, + AssetJobsDto, AssetResponseDto, AssetService, AssetStatsDto, @@ -78,6 +79,12 @@ export class AssetController { return this.service.getByTimeBucket(authUser, dto); } + @Post('jobs') + @HttpCode(HttpStatus.NO_CONTENT) + runAssetJobs(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetJobsDto): Promise { + return this.service.run(authUser, dto); + } + @Put() @HttpCode(HttpStatus.NO_CONTENT) updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise { diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 22f998972..3478daf2a 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -3,6 +3,7 @@ import { APIKeyApi, AssetApi, AssetApiFp, + AssetJobName, AuthenticationApi, Configuration, ConfigurationParameters, @@ -120,6 +121,26 @@ export class ImmichApi { return names[jobName]; } + + public getAssetJobName(job: AssetJobName) { + const names: Record = { + [AssetJobName.RefreshMetadata]: 'Refresh metadata', + [AssetJobName.RegenerateThumbnail]: 'Refresh thumbnails', + [AssetJobName.TranscodeVideo]: 'Refresh encoded videos', + }; + + return names[job]; + } + + public getAssetJobMessage(job: AssetJobName) { + const messages: Record = { + [AssetJobName.RefreshMetadata]: 'Refreshing metadata', + [AssetJobName.RegenerateThumbnail]: `Regenerating thumbnails`, + [AssetJobName.TranscodeVideo]: `Refreshing encoded video`, + }; + + return messages[job]; + } } export const api = new ImmichApi({ basePath: '/api' }); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 850aeae7f..c4d4707f3 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -525,6 +525,42 @@ export const AssetIdsResponseDtoErrorEnum = { export type AssetIdsResponseDtoErrorEnum = typeof AssetIdsResponseDtoErrorEnum[keyof typeof AssetIdsResponseDtoErrorEnum]; +/** + * + * @export + * @enum {string} + */ + +export const AssetJobName = { + RegenerateThumbnail: 'regenerate-thumbnail', + RefreshMetadata: 'refresh-metadata', + TranscodeVideo: 'transcode-video' +} as const; + +export type AssetJobName = typeof AssetJobName[keyof typeof AssetJobName]; + + +/** + * + * @export + * @interface AssetJobsDto + */ +export interface AssetJobsDto { + /** + * + * @type {Array} + * @memberof AssetJobsDto + */ + 'assetIds': Array; + /** + * + * @type {AssetJobName} + * @memberof AssetJobsDto + */ + 'name': AssetJobName; +} + + /** * * @export @@ -5784,6 +5820,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {AssetJobsDto} assetJobsDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + runAssetJobs: async (assetJobsDto: AssetJobsDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'assetJobsDto' is not null or undefined + assertParamExists('runAssetJobs', 'assetJobsDto', assetJobsDto) + const localVarPath = `/asset/jobs`; + // 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: 'POST', ...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) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(assetJobsDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {SearchAssetDto} searchAssetDto @@ -6331,6 +6411,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {AssetJobsDto} assetJobsDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async runAssetJobs(assetJobsDto: AssetJobsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.runAssetJobs(assetJobsDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {SearchAssetDto} searchAssetDto @@ -6584,6 +6674,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.importFile(requestParameters.importAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiSearchAssetRequest} requestParameters Request parameters. @@ -7066,6 +7165,20 @@ export interface AssetApiImportFileRequest { readonly importAssetDto: ImportAssetDto } +/** + * Request parameters for runAssetJobs operation in AssetApi. + * @export + * @interface AssetApiRunAssetJobsRequest + */ +export interface AssetApiRunAssetJobsRequest { + /** + * + * @type {AssetJobsDto} + * @memberof AssetApiRunAssetJobs + */ + readonly assetJobsDto: AssetJobsDto +} + /** * Request parameters for searchAsset operation in AssetApi. * @export @@ -7472,6 +7585,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiRunAssetJobsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public runAssetJobs(requestParameters: AssetApiRunAssetJobsRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).runAssetJobs(requestParameters.assetJobsDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiSearchAssetRequest} requestParameters Request parameters. diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 2dbbf9ca6..3f31459ab 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -1,7 +1,7 @@
(shouldPlayMotionPhoto = false)} on:toggleArchive={toggleArchive} on:asProfileImage={() => (isShowProfileImageCrop = true)} + on:runJob={({ detail: job }) => handleRunJob(job)} /> diff --git a/web/src/lib/components/photos-page/actions/asset-job-actions.svelte b/web/src/lib/components/photos-page/actions/asset-job-actions.svelte new file mode 100644 index 000000000..3e678cdf0 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/asset-job-actions.svelte @@ -0,0 +1,37 @@ + + +{#each jobs as job} + {#if isAllVideos || job !== AssetJobName.TranscodeVideo} + handleRunJob(job)} /> + {/if} +{/each} diff --git a/web/src/routes/(user)/photos/+page.svelte b/web/src/routes/(user)/photos/+page.svelte index 66dbc1d92..63a933984 100644 --- a/web/src/routes/(user)/photos/+page.svelte +++ b/web/src/routes/(user)/photos/+page.svelte @@ -2,6 +2,7 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; + import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; @@ -52,6 +53,7 @@ assetStore.removeAssets(ids)} /> + {/if}