From 104fa09f69b6b8c8265953673485cc658beba6d7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 25 Nov 2025 08:19:40 -0500 Subject: [PATCH] feat: queues (#24142) --- e2e/src/utils.ts | 6 +- mobile/openapi/README.md | Bin 47975 -> 49058 bytes mobile/openapi/lib/api.dart | Bin 15551 -> 15816 bytes mobile/openapi/lib/api/deprecated_api.dart | Bin 14075 -> 17934 bytes mobile/openapi/lib/api/jobs_api.dart | Bin 5813 -> 5849 bytes mobile/openapi/lib/api/queues_api.dart | Bin 0 -> 9244 bytes mobile/openapi/lib/api_client.dart | Bin 38244 -> 38790 bytes mobile/openapi/lib/api_helper.dart | Bin 7536 -> 7734 bytes mobile/openapi/lib/model/job_name.dart | Bin 0 -> 11111 bytes mobile/openapi/lib/model/permission.dart | Bin 25805 -> 26781 bytes .../openapi/lib/model/queue_delete_dto.dart | Bin 0 -> 3280 bytes .../lib/model/queue_job_response_dto.dart | Bin 0 -> 3909 bytes .../openapi/lib/model/queue_job_status.dart | Bin 0 -> 3150 bytes .../openapi/lib/model/queue_response_dto.dart | Bin 3177 -> 3338 bytes .../lib/model/queue_response_legacy_dto.dart | Bin 0 -> 3303 bytes ..._dto.dart => queue_status_legacy_dto.dart} | Bin 3057 -> 3171 bytes .../openapi/lib/model/queue_update_dto.dart | Bin 0 -> 3248 bytes ...o.dart => queues_response_legacy_dto.dart} | Bin 8017 -> 8335 bytes open-api/immich-openapi-specs.json | 519 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 204 ++++++- server/src/constants.ts | 2 + server/src/controllers/index.ts | 2 + server/src/controllers/job.controller.ts | 21 +- server/src/controllers/queue.controller.ts | 85 +++ server/src/dtos/queue-legacy.dto.ts | 89 +++ server/src/dtos/queue.dto.ts | 109 ++-- server/src/enum.ts | 22 + server/src/repositories/config.repository.ts | 2 +- server/src/repositories/job.repository.ts | 41 +- server/src/services/queue.service.spec.ts | 132 +++-- server/src/services/queue.service.ts | 74 ++- server/src/types.ts | 5 - .../test/repositories/job.repository.mock.ts | 4 +- server/test/small.factory.ts | 12 + web/src/lib/components/jobs/JobTile.svelte | 4 +- web/src/lib/components/jobs/JobsPanel.svelte | 4 +- web/src/routes/admin/jobs-status/+page.svelte | 10 +- 37 files changed, 1110 insertions(+), 237 deletions(-) create mode 100644 mobile/openapi/lib/api/queues_api.dart create mode 100644 mobile/openapi/lib/model/job_name.dart create mode 100644 mobile/openapi/lib/model/queue_delete_dto.dart create mode 100644 mobile/openapi/lib/model/queue_job_response_dto.dart create mode 100644 mobile/openapi/lib/model/queue_job_status.dart create mode 100644 mobile/openapi/lib/model/queue_response_legacy_dto.dart rename mobile/openapi/lib/model/{queue_status_dto.dart => queue_status_legacy_dto.dart} (63%) create mode 100644 mobile/openapi/lib/model/queue_update_dto.dart rename mobile/openapi/lib/model/{queues_response_dto.dart => queues_response_legacy_dto.dart} (55%) create mode 100644 server/src/controllers/queue.controller.ts create mode 100644 server/src/dtos/queue-legacy.dto.ts diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index f045ea2ef..15bb112cd 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -12,7 +12,7 @@ import { PersonCreateDto, QueueCommandDto, QueueName, - QueuesResponseDto, + QueuesResponseLegacyDto, SharedLinkCreateDto, UpdateLibraryDto, UserAdminCreateDto, @@ -564,13 +564,13 @@ export const utils = { await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); }, - isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => { + isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => { const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) }); const jobCounts = queues[queue].jobCounts; return !jobCounts.active && !jobCounts.waiting; }, - waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => { + waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 52fbbc8e7bd3101ef86279026d4bd80005905119..268c4849c58760cd236f8a78b67e7fb1df5d6fa8 100644 GIT binary patch delta 906 zcmaF*On{B9pZxg}{7wjm?uAHAE(J>&XK7LX%DO zEI0quh?1!fEKMy-{AaHF8MrPrsHfW@#| zmY!My)(El6-8BTm9-tLLsU=03sb#5nY=K(h1#}adMfE_RgS-W^3FK_tw!wS_w-W3+ z1&}5zeuLVIZY7G1^}xW#Vjs|3KAFWO3W+&6kf6h2erZ8UVo55V(1R#I3c!F+i~tm> z1zHsfHVGDfn*&`>xd{0IV-Xtvz+eOMHuHDw;PrG#%}Fgubt%b5P8@JSU{GpuDd;Lh zgPjG`7?fIEke^qKt^p`BX@;a6kP0qIEGaEUHViH}dFS*5K9~VMsp*Nym6JbC7iWVq uIc9j*Lzrl;ffxywLUj|wuV^~pf~abXQ7uQ)1eE20cn)IK=2$#C{BX`eB>?e3B4q#o diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index a47d9ddf929b807fabd42b338f6f750e2d43015b..21730074aa19dc12b617cc7b9227c1856aa7dea3 100644 GIT binary patch delta 127 zcmdmAd7^s5K~~nn($vz_$$`w`n-8)s;-6e1F0naYbU7DGN@`B(WIjn7Fd_ delta 43 zcmV+`0M!4;d%tkLn-!x9ldvE}lOZ85lM@#g1aWm`b(0|;BeTFE2ppT* B5I6t; diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index aaf7c074b95ba4041243a977add04bdb3301bbd8..d0d92d804db985938e259641b560b6ecf1c4d229 100644 GIT binary patch delta 1251 zcmdT@&ubGw6sEyK8-#YTxLX>@gM!J*nnXNEiME18R3!N0;6ZwrY~Cg*$;@_Vrm~b$ z#FHSR%t`Q%DCFdb7d32p@S0hd!N1X|6#;HbArriw5qg9? zW|U(_Y*Vm}b?BlPXIu64(^4A3o-S4|S4-31GWtC07n)O$D}e!mDW^>h4lr6Qe{|LR zi*uf&U_{Z;dcB^lwp2MQ=MH#`VL$4JCIv(qV+}SjB66VZ6ATy~q?tQ?+SPY*H1Qr0 z&?CDDlnF>tKpT-loUjmeMkj!_6xP9rC86J)>W;N{rKYJgbR4Z1pV zI}7$@Ei%L{pn6r$uaqv>*sTmotGkuaIti!;-fAn6bUZ1iLr!rmY>+AV6LS*s&}$Wq zaP>XQ;dtKl%+G!$u+=re9*acFQuTfH*12(Z$Moe7J|CPfs?m7&zlqxD!B~FsAC2P42?Al81JwSCG1YPb0EdPN&Hw-a diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 906dce6d376298ebbac0eca9d22b0ad19a92eeb6..9dda59a883b41c5d9541eabf25102098c2afd96e 100644 GIT binary patch delta 116 zcmdn0dsBCVH8-12YIqEF~OvHz*uK#C9QV9?^|7~Rcp1NbKE=l^>uf@d(`Q5cVKt#ZP$mL1K2<4!CwbE zd%J%>!fw>_kANw&`K_J?DpbghQd8IU88F^^a*n)vskR;vv|rZv1vc*1oj7I0C6qE$b^N86#m zskZ4B?X;Xc#Fu0W;>HV?HF81a4HbGHhQOJh0waXh1KIWdMqsL`B*Tebeh5 z!pRZWF*=x^R)-$uC$mv8`3sy_NA2UeodTUA)U!ivEbh94pi$=1Z&u^#q-BA@rt@rY#-vFj((a;_$xik&v4Tu#VVTjo1 z>IWQyKrtiG6}aE*wD7zSnHl5xDz9Y?>(&Kr`m#mG;MuMKO~vDo4S6RF>z@1F^X=-I zM!q*nL}&Fz!=Ce^CD_QokR7tnO@1)=#D^GevDD_33}!fgbyuk=7gG3>n%VQx*zx7N zyxBfEeNo!Xn6rSZqN7`w5AJ{a;$_HrkQdwMm5fGqVO1f7w!*$-5(w74FvV87G9CMqW^Cg(=eniyHPe+m|ge|d*= zXx&lZa-Q+Qn-59Pidd&}OOfgb$@+s>DHl~FT`KY<&pD*VgxdlF`LRN*gChE?|-Et<<&;msMbRm^H?_ zP$DH~3Nn~;6w5`9VNhn3PLl-zm0SJNv${9Is#{>`1%ozr%z46cz5nJyvwh;d6Zz#~4goYk;!N7|q{WF>km=S#iR$w)F< zKf{ul4d}9WgjZ4=W7pcbUS-fodqHU-hAy&-w9XU=)xu#}Vd(0unI zmwMzX)!Rt8?k;a#c=rFtfnOh>X_S!xr!dR;Q{o$4uLfEb7V`VAtVEoPQHKYW;>4+! z`@8A-Sst#cJgkPocV~!lz}`R)<*uvZhT}M3c(t(T)z&t?qC4aEnjmKMW-*maRPoPW zoPHx&6nwNArTUJvwA)(ob}Mh+^mZ$;KCw2gtv;)>xEthrlDm4n^}Q})5fiOk$T##b z=y9cQwhoWi^sd$&%8Il0{!-5-QVHFVxJx;BrB1v}-c`{(C;F24N{YK0u^RYm`KIe9 O|F4Sw*YSGzvi2W>AhgW@ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c0dcf542ef6049ed38027856fb2de7cf7d11b778..041be6701555c603de6ded1e984667bbff2f5e3a 100644 GIT binary patch delta 240 zcmaE|im7cq(*`BW$p>sDCf~C)+bnOXCc)y8nv*)YP)>3(e-P^=Ma{_$fvn74`AL&6 zD$9d}z-&hyZIC!8P&~LKv81$kvZ9;_NES(?QC%OXW*uKpYH>k+UU8~VYIZ61 z;vj{S_XTpWL4_RUltHFVeis-A6N0GXb%7 diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index e6d39d5eb7468c423227face4625508707833a3a..2c97eeb314a4ac11c7ee40f63f73ad1416160055 100644 GIT binary patch delta 47 zcmexhwasQjEcawX9;izh!25Mc)LCI^VQZoV!slLr8n ClMu!L delta 17 ZcmdmH^TBFEEcfP}-1V%R7YR<`0RTtD2Q~lz diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart new file mode 100644 index 0000000000000000000000000000000000000000..038a17a8e6599d2f2305f969f99321624cb6098f GIT binary patch literal 11111 zcmb7KZEqVl68^4V!2pNG0etfHJ{9h{sjs$sE@HbE$t@0sAyB)Nw9SfCxw}z?BLBTJ z&x{4CZ|X|Y%-y;v2cxMYoNDv>WlDYin^=iPU)-6>rs zC3v~#hib8F>V+1+H-!dq$+z(Dj%)Z?3ta)YuF~R}l2y%Ry(n^+uvisB8}TYRSy@O? zElMTVz*cX#TzvD7MYiF(noMB2nyD32J0bb5n8M$0CzDB5@~UD#spTUm!~UEwb^!~& zx3dKiy#Snrj$2L1NBr@V!AFhHacicPE7V^UbRGE=$u1q?!s?OlTH1jLkL z9ukSL-PMP==G7(%;HaehAGPLL{UBhzaQx&kA0C6q#Df0*{R_EO10=zEdy<`x0TLFu;_O&KFI{5(p`0 zfYeHSBkgz+SY@%)Tp#RQDNp_(VmO8}*oOdsmi;KU@V=QxCk@~+&?vKvOM^k(rdFGn zZv^uoGmDv4Z06|W7o0?F2+xDT6eE0DIM01_9!6Byb>NQTmX+(+@t4lab!ObXBOANCHZ_rN!>~I+S+c7etC&=xv3#%Cf+dgQ1wj6&7t=(; zHcYX@7$evKWccmfupp4Id_jER4<#(p@^-6o@3@r;ALVY$zNzh3_)rFeo|68X*Yfl) zO?XgMPiQ%b=VY8}9|g5kumS=w02`KN#3M#`}q0in;A^d#E__`ay{|4dTIs0>| zRLRy_?Xg4vE_%hwN}PXrXNt02IaG_PDeJ(2!y?dy7b|v#NDL~e=BfDVD~8|GqJp^j z7TsY71z<|EGh1MV3Gw+r)}G!?XA87_MbvE#NSSIV&TQwltZ%NEY|7F{V$3Q%<$3Nl z$jUMH#pvGsv|n1(jh3BMt#CDG-NszHEi&_enS8-Y{RMN!Ve3)0tPLgbnt?Z9@Gj!{ zyfXN`FtzPWVBOihl&n^5HTm4YaS63;d#JChvjg0O|4V{EyC!v9OR za&x$^FUb6gi|5@^uuu=%Z~tZYu%i&3sQn|@z$;o}tsCedz-D6CAPooS1CEz11+)8| zv#QCU5?-wU$SR5#^dcA zkvm54HF${5522qyMiyQ53fe>2#!`I;kU_zcg`Jek_6R#wrd|MQ-5ENFSkPu+W739$ zf`|gww-i*wc@*+E)EGn@8js?|?5BaB2$}}-0`2aRC-4%SM^*5+k59?CFts)BvFkjLhbagT`t_U@?eG0~6AYmK2M!#WX_17J&tA5yHC%xn|nG z2QaZN0Rp9im$;G$9uEZ*=eZ|wVh5)KM6gmCZ# z$4j>m0TTh#S)8lkP+$>b1UTroMC%IcLJ9G6(rKVw5=1u2wLg1 z1+=2J>k_N%wQFLv`(jn6Swi)gX$)u-AQmL5V+j<3^JK`j&l2wt0c`mrc(3^qLG_io zNG#~RM1p=vFB0{;FOj5I`b!DM`v?LNqu+mtX7Eagyda zMvfXw2JBj|8~l%?rj&5@_E~Ida9 lTR^erS3`lYt6U8#Mo`LVD`QwA2#cZv*7tq2>RT+p{RRFIv^xL* literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 0a2f0d1791a3f2c979be71435656d3614b793bf2..3b9a3964b6d7ece9c764307f3ab2f357086cc74d 100644 GIT binary patch delta 456 zcmX?ml5y@u#tq+8Sqe*2OD6|%%d&zPsgn&^Bp^ZtV)9U-4I)w;U`en1q{)d)atID1 z7qSeG3_=EI3=&5SNe_x?7etU`47sHy+o|zQ<~L)ToXaFM`GXqgW>vL2{PkeV15%4J z^HYLS6H|1!6o3FpFti{gu_P5q80b)la_6E{95QGI!F6CUi!o{Qd(&T9kYHg9k%k6K zp}jOj$T1!kc!J)t2w#5;K?)B+CnOn0eTn;8IZ`knFs delta 32 qcmV+*0N?+e(E-iL0kHNZlO8Azv!Eya53?^g=qi(tWeKywWZMJyP7bpG diff --git a/mobile/openapi/lib/model/queue_delete_dto.dart b/mobile/openapi/lib/model/queue_delete_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..d319238f9277824e1df904b92d44fe3a6b00d8e6 GIT binary patch literal 3280 zcmbVP?{C{S5dH4I;t~vv1E@~7Peoe0Et+IVf5cmx4H%3-pe4#?E0Zos)iBch?|Vl| zw&XfXumDYL@pyN9@7)u7a?(3Fp*MeBkDva2es%uo&Gq>iU0i-RPv~q+SL18?I6k|) z_~!s&9QisI)(?Lh{QA7dU)4+-E7QW*v{0oyrAk*$=BX^@LTaBh-_>Gitlzsslq<1z z>9X>vmES65v0aM=elLZ^UoWjga_jbQ&y03nXrHPa1y!M>4cpz`Vx`j3rKQm`g!#G9 z>GNl4HW$|QdMM|~6l9iCi=`Ui--})^D}{6PXC*6nhEsWY<_+EUh)!_)PtsRbI})Vj ziYDEt8&aXQOb!sUwqlp==ljlY{PK_gGWtB@Hkjp|;rKj(rtR(d-r}Qi&IjmPs z4>uhgCp0%J`CZy1gv_LtR(P4$>7A)8TF4ACFDmD8XrM3^*;fMk=$mr`^txs8>t%C# zdJ9273zkHl$FMyOpznJQ7z$uNmH`=~w}o|TO!=xNATaeBgjkDc^v#Q`9h}2h8V>v8 z=@*$nQQ*fO({Mylp_wTAoI7b&e*B1^2=%%up(js}nBsD+6J)-x1EoF9qz683{+6K| zj>7iMobq^TnMvn_UGsQhV&>8*7sC3rvQU75rC0QfPU~6+gC=Q|X#3!wy$w{G_@N)p zdelA1B<3Fe693*D1^7OS<;%D!p?r;@SJ?>ozjH<-OB|6xX;IP_Jfb1}6n%U5BJ{2b z3ZOP19mF#z7$;asfq)}+nh8+5Ng_o-TPx*ijn;O2iaC=73@&)1tuhGG zr7YDHR_Rri!d2%cXWHX~9o<3WFqS|=O-t;ofwNl9tjO63`=Pyr4!d+8LM1F*rX~6T zw2i)-1OzW>(HcSqnQ;Cmg8NZ#oU8`BTd5!F@N%T~lon$7H(SuSxP~jdpzz~Ai2FhI-f4*4yb(0YQz;^m|VzXk0w{Dg0@scsU+n0I!Wiw{}KQc`7)t1982}8$MLOf$5Z*Pa#Vi|F%=?V8w** zWuZBFgalD-1KCN^_K^l~)cjApL)3t}`ia6u3C?1t~gjhdVsA>xR7n ze^b&mXJI7cc^+ahq#1;$7AFs|36Lgn<5#(6lMMT#7PlleWOgXU)|Le_DQs2&BNJbQ zE9K{S#8bo9tS$pjV-WV#F39g;Tq^iZhFp+PJ&7M%?+jMY?&yp-oEP?LBB94kCUUgZ zvbWIx(aa;|@EB>;4%V9b2bF_Ub?NPLFkosnu(bq~YzA{XqRB>8d*&O3T4FfC^;2$m zZ!Y-+i8;K51qkLV-c<5y%b_fbU(mbH<14;;)x3vy8SEzh58*xU-P{L8w}`_{0FLo5 DEj}Xl literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/queue_job_response_dto.dart b/mobile/openapi/lib/model/queue_job_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..1bfaa56195c5c8edf5707308748dc80dc4fd8ddb GIT binary patch literal 3909 zcmbVPZExE)5dQ98aS4LP!BnT)r^2n>7EL;|YvZlS1`I|Z&=PI4l|_xDY8Yw$`<^59 zW?0#>eTXBG_r`mkyQ9v}2j}PX=J%V~)1NP|E}BfbTOl=*$sV~UA%wy z=MltM@^vbVoBTBT@%eyX)hgFU#!GGDrOM=(%Dl8Pjb$b`GIvq^t~P}>u2(~3Td}io zQM%a3ze;7Go{J5B3t{lzNn>H$*nagy=hlhb#VUnEwN%o0b=&JKQ@ONprt=lV+*;)E z^Jj6g7RC++aOX%bNf$B~g&N`S#bA(R!dm*Rl%@Qj7oVjq;80#TO?LyLbL8N&bfw8H z3CiV`<~^dfqhqIZD?3Vs6Omu{uPO~M`$C+lzICTI5_#Daym^0y zgAmOYUu5El?fQ2Fk5xXxubC89Qm!2!yn%Bn4ANlSj_g{OSxRg1P0~Wj976fiyv#DO z$m9`St2~j2SCTDcg7hcCDV-Cup)#IoY1uTAkYr^lk*HKIMVUGJCbCk}u(T>)c_YWw z&iJ^A;4GrG-pX&%L|$Yib7_Q=X=UE((jbIH!}6xIj+P?JXd#lX1dtFn+XgmO$mDi~ zhk?)i7FdEehD4eMv7P`B_X7v?4xo>1q>Qu2_=dDzI|GZaNToj4;La7GBF4)vsN7Kl zl3#hPaYh4ku(8lOV^yCSw zQ;_itgK^J@{T-F^I)3-$Ie1?8|Mzy9lkj%)a&Rm@^h!FQV%KjOu*uYi({vJUAh}GW zwZiPU0pO5pX_fV2L#FPiRz9O2!fFVX0j5{IO(d@N(CDhP)np1Y zY3@m8=n^3oKvw&yWZ6z7xE}|fy|u<~L98**ecZNR3h>m**c#KBRNHM~p zx1tT;1ho}HU+z%Ii;NOs-2s_~VPvqeD)&9^hTa*=FuBIG5ycVR8ktpamrOjR!(F>pR(>;QZZJnK4WSajeAov3xvh@G~| zRVMHK5vvr`4EOY_u>7cyy`J1mDZ|dYI>Z{(kc&It-&aO&d5WS!F|(|U?rWR^N=8~9 zk|^~)`}5ltYqrWApMycm_H{B+_DZ|UP2qO6S-_8JGOVoYEl)O9ZTD+}1oBM!nA)Zt z;a6GmU^bjP=qbD%4l}}_4b1Flh8%NMv*2FFD=nw_WWzdPW2&FLl`b6JHrkgp=4dY{ zbq+#uAk}TLeU|EW%e7YLAKw2l{a$Vo;CZO*(AV0|Qs01MTtacz<~wy5g?mt9)$D38 zNBpLwtq;UG4TpLZsNUAdi*_Jt8%i&@NY^|lew90(I9@;Sv&FQ=b`N2wb~-x4!)@0m zN{9RER=G8nxSaTU-h;){APL2)sq@7Qw@kj!!ysr?73-(YkYz^o7S@z?c(<1)5k0PW zVWZ2ShrRtD9zBvGkD*)D!aI%OU)hAC?W5fT$%x@<64)VC)YNWU#Zkkwo)!Ra9fF+W z^F%gWXugF&JBw?M0N(rwu#h~IE!=Wd2>Nh6yW*>PMFT&no3E{3Shu-PP49e0!sRJU So-kOd=XO@`0K7Rv(z~~hK literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/queue_job_status.dart b/mobile/openapi/lib/model/queue_job_status.dart new file mode 100644 index 0000000000000000000000000000000000000000..03a1371cc55a0c0975b6bc9d44e34338cb441a48 GIT binary patch literal 3150 zcmai0ZExE)5dQ98aRG|P0X%uzry`x328gqz=#p)5J`{!_&=M7MrAUpWVi-mK`|fy> z63cQMAhzUt<=u17aXKAMr*QeOxclk%`R)9l%f z|JBN&xe+CPR>I+Tkj|rX=ZDQRYkUwUq$T#0H%2U8xF2SkRZUwEl5+B zuvK9LVI!e(_6ZZqVfiFF6Myp7^o3aDpt2@;kp=Z6 zVRm>5cgnI;JOsd=Q6Uf^`LGeo28=RWRvIyI#I6$$nC4Q8-7&*PIAFFFDo_ar57!6> z%ql^^AGTr{88vAZ1y;^I@jqsohw|7ZMS}pg17Xh?a)rbj?aev#_{IJAW z0Njm9eC8tab5}TiZOXwf%^~@PbEoH3Ouq@fAM64OXUhnie22q}Kw%^k!1h%ayGkyc zFdjLhlx}>MF@wy-EUj*y~zOAj*@aXJTP(5c*2<_$M`7DlW zJ0VW`T8E*6;Q^^+s(>*$1e~$hsJCx{-V^0-ZJ3u;*tH(fr*$yq(ZjgZJW_p;w^4a? z4=~l04UOZ(Py(C+Tb#_m)LOrG2*5{3M4oq5V>^K7c!c(w-<%A!Gp$vybf|G#rt_{~ zG5+)DnTUM~U5{|=u@=mN$d~~#VF1BNO%gH*^3UqK6% zTm%7Yk$olYzK3LxfO{y44K5Jt*6n_TH-co^<$Q558zkgj0OV($-XZcnrrYo-mukE} z20(|7{7Vna`yD-8#fk8^QI>6uA{tgpQeIJ+MDLii0kCcS2{+gY}yUyTj0mcR|l!Pi+Py)V$oju?P&f%YaslDsmqWS+nub}MnwOlW#~q2m;O zKX&8$2N`f|Lf?bI3k6>>WIlJ!IzHqE$H?rLf>vlRASLCT{J^eV)yM1_`GH-ttdE(o_{A!hg}{As zj_qv!s3j*|!(Nx3Gy;JXF5P2IH{=N3@#%y4Y7n}7r(?w7kn@&obNcfdGFk>JwI5CiKDm_Gzn~|A5L#d#%TgY%0xJcGx}d$ z6mh{s!7c@MXMSYY%SE1#LD6L8xTGaLs2PBu>ecG3YG?Inl>?;)LbMaQU3rtdNGX>$ zz%BxQb~H&~U!SU%rIQ`S4bxbp{XC&QY)YtyoWE7$_FDaIGJtxTJ@p)7vTC z#X;yf~9LU zt+{}z0%4+=#U+`^#V#fJ3UE_U6_kP{kxT(gJLH$878PZtqyo*FtjCmW3)ih+Yl|>S zK}`+pr2LYM)FNc3fK(yOocx3-NEL2^UPfYZhI4*Os-}WAShog}%6w*LUARhH6*yA| z$+%)$6(k+cnWMen%G6^OY$4$S7qSN1p`egeoSzq6q>j*8j8Kda+`N)yHX{d2^W@!Z zYQ8WIP`#!iBnm*$fY4KHg$QqOaO$NM<>z_5 G#RUMmBC*f_ diff --git a/mobile/openapi/lib/model/queue_response_legacy_dto.dart b/mobile/openapi/lib/model/queue_response_legacy_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..214b0b31f6150adce335720cd2802d50d907822f GIT binary patch literal 3303 zcmbVO+iu%95PkPoOp2mbu&TVfPeodLTQtt1-84p%^kHEb0xi)tv$Cj=R1G7|zwZpG z3suW)(1$n@HTQF7IHS>EG=g_uZf7t5o?cC_-`!5n;o`&PG=%dRT+MFb)9n1i#lI&g zMv`w+rp)-y=#N(edKK$jDju(-idQ1zXHe#)=4s3`zU8?I>vyp&q%yr4EZecYj*HU7 zir<$)p?c1?_**cA{|#3fjVs-+p2%DqmYZ0l=uoT#S5{s3I?F`Pb)3n3jbdiQ^7z&7 zak61b4+iMYfLwtrc+LtD;os}QAjz24@VVqAzvj9?Z~T(4S+YMj5*`KsBP_!;H>Juo zFv$51=KazO5H{3=N2t~>6OcuKx)5MCLPvLU`CgW}(P7WWdy4GFFjLykkEa-OWt~oi zHX_j!Yh$G=Q0g`v&z2Q>E5CB3u&`)xDP=~bQOrmM<8d%s-thzrW;R@%!gvA>fP`fM zMIF{(zI}5~AdZ&t#S5F5HPvQ|PTHWMBjW{1Qr_!9+#)xy<_0!QZ{Ew4W2DwuLtf%+ z&J(URQ+whZ(rClA&{iyH$`;iYPT@EA$#dI&JP6>%D3PzhNcRxfS`9z<5^COf8$WLz zLqEUqbI17(-W;P=B4-)g;Sr4CK>FeFwM_}E#9R#< z=fefccUkzIs25Kw>>WCBWjn;0r-Mf#=`VB+usj7fOf(3i;Va8Zu3@F*wz4?4N7!sD z6c|!Z&aps?5b`oZ1`}O(LfannI8Z3Q!jWL7i7FH1xQ>baF%B2(pcv=SMyA9GcW|VC z|FV(T%2!CZ2x}P1Vy#%pu^$5)W(d#vneYtzm?i;c;UuYjFpt_0KnGE4EJ~O*2iOK~ zBDk?aW9mrgf%QjX4ftnHv@=H{Dz#0J$ke6E>4}?AWW-~DXU3a02ZlV2;i5HF72?r` zSJ8-|Y-m&ga*3nvO@A>bkm1LRI`A49N+5&>JK5Gs?$|QJ@c=89X&ye-m3DPjT$h=l z-Z|o8zGwj>!-N-wgoDenJ`(y$n(4MM``R*Mh@&*99LOEs4_37OUZZT`Md$s#+e%^* zRk;X)mOCe7%YNT5T{4tX#M0C;%+qwmrZy!zozhoYMpAM?UA8l*pR5(z-W4wXbn1_P z0$Wz}qEiR#N`z7bKt1Y?7^`O)WnTkqes6>k-*+h!y9Z(976S(sfH_I_+4g03l}N3gJI=(e@PhIKu20Udf| z8Nuy_H@s-pS)`!GZGs{2B~oU|0qR7DYA)e{uw)42)UAcpS#GX8HzW8Z!TfF%4K2 z!I+m=9AQjh)+`vamUTCbX~7l^V@_r(ficN0LVQyn(E(#zZQFUc?Wm9xvbaiu+h5^Tu zQv&vrPXqsxR0N`v`~*RhLj`J+vjw{bM09UHAZ(L93P6*(1{IUl26U582e^|B2t|`v i2+5Nt3AvNz2}qMP3TXsHbZCM-x@zL++7w2EzT%DiMyZ0Z@6FM8y#rTRojnCe{ z`+E;z9QisI)(?Lh{Q9EDKh;bdE7Pg5=~NZ+geqM*nWwUl3#on5{8o$7Sif_JD3)U7 z(z5cYmH$-AV!IX#e3ruEUoWi#xOKa?XGS|Ov`5J!SHW$|QdPwKVOv#i|i&72n_hqk_6~a0CvyzqkTIRybGjHg&M|6zif04ej+L0hF zmo(`{U6Tr>WpaSvnM4Lju9O*w$%I!%6YEri=SDh)Mxj|zk1ANnwzEkA#D;uW>QNFhVmNTnaZMs%m8^&IZsnj z6m%o9?*tA}H)Dgkx@7XJGEl|LzJ;t%)RM^a7`CGT)cwE#Ljvr_GALvASOWm#^TwFs zlp59p->(f0VG&<1vj=bzV`(_-k8i%o49)?A zovici4I1ERxHbWo-UiW4{L&9+KIr;)(%=t&X}(<_1S&s?@>SfJP`*MVt84@{J~*R+ z6m36MS`_pRk7!8uqHphBhBK%s1%fw(?ZvY+Fccsr_Mj=@;2LBJ0Z)+$*t<@m zV8L%oTUoC6Q(6^2pMF;`Fle5qxTrBCcB%dAM%7; z^@u#7g(&~x(KMc3VOqT6ULN(?NeQ)laLBWi|J1eKg`ahjM(Chu&Cy4N`nuUiRN&z| z$kib$=DOv?G_z*O!xpZ?Xmc{TYev$@dyJ<;V5=%XC#$7 zg9AGh*SoyoL-iY)PS%74ls5nEsj7D#)hG=wAPfASsThEb1) z{EI%`G-h*MNpWMq#fUFJaT<5D!&AFz)En?GC0%nCW-^}VF%<*MAVjq^c|=W+G>IF( z%M}mHus?cnBVr?FM^bEUjX@TL2Ug(7#24gJ`8ghO*YGu}tH4nV!k&5(@*5i04Zd>$ z3l^$7@l)&l!S2~^oe_ug!cI>l^t8?dM%yhr6a630JkbtMu~t37T3!FBb8xDzyj=|j zEX|s>mVuIWV{TeBS-WaSeb%C+vzC7xh0hqtkSzou}xwsS*0!vd%Q-e~A3-a@dQzuVnm7Tnc zRdMn|R=vrhY?_l@*itsuzrIVisxlGm*&YiqaSZne{VP7DY zm~10r38Y0P_lwv}z9`}k#Nv}}MUBB?U7}`S`j)5#nAQ=4h*yf~g82u;)IjtkagcE$ zlU>C1!E~><1DJj;?g6GfCG5fUazVk#n<<$DoIoGhDdc1pmrQ;yB|6zgR%3Fv zq{QTvlI4@tq;^k!F6F@tR55vitm))j=_#y0wqDxgLOJ2d@-j;%pOo>QEG3&WxkvWe J<_tM?CIDbYd;tIe diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index ffaad8590..68af1438c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4929,6 +4929,7 @@ }, "/jobs": { "get": { + "deprecated": true, "description": "Retrieve the counts of the current queue, as well as the current status.", "operationId": "getQueuesLegacy", "parameters": [], @@ -4937,7 +4938,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueuesResponseDto" + "$ref": "#/components/schemas/QueuesResponseLegacyDto" } } }, @@ -4957,7 +4958,8 @@ ], "summary": "Retrieve queue counts and status", "tags": [ - "Jobs" + "Jobs", + "Deprecated" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -4972,10 +4974,14 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v2.4.0", + "state": "Deprecated" } ], "x-immich-permission": "job.read", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" }, "post": { "description": "Run a specific job. Most jobs are queued automatically, but this endpoint allows for manual creation of a handful of jobs, including various cleanup tasks, as well as creating a new database backup.", @@ -5032,6 +5038,7 @@ }, "/jobs/{name}": { "put": { + "deprecated": true, "description": "Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.", "operationId": "runQueueCommandLegacy", "parameters": [ @@ -5059,7 +5066,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" } } }, @@ -5079,7 +5086,8 @@ ], "summary": "Run jobs", "tags": [ - "Jobs" + "Jobs", + "Deprecated" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -5094,10 +5102,14 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v2.4.0", + "state": "Deprecated" } ], "x-immich-permission": "job.create", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/libraries": { @@ -8064,6 +8076,303 @@ "x-immich-state": "Alpha" } }, + "/queues": { + "get": { + "description": "Retrieves a list of queues.", + "operationId": "getQueues", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/QueueResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all queues", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-permission": "queue.read", + "x-immich-state": "Alpha" + } + }, + "/queues/{name}": { + "get": { + "description": "Retrieves a specific queue by its name.", + "operationId": "getQueue", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/QueueName" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve a queue", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-permission": "queue.read", + "x-immich-state": "Alpha" + }, + "put": { + "description": "Change the paused status of a specific queue.", + "operationId": "updateQueue", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/QueueName" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update a queue", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-permission": "queue.update", + "x-immich-state": "Alpha" + } + }, + "/queues/{name}/jobs": { + "delete": { + "description": "Removes all jobs from the specified queue.", + "operationId": "emptyQueue", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/QueueName" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Empty a queue", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-permission": "queueJob.delete", + "x-immich-state": "Alpha" + }, + "get": { + "description": "Retrieves a list of queue jobs from the specified queue.", + "operationId": "getQueueJobs", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/QueueName" + } + }, + { + "name": "status", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QueueJobStatus" + } + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/QueueJobResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve queue jobs", + "tags": [ + "Queues" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-permission": "queueJob.read", + "x-immich-state": "Alpha" + } + }, "/search/cities": { "get": { "description": "Retrieve a list of assets with each asset belonging to a different city. This endpoint is used on the places pages to show a single thumbnail for each city the user has assets in.", @@ -14043,6 +14352,10 @@ "name": "Plugins", "description": "A plugin is an installed module that makes filters and actions available for the workflow feature." }, + { + "name": "Queues", + "description": "Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed." + }, { "name": "Search", "description": "Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting." @@ -16291,6 +16604,66 @@ ], "type": "object" }, + "JobName": { + "enum": [ + "AssetDelete", + "AssetDeleteCheck", + "AssetDetectFacesQueueAll", + "AssetDetectFaces", + "AssetDetectDuplicatesQueueAll", + "AssetDetectDuplicates", + "AssetEncodeVideoQueueAll", + "AssetEncodeVideo", + "AssetEmptyTrash", + "AssetExtractMetadataQueueAll", + "AssetExtractMetadata", + "AssetFileMigration", + "AssetGenerateThumbnailsQueueAll", + "AssetGenerateThumbnails", + "AuditLogCleanup", + "AuditTableCleanup", + "DatabaseBackup", + "FacialRecognitionQueueAll", + "FacialRecognition", + "FileDelete", + "FileMigrationQueueAll", + "LibraryDeleteCheck", + "LibraryDelete", + "LibraryRemoveAsset", + "LibraryScanAssetsQueueAll", + "LibrarySyncAssets", + "LibrarySyncFilesQueueAll", + "LibrarySyncFiles", + "LibraryScanQueueAll", + "MemoryCleanup", + "MemoryGenerate", + "NotificationsCleanup", + "NotifyUserSignup", + "NotifyAlbumInvite", + "NotifyAlbumUpdate", + "UserDelete", + "UserDeleteCheck", + "UserSyncUsage", + "PersonCleanup", + "PersonFileMigration", + "PersonGenerateThumbnail", + "SessionCleanup", + "SendMail", + "SidecarQueueAll", + "SidecarCheck", + "SidecarWrite", + "SmartSearchQueueAll", + "SmartSearch", + "StorageTemplateMigration", + "StorageTemplateMigrationSingle", + "TagCleanup", + "VersionCheck", + "OcrQueueAll", + "Ocr", + "WorkflowRun" + ], + "type": "string" + }, "JobSettingsDto": { "properties": { "concurrency": { @@ -17583,6 +17956,12 @@ "userProfileImage.read", "userProfileImage.update", "userProfileImage.delete", + "queue.read", + "queue.update", + "queueJob.create", + "queueJob.read", + "queueJob.update", + "queueJob.delete", "workflow.create", "workflow.read", "workflow.update", @@ -18083,6 +18462,63 @@ ], "type": "object" }, + "QueueDeleteDto": { + "properties": { + "failed": { + "description": "If true, will also remove failed jobs from the queue.", + "type": "boolean", + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Alpha" + } + ], + "x-immich-state": "Alpha" + } + }, + "type": "object" + }, + "QueueJobResponseDto": { + "properties": { + "data": { + "type": "object" + }, + "id": { + "type": "string" + }, + "name": { + "allOf": [ + { + "$ref": "#/components/schemas/JobName" + } + ] + }, + "timestamp": { + "type": "integer" + } + }, + "required": [ + "data", + "name", + "timestamp" + ], + "type": "object" + }, + "QueueJobStatus": { + "enum": [ + "active", + "failed", + "completed", + "delayed", + "waiting", + "paused" + ], + "type": "string" + }, "QueueName": { "enum": [ "thumbnailGeneration", @@ -18106,12 +18542,35 @@ "type": "string" }, "QueueResponseDto": { + "properties": { + "isPaused": { + "type": "boolean" + }, + "name": { + "allOf": [ + { + "$ref": "#/components/schemas/QueueName" + } + ] + }, + "statistics": { + "$ref": "#/components/schemas/QueueStatisticsDto" + } + }, + "required": [ + "isPaused", + "name", + "statistics" + ], + "type": "object" + }, + "QueueResponseLegacyDto": { "properties": { "jobCounts": { "$ref": "#/components/schemas/QueueStatisticsDto" }, "queueStatus": { - "$ref": "#/components/schemas/QueueStatusDto" + "$ref": "#/components/schemas/QueueStatusLegacyDto" } }, "required": [ @@ -18151,7 +18610,7 @@ ], "type": "object" }, - "QueueStatusDto": { + "QueueStatusLegacyDto": { "properties": { "isActive": { "type": "boolean" @@ -18166,58 +18625,66 @@ ], "type": "object" }, - "QueuesResponseDto": { + "QueueUpdateDto": { + "properties": { + "isPaused": { + "type": "boolean" + } + }, + "type": "object" + }, + "QueuesResponseLegacyDto": { "properties": { "backgroundTask": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "backupDatabase": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "duplicateDetection": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "faceDetection": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "facialRecognition": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "library": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "metadataExtraction": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "migration": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "notifications": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "ocr": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "search": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "sidecar": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "smartSearch": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "storageTemplateMigration": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "thumbnailGeneration": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "videoConversion": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" }, "workflow": { - "$ref": "#/components/schemas/QueueResponseDto" + "$ref": "#/components/schemas/QueueResponseLegacyDto" } }, "required": [ diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0de0e7696..bbcc2311b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -716,32 +716,32 @@ export type QueueStatisticsDto = { paused: number; waiting: number; }; -export type QueueStatusDto = { +export type QueueStatusLegacyDto = { isActive: boolean; isPaused: boolean; }; -export type QueueResponseDto = { +export type QueueResponseLegacyDto = { jobCounts: QueueStatisticsDto; - queueStatus: QueueStatusDto; + queueStatus: QueueStatusLegacyDto; }; -export type QueuesResponseDto = { - backgroundTask: QueueResponseDto; - backupDatabase: QueueResponseDto; - duplicateDetection: QueueResponseDto; - faceDetection: QueueResponseDto; - facialRecognition: QueueResponseDto; - library: QueueResponseDto; - metadataExtraction: QueueResponseDto; - migration: QueueResponseDto; - notifications: QueueResponseDto; - ocr: QueueResponseDto; - search: QueueResponseDto; - sidecar: QueueResponseDto; - smartSearch: QueueResponseDto; - storageTemplateMigration: QueueResponseDto; - thumbnailGeneration: QueueResponseDto; - videoConversion: QueueResponseDto; - workflow: QueueResponseDto; +export type QueuesResponseLegacyDto = { + backgroundTask: QueueResponseLegacyDto; + backupDatabase: QueueResponseLegacyDto; + duplicateDetection: QueueResponseLegacyDto; + faceDetection: QueueResponseLegacyDto; + facialRecognition: QueueResponseLegacyDto; + library: QueueResponseLegacyDto; + metadataExtraction: QueueResponseLegacyDto; + migration: QueueResponseLegacyDto; + notifications: QueueResponseLegacyDto; + ocr: QueueResponseLegacyDto; + search: QueueResponseLegacyDto; + sidecar: QueueResponseLegacyDto; + smartSearch: QueueResponseLegacyDto; + storageTemplateMigration: QueueResponseLegacyDto; + thumbnailGeneration: QueueResponseLegacyDto; + videoConversion: QueueResponseLegacyDto; + workflow: QueueResponseLegacyDto; }; export type JobCreateDto = { name: ManualJobName; @@ -966,6 +966,24 @@ export type PluginResponseDto = { updatedAt: string; version: string; }; +export type QueueResponseDto = { + isPaused: boolean; + name: QueueName; + statistics: QueueStatisticsDto; +}; +export type QueueUpdateDto = { + isPaused?: boolean; +}; +export type QueueDeleteDto = { + /** If true, will also remove failed jobs from the queue. */ + failed?: boolean; +}; +export type QueueJobResponseDto = { + data: object; + id?: string; + name: JobName; + timestamp: number; +}; export type SearchExploreItem = { data: AssetResponseDto; value: string; @@ -2925,7 +2943,7 @@ export function reassignFacesById({ id, faceDto }: { export function getQueuesLegacy(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: QueuesResponseDto; + data: QueuesResponseLegacyDto; }>("/jobs", { ...opts })); @@ -2951,7 +2969,7 @@ export function runQueueCommandLegacy({ name, queueCommandDto }: { }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: QueueResponseDto; + data: QueueResponseLegacyDto; }>(`/jobs/${encodeURIComponent(name)}`, oazapfts.json({ ...opts, method: "PUT", @@ -3651,6 +3669,75 @@ export function getPlugin({ id }: { ...opts })); } +/** + * List all queues + */ +export function getQueues(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: QueueResponseDto[]; + }>("/queues", { + ...opts + })); +} +/** + * Retrieve a queue + */ +export function getQueue({ name }: { + name: QueueName; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: QueueResponseDto; + }>(`/queues/${encodeURIComponent(name)}`, { + ...opts + })); +} +/** + * Update a queue + */ +export function updateQueue({ name, queueUpdateDto }: { + name: QueueName; + queueUpdateDto: QueueUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: QueueResponseDto; + }>(`/queues/${encodeURIComponent(name)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: queueUpdateDto + }))); +} +/** + * Empty a queue + */ +export function emptyQueue({ name, queueDeleteDto }: { + name: QueueName; + queueDeleteDto: QueueDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/queues/${encodeURIComponent(name)}/jobs`, oazapfts.json({ + ...opts, + method: "DELETE", + body: queueDeleteDto + }))); +} +/** + * Retrieve queue jobs + */ +export function getQueueJobs({ name, status }: { + name: QueueName; + status?: QueueJobStatus[]; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: QueueJobResponseDto[]; + }>(`/queues/${encodeURIComponent(name)}/jobs${QS.query(QS.explode({ + status + }))}`, { + ...opts + })); +} /** * Retrieve assets by city */ @@ -5241,6 +5328,12 @@ export enum Permission { UserProfileImageRead = "userProfileImage.read", UserProfileImageUpdate = "userProfileImage.update", UserProfileImageDelete = "userProfileImage.delete", + QueueRead = "queue.read", + QueueUpdate = "queue.update", + QueueJobCreate = "queueJob.create", + QueueJobRead = "queueJob.read", + QueueJobUpdate = "queueJob.update", + QueueJobDelete = "queueJob.delete", WorkflowCreate = "workflow.create", WorkflowRead = "workflow.read", WorkflowUpdate = "workflow.update", @@ -5330,6 +5423,71 @@ export enum PluginContext { Album = "album", Person = "person" } +export enum QueueJobStatus { + Active = "active", + Failed = "failed", + Completed = "completed", + Delayed = "delayed", + Waiting = "waiting", + Paused = "paused" +} +export enum JobName { + AssetDelete = "AssetDelete", + AssetDeleteCheck = "AssetDeleteCheck", + AssetDetectFacesQueueAll = "AssetDetectFacesQueueAll", + AssetDetectFaces = "AssetDetectFaces", + AssetDetectDuplicatesQueueAll = "AssetDetectDuplicatesQueueAll", + AssetDetectDuplicates = "AssetDetectDuplicates", + AssetEncodeVideoQueueAll = "AssetEncodeVideoQueueAll", + AssetEncodeVideo = "AssetEncodeVideo", + AssetEmptyTrash = "AssetEmptyTrash", + AssetExtractMetadataQueueAll = "AssetExtractMetadataQueueAll", + AssetExtractMetadata = "AssetExtractMetadata", + AssetFileMigration = "AssetFileMigration", + AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll", + AssetGenerateThumbnails = "AssetGenerateThumbnails", + AuditLogCleanup = "AuditLogCleanup", + AuditTableCleanup = "AuditTableCleanup", + DatabaseBackup = "DatabaseBackup", + FacialRecognitionQueueAll = "FacialRecognitionQueueAll", + FacialRecognition = "FacialRecognition", + FileDelete = "FileDelete", + FileMigrationQueueAll = "FileMigrationQueueAll", + LibraryDeleteCheck = "LibraryDeleteCheck", + LibraryDelete = "LibraryDelete", + LibraryRemoveAsset = "LibraryRemoveAsset", + LibraryScanAssetsQueueAll = "LibraryScanAssetsQueueAll", + LibrarySyncAssets = "LibrarySyncAssets", + LibrarySyncFilesQueueAll = "LibrarySyncFilesQueueAll", + LibrarySyncFiles = "LibrarySyncFiles", + LibraryScanQueueAll = "LibraryScanQueueAll", + MemoryCleanup = "MemoryCleanup", + MemoryGenerate = "MemoryGenerate", + NotificationsCleanup = "NotificationsCleanup", + NotifyUserSignup = "NotifyUserSignup", + NotifyAlbumInvite = "NotifyAlbumInvite", + NotifyAlbumUpdate = "NotifyAlbumUpdate", + UserDelete = "UserDelete", + UserDeleteCheck = "UserDeleteCheck", + UserSyncUsage = "UserSyncUsage", + PersonCleanup = "PersonCleanup", + PersonFileMigration = "PersonFileMigration", + PersonGenerateThumbnail = "PersonGenerateThumbnail", + SessionCleanup = "SessionCleanup", + SendMail = "SendMail", + SidecarQueueAll = "SidecarQueueAll", + SidecarCheck = "SidecarCheck", + SidecarWrite = "SidecarWrite", + SmartSearchQueueAll = "SmartSearchQueueAll", + SmartSearch = "SmartSearch", + StorageTemplateMigration = "StorageTemplateMigration", + StorageTemplateMigrationSingle = "StorageTemplateMigrationSingle", + TagCleanup = "TagCleanup", + VersionCheck = "VersionCheck", + OcrQueueAll = "OcrQueueAll", + Ocr = "Ocr", + WorkflowRun = "WorkflowRun" +} export enum SearchSuggestionType { Country = "country", State = "state", diff --git a/server/src/constants.ts b/server/src/constants.ts index 68534c00e..33f8e3b4c 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -163,6 +163,8 @@ export const endpointTags: Record = { 'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.', [ApiTag.Plugins]: 'A plugin is an installed module that makes filters and actions available for the workflow feature.', + [ApiTag.Queues]: + 'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.', [ApiTag.Search]: 'Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting.', [ApiTag.Server]: diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index d5811de48..6ba3d38a7 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -20,6 +20,7 @@ import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; import { PluginController } from 'src/controllers/plugin.controller'; +import { QueueController } from 'src/controllers/queue.controller'; import { SearchController } from 'src/controllers/search.controller'; import { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; @@ -59,6 +60,7 @@ export const controllers = [ PartnerController, PersonController, PluginController, + QueueController, SearchController, ServerController, SessionController, diff --git a/server/src/controllers/job.controller.ts b/server/src/controllers/job.controller.ts index 977f1e0f1..783d5a313 100644 --- a/server/src/controllers/job.controller.ts +++ b/server/src/controllers/job.controller.ts @@ -1,10 +1,12 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; import { JobCreateDto } from 'src/dtos/job.dto'; -import { QueueCommandDto, QueueNameParamDto, QueueResponseDto, QueuesResponseDto } from 'src/dtos/queue.dto'; +import { QueueResponseLegacyDto, QueuesResponseLegacyDto } from 'src/dtos/queue-legacy.dto'; +import { QueueCommandDto, QueueNameParamDto } from 'src/dtos/queue.dto'; import { ApiTag, Permission } from 'src/enum'; -import { Authenticated } from 'src/middleware/auth.guard'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { JobService } from 'src/services/job.service'; import { QueueService } from 'src/services/queue.service'; @@ -21,10 +23,10 @@ export class JobController { @Endpoint({ summary: 'Retrieve queue counts and status', description: 'Retrieve the counts of the current queue, as well as the current status.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder().added('v1').beta('v1').stable('v2').deprecated('v2.4.0'), }) - getQueuesLegacy(): Promise { - return this.queueService.getAll(); + getQueuesLegacy(@Auth() auth: AuthDto): Promise { + return this.queueService.getAllLegacy(auth); } @Post() @@ -46,9 +48,12 @@ export class JobController { summary: 'Run jobs', description: 'Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder().added('v1').beta('v1').stable('v2').deprecated('v2.4.0'), }) - runQueueCommandLegacy(@Param() { name }: QueueNameParamDto, @Body() dto: QueueCommandDto): Promise { - return this.queueService.runCommand(name, dto); + runQueueCommandLegacy( + @Param() { name }: QueueNameParamDto, + @Body() dto: QueueCommandDto, + ): Promise { + return this.queueService.runCommandLegacy(name, dto); } } diff --git a/server/src/controllers/queue.controller.ts b/server/src/controllers/queue.controller.ts new file mode 100644 index 000000000..1d8d918c5 --- /dev/null +++ b/server/src/controllers/queue.controller.ts @@ -0,0 +1,85 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + QueueDeleteDto, + QueueJobResponseDto, + QueueJobSearchDto, + QueueNameParamDto, + QueueResponseDto, + QueueUpdateDto, +} from 'src/dtos/queue.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { QueueService } from 'src/services/queue.service'; + +@ApiTags(ApiTag.Queues) +@Controller('queues') +export class QueueController { + constructor(private service: QueueService) {} + + @Get() + @Authenticated({ permission: Permission.QueueRead, admin: true }) + @Endpoint({ + summary: 'List all queues', + description: 'Retrieves a list of queues.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + getQueues(@Auth() auth: AuthDto): Promise { + return this.service.getAll(auth); + } + + @Get(':name') + @Authenticated({ permission: Permission.QueueRead, admin: true }) + @Endpoint({ + summary: 'Retrieve a queue', + description: 'Retrieves a specific queue by its name.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + getQueue(@Auth() auth: AuthDto, @Param() { name }: QueueNameParamDto): Promise { + return this.service.get(auth, name); + } + + @Put(':name') + @Authenticated({ permission: Permission.QueueUpdate, admin: true }) + @Endpoint({ + summary: 'Update a queue', + description: 'Change the paused status of a specific queue.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + updateQueue( + @Auth() auth: AuthDto, + @Param() { name }: QueueNameParamDto, + @Body() dto: QueueUpdateDto, + ): Promise { + return this.service.update(auth, name, dto); + } + + @Get(':name/jobs') + @Authenticated({ permission: Permission.QueueJobRead, admin: true }) + @Endpoint({ + summary: 'Retrieve queue jobs', + description: 'Retrieves a list of queue jobs from the specified queue.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + getQueueJobs( + @Auth() auth: AuthDto, + @Param() { name }: QueueNameParamDto, + @Query() dto: QueueJobSearchDto, + ): Promise { + return this.service.searchJobs(auth, name, dto); + } + + @Delete(':name/jobs') + @Authenticated({ permission: Permission.QueueJobDelete, admin: true }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Empty a queue', + description: 'Removes all jobs from the specified queue.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + emptyQueue(@Auth() auth: AuthDto, @Param() { name }: QueueNameParamDto, @Body() dto: QueueDeleteDto): Promise { + return this.service.emptyQueue(auth, name, dto); + } +} diff --git a/server/src/dtos/queue-legacy.dto.ts b/server/src/dtos/queue-legacy.dto.ts new file mode 100644 index 000000000..79155e3f7 --- /dev/null +++ b/server/src/dtos/queue-legacy.dto.ts @@ -0,0 +1,89 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { QueueResponseDto, QueueStatisticsDto } from 'src/dtos/queue.dto'; +import { QueueName } from 'src/enum'; + +export class QueueStatusLegacyDto { + isActive!: boolean; + isPaused!: boolean; +} + +export class QueueResponseLegacyDto { + @ApiProperty({ type: QueueStatusLegacyDto }) + queueStatus!: QueueStatusLegacyDto; + + @ApiProperty({ type: QueueStatisticsDto }) + jobCounts!: QueueStatisticsDto; +} + +export class QueuesResponseLegacyDto implements Record { + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.ThumbnailGeneration]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.MetadataExtraction]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.VideoConversion]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.SmartSearch]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.StorageTemplateMigration]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Migration]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.BackgroundTask]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Search]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.DuplicateDetection]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.FaceDetection]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.FacialRecognition]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Sidecar]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Library]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Notification]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.BackupDatabase]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Ocr]!: QueueResponseLegacyDto; + + @ApiProperty({ type: QueueResponseLegacyDto }) + [QueueName.Workflow]!: QueueResponseLegacyDto; +} + +export const mapQueueLegacy = (response: QueueResponseDto): QueueResponseLegacyDto => { + return { + queueStatus: { + isPaused: response.isPaused, + isActive: response.statistics.active > 0, + }, + jobCounts: response.statistics, + }; +}; + +export const mapQueuesLegacy = (responses: QueueResponseDto[]): QueuesResponseLegacyDto => { + const legacy = new QueuesResponseLegacyDto(); + + for (const response of responses) { + legacy[response.name] = mapQueueLegacy(response); + } + + return legacy; +}; diff --git a/server/src/dtos/queue.dto.ts b/server/src/dtos/queue.dto.ts index df00c5cfc..38a4a4ac6 100644 --- a/server/src/dtos/queue.dto.ts +++ b/server/src/dtos/queue.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { QueueCommand, QueueName } from 'src/enum'; +import { HistoryBuilder, Property } from 'src/decorators'; +import { JobName, QueueCommand, QueueJobStatus, QueueName } from 'src/enum'; import { ValidateBoolean, ValidateEnum } from 'src/validation'; export class QueueNameParamDto { @@ -15,6 +16,46 @@ export class QueueCommandDto { force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit } +export class QueueUpdateDto { + @ValidateBoolean({ optional: true }) + isPaused?: boolean; +} + +export class QueueDeleteDto { + @ValidateBoolean({ optional: true }) + @Property({ + description: 'If true, will also remove failed jobs from the queue.', + history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'), + }) + failed?: boolean; +} + +export class QueueJobSearchDto { + @ValidateEnum({ enum: QueueJobStatus, name: 'QueueJobStatus', optional: true, each: true }) + status?: QueueJobStatus[]; +} +export class QueueJobResponseDto { + id?: string; + + @ValidateEnum({ enum: JobName, name: 'JobName' }) + name!: JobName; + + data!: object; + + @ApiProperty({ type: 'integer' }) + timestamp!: number; +} + +export class QueueResponseDto { + @ValidateEnum({ enum: QueueName, name: 'QueueName' }) + name!: QueueName; + + @ValidateBoolean() + isPaused!: boolean; + + statistics!: QueueStatisticsDto; +} + export class QueueStatisticsDto { @ApiProperty({ type: 'integer' }) active!: number; @@ -29,69 +70,3 @@ export class QueueStatisticsDto { @ApiProperty({ type: 'integer' }) paused!: number; } - -export class QueueStatusDto { - isActive!: boolean; - isPaused!: boolean; -} - -export class QueueResponseDto { - @ApiProperty({ type: QueueStatisticsDto }) - jobCounts!: QueueStatisticsDto; - - @ApiProperty({ type: QueueStatusDto }) - queueStatus!: QueueStatusDto; -} - -export class QueuesResponseDto implements Record { - @ApiProperty({ type: QueueResponseDto }) - [QueueName.ThumbnailGeneration]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.MetadataExtraction]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.VideoConversion]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.SmartSearch]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.StorageTemplateMigration]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Migration]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.BackgroundTask]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Search]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.DuplicateDetection]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.FaceDetection]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.FacialRecognition]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Sidecar]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Library]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Notification]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.BackupDatabase]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Ocr]!: QueueResponseDto; - - @ApiProperty({ type: QueueResponseDto }) - [QueueName.Workflow]!: QueueResponseDto; -} diff --git a/server/src/enum.ts b/server/src/enum.ts index d397f9d2a..87ff282f3 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -248,6 +248,14 @@ export enum Permission { UserProfileImageUpdate = 'userProfileImage.update', UserProfileImageDelete = 'userProfileImage.delete', + QueueRead = 'queue.read', + QueueUpdate = 'queue.update', + + QueueJobCreate = 'queueJob.create', + QueueJobRead = 'queueJob.read', + QueueJobUpdate = 'queueJob.update', + QueueJobDelete = 'queueJob.delete', + WorkflowCreate = 'workflow.create', WorkflowRead = 'workflow.read', WorkflowUpdate = 'workflow.update', @@ -543,6 +551,15 @@ export enum QueueName { Workflow = 'workflow', } +export enum QueueJobStatus { + Active = 'active', + Failed = 'failed', + Complete = 'completed', + Delayed = 'delayed', + Waiting = 'waiting', + Paused = 'paused', +} + export enum JobName { AssetDelete = 'AssetDelete', AssetDeleteCheck = 'AssetDeleteCheck', @@ -624,9 +641,13 @@ export enum JobName { export enum QueueCommand { Start = 'start', + /** @deprecated Use `updateQueue` instead */ Pause = 'pause', + /** @deprecated Use `updateQueue` instead */ Resume = 'resume', + /** @deprecated Use `emptyQueue` instead */ Empty = 'empty', + /** @deprecated Use `emptyQueue` instead */ ClearFailed = 'clear-failed', } @@ -823,6 +844,7 @@ export enum ApiTag { Partners = 'Partners', People = 'People', Plugins = 'Plugins', + Queues = 'Queues', Search = 'Search', Server = 'Server', Sessions = 'Sessions', diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 05d4bd2ac..60ec021b3 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -249,7 +249,7 @@ const getEnv = (): EnvData => { prefix: 'immich_bull', connection: { ...redisConfig }, defaultJobOptions: { - attempts: 3, + attempts: 1, removeOnComplete: true, removeOnFail: false, }, diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index cf2799a4c..b12accb68 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -5,11 +5,12 @@ import { JobsOptions, Queue, Worker } from 'bullmq'; import { ClassConstructor } from 'class-transformer'; import { setTimeout } from 'node:timers/promises'; import { JobConfig } from 'src/decorators'; -import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueName } from 'src/enum'; +import { QueueJobResponseDto, QueueJobSearchDto } from 'src/dtos/queue.dto'; +import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueJobStatus, QueueName } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -import { JobCounts, JobItem, JobOf, QueueStatus } from 'src/types'; +import { JobCounts, JobItem, JobOf } from 'src/types'; import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc'; type JobMapItem = { @@ -115,13 +116,14 @@ export class JobRepository { worker.concurrency = concurrency; } - async getQueueStatus(name: QueueName): Promise { + async isActive(name: QueueName): Promise { const queue = this.getQueue(name); + const count = await queue.getActiveCount(); + return count > 0; + } - return { - isActive: !!(await queue.getActiveCount()), - isPaused: await queue.isPaused(), - }; + async isPaused(name: QueueName): Promise { + return this.getQueue(name).isPaused(); } pause(name: QueueName) { @@ -192,17 +194,28 @@ export class JobRepository { } async waitForQueueCompletion(...queues: QueueName[]): Promise { - let activeQueue: QueueStatus | undefined; - do { - const statuses = await Promise.all(queues.map((name) => this.getQueueStatus(name))); - activeQueue = statuses.find((status) => status.isActive); - } while (activeQueue); - { - this.logger.verbose(`Waiting for ${activeQueue} queue to stop...`); + const getPending = async () => { + const results = await Promise.all(queues.map(async (name) => ({ pending: await this.isActive(name), name }))); + return results.filter(({ pending }) => pending).map(({ name }) => name); + }; + + let pending = await getPending(); + + while (pending.length > 0) { + this.logger.verbose(`Waiting for ${pending[0]} queue to stop...`); await setTimeout(1000); + pending = await getPending(); } } + async searchJobs(name: QueueName, dto: QueueJobSearchDto): Promise { + const jobs = await this.getQueue(name).getJobs(dto.status ?? Object.values(QueueJobStatus), 0, 1000); + return jobs.map((job) => { + const { id, name, timestamp, data } = job; + return { id, name: name as JobName, timestamp, data }; + }); + } + private getJobOptions(item: JobItem): JobsOptions | null { switch (item.name) { case JobName.NotifyAlbumUpdate: { diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts index 5dce9476e..f5cf20413 100644 --- a/server/src/services/queue.service.spec.ts +++ b/server/src/services/queue.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { defaults, SystemConfig } from 'src/config'; import { ImmichWorker, JobName, QueueCommand, QueueName } from 'src/enum'; import { QueueService } from 'src/services/queue.service'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(QueueService.name, () => { @@ -52,80 +53,64 @@ describe(QueueService.name, () => { describe('getAllJobStatus', () => { it('should get all job statuses', async () => { - mocks.job.getJobCounts.mockResolvedValue({ - active: 1, - completed: 1, - failed: 1, - delayed: 1, - waiting: 1, - paused: 1, - }); - mocks.job.getQueueStatus.mockResolvedValue({ - isActive: true, - isPaused: true, - }); + const stats = factory.queueStatistics({ active: 1 }); + const expected = { jobCounts: stats, queueStatus: { isActive: true, isPaused: true } }; - const expectedJobStatus = { - jobCounts: { - active: 1, - completed: 1, - delayed: 1, - failed: 1, - waiting: 1, - paused: 1, - }, - queueStatus: { - isActive: true, - isPaused: true, - }, - }; + mocks.job.getJobCounts.mockResolvedValue(stats); + mocks.job.isPaused.mockResolvedValue(true); - await expect(sut.getAll()).resolves.toEqual({ - [QueueName.BackgroundTask]: expectedJobStatus, - [QueueName.DuplicateDetection]: expectedJobStatus, - [QueueName.SmartSearch]: expectedJobStatus, - [QueueName.MetadataExtraction]: expectedJobStatus, - [QueueName.Search]: expectedJobStatus, - [QueueName.StorageTemplateMigration]: expectedJobStatus, - [QueueName.Migration]: expectedJobStatus, - [QueueName.ThumbnailGeneration]: expectedJobStatus, - [QueueName.VideoConversion]: expectedJobStatus, - [QueueName.FaceDetection]: expectedJobStatus, - [QueueName.FacialRecognition]: expectedJobStatus, - [QueueName.Sidecar]: expectedJobStatus, - [QueueName.Library]: expectedJobStatus, - [QueueName.Notification]: expectedJobStatus, - [QueueName.BackupDatabase]: expectedJobStatus, - [QueueName.Ocr]: expectedJobStatus, - [QueueName.Workflow]: expectedJobStatus, + await expect(sut.getAllLegacy(factory.auth())).resolves.toEqual({ + [QueueName.BackgroundTask]: expected, + [QueueName.DuplicateDetection]: expected, + [QueueName.SmartSearch]: expected, + [QueueName.MetadataExtraction]: expected, + [QueueName.Search]: expected, + [QueueName.StorageTemplateMigration]: expected, + [QueueName.Migration]: expected, + [QueueName.ThumbnailGeneration]: expected, + [QueueName.VideoConversion]: expected, + [QueueName.FaceDetection]: expected, + [QueueName.FacialRecognition]: expected, + [QueueName.Sidecar]: expected, + [QueueName.Library]: expected, + [QueueName.Notification]: expected, + [QueueName.BackupDatabase]: expected, + [QueueName.Ocr]: expected, + [QueueName.Workflow]: expected, }); }); }); describe('handleCommand', () => { it('should handle a pause command', async () => { - await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Pause, force: false }); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); + + await sut.runCommandLegacy(QueueName.MetadataExtraction, { command: QueueCommand.Pause, force: false }); expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.MetadataExtraction); }); it('should handle a resume command', async () => { - await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Resume, force: false }); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); + + await sut.runCommandLegacy(QueueName.MetadataExtraction, { command: QueueCommand.Resume, force: false }); expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.MetadataExtraction); }); it('should handle an empty command', async () => { - await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Empty, force: false }); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); + + await sut.runCommandLegacy(QueueName.MetadataExtraction, { command: QueueCommand.Empty, force: false }); expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.MetadataExtraction); }); it('should not start a job that is already running', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); + mocks.job.isActive.mockResolvedValue(true); await expect( - sut.runCommand(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }), + sut.runCommandLegacy(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.job.queue).not.toHaveBeenCalled(); @@ -133,33 +118,37 @@ describe(QueueService.name, () => { }); it('should handle a start video conversion command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.VideoConversion, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEncodeVideoQueueAll, data: { force: false } }); }); it('should handle a start storage template migration command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.StorageTemplateMigration, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.StorageTemplateMigration, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.StorageTemplateMigration }); }); it('should handle a start smart search command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.SmartSearch, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.SmartSearch, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SmartSearchQueueAll, data: { force: false } }); }); it('should handle a start metadata extraction command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.MetadataExtraction, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.MetadataExtraction, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetExtractMetadataQueueAll, @@ -168,17 +157,19 @@ describe(QueueService.name, () => { }); it('should handle a start sidecar command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.Sidecar, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.Sidecar, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SidecarQueueAll, data: { force: false } }); }); it('should handle a start thumbnail generation command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.ThumbnailGeneration, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.ThumbnailGeneration, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetGenerateThumbnailsQueueAll, @@ -187,34 +178,37 @@ describe(QueueService.name, () => { }); it('should handle a start face detection command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.FaceDetection, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.FaceDetection, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetDetectFacesQueueAll, data: { force: false } }); }); it('should handle a start facial recognition command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.FacialRecognition, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.FacialRecognition, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognitionQueueAll, data: { force: false } }); }); it('should handle a start backup database command', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); + mocks.job.getJobCounts.mockResolvedValue(factory.queueStatistics()); - await sut.runCommand(QueueName.BackupDatabase, { command: QueueCommand.Start, force: false }); + await sut.runCommandLegacy(QueueName.BackupDatabase, { command: QueueCommand.Start, force: false }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DatabaseBackup, data: { force: false } }); }); it('should throw a bad request when an invalid queue is used', async () => { - mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + mocks.job.isActive.mockResolvedValue(false); await expect( - sut.runCommand(QueueName.BackgroundTask, { command: QueueCommand.Start, force: false }), + sut.runCommandLegacy(QueueName.BackgroundTask, { command: QueueCommand.Start, force: false }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.job.queue).not.toHaveBeenCalled(); diff --git a/server/src/services/queue.service.ts b/server/src/services/queue.service.ts index bea665e8f..cdfa2ad2e 100644 --- a/server/src/services/queue.service.ts +++ b/server/src/services/queue.service.ts @@ -2,7 +2,21 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ClassConstructor } from 'class-transformer'; import { SystemConfig } from 'src/config'; import { OnEvent } from 'src/decorators'; -import { QueueCommandDto, QueueResponseDto, QueuesResponseDto } from 'src/dtos/queue.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + mapQueueLegacy, + mapQueuesLegacy, + QueueResponseLegacyDto, + QueuesResponseLegacyDto, +} from 'src/dtos/queue-legacy.dto'; +import { + QueueCommandDto, + QueueDeleteDto, + QueueJobResponseDto, + QueueJobSearchDto, + QueueResponseDto, + QueueUpdateDto, +} from 'src/dtos/queue.dto'; import { BootstrapEventPriority, CronJob, @@ -86,7 +100,7 @@ export class QueueService extends BaseService { this.services = services; } - async runCommand(name: QueueName, dto: QueueCommandDto): Promise { + async runCommandLegacy(name: QueueName, dto: QueueCommandDto): Promise { this.logger.debug(`Handling command: queue=${name},command=${dto.command},force=${dto.force}`); switch (dto.command) { @@ -117,28 +131,60 @@ export class QueueService extends BaseService { } } + const response = await this.getByName(name); + + return mapQueueLegacy(response); + } + + async getAll(_auth: AuthDto): Promise { + return Promise.all(Object.values(QueueName).map((name) => this.getByName(name))); + } + + async getAllLegacy(auth: AuthDto): Promise { + const responses = await this.getAll(auth); + return mapQueuesLegacy(responses); + } + + get(auth: AuthDto, name: QueueName): Promise { return this.getByName(name); } - async getAll(): Promise { - const response = new QueuesResponseDto(); - for (const name of Object.values(QueueName)) { - response[name] = await this.getByName(name); + async update(auth: AuthDto, name: QueueName, dto: QueueUpdateDto): Promise { + if (dto.isPaused === true) { + if (name === QueueName.BackgroundTask) { + throw new BadRequestException(`The BackgroundTask queue cannot be paused`); + } + await this.jobRepository.pause(name); } - return response; + + if (dto.isPaused === false) { + await this.jobRepository.resume(name); + } + + return this.getByName(name); } - async getByName(name: QueueName): Promise { - const [jobCounts, queueStatus] = await Promise.all([ - this.jobRepository.getJobCounts(name), - this.jobRepository.getQueueStatus(name), - ]); + searchJobs(auth: AuthDto, name: QueueName, dto: QueueJobSearchDto): Promise { + return this.jobRepository.searchJobs(name, dto); + } - return { jobCounts, queueStatus }; + async emptyQueue(auth: AuthDto, name: QueueName, dto: QueueDeleteDto) { + await this.jobRepository.empty(name); + if (dto.failed) { + await this.jobRepository.clear(name, QueueCleanType.Failed); + } + } + + private async getByName(name: QueueName): Promise { + const [statistics, isPaused] = await Promise.all([ + this.jobRepository.getJobCounts(name), + this.jobRepository.isPaused(name), + ]); + return { name, isPaused, statistics }; } private async start(name: QueueName, { force }: QueueCommandDto): Promise { - const { isActive } = await this.jobRepository.getQueueStatus(name); + const isActive = await this.jobRepository.isActive(name); if (isActive) { throw new BadRequestException(`Job is already running`); } diff --git a/server/src/types.ts b/server/src/types.ts index dd3d25a7c..848d19177 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -291,11 +291,6 @@ export interface JobCounts { paused: number; } -export interface QueueStatus { - isActive: boolean; - isPaused: boolean; -} - export type JobItem = // Audit | { name: JobName.AuditTableCleanup; data?: IBaseJob } diff --git a/server/test/repositories/job.repository.mock.ts b/server/test/repositories/job.repository.mock.ts index f0f4fdda0..4fc5460c8 100644 --- a/server/test/repositories/job.repository.mock.ts +++ b/server/test/repositories/job.repository.mock.ts @@ -11,9 +11,11 @@ export const newJobRepositoryMock = (): Mocked Promise.resolve()), queueAll: vitest.fn().mockImplementation(() => Promise.resolve()), - getQueueStatus: vitest.fn(), + isActive: vitest.fn(), + isPaused: vitest.fn(), getJobCounts: vitest.fn(), clear: vitest.fn(), waitForQueueCompletion: vitest.fn(), diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index ea0df585e..a0de947b2 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -14,6 +14,7 @@ import { } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { QueueStatisticsDto } from 'src/dtos/queue.dto'; import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserMetadataKey, UserStatus } from 'src/enum'; import { OnThisDayData, UserMetadataItem } from 'src/types'; import { v4, v7 } from 'uuid'; @@ -139,6 +140,16 @@ const sessionFactory = (session: Partial = {}) => ({ ...session, }); +const queueStatisticsFactory = (dto?: Partial) => ({ + active: 0, + completed: 0, + failed: 0, + delayed: 0, + waiting: 0, + paused: 0, + ...dto, +}); + const stackFactory = () => ({ id: newUuid(), ownerId: newUuid(), @@ -353,6 +364,7 @@ export const factory = { library: libraryFactory, memory: memoryFactory, partner: partnerFactory, + queueStatistics: queueStatisticsFactory, session: sessionFactory, stack: stackFactory, user: userFactory, diff --git a/web/src/lib/components/jobs/JobTile.svelte b/web/src/lib/components/jobs/JobTile.svelte index 64a6db5b7..8bdd7c169 100644 --- a/web/src/lib/components/jobs/JobTile.svelte +++ b/web/src/lib/components/jobs/JobTile.svelte @@ -1,7 +1,7 @@