From 386eef046db4c6cd391665b97a119123cd6af459 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 20 Mar 2023 11:55:28 -0400 Subject: [PATCH] refactor(server): jobs (#2023) * refactor: job to domain * chore: regenerate open api * chore: tests * fix: missing breaks * fix: get asset with missing exif data --------- Co-authored-by: Alex Tran --- mobile/openapi/.openapi-generator/FILES | 12 +- mobile/openapi/README.md | Bin 15123 -> 15133 bytes mobile/openapi/doc/AllJobStatusResponseDto.md | Bin 695 -> 942 bytes mobile/openapi/doc/JobApi.md | Bin 3533 -> 3513 bytes mobile/openapi/doc/JobCommandDto.md | Bin 469 -> 458 bytes .../doc/{JobCounts.md => JobCountsDto.md} | Bin 515 -> 518 bytes mobile/openapi/doc/{JobId.md => JobName.md} | Bin 371 -> 373 bytes mobile/openapi/lib/api.dart | Bin 5079 -> 5085 bytes mobile/openapi/lib/api/job_api.dart | Bin 3547 -> 3105 bytes mobile/openapi/lib/api_client.dart | Bin 17032 -> 17042 bytes mobile/openapi/lib/api_helper.dart | Bin 4050 -> 4054 bytes .../model/all_job_status_response_dto.dart | Bin 5137 -> 6508 bytes mobile/openapi/lib/model/job_command.dart | Bin 2550 -> 2661 bytes mobile/openapi/lib/model/job_command_dto.dart | Bin 3649 -> 3517 bytes .../{job_counts.dart => job_counts_dto.dart} | Bin 4142 -> 4202 bytes mobile/openapi/lib/model/job_id.dart | Bin 3065 -> 0 bytes mobile/openapi/lib/model/job_name.dart | Bin 0 -> 3720 bytes .../all_job_status_response_dto_test.dart | Bin 1114 -> 1542 bytes mobile/openapi/test/job_api_test.dart | Bin 754 -> 751 bytes mobile/openapi/test/job_command_dto_test.dart | Bin 680 -> 658 bytes ...nts_test.dart => job_counts_dto_test.dart} | Bin 930 -> 939 bytes .../{job_id_test.dart => job_name_test.dart} | Bin 407 -> 411 bytes .../src/api-v1/asset/asset-repository.ts | 43 --- .../src/api-v1/asset/asset.service.spec.ts | 4 - .../immich/src/api-v1/asset/asset.service.ts | 2 +- .../immich/src/api-v1/job/dto/get-job.dto.ts | 23 -- .../src/api-v1/job/dto/job-command.dto.ts | 16 - .../immich/src/api-v1/job/job.controller.ts | 33 -- .../apps/immich/src/api-v1/job/job.module.ts | 11 - .../apps/immich/src/api-v1/job/job.service.ts | 142 -------- .../all-job-status-response.dto.ts | 32 -- server/apps/immich/src/app.module.ts | 5 +- server/apps/immich/src/controllers/index.ts | 1 + .../immich/src/controllers/job.controller.ts | 21 ++ .../microservices/src/microservices.module.ts | 6 +- server/apps/microservices/src/processors.ts | 35 +- .../metadata-extraction.processor.ts | 25 +- .../processors/video-transcode.processor.ts | 31 +- server/immich-openapi-specs.json | 339 +++++++++--------- server/libs/common/src/constants/index.ts | 8 + .../libs/domain/src/asset/asset.repository.ts | 10 + server/libs/domain/src/domain.module.ts | 2 + server/libs/domain/src/index.ts | 1 + server/libs/domain/src/job/dto/index.ts | 2 + .../domain/src/job/dto/job-command.dto.ts | 14 + server/libs/domain/src/job/dto/job-id.dto.ts | 10 + server/libs/domain/src/job/index.ts | 3 + server/libs/domain/src/job/job.constants.ts | 41 ++- server/libs/domain/src/job/job.interface.ts | 18 +- server/libs/domain/src/job/job.repository.ts | 34 +- .../libs/domain/src/job/job.service.spec.ts | 170 +++++++++ server/libs/domain/src/job/job.service.ts | 68 ++++ .../all-job-status-response.dto.ts | 41 +++ .../libs/domain/src/job/response-dto/index.ts | 1 + server/libs/domain/src/media/media.service.ts | 28 +- .../smart-info/machine-learning.interface.ts | 2 +- .../src/smart-info/smart-info.service.spec.ts | 110 +++++- .../src/smart-info/smart-info.service.ts | 57 ++- .../domain/src/util.ts} | 0 .../libs/domain/test/asset.repository.mock.ts | 1 + .../libs/domain/test/job.repository.mock.ts | 1 + .../test/machine-learning.repository.mock.ts | 2 +- .../src/db/repository/asset.repository.ts | 73 +++- server/libs/infra/src/job/job.repository.ts | 76 ++-- .../machine-learning.repository.ts | 2 +- web/src/api/open-api/api.ts | 90 +++-- .../admin-page/jobs/job-tile.svelte | 8 +- .../admin-page/jobs/jobs-panel.svelte | 131 +++---- 68 files changed, 1094 insertions(+), 691 deletions(-) rename mobile/openapi/doc/{JobCounts.md => JobCountsDto.md} (94%) rename mobile/openapi/doc/{JobId.md => JobName.md} (93%) rename mobile/openapi/lib/model/{job_counts.dart => job_counts_dto.dart} (72%) delete mode 100644 mobile/openapi/lib/model/job_id.dart create mode 100644 mobile/openapi/lib/model/job_name.dart rename mobile/openapi/test/{job_counts_test.dart => job_counts_dto_test.dart} (89%) rename mobile/openapi/test/{job_id_test.dart => job_name_test.dart} (87%) delete mode 100644 server/apps/immich/src/api-v1/job/dto/get-job.dto.ts delete mode 100644 server/apps/immich/src/api-v1/job/dto/job-command.dto.ts delete mode 100644 server/apps/immich/src/api-v1/job/job.controller.ts delete mode 100644 server/apps/immich/src/api-v1/job/job.module.ts delete mode 100644 server/apps/immich/src/api-v1/job/job.service.ts delete mode 100644 server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts create mode 100644 server/apps/immich/src/controllers/job.controller.ts create mode 100644 server/libs/domain/src/job/dto/index.ts create mode 100644 server/libs/domain/src/job/dto/job-command.dto.ts create mode 100644 server/libs/domain/src/job/dto/job-id.dto.ts create mode 100644 server/libs/domain/src/job/job.service.spec.ts create mode 100644 server/libs/domain/src/job/job.service.ts create mode 100644 server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts create mode 100644 server/libs/domain/src/job/response-dto/index.ts rename server/{apps/immich/src/utils/file-name.util.ts => libs/domain/src/util.ts} (100%) diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index f01b4603b..abe3d9b87 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -51,8 +51,8 @@ doc/GetAssetCountByTimeBucketDto.md doc/JobApi.md doc/JobCommand.md doc/JobCommandDto.md -doc/JobCounts.md -doc/JobId.md +doc/JobCountsDto.md +doc/JobName.md doc/LoginCredentialDto.md doc/LoginResponseDto.md doc/LogoutResponseDto.md @@ -168,8 +168,8 @@ lib/model/get_asset_by_time_bucket_dto.dart lib/model/get_asset_count_by_time_bucket_dto.dart lib/model/job_command.dart lib/model/job_command_dto.dart -lib/model/job_counts.dart -lib/model/job_id.dart +lib/model/job_counts_dto.dart +lib/model/job_name.dart lib/model/login_credential_dto.dart lib/model/login_response_dto.dart lib/model/logout_response_dto.dart @@ -262,8 +262,8 @@ test/get_asset_count_by_time_bucket_dto_test.dart test/job_api_test.dart test/job_command_dto_test.dart test/job_command_test.dart -test/job_counts_test.dart -test/job_id_test.dart +test/job_counts_dto_test.dart +test/job_name_test.dart test/login_credential_dto_test.dart test/login_response_dto_test.dart test/logout_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 98eabb4abc0e488e0a113e9ce0fa675729a910d5..8ec63af3382c8ab86444ff9f8ad1ae3497c51d06 100644 GIT binary patch delta 65 zcmbPSHn(hpq@jsRNq($GN`A7wzE^&dbAD-FNik4JFE>S#OF>s58Yt+On45}F0^)6U IHoU+I0QR94lmGw# delta 55 zcmbPRHo0trq@h-7(UP-ZDZi*(Cg04a|P{cC@A?um4+1Ky_Cjb;I B5}p76 diff --git a/mobile/openapi/doc/AllJobStatusResponseDto.md b/mobile/openapi/doc/AllJobStatusResponseDto.md index 66695ac2e1bf05adc2c6fb5bf47095b3035e516b..306c902efc0f752fce2b383cd3252708da9d8abd 100644 GIT binary patch literal 942 zcmbtT!D_=W488j+3^}w7B)xB^ZY^VDEnU}JDO8EI)N5iHTPYjtwHqz4^%uea2r<`J-LWeCZ&=@KN1+td(j@VFE20E6oedQw^jxeRPEss%KtKlJ+DcgGZC8LVeZPOJs7AhL@k&I>Ec zezR4Y^1f{f2e!ULhXFe2rxsgGQz38CRLZ@j)^U_F({ww$wDZP{1SMO98e}lQFni+w zWq+NcHAuJQju>L^WT<$>lGjjG)>ZxLDQoLzt|Zf2AuXR;ild_8_8dbuf@KqG*NOG~RP yGbJ_OIX|x~wWv5VKaT{RxrxacnR%%`sfk5-nR)3*I)$;h%YaFhkkkJ$Z2$l*TuR>n diff --git a/mobile/openapi/doc/JobApi.md b/mobile/openapi/doc/JobApi.md index edb412cd579d33f46a7f6ea8375dbbb1c14205b1..f3a6b81ccb69406966c4b6dfb141001681507f48 100644 GIT binary patch delta 88 zcmX>ry;FLFJNssDc4KB1zr@_s$qigylW%evPnPGt4H5Xmogi73pP8bdk(ygjQmIgs pT3nEySDdPll%G;L`4nsOb`xeM&y>k^Twaq`vB*rm%B42>7K`^}WA4Qu;al7Z>{?nt Ug<4vZPqQ{nzQ&_4IfLy40QhwmM*si- diff --git a/mobile/openapi/doc/JobCommandDto.md b/mobile/openapi/doc/JobCommandDto.md index 68cbee51a82ff3ae674d6fdf54a6c437dfc2593d..49dc499f46e7724797c5809bc52cbab70e225450 100644 GIT binary patch delta 17 Ycmcc0e2RGk4X=9ln$?Q^+zftlPBLE;A1eyQ< delta 10 RcmZo;X=a%qxl!g7BLEU;19JcX diff --git a/mobile/openapi/doc/JobId.md b/mobile/openapi/doc/JobName.md similarity index 93% rename from mobile/openapi/doc/JobId.md rename to mobile/openapi/doc/JobName.md index d2f68234d032b0d7776a5dc83e5bab9c6411b7cb..43fb27c79401f0cddbaa2a21db29d083fd9b92e3 100644 GIT binary patch delta 32 ncmey&^p#0mSs}k5H7~IsQ!h6^B{fISD?iCEF*kLiXdEK|!bA&U delta 30 lcmey$^qEOiSs}k5H7~IsQ!h6^B{fISD?iCIWutf;BLJ$f3XK2& diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d70ad049936a6182942ad2f835930b577d69a775..4ccf97fad267769822e239aeef1dddf31213aac3 100644 GIT binary patch delta 29 lcmcbveph`%5%=VctP&hq`APA4iMgqh3t2=qFW@%k006E_3XK2& delta 27 jcmcbseqDV-5%*+6ZV9H$l*xrGB9qT?vu$3(ZO8!tj6(?* diff --git a/mobile/openapi/lib/api/job_api.dart b/mobile/openapi/lib/api/job_api.dart index 5d53dfb9193f004d115fde149b94d21cc2ceec55..85174188aadc5c55ced2ef6300527dd8565bb786 100644 GIT binary patch delta 86 zcmcaDy-;F9Av=p-Vs7f>nOvchPq1r1I2_zYljWFfHz#tKF~Ox}%JMT)>=cSq^HRL> blbrK&a})DYG=NNy{>>cRyo@ZhT(w*P-AWsM delta 99 zcmZ1|aa(#rAv=?2%H;W6p_5OsYk-&n+(wh-m~1zva+onerKIvobM5UEic|Aayz-Nr t^K)|(^HMZ`OwW|f0^GcenF| GIdK3N1ryHz delta 51 zcmbQ#%GlA$xWPbca-yWjp|S#(LNnUUtd?~asY zSrp~1Uk0o|lbF1Fc)Zt#K0Q4;J%!hQ+*}QR8()sEU*C)`;O)DQ;|MOU;PUDQK3!eB zd;8}xijm~2Ia4NnK6>{2h(3yCCKaD9q?#^7%FiIra?R&cp7J%%OjLdr>y1>VRfDDX zY^$f6+)Ne!nhS;M8C&De4O95raHSDk>2~!*X4kS^tFN}nT zBY;!%!!_348E~O5 z*yccA%6FeH<2lb4>2#eh-r_i4sOC1i`AT9gC`=1<-b~7rssRHe6~yu2%BBHGv*PLm z;&X5{m#}m|SsIl)-@m)RK!hvj=~J7!PB56?cxo_gun!T486kwW_5s>rN!qRJ`>K9` zNwH;hU(Jg-Y59x$N}X9i0Nn!nU@bX95N*r*inYA4XYQ)cOCymPSaJg^rdMy|oMYnH z&{yMwrbfmSt~FCz>iXEoS6mBi#W1znd*7%8Jb|B_WFXGIU|UFiDL;O`j2A6mq%$7A zc#H3Sp_==5o1W)C8-=YHjC6ktY|Zuqb7vCrbKDE@#2Y(t z&tHrDl8Jj>T%zu}hR0g@E-QL4{7U?we~SQ}459&5#%;(e_RvHs zMH>8~0c0aOM0QqiizZ52#2XMU)I;NFM-#wLgoZk$z>pVwq^a%YM?<;DhM_5V%5Ys0 zl_pYW-ZW5m`GU97cTK3>c13j>04|-HdbK2T^2*Xtn;x|ANL1)YXezL54vxEP5Jtl{ zmgZc;LdkUjIk8K~Y!@idCk8yh48M&a%Tr_zL>2CZwuHCV@HdJtkbztBuJQ!=g`N`g zm?D=qBAH^2t>l~rfLjQW<(JkJTRTN9ITOo5LaPSWK%ydOSGf+bS7KIVlrU|)VjDOj zNt7JzA7?sO94m?${J~+(Hh`Bf42iv6sA&5g-)ds{h2o_&9G;z7nZk)Q=lTYc6;JN? z9PkZAwu`wiTbS{!MAy$a_3un(L-J-5?m5BXy^wKpx}U~&zu6#^$ng1)a;DKh+&eDJ zz=sT1W>9q*GOe`t1DT5^j9Ic84AA(Ur%0?&rH(Wz?~5u{FoOPd@o)oFgeRc^)5G1{ zCuHebXiYbxJc9S1uP#S@bTb{~{?(3~kP;lnHlP0OQOFSJNk*AiWI^U1M^ALMJYVmL@tt@%BPwlMLYf|uvG@C;QS?|xc#=w?Xodbjz{odUzPZvzh|=)-vY2; zspipR*>7B5R$S+)A@UPCWOC~YlsY^iQ(Gac@KtE%-biTdx#RW5)LI$!*g!POd|(qiWa8gDfM8bT`xX+0KbMQ12%%wiX+(BP$s>%L(; zgX+t`DGmo*q^}p<0}+F^3oEXwZ5h}ReG*)km->MKTfC9Yhg!G}1RC8oaf)_$;a3Pm zo63Pw#<%>v7j0CnY7K9XA8CSFXSsd|_rk1jiAs#VtqjCu=%i7(AZ_@>KrQoq(>61dGk*T8!_q`)qUdsFRv(SmH%qF6;3G8Pz7gVpImwqI`+T8JT&hKBUFpj6lyiCxqy^c zev)&3X_ OP@Wac+Wee@pAi7h-!{nr delta 83 zcmaDV@=bU{ER$4mNq&KXt%6s6l5>7;Zem`FUc5$;I!JW#3Z@Dc5M#3e^L18P5KG-k Tp(wSav?vc*Jy=N?CqE+q{9PTg diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index adf9fc3344a8ce15122f099f1c50564ccb3d0c45..fe5c3834391c4f17535c382bdd41e0c02069b63e 100644 GIT binary patch delta 137 zcmX>ovsZe<5=Pdv{G#O4$y*rJp{&=8llj163bwWi5XnVMSx`wC=0F~>n5_y#s-L-0 x1k6&8Rj^ek$;d3$19LZ9vGg%RHQivBhp;xYbI7v^K$PonDJaxhbJcQj0RS(&EzJM` delta 269 zcmdlheNbk@5=Mc{yyTqHlvKx@9LM6~)RN-KD;d=>1+OwrRzX*(U~8*@E;hN0DGO8g zQ>H*Abd|Ok=1q2DZq!0orXH(ct5A}WS*(XHv-vu6A2X(1S?ux{f}8u;<=Lds4N%wN NQc$S1=Bnl50st;KW4{0Z diff --git a/mobile/openapi/lib/model/job_counts.dart b/mobile/openapi/lib/model/job_counts_dto.dart similarity index 72% rename from mobile/openapi/lib/model/job_counts.dart rename to mobile/openapi/lib/model/job_counts_dto.dart index 795821a19bf586df9d8ddbd791a9517a2e9969cd..41174e57a7dc5c881add82c31d5d5da3f9f7597c 100644 GIT binary patch delta 188 zcmZ3d@JeBW0VA_ZN&aL5Mny0k$SBSVN0LVQyn(E(#zZN^fFAZ*^{TbF*;)Jpr?^ z0-FJ|PXwI-lkWwcvyTRz0h2HYU6YUqX|wzYTLF_=3Rjb~3U8Ap3$v5l3qX?}48)V> k478J-4MPV$J|Jyja7>e43P6)84jlnLlOGJkvl0(j0oxlbZU6uP diff --git a/mobile/openapi/lib/model/job_id.dart b/mobile/openapi/lib/model/job_id.dart deleted file mode 100644 index 19919f6d72d93c192afe59a345e900cefb362a32..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3065 zcmai0ZExE)5dQ98aRr9P0sOM{Q<2V1gT?7wq{*;49|pq^Xz6TorAUpW;u%H$`|e0d zR%9m&ki_!6-#z!7#^c_23|9~H`{C#5-Sp3^`ScoYX1CJ;T;Idp{T#mDU(as-dP8cg z`6(0DPu`7wdDr7dxzWaAvNkqZONAFu>e6ABpu!xrAJoq>FO2mkEri;My-SMHCl>!H zr6qGMa{5~cOaDDuN6xJ~ZJrwKywE<883mMUi8h$KlfjhK=n`f0hSdC4=;Zy+NxBu* z^?DS}gIR+qP>Vv2==Vdf*GrXf4!)SxEQ23CfH9Rli!r6}2m&Xx3$zl z!8dOk&EV#fc|>a|G^tcVZ{mmkIZQrAr$Mz5e{n{4h160n(<*1bE%TKYQhi2^mIOw_ zq^ZQ7HS*!6Q8=Tr0G_#_QHo4>G5yzDksf)e#Y1f5C!BCw&2L6inXa2hkPWHNmGFdl zF48SQ;1-3|+_`ocm29Xg;R$o+jTIZ5V_qnl{a3j;(T+-K=qs&ZUaPUkYSy>X1+WRF zpfJwKmBKh22U5q8FBnwIMSxy_(6e?1csQ!y*P!rmL7ssOk7hLpg1Wtj?#J&t4Xu=) zw<&)4Qt>QZhQg}+LOz$Uwk8kQ%J#I+gr*30Mq$vH?+ct;p&hYDj<$b3$}lw<_FHRF zdE{LN;MTIBtv_72ixi zmrAw$0K|kxBFmcIL_2_|ka_(-K3Vjo)wPv3TuZ-h%0<(j5dW$7#6&)ZW)g9~BkK`E zdPs!^f+CM*3G`V8@=KAasRAc>Q_;;JI1`$vFHUm__4s-BVI|Vpiur0sk_i}Fww_s6M z8^*a@u=0`%78POAyr|vrFU+We!~kY@M>AhC?z}DOq)_|#jGT5x^U%h_3L$nl16QUg zI=3b#d)`K#C)w)&CH5K-f!PRtH#;PH95C2Bp~J4c9=r1WKN-Z>jG6~chd;gwU^cbZ z*jVJ2;IH|N9a_Q1uagkvxd*z@*&cRW4SBr!V+M4t>3mbOsY|Wn8~2Bbr8S|;OY3Pd zr*oLq9btWXIkBW+kxr8&>Mb2#0di~ze+E;C#UIt3RrOVlx5A#Emq&W~6*?(sJQs6tOyDN<&K XvS^A%b z3$Sb&DZt{#kH_=&45LwRG=i&#>HYBI`S%sU z82KR)#*N=ZzrE@4qg*R(aIw;6v63lXK%okY$pTZ%P`N?%EVEo2cXEeFw_;}(dEpiY zzZcTbb|o@;&4rMjLlB^F3#f_=w$g3D zZ4h#!pU9#FW=|q5a30Y7c+nrM-np(4;%5*5Fs!D8_~(GR&4} zm~SPONa3q9HPX|3&`)R#9ZryP(dqD^e-7jKfz(6ml!!w&cHLeBftSC#a1N3iOGM?h`5WgLJhHobuzNc^&eW1 z18j$d9x|j*l$cH~z5Ir;n~L?C+_nnJFvmCF=Eidv8=B>s$xYd$NB&==D|Ciyb4l#9QYkoPlU2ikl(YX^R{%B-XO z3yY^xDXg>;BYfWqbojc7OC7ne@M0iJNaIZYV(yE!ym@)kOIR75`T9+E)HkVOLrdXG zFEP_yj#DF)r9PdZ>7PdlQbJ;Xulba$9F5wEmypuq%`yKsY8C_dvD7*R%E_(Zh>c+- zQj3Gj1a&-oFNL5oq(}~8p@0&e12hNvEWOmk$TCou061MX_kpJq zilin)bq-Y`aB~IDKStMEqeDqMN)8{r6}4k+cAfddxwWY6y1X18YTFSdNnH@Q0|D#; zm6{YB&@VF>_$pq!_(KrQS~IVGGF#H&S#@(C-1&^bbH2Q&uEM%W?hmhUOCF`ZqqldI z19Q$qXGqi7nJnU`=_o!*J6IwFe})~&8=TUC@fyj3Hr!$`e`gIM<=?lTO8R;gQ>*5XZAjhYc@zZb;9BD^@( zT$I7BjEuT8Cv-jTBPD4iKkdP$#aTg*l&VG9G2FEHPKjHK?BsmYqH3#Qi|EvZ)8c8V zXN#s0()|IaMbyA*y=_tbbgpT!z9`mh{{2y>&DE%Svopb!Af>#Zbg%w6b6?eYy`Pqh za9qT2W!44%((=XKKYLJ*ib^~DQV11E-7l2Q1^mKw5C-w;j2HBbb&>5O!3r(yE7fX> ejwHL%lA|6@&85^TuG+&n4QDRAEeqLZ{YH8}^kIagb)tQ7@Av_&s zGii|W+|-i9l*E!m*NT#&#AH;}OPGWZswF_G%Q90^^PTha%2JDpQ4E%4=0_+M11Zf< z%1TWx2}w*(&&*4QyFdtwFwiNYARWm$nFX$S$@wYB8U!&#f$9}O>WfSAixSgQLsD}K zauQ2YeKXThy=#eZkvK?wQetv;dQpC9UP?$}aW>pZ!q~)tcJPDkNKGtC&VZ>^;zHu; LFsn@tVBrD)pyi6f delta 184 zcmZqUxy7;J6QhViNk(aIQeI+aj(cieYEfcIW`5peLsrGf`b@%;FEE)2E99n@B&H;m zB)V3V6eT9Z<##d(f#d}g$}&?@^PTha%2JDpVVdok`5}_IiOCt6d8t0BiA8ytdFddN q`O(FIrbsFjm*f{Erl*Fa<`(26mZbV-rX#uKIEw_(IJL=tSh)bykU}#6 diff --git a/mobile/openapi/test/job_api_test.dart b/mobile/openapi/test/job_api_test.dart index cfdfa6479c77c3065ec34d814669da930b688ec8..6f980417e081b716135074586a0fa07246068c54 100644 GIT binary patch delta 35 pcmeyw`kr+|GLyJMacW+QSALRner|4JUWx{g>6e(BI=Pmq6aem<4F>=K delta 38 scmaFQ`iXTzGLw`|UTLnKLUC$didTM;bAE1aVqS^{km;E+xr(V202Y%C{Qv*} diff --git a/mobile/openapi/test/job_command_dto_test.dart b/mobile/openapi/test/job_command_dto_test.dart index fe847827ac50d7f533dfb1c5fa0927af63676cf1..83a27b4d9c182eda07f0022af7eb98df79eecacf 100644 GIT binary patch delta 27 dcmZ3%I*E0IITLGIeo=DjzUUUE)pN~&W{j$?6gYDw|ryNrsMf~-txTwDqYwVKvkT(w*PgT@n3 diff --git a/mobile/openapi/test/job_counts_test.dart b/mobile/openapi/test/job_counts_dto_test.dart similarity index 89% rename from mobile/openapi/test/job_counts_test.dart rename to mobile/openapi/test/job_counts_dto_test.dart index 15f1ea87c94ca40d7379861b0f50f764c5ace5d7..33c14891f317c9f5e8dc307c1e6c51f06c80e9a4 100644 GIT binary patch delta 55 zcmZ3)zM6eQ8Y8nyN&e(CMm;4Ut)Xen#igK-UX)*2prKxpT3n*wm7nCCUz%4^43yry If$(YLNRhgZ O0!+46)0&H`mJ0yADiZDh delta 35 qcmbQuJe_$%0wa@W%H$MAMG?;wbsYr_O@(SME(L{JO=~W$S}p*iB?tll diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index 9c36da9ae..c4f8e3364 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -38,10 +38,6 @@ export interface IAssetRepository { getAssetCountByUserId(userId: string): Promise; getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise; getAssetByChecksum(userId: string, checksum: Buffer): Promise; - getAssetWithNoThumbnail(): Promise; - getAssetWithNoEncodedVideo(): Promise; - getAssetWithNoEXIF(): Promise; - getAssetWithNoSmartInfo(): Promise; getExistingAssets( userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto, @@ -76,45 +72,6 @@ export class AssetRepository implements IAssetRepository { }); } - async getAssetWithNoSmartInfo(): Promise { - return await this.assetRepository - .createQueryBuilder('asset') - .leftJoinAndSelect('asset.smartInfo', 'si') - .where('asset.resizePath IS NOT NULL') - .andWhere('si.assetId IS NULL') - .andWhere('asset.isVisible = true') - .getMany(); - } - - async getAssetWithNoThumbnail(): Promise { - return await this.assetRepository.find({ - where: [ - { resizePath: IsNull(), isVisible: true }, - { resizePath: '', isVisible: true }, - { webpPath: IsNull(), isVisible: true }, - { webpPath: '', isVisible: true }, - ], - }); - } - - async getAssetWithNoEncodedVideo(): Promise { - return await this.assetRepository.find({ - where: [ - { type: AssetType.VIDEO, encodedVideoPath: IsNull() }, - { type: AssetType.VIDEO, encodedVideoPath: '' }, - ], - }); - } - - async getAssetWithNoEXIF(): Promise { - return await this.assetRepository - .createQueryBuilder('asset') - .leftJoinAndSelect('asset.exifInfo', 'ei') - .where('ei."assetId" IS NULL') - .andWhere('asset.isVisible = true') - .getMany(); - } - async getAssetCountByUserId(ownerId: string): Promise { // Get asset count by AssetType const items = await this.assetRepository diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index a4875a95a..40d2b23da 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -146,10 +146,6 @@ describe('AssetService', () => { getAssetByTimeBucket: jest.fn(), getAssetByChecksum: jest.fn(), getAssetCountByUserId: jest.fn(), - getAssetWithNoEXIF: jest.fn(), - getAssetWithNoThumbnail: jest.fn(), - getAssetWithNoSmartInfo: jest.fn(), - getAssetWithNoEncodedVideo: jest.fn(), getExistingAssets: jest.fn(), countByIdAndUser: jest.fn(), }; diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 3f051fb22..76a0ab232 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -63,7 +63,7 @@ import { AssetSearchDto } from './dto/asset-search.dto'; import { AddAssetsDto } from '../album/dto/add-assets.dto'; import { RemoveAssetsDto } from '../album/dto/remove-assets.dto'; import path from 'path'; -import { getFileNameWithoutExtension } from '../../utils/file-name.util'; +import { getFileNameWithoutExtension } from '@app/domain'; const fileInfo = promisify(stat); diff --git a/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts b/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts deleted file mode 100644 index f67b48e0f..000000000 --- a/server/apps/immich/src/api-v1/job/dto/get-job.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty } from 'class-validator'; - -export enum JobId { - THUMBNAIL_GENERATION = 'thumbnail-generation', - METADATA_EXTRACTION = 'metadata-extraction', - VIDEO_CONVERSION = 'video-conversion', - MACHINE_LEARNING = 'machine-learning', - STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', -} - -export class GetJobDto { - @IsNotEmpty() - @IsEnum(JobId, { - message: `params must be one of ${Object.values(JobId).join()}`, - }) - @ApiProperty({ - type: String, - enum: JobId, - enumName: 'JobId', - }) - jobId!: JobId; -} diff --git a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts deleted file mode 100644 index 598440418..000000000 --- a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsIn, IsNotEmpty, IsOptional } from 'class-validator'; - -export class JobCommandDto { - @IsNotEmpty() - @IsIn(['start', 'stop']) - @ApiProperty({ - enum: ['start', 'stop'], - enumName: 'JobCommand', - }) - command!: string; - - @IsOptional() - @IsBoolean() - includeAllAssets!: boolean; -} diff --git a/server/apps/immich/src/api-v1/job/job.controller.ts b/server/apps/immich/src/api-v1/job/job.controller.ts deleted file mode 100644 index 6abcdb542..000000000 --- a/server/apps/immich/src/api-v1/job/job.controller.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Body, Controller, Get, Param, Put, ValidationPipe } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { Authenticated } from '../../decorators/authenticated.decorator'; -import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto'; -import { GetJobDto } from './dto/get-job.dto'; -import { JobService } from './job.service'; -import { JobCommandDto } from './dto/job-command.dto'; - -@Authenticated({ admin: true }) -@ApiTags('Job') -@Controller('jobs') -export class JobController { - constructor(private readonly jobService: JobService) {} - - @Get() - getAllJobsStatus(): Promise { - return this.jobService.getAllJobsStatus(); - } - - @Put('/:jobId') - async sendJobCommand( - @Param(ValidationPipe) params: GetJobDto, - @Body(ValidationPipe) dto: JobCommandDto, - ): Promise { - if (dto.command === 'start') { - return await this.jobService.start(params.jobId, dto.includeAllAssets); - } - if (dto.command === 'stop') { - return await this.jobService.stop(params.jobId); - } - return 0; - } -} diff --git a/server/apps/immich/src/api-v1/job/job.module.ts b/server/apps/immich/src/api-v1/job/job.module.ts deleted file mode 100644 index cc513af40..000000000 --- a/server/apps/immich/src/api-v1/job/job.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { JobService } from './job.service'; -import { JobController } from './job.controller'; -import { AssetModule } from '../asset/asset.module'; - -@Module({ - imports: [AssetModule], - controllers: [JobController], - providers: [JobService], -}) -export class JobModule {} diff --git a/server/apps/immich/src/api-v1/job/job.service.ts b/server/apps/immich/src/api-v1/job/job.service.ts deleted file mode 100644 index 3a630130c..000000000 --- a/server/apps/immich/src/api-v1/job/job.service.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { JobName, IJobRepository, QueueName } from '@app/domain'; -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { AllJobStatusResponseDto } from './response-dto/all-job-status-response.dto'; -import { IAssetRepository } from '../asset/asset-repository'; -import { AssetType } from '@app/infra'; -import { JobId } from './dto/get-job.dto'; -import { MACHINE_LEARNING_ENABLED } from '@app/common'; -import { getFileNameWithoutExtension } from '../../utils/file-name.util'; -const jobIds = Object.values(JobId) as JobId[]; - -@Injectable() -export class JobService { - constructor( - @Inject(IAssetRepository) private _assetRepository: IAssetRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - ) { - for (const jobId of jobIds) { - this.jobRepository.empty(this.asQueueName(jobId)); - } - } - - start(jobId: JobId, includeAllAssets: boolean): Promise { - return this.run(this.asQueueName(jobId), includeAllAssets); - } - - async stop(jobId: JobId): Promise { - await this.jobRepository.empty(this.asQueueName(jobId)); - return 0; - } - - async getAllJobsStatus(): Promise { - const response = new AllJobStatusResponseDto(); - for (const jobId of jobIds) { - response[jobId] = await this.jobRepository.getJobCounts(this.asQueueName(jobId)); - } - return response; - } - - private async run(name: QueueName, includeAllAssets: boolean): Promise { - const isActive = await this.jobRepository.isActive(name); - if (isActive) { - throw new BadRequestException(`Job is already running`); - } - - switch (name) { - case QueueName.VIDEO_CONVERSION: { - const assets = includeAllAssets - ? await this._assetRepository.getAllVideos() - : await this._assetRepository.getAssetWithNoEncodedVideo(); - for (const asset of assets) { - await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } }); - } - - return assets.length; - } - - case QueueName.STORAGE_TEMPLATE_MIGRATION: - await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); - return 1; - - case QueueName.MACHINE_LEARNING: { - if (!MACHINE_LEARNING_ENABLED) { - throw new BadRequestException('Machine learning is not enabled.'); - } - - const assets = includeAllAssets - ? await this._assetRepository.getAll() - : await this._assetRepository.getAssetWithNoSmartInfo(); - - for (const asset of assets) { - await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } }); - await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } }); - await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } }); - } - return assets.length; - } - - case QueueName.METADATA_EXTRACTION: { - const assets = includeAllAssets - ? await this._assetRepository.getAll() - : await this._assetRepository.getAssetWithNoEXIF(); - - for (const asset of assets) { - if (asset.type === AssetType.VIDEO) { - await this.jobRepository.queue({ - name: JobName.EXTRACT_VIDEO_METADATA, - data: { - asset, - fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath), - }, - }); - } else { - await this.jobRepository.queue({ - name: JobName.EXIF_EXTRACTION, - data: { - asset, - fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath), - }, - }); - } - } - return assets.length; - } - - case QueueName.THUMBNAIL_GENERATION: { - const assets = includeAllAssets - ? await this._assetRepository.getAll() - : await this._assetRepository.getAssetWithNoThumbnail(); - - for (const asset of assets) { - await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } }); - } - return assets.length; - } - - default: - return 0; - } - } - - private asQueueName(jobId: JobId) { - switch (jobId) { - case JobId.THUMBNAIL_GENERATION: - return QueueName.THUMBNAIL_GENERATION; - - case JobId.METADATA_EXTRACTION: - return QueueName.METADATA_EXTRACTION; - - case JobId.VIDEO_CONVERSION: - return QueueName.VIDEO_CONVERSION; - - case JobId.STORAGE_TEMPLATE_MIGRATION: - return QueueName.STORAGE_TEMPLATE_MIGRATION; - - case JobId.MACHINE_LEARNING: - return QueueName.MACHINE_LEARNING; - - default: - throw new BadRequestException(`Invalid job id: ${jobId}`); - } - } -} diff --git a/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts b/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts deleted file mode 100644 index 9b26936ce..000000000 --- a/server/apps/immich/src/api-v1/job/response-dto/all-job-status-response.dto.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { JobId } from '../dto/get-job.dto'; - -export class JobCounts { - @ApiProperty({ type: 'integer' }) - active!: number; - @ApiProperty({ type: 'integer' }) - completed!: number; - @ApiProperty({ type: 'integer' }) - failed!: number; - @ApiProperty({ type: 'integer' }) - delayed!: number; - @ApiProperty({ type: 'integer' }) - waiting!: number; -} - -export class AllJobStatusResponseDto { - @ApiProperty({ type: JobCounts }) - [JobId.THUMBNAIL_GENERATION]!: JobCounts; - - @ApiProperty({ type: JobCounts }) - [JobId.METADATA_EXTRACTION]!: JobCounts; - - @ApiProperty({ type: JobCounts }) - [JobId.VIDEO_CONVERSION]!: JobCounts; - - @ApiProperty({ type: JobCounts }) - [JobId.MACHINE_LEARNING]!: JobCounts; - - @ApiProperty({ type: JobCounts }) - [JobId.STORAGE_TEMPLATE_MIGRATION]!: JobCounts; -} diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index ec8bc4aae..12fd9d231 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -7,7 +7,6 @@ import { AlbumModule } from './api-v1/album/album.module'; import { AppController } from './app.controller'; import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module'; -import { JobModule } from './api-v1/job/job.module'; import { TagModule } from './api-v1/tag/tag.module'; import { DomainModule, SearchService } from '@app/domain'; import { InfraModule } from '@app/infra'; @@ -15,6 +14,7 @@ import { APIKeyController, AuthController, DeviceInfoController, + JobController, OAuthController, SearchController, ShareController, @@ -42,8 +42,6 @@ import { AuthGuard } from './middlewares/auth.guard'; ScheduleTasksModule, - JobModule, - TagModule, ], controllers: [ @@ -51,6 +49,7 @@ import { AuthGuard } from './middlewares/auth.guard'; APIKeyController, AuthController, DeviceInfoController, + JobController, OAuthController, SearchController, ShareController, diff --git a/server/apps/immich/src/controllers/index.ts b/server/apps/immich/src/controllers/index.ts index 171a0debb..a621302b5 100644 --- a/server/apps/immich/src/controllers/index.ts +++ b/server/apps/immich/src/controllers/index.ts @@ -1,6 +1,7 @@ export * from './api-key.controller'; export * from './auth.controller'; export * from './device-info.controller'; +export * from './job.controller'; export * from './oauth.controller'; export * from './search.controller'; export * from './share.controller'; diff --git a/server/apps/immich/src/controllers/job.controller.ts b/server/apps/immich/src/controllers/job.controller.ts new file mode 100644 index 000000000..942234c9c --- /dev/null +++ b/server/apps/immich/src/controllers/job.controller.ts @@ -0,0 +1,21 @@ +import { AllJobStatusResponseDto, JobCommandDto, JobIdDto, JobService } from '@app/domain'; +import { Body, Controller, Get, Param, Put, ValidationPipe } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Authenticated } from '../decorators/authenticated.decorator'; + +@Authenticated({ admin: true }) +@ApiTags('Job') +@Controller('jobs') +export class JobController { + constructor(private readonly jobService: JobService) {} + + @Get() + getAllJobsStatus(): Promise { + return this.jobService.getAllJobsStatus(); + } + + @Put('/:jobId') + sendJobCommand(@Param(ValidationPipe) { jobId }: JobIdDto, @Body(ValidationPipe) dto: JobCommandDto): Promise { + return this.jobService.handleCommand(jobId, dto); + } +} diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts index 68b755af6..4530a139c 100644 --- a/server/apps/microservices/src/microservices.module.ts +++ b/server/apps/microservices/src/microservices.module.ts @@ -6,7 +6,8 @@ import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BackgroundTaskProcessor, - MachineLearningProcessor, + ClipEncodingProcessor, + ObjectTaggingProcessor, SearchIndexProcessor, StorageTemplateMigrationProcessor, ThumbnailGeneratorProcessor, @@ -24,7 +25,8 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor' ThumbnailGeneratorProcessor, MetadataExtractionProcessor, VideoTranscodeProcessor, - MachineLearningProcessor, + ObjectTaggingProcessor, + ClipEncodingProcessor, StorageTemplateMigrationProcessor, BackgroundTaskProcessor, SearchIndexProcessor, diff --git a/server/apps/microservices/src/processors.ts b/server/apps/microservices/src/processors.ts index fe935744d..61dfbddda 100644 --- a/server/apps/microservices/src/processors.ts +++ b/server/apps/microservices/src/processors.ts @@ -2,6 +2,7 @@ import { AssetService, IAssetJob, IAssetUploadedJob, + IBaseJob, IBulkEntityJob, IDeleteFilesJob, IUserDeletionJob, @@ -48,20 +49,35 @@ export class BackgroundTaskProcessor { } } -@Processor(QueueName.MACHINE_LEARNING) -export class MachineLearningProcessor { +@Processor(QueueName.OBJECT_TAGGING) +export class ObjectTaggingProcessor { constructor(private smartInfoService: SmartInfoService) {} - @Process({ name: JobName.IMAGE_TAGGING, concurrency: 1 }) - async onTagImage(job: Job) { - await this.smartInfoService.handleTagImage(job.data); + @Process({ name: JobName.QUEUE_OBJECT_TAGGING, concurrency: 1 }) + async onQueueObjectTagging(job: Job) { + await this.smartInfoService.handleQueueObjectTagging(job.data); } - @Process({ name: JobName.OBJECT_DETECTION, concurrency: 1 }) - async onDetectObject(job: Job) { + @Process({ name: JobName.DETECT_OBJECTS, concurrency: 1 }) + async onDetectObjects(job: Job) { await this.smartInfoService.handleDetectObjects(job.data); } + @Process({ name: JobName.CLASSIFY_IMAGE, concurrency: 1 }) + async onClassifyImage(job: Job) { + await this.smartInfoService.handleClassifyImage(job.data); + } +} + +@Processor(QueueName.CLIP_ENCODING) +export class ClipEncodingProcessor { + constructor(private smartInfoService: SmartInfoService) {} + + @Process({ name: JobName.QUEUE_ENCODE_CLIP, concurrency: 1 }) + async onQueueClipEncoding(job: Job) { + await this.smartInfoService.handleQueueEncodeClip(job.data); + } + @Process({ name: JobName.ENCODE_CLIP, concurrency: 1 }) async onEncodeClip(job: Job) { await this.smartInfoService.handleEncodeClip(job.data); @@ -117,6 +133,11 @@ export class StorageTemplateMigrationProcessor { export class ThumbnailGeneratorProcessor { constructor(private mediaService: MediaService) {} + @Process({ name: JobName.QUEUE_GENERATE_THUMBNAILS, concurrency: 1 }) + async handleQueueGenerateThumbnails(job: Job) { + await this.mediaService.handleQueueGenerateThumbnails(job.data); + } + @Process({ name: JobName.GENERATE_JPEG_THUMBNAIL, concurrency: 3 }) async handleGenerateJpegThumbnail(job: Job) { await this.mediaService.handleGenerateJpegThumbnail(job.data); diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 5bc8c4e78..afa8721b1 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -1,11 +1,14 @@ import { AssetCore, + getFileNameWithoutExtension, IAssetRepository, IAssetUploadedJob, + IBaseJob, IJobRepository, IReverseGeocodingJob, JobName, QueueName, + WithoutProperty, } from '@app/domain'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra'; import { Process, Processor } from '@nestjs/bull'; @@ -85,8 +88,8 @@ export class MetadataExtractionProcessor { private assetCore: AssetCore; constructor( - @Inject(IAssetRepository) assetRepository: IAssetRepository, - @Inject(IJobRepository) jobRepository: IJobRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, @InjectRepository(ExifEntity) private exifRepository: Repository, @@ -148,6 +151,24 @@ export class MetadataExtractionProcessor { return { country, state, city }; } + @Process(JobName.QUEUE_METADATA_EXTRACTION) + async handleQueueMetadataExtraction(job: Job) { + try { + const { force } = job.data; + const assets = force + ? await this.assetRepository.getAll() + : await this.assetRepository.getWithout(WithoutProperty.EXIF); + + for (const asset of assets) { + const fileName = asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath); + const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION; + await this.jobRepository.queue({ name, data: { asset, fileName } }); + } + } catch (error: any) { + this.logger.error(`Unable to queue metadata extraction`, error?.stack); + } + } + @Process(JobName.EXIF_EXTRACTION) async extractExifInfo(job: Job) { try { diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index 93a43f64c..4d8fd6b48 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/server/apps/microservices/src/processors/video-transcode.processor.ts @@ -1,6 +1,15 @@ import { APP_UPLOAD_LOCATION } from '@app/common/constants'; -import { AssetEntity } from '@app/infra'; -import { IAssetJob, IAssetRepository, JobName, QueueName, SystemConfigService } from '@app/domain'; +import { AssetEntity, AssetType } from '@app/infra'; +import { + IAssetJob, + IAssetRepository, + IBaseJob, + IJobRepository, + JobName, + QueueName, + SystemConfigService, + WithoutProperty, +} from '@app/domain'; import { Process, Processor } from '@nestjs/bull'; import { Inject, Logger } from '@nestjs/common'; import { Job } from 'bull'; @@ -12,11 +21,27 @@ export class VideoTranscodeProcessor { readonly logger = new Logger(VideoTranscodeProcessor.name); constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, private systemConfigService: SystemConfigService, ) {} + @Process({ name: JobName.QUEUE_VIDEO_CONVERSION, concurrency: 1 }) + async handleQueueVideoConversion(job: Job): Promise { + try { + const { force } = job.data; + const assets = force + ? await this.assetRepository.getAll({ type: AssetType.VIDEO }) + : await this.assetRepository.getWithout(WithoutProperty.ENCODED_VIDEO); + for (const asset of assets) { + await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { asset } }); + } + } catch (error: any) { + this.logger.error('Failed to queue video conversions', error.stack); + } + } + @Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 }) - async videoConversion(job: Job) { + async handleVideoConversion(job: Job) { const { asset } = job.data; const basePath = APP_UPLOAD_LOCATION; const encodedVideoPath = `${basePath}/${asset.ownerId}/encoded-video`; diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 26653e5da..836104eb8 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -395,6 +395,78 @@ ] } }, + "/jobs": { + "get": { + "operationId": "getAllJobsStatus", + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AllJobStatusResponseDto" + } + } + } + } + }, + "tags": [ + "Job" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + } + ] + } + }, + "/jobs/{jobId}": { + "put": { + "operationId": "sendJobCommand", + "description": "", + "parameters": [ + { + "name": "jobId", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/JobName" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobCommandDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Job" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + } + ] + } + }, "/oauth/mobile-redirect": { "get": { "operationId": "mobileRedirect", @@ -3169,85 +3241,6 @@ } ] } - }, - "/jobs": { - "get": { - "operationId": "getAllJobsStatus", - "description": "", - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AllJobStatusResponseDto" - } - } - } - } - }, - "tags": [ - "Job" - ], - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - } - ] - } - }, - "/jobs/{jobId}": { - "put": { - "operationId": "sendJobCommand", - "description": "", - "parameters": [ - { - "name": "jobId", - "required": true, - "in": "path", - "schema": { - "$ref": "#/components/schemas/JobId" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/JobCommandDto" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "number" - } - } - } - } - }, - "tags": [ - "Job" - ], - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - } - ] - } } }, "info": { @@ -3604,6 +3597,108 @@ "isAutoBackup" ] }, + "JobCountsDto": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "completed": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "delayed": { + "type": "integer" + }, + "waiting": { + "type": "integer" + } + }, + "required": [ + "active", + "completed", + "failed", + "delayed", + "waiting" + ] + }, + "AllJobStatusResponseDto": { + "type": "object", + "properties": { + "thumbnail-generation-queue": { + "$ref": "#/components/schemas/JobCountsDto" + }, + "metadata-extraction-queue": { + "$ref": "#/components/schemas/JobCountsDto" + }, + "video-conversion-queue": { + "$ref": "#/components/schemas/JobCountsDto" + }, + "object-tagging-queue": { + "$ref": "#/components/schemas/JobCountsDto" + }, + "clip-encoding-queue": { + "$ref": "#/components/schemas/JobCountsDto" + }, + "storage-template-migration-queue": { + "$ref": "#/components/schemas/JobCountsDto" + }, + "background-task-queue": { + "$ref": "#/components/schemas/JobCountsDto" + }, + "search-queue": { + "$ref": "#/components/schemas/JobCountsDto" + } + }, + "required": [ + "thumbnail-generation-queue", + "metadata-extraction-queue", + "video-conversion-queue", + "object-tagging-queue", + "clip-encoding-queue", + "storage-template-migration-queue", + "background-task-queue", + "search-queue" + ] + }, + "JobName": { + "type": "string", + "enum": [ + "thumbnail-generation-queue", + "metadata-extraction-queue", + "video-conversion-queue", + "object-tagging-queue", + "clip-encoding-queue", + "background-task-queue", + "storage-template-migration-queue", + "search-queue" + ] + }, + "JobCommand": { + "type": "string", + "enum": [ + "start", + "pause", + "empty" + ] + }, + "JobCommandDto": { + "type": "object", + "properties": { + "command": { + "$ref": "#/components/schemas/JobCommand" + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "command", + "force" + ] + }, "OAuthConfigDto": { "type": "object", "properties": { @@ -5193,92 +5288,6 @@ "usage", "usageByUser" ] - }, - "JobCounts": { - "type": "object", - "properties": { - "active": { - "type": "integer" - }, - "completed": { - "type": "integer" - }, - "failed": { - "type": "integer" - }, - "delayed": { - "type": "integer" - }, - "waiting": { - "type": "integer" - } - }, - "required": [ - "active", - "completed", - "failed", - "delayed", - "waiting" - ] - }, - "AllJobStatusResponseDto": { - "type": "object", - "properties": { - "thumbnail-generation": { - "$ref": "#/components/schemas/JobCounts" - }, - "metadata-extraction": { - "$ref": "#/components/schemas/JobCounts" - }, - "video-conversion": { - "$ref": "#/components/schemas/JobCounts" - }, - "machine-learning": { - "$ref": "#/components/schemas/JobCounts" - }, - "storage-template-migration": { - "$ref": "#/components/schemas/JobCounts" - } - }, - "required": [ - "thumbnail-generation", - "metadata-extraction", - "video-conversion", - "machine-learning", - "storage-template-migration" - ] - }, - "JobId": { - "type": "string", - "enum": [ - "thumbnail-generation", - "metadata-extraction", - "video-conversion", - "machine-learning", - "storage-template-migration" - ] - }, - "JobCommand": { - "type": "string", - "enum": [ - "start", - "stop" - ] - }, - "JobCommandDto": { - "type": "object", - "properties": { - "command": { - "$ref": "#/components/schemas/JobCommand" - }, - "includeAllAssets": { - "type": "boolean" - } - }, - "required": [ - "command", - "includeAllAssets" - ] } } } diff --git a/server/libs/common/src/constants/index.ts b/server/libs/common/src/constants/index.ts index c0ceffa4c..436e4d6f0 100644 --- a/server/libs/common/src/constants/index.ts +++ b/server/libs/common/src/constants/index.ts @@ -1,4 +1,12 @@ +import { BadRequestException } from '@nestjs/common'; + export * from './upload_location.constant'; export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'; export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false'; + +export function assertMachineLearningEnabled() { + if (!MACHINE_LEARNING_ENABLED) { + throw new BadRequestException('Machine learning is not enabled.'); + } +} diff --git a/server/libs/domain/src/asset/asset.repository.ts b/server/libs/domain/src/asset/asset.repository.ts index 9b75eb814..3d0b60c36 100644 --- a/server/libs/domain/src/asset/asset.repository.ts +++ b/server/libs/domain/src/asset/asset.repository.ts @@ -2,12 +2,22 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities'; export interface AssetSearchOptions { isVisible?: boolean; + type?: AssetType; +} + +export enum WithoutProperty { + THUMBNAIL = 'thumbnail', + ENCODED_VIDEO = 'encoded-video', + EXIF = 'exif', + CLIP_ENCODING = 'clip-embedding', + OBJECT_TAGS = 'object-tags', } export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { getByIds(ids: string[]): Promise; + getWithout(property: WithoutProperty): Promise; deleteAll(ownerId: string): Promise; getAll(options?: AssetSearchOptions): Promise; save(asset: Partial): Promise; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index c469a2bc5..0e9071ee8 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -3,6 +3,7 @@ import { APIKeyService } from './api-key'; import { AssetService } from './asset'; import { AuthService } from './auth'; import { DeviceInfoService } from './device-info'; +import { JobService } from './job'; import { MediaService } from './media'; import { OAuthService } from './oauth'; import { SearchService } from './search'; @@ -18,6 +19,7 @@ const providers: Provider[] = [ APIKeyService, AuthService, DeviceInfoService, + JobService, MediaService, OAuthService, SmartInfoService, diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index cf4403aed..734640f94 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -18,3 +18,4 @@ export * from './system-config'; export * from './tag'; export * from './user'; export * from './user-token'; +export * from './util'; diff --git a/server/libs/domain/src/job/dto/index.ts b/server/libs/domain/src/job/dto/index.ts new file mode 100644 index 000000000..abb9f9681 --- /dev/null +++ b/server/libs/domain/src/job/dto/index.ts @@ -0,0 +1,2 @@ +export * from './job-command.dto'; +export * from './job-id.dto'; diff --git a/server/libs/domain/src/job/dto/job-command.dto.ts b/server/libs/domain/src/job/dto/job-command.dto.ts new file mode 100644 index 000000000..94ddbb974 --- /dev/null +++ b/server/libs/domain/src/job/dto/job-command.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; +import { JobCommand } from '../job.constants'; + +export class JobCommandDto { + @IsNotEmpty() + @IsEnum(JobCommand) + @ApiProperty({ type: 'string', enum: JobCommand, enumName: 'JobCommand' }) + command!: JobCommand; + + @IsOptional() + @IsBoolean() + force!: boolean; +} diff --git a/server/libs/domain/src/job/dto/job-id.dto.ts b/server/libs/domain/src/job/dto/job-id.dto.ts new file mode 100644 index 000000000..28e8d4004 --- /dev/null +++ b/server/libs/domain/src/job/dto/job-id.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty } from 'class-validator'; +import { QueueName } from '../job.constants'; + +export class JobIdDto { + @IsNotEmpty() + @IsEnum(QueueName) + @ApiProperty({ type: String, enum: QueueName, enumName: 'JobName' }) + jobId!: QueueName; +} diff --git a/server/libs/domain/src/job/index.ts b/server/libs/domain/src/job/index.ts index 5721a508c..755d7994a 100644 --- a/server/libs/domain/src/job/index.ts +++ b/server/libs/domain/src/job/index.ts @@ -1,3 +1,6 @@ +export * from './dto'; export * from './job.constants'; export * from './job.interface'; export * from './job.repository'; +export * from './job.service'; +export * from './response-dto'; diff --git a/server/libs/domain/src/job/job.constants.ts b/server/libs/domain/src/job/job.constants.ts index 0404f33dd..2a75cd3d3 100644 --- a/server/libs/domain/src/job/job.constants.ts +++ b/server/libs/domain/src/job/job.constants.ts @@ -2,32 +2,63 @@ export enum QueueName { THUMBNAIL_GENERATION = 'thumbnail-generation-queue', METADATA_EXTRACTION = 'metadata-extraction-queue', VIDEO_CONVERSION = 'video-conversion-queue', - MACHINE_LEARNING = 'machine-learning-queue', - BACKGROUND_TASK = 'background-task', + OBJECT_TAGGING = 'object-tagging-queue', + CLIP_ENCODING = 'clip-encoding-queue', + BACKGROUND_TASK = 'background-task-queue', STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue', SEARCH = 'search-queue', } +export enum JobCommand { + START = 'start', + PAUSE = 'pause', + EMPTY = 'empty', +} + export enum JobName { + // upload ASSET_UPLOADED = 'asset-uploaded', - VIDEO_CONVERSION = 'mp4-conversion', + + // conversion + QUEUE_VIDEO_CONVERSION = 'queue-video-conversion', + VIDEO_CONVERSION = 'video-conversion', + + // thumbnails + QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail', GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail', + + // metadata + QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction', EXIF_EXTRACTION = 'exif-extraction', EXTRACT_VIDEO_METADATA = 'extract-video-metadata', REVERSE_GEOCODING = 'reverse-geocoding', + + // user deletion USER_DELETION = 'user-deletion', USER_DELETE_CHECK = 'user-delete-check', + + // storage template STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', SYSTEM_CONFIG_CHANGE = 'system-config-change', - OBJECT_DETECTION = 'detect-object', - IMAGE_TAGGING = 'tag-image', + + // object tagging + QUEUE_OBJECT_TAGGING = 'queue-object-tagging', + DETECT_OBJECTS = 'detect-objects', + CLASSIFY_IMAGE = 'classify-image', + + // cleanup DELETE_FILES = 'delete-files', + + // search SEARCH_INDEX_ASSETS = 'search-index-assets', SEARCH_INDEX_ASSET = 'search-index-asset', SEARCH_INDEX_ALBUMS = 'search-index-albums', SEARCH_INDEX_ALBUM = 'search-index-album', SEARCH_REMOVE_ALBUM = 'search-remove-album', SEARCH_REMOVE_ASSET = 'search-remove-asset', + + // clip + QUEUE_ENCODE_CLIP = 'queue-clip-encode', ENCODE_CLIP = 'clip-encode', } diff --git a/server/libs/domain/src/job/job.interface.ts b/server/libs/domain/src/job/job.interface.ts index ad21fb148..640cc1294 100644 --- a/server/libs/domain/src/job/job.interface.ts +++ b/server/libs/domain/src/job/job.interface.ts @@ -1,31 +1,35 @@ import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities'; -export interface IAlbumJob { +export interface IBaseJob { + force?: boolean; +} + +export interface IAlbumJob extends IBaseJob { album: AlbumEntity; } -export interface IAssetJob { +export interface IAssetJob extends IBaseJob { asset: AssetEntity; } -export interface IBulkEntityJob { +export interface IBulkEntityJob extends IBaseJob { ids: string[]; } -export interface IAssetUploadedJob { +export interface IAssetUploadedJob extends IBaseJob { asset: AssetEntity; fileName: string; } -export interface IDeleteFilesJob { +export interface IDeleteFilesJob extends IBaseJob { files: Array; } -export interface IUserDeletionJob { +export interface IUserDeletionJob extends IBaseJob { user: UserEntity; } -export interface IReverseGeocodingJob { +export interface IReverseGeocodingJob extends IBaseJob { assetId: string; latitude: number; longitude: number; diff --git a/server/libs/domain/src/job/job.repository.ts b/server/libs/domain/src/job/job.repository.ts index d9f72586c..ea6d6309e 100644 --- a/server/libs/domain/src/job/job.repository.ts +++ b/server/libs/domain/src/job/job.repository.ts @@ -2,6 +2,7 @@ import { JobName, QueueName } from './job.constants'; import { IAssetJob, IAssetUploadedJob, + IBaseJob, IBulkEntityJob, IDeleteFilesJob, IReverseGeocodingJob, @@ -17,21 +18,45 @@ export interface JobCounts { } export type JobItem = + // Asset Upload | { name: JobName.ASSET_UPLOADED; data: IAssetUploadedJob } + + // Transcoding + | { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob } | { name: JobName.VIDEO_CONVERSION; data: IAssetJob } + + // Thumbnails + | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } | { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IAssetJob } | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IAssetJob } - | { name: JobName.EXIF_EXTRACTION; data: IAssetUploadedJob } - | { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingJob } + + // User Deletion | { name: JobName.USER_DELETE_CHECK } | { name: JobName.USER_DELETION; data: IUserDeletionJob } + + // Storage Template | { name: JobName.STORAGE_TEMPLATE_MIGRATION } | { name: JobName.SYSTEM_CONFIG_CHANGE } + + // Metadata Extraction + | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } + | { name: JobName.EXIF_EXTRACTION; data: IAssetUploadedJob } | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob } - | { name: JobName.OBJECT_DETECTION; data: IAssetJob } - | { name: JobName.IMAGE_TAGGING; data: IAssetJob } + | { name: JobName.REVERSE_GEOCODING; data: IReverseGeocodingJob } + + // Object Tagging + | { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob } + | { name: JobName.DETECT_OBJECTS; data: IAssetJob } + | { name: JobName.CLASSIFY_IMAGE; data: IAssetJob } + + // Clip Embedding + | { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob } | { name: JobName.ENCODE_CLIP; data: IAssetJob } + + // Filesystem | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } + + // Search | { name: JobName.SEARCH_INDEX_ASSETS } | { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob } | { name: JobName.SEARCH_INDEX_ALBUMS } @@ -43,6 +68,7 @@ export const IJobRepository = 'IJobRepository'; export interface IJobRepository { queue(item: JobItem): Promise; + pause(name: QueueName): Promise; empty(name: QueueName): Promise; isActive(name: QueueName): Promise; getJobCounts(name: QueueName): Promise; diff --git a/server/libs/domain/src/job/job.service.spec.ts b/server/libs/domain/src/job/job.service.spec.ts new file mode 100644 index 000000000..42ce5e514 --- /dev/null +++ b/server/libs/domain/src/job/job.service.spec.ts @@ -0,0 +1,170 @@ +import { BadRequestException } from '@nestjs/common'; +import { newJobRepositoryMock } from '../../test'; +import { IJobRepository, JobCommand, JobName, JobService, QueueName } from '../job'; + +describe(JobService.name, () => { + let sut: JobService; + let jobMock: jest.Mocked; + + beforeEach(async () => { + jobMock = newJobRepositoryMock(); + sut = new JobService(jobMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('getAllJobStatus', () => { + it('should get all job statuses', async () => { + jobMock.getJobCounts.mockResolvedValue({ + active: 1, + completed: 1, + failed: 1, + delayed: 1, + waiting: 1, + }); + + await expect(sut.getAllJobsStatus()).resolves.toEqual({ + 'background-task-queue': { + active: 1, + completed: 1, + delayed: 1, + failed: 1, + waiting: 1, + }, + 'clip-encoding-queue': { + active: 1, + completed: 1, + delayed: 1, + failed: 1, + waiting: 1, + }, + 'metadata-extraction-queue': { + active: 1, + completed: 1, + delayed: 1, + failed: 1, + waiting: 1, + }, + 'object-tagging-queue': { + active: 1, + completed: 1, + delayed: 1, + failed: 1, + waiting: 1, + }, + 'search-queue': { + active: 1, + completed: 1, + delayed: 1, + failed: 1, + waiting: 1, + }, + 'storage-template-migration-queue': { + active: 1, + completed: 1, + delayed: 1, + failed: 1, + waiting: 1, + }, + 'thumbnail-generation-queue': { + active: 1, + completed: 1, + delayed: 1, + failed: 1, + waiting: 1, + }, + 'video-conversion-queue': { + active: 1, + completed: 1, + delayed: 1, + failed: 1, + waiting: 1, + }, + }); + }); + }); + + describe('handleCommand', () => { + it('should handle a pause command', async () => { + await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false }); + + expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + }); + + it('should handle an empty command', async () => { + await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false }); + + expect(jobMock.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + }); + + it('should not start a job that is already running', async () => { + jobMock.isActive.mockResolvedValue(true); + + await expect( + sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should handle a start video conversion command', async () => { + jobMock.isActive.mockResolvedValue(false); + + await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }); + + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } }); + }); + + it('should handle a start storage template migration command', async () => { + jobMock.isActive.mockResolvedValue(false); + + await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false }); + + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); + }); + + it('should handle a start object tagging command', async () => { + jobMock.isActive.mockResolvedValue(false); + + await sut.handleCommand(QueueName.OBJECT_TAGGING, { command: JobCommand.START, force: false }); + + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force: false } }); + }); + + it('should handle a start clip encoding command', async () => { + jobMock.isActive.mockResolvedValue(false); + + await sut.handleCommand(QueueName.CLIP_ENCODING, { command: JobCommand.START, force: false }); + + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_ENCODE_CLIP, data: { force: false } }); + }); + + it('should handle a start metadata extraction command', async () => { + jobMock.isActive.mockResolvedValue(false); + + await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false }); + + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } }); + }); + + it('should handle a start thumbnail generation command', async () => { + jobMock.isActive.mockResolvedValue(false); + + await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false }); + + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); + }); + + it('should throw a bad request when an invalid queue is used', async () => { + jobMock.isActive.mockResolvedValue(false); + + await expect( + sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/libs/domain/src/job/job.service.ts b/server/libs/domain/src/job/job.service.ts new file mode 100644 index 000000000..fd89573ec --- /dev/null +++ b/server/libs/domain/src/job/job.service.ts @@ -0,0 +1,68 @@ +import { assertMachineLearningEnabled } from '@app/common'; +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; +import { JobCommandDto } from './dto'; +import { JobCommand, JobName, QueueName } from './job.constants'; +import { IJobRepository } from './job.repository'; +import { AllJobStatusResponseDto } from './response-dto'; + +@Injectable() +export class JobService { + private logger = new Logger(JobService.name); + + constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {} + + handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { + this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`); + + switch (dto.command) { + case JobCommand.START: + return this.start(queueName, dto); + + case JobCommand.PAUSE: + return this.jobRepository.pause(queueName); + + case JobCommand.EMPTY: + return this.jobRepository.empty(queueName); + } + } + + async getAllJobsStatus(): Promise { + const response = new AllJobStatusResponseDto(); + for (const queueName of Object.values(QueueName)) { + response[queueName] = await this.jobRepository.getJobCounts(queueName); + } + return response; + } + + private async start(name: QueueName, { force }: JobCommandDto): Promise { + const isActive = await this.jobRepository.isActive(name); + if (isActive) { + throw new BadRequestException(`Job is already running`); + } + + switch (name) { + case QueueName.VIDEO_CONVERSION: + return this.jobRepository.queue({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force } }); + + case QueueName.STORAGE_TEMPLATE_MIGRATION: + return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); + + case QueueName.OBJECT_TAGGING: + assertMachineLearningEnabled(); + return this.jobRepository.queue({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force } }); + + case QueueName.CLIP_ENCODING: + assertMachineLearningEnabled(); + return this.jobRepository.queue({ name: JobName.QUEUE_ENCODE_CLIP, data: { force } }); + + case QueueName.METADATA_EXTRACTION: + return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } }); + + case QueueName.THUMBNAIL_GENERATION: + return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } }); + + default: + throw new BadRequestException(`Invalid job name: ${name}`); + } + } +} diff --git a/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts b/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts new file mode 100644 index 000000000..09bb55c30 --- /dev/null +++ b/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { QueueName } from '../job.constants'; + +export class JobCountsDto { + @ApiProperty({ type: 'integer' }) + active!: number; + @ApiProperty({ type: 'integer' }) + completed!: number; + @ApiProperty({ type: 'integer' }) + failed!: number; + @ApiProperty({ type: 'integer' }) + delayed!: number; + @ApiProperty({ type: 'integer' }) + waiting!: number; +} + +export class AllJobStatusResponseDto implements Record { + @ApiProperty({ type: JobCountsDto }) + [QueueName.THUMBNAIL_GENERATION]!: JobCountsDto; + + @ApiProperty({ type: JobCountsDto }) + [QueueName.METADATA_EXTRACTION]!: JobCountsDto; + + @ApiProperty({ type: JobCountsDto }) + [QueueName.VIDEO_CONVERSION]!: JobCountsDto; + + @ApiProperty({ type: JobCountsDto }) + [QueueName.OBJECT_TAGGING]!: JobCountsDto; + + @ApiProperty({ type: JobCountsDto }) + [QueueName.CLIP_ENCODING]!: JobCountsDto; + + @ApiProperty({ type: JobCountsDto }) + [QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobCountsDto; + + @ApiProperty({ type: JobCountsDto }) + [QueueName.BACKGROUND_TASK]!: JobCountsDto; + + @ApiProperty({ type: JobCountsDto }) + [QueueName.SEARCH]!: JobCountsDto; +} diff --git a/server/libs/domain/src/job/response-dto/index.ts b/server/libs/domain/src/job/response-dto/index.ts new file mode 100644 index 000000000..7229910ae --- /dev/null +++ b/server/libs/domain/src/job/response-dto/index.ts @@ -0,0 +1 @@ +export * from './all-job-status-response.dto'; diff --git a/server/libs/domain/src/media/media.service.ts b/server/libs/domain/src/media/media.service.ts index 97f84162c..f39cc2ab8 100644 --- a/server/libs/domain/src/media/media.service.ts +++ b/server/libs/domain/src/media/media.service.ts @@ -3,9 +3,9 @@ import { AssetType } from '@app/infra/db/entities'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { join } from 'path'; import sanitize from 'sanitize-filename'; -import { IAssetRepository, mapAsset } from '../asset'; +import { IAssetRepository, mapAsset, WithoutProperty } from '../asset'; import { CommunicationEvent, ICommunicationRepository } from '../communication'; -import { IAssetJob, IJobRepository, JobName } from '../job'; +import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job'; import { IStorageRepository } from '../storage'; import { IMediaRepository } from './media.repository'; @@ -21,6 +21,22 @@ export class MediaService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) {} + async handleQueueGenerateThumbnails(job: IBaseJob): Promise { + try { + const { force } = job; + + const assets = force + ? await this.assetRepository.getAll() + : await this.assetRepository.getWithout(WithoutProperty.THUMBNAIL); + + for (const asset of assets) { + await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } }); + } + } catch (error: any) { + this.logger.error('Failed to queue generate thumbnail jobs', error.stack); + } + } + async handleGenerateJpegThumbnail(data: IAssetJob): Promise { const { asset } = data; @@ -52,8 +68,8 @@ export class MediaService { asset.resizePath = jpegThumbnailPath; await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } }); - await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } }); - await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } }); + await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } }); + await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } }); await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } }); this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); @@ -71,8 +87,8 @@ export class MediaService { asset.resizePath = jpegThumbnailPath; await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } }); - await this.jobRepository.queue({ name: JobName.IMAGE_TAGGING, data: { asset } }); - await this.jobRepository.queue({ name: JobName.OBJECT_DETECTION, data: { asset } }); + await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } }); + await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } }); await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } }); this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); diff --git a/server/libs/domain/src/smart-info/machine-learning.interface.ts b/server/libs/domain/src/smart-info/machine-learning.interface.ts index a42bf3023..9d22e0857 100644 --- a/server/libs/domain/src/smart-info/machine-learning.interface.ts +++ b/server/libs/domain/src/smart-info/machine-learning.interface.ts @@ -5,7 +5,7 @@ export interface MachineLearningInput { } export interface IMachineLearningRepository { - tagImage(input: MachineLearningInput): Promise; + classifyImage(input: MachineLearningInput): Promise; detectObjects(input: MachineLearningInput): Promise; encodeImage(input: MachineLearningInput): Promise; encodeText(input: string): Promise; diff --git a/server/libs/domain/src/smart-info/smart-info.service.spec.ts b/server/libs/domain/src/smart-info/smart-info.service.spec.ts index 41e3887b6..2a763a848 100644 --- a/server/libs/domain/src/smart-info/smart-info.service.spec.ts +++ b/server/libs/domain/src/smart-info/smart-info.service.spec.ts @@ -1,6 +1,13 @@ import { AssetEntity } from '@app/infra/db/entities'; -import { newJobRepositoryMock, newMachineLearningRepositoryMock, newSmartInfoRepositoryMock } from '../../test'; -import { IJobRepository } from '../job'; +import { + assetEntityStub, + newAssetRepositoryMock, + newJobRepositoryMock, + newMachineLearningRepositoryMock, + newSmartInfoRepositoryMock, +} from '../../test'; +import { IAssetRepository, WithoutProperty } from '../asset'; +import { IJobRepository, JobName } from '../job'; import { IMachineLearningRepository } from './machine-learning.interface'; import { ISmartInfoRepository } from './smart-info.repository'; import { SmartInfoService } from './smart-info.service'; @@ -12,35 +19,63 @@ const asset = { describe(SmartInfoService.name, () => { let sut: SmartInfoService; + let assetMock: jest.Mocked; let jobMock: jest.Mocked; let smartMock: jest.Mocked; let machineMock: jest.Mocked; beforeEach(async () => { + assetMock = newAssetRepositoryMock(); smartMock = newSmartInfoRepositoryMock(); jobMock = newJobRepositoryMock(); machineMock = newMachineLearningRepositoryMock(); - sut = new SmartInfoService(jobMock, smartMock, machineMock); + sut = new SmartInfoService(assetMock, jobMock, smartMock, machineMock); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('handleQueueObjectTagging', () => { + it('should queue the assets without tags', async () => { + assetMock.getWithout.mockResolvedValue([assetEntityStub.image]); + + await sut.handleQueueObjectTagging({ force: false }); + + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }], + [{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }], + ]); + expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.OBJECT_TAGS); + }); + + it('should queue all the assets', async () => { + assetMock.getAll.mockResolvedValue([assetEntityStub.image]); + + await sut.handleQueueObjectTagging({ force: true }); + + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.CLASSIFY_IMAGE, data: { asset: assetEntityStub.image } }], + [{ name: JobName.DETECT_OBJECTS, data: { asset: assetEntityStub.image } }], + ]); + expect(assetMock.getAll).toHaveBeenCalled(); + }); + }); + describe('handleTagImage', () => { it('should skip assets without a resize path', async () => { - await sut.handleTagImage({ asset: { resizePath: '' } as AssetEntity }); + await sut.handleClassifyImage({ asset: { resizePath: '' } as AssetEntity }); expect(smartMock.upsert).not.toHaveBeenCalled(); - expect(machineMock.tagImage).not.toHaveBeenCalled(); + expect(machineMock.classifyImage).not.toHaveBeenCalled(); }); it('should save the returned tags', async () => { - machineMock.tagImage.mockResolvedValue(['tag1', 'tag2', 'tag3']); + machineMock.classifyImage.mockResolvedValue(['tag1', 'tag2', 'tag3']); - await sut.handleTagImage({ asset }); + await sut.handleClassifyImage({ asset }); - expect(machineMock.tagImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' }); + expect(machineMock.classifyImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' }); expect(smartMock.upsert).toHaveBeenCalledWith({ assetId: 'asset-1', tags: ['tag1', 'tag2', 'tag3'], @@ -48,19 +83,19 @@ describe(SmartInfoService.name, () => { }); it('should handle an error with the machine learning pipeline', async () => { - machineMock.tagImage.mockRejectedValue(new Error('Unable to read thumbnail')); + machineMock.classifyImage.mockRejectedValue(new Error('Unable to read thumbnail')); - await sut.handleTagImage({ asset }); + await sut.handleClassifyImage({ asset }); expect(smartMock.upsert).not.toHaveBeenCalled(); }); it('should no update the smart info if no tags were returned', async () => { - machineMock.tagImage.mockResolvedValue([]); + machineMock.classifyImage.mockResolvedValue([]); - await sut.handleTagImage({ asset }); + await sut.handleClassifyImage({ asset }); - expect(machineMock.tagImage).toHaveBeenCalled(); + expect(machineMock.classifyImage).toHaveBeenCalled(); expect(smartMock.upsert).not.toHaveBeenCalled(); }); }); @@ -102,4 +137,53 @@ describe(SmartInfoService.name, () => { expect(smartMock.upsert).not.toHaveBeenCalled(); }); }); + + describe('handleQueueEncodeClip', () => { + it('should queue the assets without clip embeddings', async () => { + assetMock.getWithout.mockResolvedValue([assetEntityStub.image]); + + await sut.handleQueueEncodeClip({ force: false }); + + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset: assetEntityStub.image } }); + expect(assetMock.getWithout).toHaveBeenCalledWith(WithoutProperty.CLIP_ENCODING); + }); + + it('should queue all the assets', async () => { + assetMock.getAll.mockResolvedValue([assetEntityStub.image]); + + await sut.handleQueueEncodeClip({ force: true }); + + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.ENCODE_CLIP, data: { asset: assetEntityStub.image } }); + expect(assetMock.getAll).toHaveBeenCalled(); + }); + }); + + describe('handleEncodeClip', () => { + it('should skip assets without a resize path', async () => { + await sut.handleEncodeClip({ asset: { resizePath: '' } as AssetEntity }); + + expect(smartMock.upsert).not.toHaveBeenCalled(); + expect(machineMock.encodeImage).not.toHaveBeenCalled(); + }); + + it('should save the returned objects', async () => { + machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); + + await sut.handleEncodeClip({ asset }); + + expect(machineMock.encodeImage).toHaveBeenCalledWith({ thumbnailPath: 'path/to/resize.ext' }); + expect(smartMock.upsert).toHaveBeenCalledWith({ + assetId: 'asset-1', + clipEmbedding: [0.01, 0.02, 0.03], + }); + }); + + it('should handle an error with the machine learning pipeline', async () => { + machineMock.encodeImage.mockRejectedValue(new Error('Unable to read thumbnail')); + + await sut.handleEncodeClip({ asset }); + + expect(smartMock.upsert).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/libs/domain/src/smart-info/smart-info.service.ts b/server/libs/domain/src/smart-info/smart-info.service.ts index 2621576ee..8e7cd014a 100644 --- a/server/libs/domain/src/smart-info/smart-info.service.ts +++ b/server/libs/domain/src/smart-info/smart-info.service.ts @@ -1,6 +1,7 @@ import { MACHINE_LEARNING_ENABLED } from '@app/common'; import { Inject, Injectable, Logger } from '@nestjs/common'; -import { IAssetJob, IJobRepository, JobName } from '../job'; +import { IAssetRepository, WithoutProperty } from '../asset'; +import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job'; import { IMachineLearningRepository } from './machine-learning.interface'; import { ISmartInfoRepository } from './smart-info.repository'; @@ -9,26 +10,24 @@ export class SmartInfoService { private logger = new Logger(SmartInfoService.name); constructor( + @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISmartInfoRepository) private repository: ISmartInfoRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, ) {} - async handleTagImage(data: IAssetJob) { - const { asset } = data; - - if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) { - return; - } - + async handleQueueObjectTagging({ force }: IBaseJob) { try { - const tags = await this.machineLearning.tagImage({ thumbnailPath: asset.resizePath }); - if (tags.length > 0) { - await this.repository.upsert({ assetId: asset.id, tags }); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } }); + const assets = force + ? await this.assetRepository.getAll() + : await this.assetRepository.getWithout(WithoutProperty.OBJECT_TAGS); + + for (const asset of assets) { + await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { asset } }); + await this.jobRepository.queue({ name: JobName.DETECT_OBJECTS, data: { asset } }); } } catch (error: any) { - this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack); + this.logger.error(`Unable to queue object tagging`, error?.stack); } } @@ -50,6 +49,38 @@ export class SmartInfoService { } } + async handleClassifyImage(data: IAssetJob) { + const { asset } = data; + + if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) { + return; + } + + try { + const tags = await this.machineLearning.classifyImage({ thumbnailPath: asset.resizePath }); + if (tags.length > 0) { + await this.repository.upsert({ assetId: asset.id, tags }); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [asset.id] } }); + } + } catch (error: any) { + this.logger.error(`Unable to run image tagging pipeline: ${asset.id}`, error?.stack); + } + } + + async handleQueueEncodeClip({ force }: IBaseJob) { + try { + const assets = force + ? await this.assetRepository.getAll() + : await this.assetRepository.getWithout(WithoutProperty.CLIP_ENCODING); + + for (const asset of assets) { + await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: { asset } }); + } + } catch (error: any) { + this.logger.error(`Unable to queue clip encoding`, error?.stack); + } + } + async handleEncodeClip(data: IAssetJob) { const { asset } = data; diff --git a/server/apps/immich/src/utils/file-name.util.ts b/server/libs/domain/src/util.ts similarity index 100% rename from server/apps/immich/src/utils/file-name.util.ts rename to server/libs/domain/src/util.ts diff --git a/server/libs/domain/test/asset.repository.mock.ts b/server/libs/domain/test/asset.repository.mock.ts index b56bd1419..314804c2c 100644 --- a/server/libs/domain/test/asset.repository.mock.ts +++ b/server/libs/domain/test/asset.repository.mock.ts @@ -3,6 +3,7 @@ import { IAssetRepository } from '../src'; export const newAssetRepositoryMock = (): jest.Mocked => { return { getByIds: jest.fn(), + getWithout: jest.fn(), getAll: jest.fn(), deleteAll: jest.fn(), save: jest.fn(), diff --git a/server/libs/domain/test/job.repository.mock.ts b/server/libs/domain/test/job.repository.mock.ts index 22c47b2da..5b2d9547b 100644 --- a/server/libs/domain/test/job.repository.mock.ts +++ b/server/libs/domain/test/job.repository.mock.ts @@ -3,6 +3,7 @@ import { IJobRepository } from '../src'; export const newJobRepositoryMock = (): jest.Mocked => { return { empty: jest.fn(), + pause: jest.fn(), queue: jest.fn().mockImplementation(() => Promise.resolve()), isActive: jest.fn(), getJobCounts: jest.fn(), diff --git a/server/libs/domain/test/machine-learning.repository.mock.ts b/server/libs/domain/test/machine-learning.repository.mock.ts index 0bc06814e..b9205d44e 100644 --- a/server/libs/domain/test/machine-learning.repository.mock.ts +++ b/server/libs/domain/test/machine-learning.repository.mock.ts @@ -2,7 +2,7 @@ import { IMachineLearningRepository } from '../src'; export const newMachineLearningRepositoryMock = (): jest.Mocked => { return { - tagImage: jest.fn(), + classifyImage: jest.fn(), detectObjects: jest.fn(), encodeImage: jest.fn(), encodeText: jest.fn(), diff --git a/server/libs/infra/src/db/repository/asset.repository.ts b/server/libs/infra/src/db/repository/asset.repository.ts index 17adb46bb..38e57b4f1 100644 --- a/server/libs/infra/src/db/repository/asset.repository.ts +++ b/server/libs/infra/src/db/repository/asset.repository.ts @@ -1,7 +1,7 @@ -import { AssetSearchOptions, IAssetRepository } from '@app/domain'; +import { AssetSearchOptions, IAssetRepository, WithoutProperty } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Not, Repository } from 'typeorm'; +import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm'; import { AssetEntity, AssetType } from '../entities'; @Injectable() @@ -65,4 +65,73 @@ export class AssetRepository implements IAssetRepository { }, }); } + + getWithout(property: WithoutProperty): Promise { + let relations: FindOptionsRelations = {}; + let where: FindOptionsWhere | FindOptionsWhere[] = {}; + + switch (property) { + case WithoutProperty.THUMBNAIL: + where = [ + { resizePath: IsNull(), isVisible: true }, + { resizePath: '', isVisible: true }, + { webpPath: IsNull(), isVisible: true }, + { webpPath: '', isVisible: true }, + ]; + break; + + case WithoutProperty.ENCODED_VIDEO: + where = [ + { type: AssetType.VIDEO, encodedVideoPath: IsNull() }, + { type: AssetType.VIDEO, encodedVideoPath: '' }, + ]; + break; + + case WithoutProperty.EXIF: + relations = { + exifInfo: true, + }; + where = { + isVisible: true, + resizePath: Not(IsNull()), + exifInfo: { + assetId: IsNull(), + }, + }; + break; + + case WithoutProperty.CLIP_ENCODING: + relations = { + smartInfo: true, + }; + where = { + isVisible: true, + smartInfo: { + clipEmbedding: IsNull(), + }, + }; + break; + + case WithoutProperty.OBJECT_TAGS: + relations = { + smartInfo: true, + }; + where = { + resizePath: IsNull(), + isVisible: true, + smartInfo: { + tags: IsNull(), + }, + }; + break; + + default: + throw new Error(`Invalid getWithout property: ${property}`); + } + + return this.repository.find({ + relations, + where, + }); + } } diff --git a/server/libs/infra/src/job/job.repository.ts b/server/libs/infra/src/job/job.repository.ts index 53f3fb9a7..1fbacd840 100644 --- a/server/libs/infra/src/job/job.repository.ts +++ b/server/libs/infra/src/job/job.repository.ts @@ -1,18 +1,38 @@ -import { IAssetJob, IJobRepository, IMetadataExtractionJob, JobCounts, JobItem, JobName, QueueName } from '@app/domain'; +import { + IAssetJob, + IBaseJob, + IJobRepository, + IMetadataExtractionJob, + JobCounts, + JobItem, + JobName, + QueueName, +} from '@app/domain'; import { InjectQueue } from '@nestjs/bull'; -import { BadRequestException, Logger } from '@nestjs/common'; +import { Logger } from '@nestjs/common'; import { Queue } from 'bull'; export class JobRepository implements IJobRepository { private logger = new Logger(JobRepository.name); + private queueMap: Record = { + [QueueName.STORAGE_TEMPLATE_MIGRATION]: this.storageTemplateMigration, + [QueueName.THUMBNAIL_GENERATION]: this.generateThumbnail, + [QueueName.METADATA_EXTRACTION]: this.metadataExtraction, + [QueueName.OBJECT_TAGGING]: this.objectTagging, + [QueueName.CLIP_ENCODING]: this.clipEmbedding, + [QueueName.VIDEO_CONVERSION]: this.videoTranscode, + [QueueName.BACKGROUND_TASK]: this.backgroundTask, + [QueueName.SEARCH]: this.searchIndex, + }; constructor( @InjectQueue(QueueName.BACKGROUND_TASK) private backgroundTask: Queue, - @InjectQueue(QueueName.MACHINE_LEARNING) private machineLearning: Queue, - @InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue, + @InjectQueue(QueueName.OBJECT_TAGGING) private objectTagging: Queue, + @InjectQueue(QueueName.CLIP_ENCODING) private clipEmbedding: Queue, + @InjectQueue(QueueName.METADATA_EXTRACTION) private metadataExtraction: Queue, @InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue, - @InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue, - @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue, + @InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue, + @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue, @InjectQueue(QueueName.SEARCH) private searchIndex: Queue, ) {} @@ -21,12 +41,16 @@ export class JobRepository implements IJobRepository { return !!counts.active; } + pause(name: QueueName) { + return this.queueMap[name].pause(); + } + empty(name: QueueName) { - return this.getQueue(name).empty(); + return this.queueMap[name].empty(); } getJobCounts(name: QueueName): Promise { - return this.getQueue(name).getJobCounts(); + return this.queueMap[name].getJobCounts(); } async queue(item: JobItem): Promise { @@ -39,21 +63,28 @@ export class JobRepository implements IJobRepository { await this.backgroundTask.add(item.name, item.data); break; - case JobName.OBJECT_DETECTION: - case JobName.IMAGE_TAGGING: - case JobName.ENCODE_CLIP: - await this.machineLearning.add(item.name, item.data); + case JobName.QUEUE_OBJECT_TAGGING: + case JobName.DETECT_OBJECTS: + case JobName.CLASSIFY_IMAGE: + await this.objectTagging.add(item.name, item.data); break; + case JobName.QUEUE_ENCODE_CLIP: + case JobName.ENCODE_CLIP: + await this.clipEmbedding.add(item.name, item.data); + break; + + case JobName.QUEUE_METADATA_EXTRACTION: case JobName.EXIF_EXTRACTION: case JobName.EXTRACT_VIDEO_METADATA: case JobName.REVERSE_GEOCODING: await this.metadataExtraction.add(item.name, item.data); break; + case JobName.QUEUE_GENERATE_THUMBNAILS: case JobName.GENERATE_JPEG_THUMBNAIL: case JobName.GENERATE_WEBP_THUMBNAIL: - await this.thumbnail.add(item.name, item.data); + await this.generateThumbnail.add(item.name, item.data); break; case JobName.USER_DELETION: @@ -68,6 +99,7 @@ export class JobRepository implements IJobRepository { await this.backgroundTask.add(item.name, {}); break; + case JobName.QUEUE_VIDEO_CONVERSION: case JobName.VIDEO_CONVERSION: await this.videoTranscode.add(item.name, item.data); break; @@ -85,25 +117,7 @@ export class JobRepository implements IJobRepository { break; default: - // TODO inject remaining queues and map job to queue this.logger.error('Invalid job', item); } } - - private getQueue(name: QueueName) { - switch (name) { - case QueueName.STORAGE_TEMPLATE_MIGRATION: - return this.storageTemplateMigration; - case QueueName.THUMBNAIL_GENERATION: - return this.thumbnail; - case QueueName.METADATA_EXTRACTION: - return this.metadataExtraction; - case QueueName.VIDEO_CONVERSION: - return this.videoTranscode; - case QueueName.MACHINE_LEARNING: - return this.machineLearning; - default: - throw new BadRequestException('Invalid job name'); - } - } } diff --git a/server/libs/infra/src/machine-learning/machine-learning.repository.ts b/server/libs/infra/src/machine-learning/machine-learning.repository.ts index a69d68744..78fc80977 100644 --- a/server/libs/infra/src/machine-learning/machine-learning.repository.ts +++ b/server/libs/infra/src/machine-learning/machine-learning.repository.ts @@ -7,7 +7,7 @@ const client = axios.create({ baseURL: MACHINE_LEARNING_URL }); @Injectable() export class MachineLearningRepository implements IMachineLearningRepository { - tagImage(input: MachineLearningInput): Promise { + classifyImage(input: MachineLearningInput): Promise { return client.post('/image-classifier/tag-image', input).then((res) => res.data); } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 5b514cc3f..c2607f707 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -291,34 +291,52 @@ export interface AlbumResponseDto { export interface AllJobStatusResponseDto { /** * - * @type {JobCounts} + * @type {JobCountsDto} * @memberof AllJobStatusResponseDto */ - 'thumbnail-generation': JobCounts; + 'thumbnail-generation-queue': JobCountsDto; /** * - * @type {JobCounts} + * @type {JobCountsDto} * @memberof AllJobStatusResponseDto */ - 'metadata-extraction': JobCounts; + 'metadata-extraction-queue': JobCountsDto; /** * - * @type {JobCounts} + * @type {JobCountsDto} * @memberof AllJobStatusResponseDto */ - 'video-conversion': JobCounts; + 'video-conversion-queue': JobCountsDto; /** * - * @type {JobCounts} + * @type {JobCountsDto} * @memberof AllJobStatusResponseDto */ - 'machine-learning': JobCounts; + 'object-tagging-queue': JobCountsDto; /** * - * @type {JobCounts} + * @type {JobCountsDto} * @memberof AllJobStatusResponseDto */ - 'storage-template-migration': JobCounts; + 'clip-encoding-queue': JobCountsDto; + /** + * + * @type {JobCountsDto} + * @memberof AllJobStatusResponseDto + */ + 'storage-template-migration-queue': JobCountsDto; + /** + * + * @type {JobCountsDto} + * @memberof AllJobStatusResponseDto + */ + 'background-task-queue': JobCountsDto; + /** + * + * @type {JobCountsDto} + * @memberof AllJobStatusResponseDto + */ + 'search-queue': JobCountsDto; } /** * @@ -1203,7 +1221,8 @@ export interface GetAssetCountByTimeBucketDto { export const JobCommand = { Start: 'start', - Stop: 'stop' + Pause: 'pause', + Empty: 'empty' } as const; export type JobCommand = typeof JobCommand[keyof typeof JobCommand]; @@ -1226,42 +1245,42 @@ export interface JobCommandDto { * @type {boolean} * @memberof JobCommandDto */ - 'includeAllAssets': boolean; + 'force': boolean; } /** * * @export - * @interface JobCounts + * @interface JobCountsDto */ -export interface JobCounts { +export interface JobCountsDto { /** * * @type {number} - * @memberof JobCounts + * @memberof JobCountsDto */ 'active': number; /** * * @type {number} - * @memberof JobCounts + * @memberof JobCountsDto */ 'completed': number; /** * * @type {number} - * @memberof JobCounts + * @memberof JobCountsDto */ 'failed': number; /** * * @type {number} - * @memberof JobCounts + * @memberof JobCountsDto */ 'delayed': number; /** * * @type {number} - * @memberof JobCounts + * @memberof JobCountsDto */ 'waiting': number; } @@ -1271,15 +1290,18 @@ export interface JobCounts { * @enum {string} */ -export const JobId = { - ThumbnailGeneration: 'thumbnail-generation', - MetadataExtraction: 'metadata-extraction', - VideoConversion: 'video-conversion', - MachineLearning: 'machine-learning', - StorageTemplateMigration: 'storage-template-migration' +export const JobName = { + ThumbnailGenerationQueue: 'thumbnail-generation-queue', + MetadataExtractionQueue: 'metadata-extraction-queue', + VideoConversionQueue: 'video-conversion-queue', + ObjectTaggingQueue: 'object-tagging-queue', + ClipEncodingQueue: 'clip-encoding-queue', + BackgroundTaskQueue: 'background-task-queue', + StorageTemplateMigrationQueue: 'storage-template-migration-queue', + SearchQueue: 'search-queue' } as const; -export type JobId = typeof JobId[keyof typeof JobId]; +export type JobName = typeof JobName[keyof typeof JobName]; /** @@ -6169,12 +6191,12 @@ export const JobApiAxiosParamCreator = function (configuration?: Configuration) }, /** * - * @param {JobId} jobId + * @param {JobName} jobId * @param {JobCommandDto} jobCommandDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - sendJobCommand: async (jobId: JobId, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise => { + sendJobCommand: async (jobId: JobName, jobCommandDto: JobCommandDto, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'jobId' is not null or undefined assertParamExists('sendJobCommand', 'jobId', jobId) // verify required parameter 'jobCommandDto' is not null or undefined @@ -6233,12 +6255,12 @@ export const JobApiFp = function(configuration?: Configuration) { }, /** * - * @param {JobId} jobId + * @param {JobName} jobId * @param {JobCommandDto} jobCommandDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.sendJobCommand(jobId, jobCommandDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -6262,12 +6284,12 @@ export const JobApiFactory = function (configuration?: Configuration, basePath?: }, /** * - * @param {JobId} jobId + * @param {JobName} jobId * @param {JobCommandDto} jobCommandDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: any): AxiosPromise { + sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: any): AxiosPromise { return localVarFp.sendJobCommand(jobId, jobCommandDto, options).then((request) => request(axios, basePath)); }, }; @@ -6292,13 +6314,13 @@ export class JobApi extends BaseAPI { /** * - * @param {JobId} jobId + * @param {JobName} jobId * @param {JobCommandDto} jobCommandDto * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof JobApi */ - public sendJobCommand(jobId: JobId, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig) { + public sendJobCommand(jobId: JobName, jobCommandDto: JobCommandDto, options?: AxiosRequestConfig) { return JobApiFp(this.configuration).sendJobCommand(jobId, jobCommandDto, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index f7b23c8ad..625c4167d 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -5,11 +5,11 @@ import AllInclusive from 'svelte-material-icons/AllInclusive.svelte'; import { locale } from '$lib/stores/preferences.store'; import { createEventDispatcher } from 'svelte'; - import { JobCounts } from '@api'; + import { JobCountsDto } from '@api'; export let title: string; export let subtitle: string; - export let jobCounts: JobCounts; + export let jobCounts: JobCountsDto; /** * Show options to run job on all assets of just missing ones */ @@ -19,8 +19,8 @@ const dispatch = createEventDispatcher(); - const run = (includeAllAssets: boolean) => { - dispatch('click', { includeAllAssets }); + const run = (force: boolean) => { + dispatch('click', { force }); }; diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 815cfe6fe..3a6685507 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -4,7 +4,7 @@ NotificationType } from '$lib/components/shared-components/notification/notification'; import { handleError } from '$lib/utils/handle-error'; - import { AllJobStatusResponseDto, api, JobCommand, JobId } from '@api'; + import { AllJobStatusResponseDto, api, JobCommand, JobName } from '@api'; import { onDestroy, onMount } from 'svelte'; import JobTile from './job-tile.svelte'; @@ -18,35 +18,42 @@ onMount(async () => { await load(); - timer = setInterval(async () => await load(), 1_000); + timer = setInterval(async () => await load(), 5_000); }); onDestroy(() => { clearInterval(timer); }); - const run = async ( - jobId: JobId, - jobName: string, - emptyMessage: string, - includeAllAssets: boolean - ) => { - try { - const { data } = await api.jobApi.sendJobCommand(jobId, { - command: JobCommand.Start, - includeAllAssets - }); + function getJobLabel(jobName: JobName) { + const names: Record = { + [JobName.ThumbnailGenerationQueue]: 'Generate Thumbnails', + [JobName.MetadataExtractionQueue]: 'Extract Metadata', + [JobName.VideoConversionQueue]: 'Transcode Videos', + [JobName.ObjectTaggingQueue]: 'Tag Objects', + [JobName.ClipEncodingQueue]: 'Clip Encoding', + [JobName.BackgroundTaskQueue]: 'Background Task', + [JobName.StorageTemplateMigrationQueue]: 'Storage Template Migration', + [JobName.SearchQueue]: 'Search' + }; - if (data) { - notificationController.show({ - message: includeAllAssets ? `Started ${jobName} for all assets` : `Started ${jobName}`, - type: NotificationType.Info - }); - } else { - notificationController.show({ message: emptyMessage, type: NotificationType.Info }); - } + return names[jobName]; + } + + const start = async (jobId: JobName, force: boolean) => { + const label = getJobLabel(jobId); + + try { + await api.jobApi.sendJobCommand(jobId, { command: JobCommand.Start, force }); + + jobs[jobId].active += 1; + + notificationController.show({ + message: `Started job: ${label}`, + type: NotificationType.Info + }); } catch (error) { - handleError(error, `Unable to start ${jobName}`); + handleError(error, `Unable to start job: ${label}`); } }; @@ -54,76 +61,48 @@
{#if jobs} { - const { includeAllAssets } = e.detail; - - run( - JobId.ThumbnailGeneration, - 'thumbnail generation', - 'No missing thumbnails found', - includeAllAssets - ); - }} - jobCounts={jobs[JobId.ThumbnailGeneration]} + title="Generate thumbnails" + subtitle="Regenerate JPEG and WebP thumbnails" + on:click={(e) => start(JobName.ThumbnailGenerationQueue, e.detail.force)} + jobCounts={jobs[JobName.ThumbnailGenerationQueue]} /> { - const { includeAllAssets } = e.detail; - run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found', includeAllAssets); - }} - jobCounts={jobs[JobId.MetadataExtraction]} + title="Extract Metadata" + subtitle="Extract metadata information i.e. GPS, resolution...etc" + on:click={(e) => start(JobName.MetadataExtractionQueue, e.detail.force)} + jobCounts={jobs[JobName.MetadataExtractionQueue]} /> { - const { includeAllAssets } = e.detail; - - run( - JobId.MachineLearning, - 'object detection', - 'No missing object detection found', - includeAllAssets - ); - }} - jobCounts={jobs[JobId.MachineLearning]} + title="Tag Objects" + subtitle="Run machine learning to tag objects" + on:click={(e) => start(JobName.ObjectTaggingQueue, e.detail.force)} + jobCounts={jobs[JobName.ObjectTaggingQueue]} > Note that some assets may not have any objects detected { - const { includeAllAssets } = e.detail; - run( - JobId.VideoConversion, - 'video conversion', - 'No videos without an encoded version found', - includeAllAssets - ); - }} - jobCounts={jobs[JobId.VideoConversion]} + title="Encode Clip" + subtitle="Run machine learning to generate clip embeddings" + on:click={(e) => start(JobName.ClipEncodingQueue, e.detail.force)} + jobCounts={jobs[JobName.ClipEncodingQueue]} /> start(JobName.VideoConversionQueue, e.detail.force)} + jobCounts={jobs[JobName.VideoConversionQueue]} + /> + + - run( - JobId.StorageTemplateMigration, - 'storage template migration', - 'All files have been migrated to the new storage template', - false - )} - jobCounts={jobs[JobId.StorageTemplateMigration]} + on:click={(e) => start(JobName.StorageTemplateMigrationQueue, e.detail.force)} + jobCounts={jobs[JobName.StorageTemplateMigrationQueue]} > Apply the current