From 77da10e3aba14abe82cf3470f5312b176bc525ce Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 17 Mar 2024 15:30:52 -0400 Subject: [PATCH] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wip use prisma for paginated queries remove migration file redundant spread simplified extend use bigint for comparison handle deleted assets in extension Squashed commit of the following: commit 64aac239f00ec02fefab85a626f568d0d9999e63 Author: Alex Date: Thu Mar 21 18:00:22 2024 -0500 chore: consolidate readme files (#8171) commit d6823b128ce536cdf8d268d19d0af25e071ffd12 Author: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu Mar 21 23:59:21 2024 +0100 fix(server): validation events actually throwing an error (#8172) * fix validation events * add e2e test commit 508f32c08a5a0de1a45e00a8872cb3bd984bc347 Author: martin <74269598+martabal@users.noreply.github.com> Date: Thu Mar 21 21:01:08 2024 +0100 feat(web): improvements to slideshow (#8032) * feat: improvements to slideshow * feat: pause video with slideshow bar * pr feedback * fix: remove dispatch * fix: simplify * pr feedback * pr feedback --------- Co-authored-by: Alex Tran commit 8ed6ed4d2b840c28a4af6a65b1d6fe06d8c53ce5 Author: Ethan Margaillan Date: Thu Mar 21 19:39:33 2024 +0100 feat(web): rework context menus: add icons and reorder items (#8090) commit 1abb0bdae830401bd510953fa3952a3e65f9a840 Author: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Thu Mar 21 17:51:03 2024 +0100 feat(mobile): faster image loader (#8140) Co-authored-by: Alex Tran commit 5ef62155461eead476495a79be01c6c6ab9927c8 Author: martyfuhry Date: Thu Mar 21 12:31:18 2024 -0400 chore(mobile): Bump to Flutter 3.19.0 (#7167) * Bump to Flutter 3.19.0 * Ran pub upgrade --major-versions and removed isar_version alias Wrong http version * Updated share_plus to fix android build * Updates github actions to 3.19.0 * upgrade to 3.19.3 * upgrade to 3.19.3 --------- Co-authored-by: Alex Tran commit 95fb9c4365cbd944936ce3ed399f47abc6e915ea Author: waclaw66 Date: Thu Mar 21 18:23:06 2024 +0200 fix(mobile): spacing fixes of #8087 (#8163) fix(mobile): spacing fix of https://github.com/immich-app/immich/pull/8087 commit fa0a5107c24a63baec7e929349fc478a1340d6b7 Author: aviv926 <51673860+aviv926@users.noreply.github.com> Date: Thu Mar 21 17:05:45 2024 +0200 fix(docs): Immich quota claim note (#8151) * Add a note about immich_quota_claim. * Fix * PR feedback * npm run format:fix * use ยน commit dc3c32943127aaf0d562f1dfc6720898f1dafbd1 Author: Jason Rasmussen Date: Thu Mar 21 09:36:10 2024 -0500 chore: remove unused type (#8157) commit 2a9f2b4515d1f055b395f5360e8bec928c2c5e69 Author: Jason Rasmussen Date: Thu Mar 21 09:08:29 2024 -0500 refactor: app modules, main.ts (#8156) commit 793049388b7ed2aa8d3a05819f660b61edf73709 Author: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu Mar 21 14:44:54 2024 +0100 refactor(web): cleanup notification components (#8150) * refactor(web): cleanup notification components * use counter for ID commit 382b63954ce198cdc57763fac11b812ae07a0fc5 Author: Jason Rasmussen Date: Thu Mar 21 08:07:47 2024 -0500 refactor: asset v1, app.utils (#8152) commit 87ccba7f9ddf5154558f5b785fc27b64d4de89f4 Author: Ben Basten <45583362+ben-basten@users.noreply.github.com> Date: Thu Mar 21 12:24:19 2024 +0000 feat(web): keyboard access for search dropdown, combobox fixes (#8079) * feat(web): keyboard access for search dropdown Also: fixing cosmetic issue with combobox component. * fix: revert changing required field * fix: create new focusChange action * fix: combobox usability improvements * handle escape key on the clear button * move focus to input when clear button is clicked * leave the dropdown closed if the user has already closed the dropdown and tabs over to the clear button * activate the combobox if a user tabs backwards onto the clear button * rename focusChange to focusOutside * small fixes * do not activate combobox on backwards tabbing * simplify classes in "No results" option * prevent dropdown option from being preselected when clear button is clicked * fix: remove unused event dispatcher interface commit e21c96c0efc4f6040402f086620f45121915b9b2 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Thu Mar 21 07:14:44 2024 -0500 chore(deps): update redis:6.2-alpine docker digest to 3fcb624 (#8137) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 4de0b2f44e50daa9e42e05e1813d30323ade1543 Author: Ethan Margaillan Date: Thu Mar 21 13:14:13 2024 +0100 feat(web): add ctrl+a / ctrl+d shortcuts to select / deselect all assets (#8105) * feat(web): use ctrl+a / ctrl+d to select / deselect all assets * fix(web): use shortcutList for ctrl+a / ctrl+d * fix(web): remove useless get() * feat(web): asset interaction store can now select many assets at once commit b588a87d4aad333bc2a7c462f9674b5a7c770d65 Author: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu Mar 21 12:59:49 2024 +0100 chore(server): rename domain `repositories` -> `interfaces` (#8147) rename domain repositories commit 44ed1f091957f061c837767cc1d5dafc36d592e5 Author: Alex Date: Thu Mar 21 00:18:38 2024 -0500 fix(web): asset-grid padding/margin left fix (#8125) use media query for grid padding/margin size commit 16d0df796c3dec465096db83d409aef85c1185bd Author: Jason Rasmussen Date: Wed Mar 20 22:15:09 2024 -0500 refactor: infra folder (#8138) commit 9fd5d2ad9ca8ea7feda7973ee431fad1b03aa538 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 22:59:15 2024 -0400 fix(deps): update machine-learning (#8057) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 28ad004b01c4f23981e784c08a23811a99d84658 Author: Kirill <44521162+kirilldem@users.noreply.github.com> Date: Thu Mar 21 03:58:52 2024 +0100 Update remote-machine-learning.md (#8038) * Update remote-machine-learning.md provide an example to use cuda or another container * Update docs/docs/guides/remote-machine-learning.md Co-authored-by: aviv926 <51673860+aviv926@users.noreply.github.com> * Update docs/docs/guides/remote-machine-learning.md --------- Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> Co-authored-by: aviv926 <51673860+aviv926@users.noreply.github.com> commit ef4a492cb14424de3a791004af6604a795e1eae9 Author: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu Mar 21 00:07:30 2024 +0100 chore(server): move services (#8133) move services commit 6d9e7694b1361527660fa4f30d506964ed9daeb9 Author: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed Mar 20 23:53:07 2024 +0100 chore(server): move dtos (#8131) move dtos commit 0c13c63bb62ab6e16730a1bab337a57dc12acd5a Author: Jason Rasmussen Date: Wed Mar 20 16:46:59 2024 -0500 refactor: infra/domain module (#8130) commit 907eb869bc8804de505da827869164f7f7982639 Author: Jason Rasmussen Date: Wed Mar 20 16:22:47 2024 -0500 chore: move apps and test utils (#8129) commit c1402eee8e0020994c488c22b175fd693e40be3d Author: Jason Rasmussen Date: Wed Mar 20 16:02:51 2024 -0500 chore: migrate database files (#8126) commit 84f7ca855a01c5c8d1bd2ce8c8355f17bb8a6125 Author: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed Mar 20 21:42:58 2024 +0100 chore(server): move domain interfaces (#8124) move domain interfaces commit 2dcce0335250a593e7990ad5717ec997cfc143a7 Author: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed Mar 20 21:25:33 2024 +0100 chore(server): move commands (#8121) move commands commit 96a22ec3c1912766d46b1ddfabb17e4248523aa4 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 16:21:27 2024 -0400 chore(deps): update base-image to v20240319 (major) (#8115) chore(deps): update base-image to v20240319 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 4b29bccc7c693983d25279e43cebf27ebd27f95f Author: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed Mar 20 21:20:38 2024 +0100 chore(server): move cores (#8120) move cores commit 40e079a247f29dedb7d3e55a7b7954a873300d28 Author: Jason Rasmussen Date: Wed Mar 20 15:15:01 2024 -0500 chore: move controllers and middleware (#8119) commit 81f0265095779a5bb4ba8bea53893aea9a2552af Author: Jason Rasmussen Date: Wed Mar 20 15:04:03 2024 -0500 chore: organize config, validation, decorators (#8118) * refactor: validation * refactor: utilities * refactor: config commit 92cc647cf6e3b0927146b6ee034a111a623fa3c6 Author: Jason Rasmussen Date: Wed Mar 20 14:50:01 2024 -0500 chore: renovate grouping (#8113) commit 048d437b0b7f6b21312f0e5c20e99a2c9dde50fa Author: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed Mar 20 20:40:41 2024 +0100 fix(web): prevent duplicate time bucket loads (#8091) commit ec9a6bca144c565e2e29e0ae34f8da01a663e94d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 15:38:58 2024 -0400 chore(deps): update dependency socket.io-client to v4.7.5 (#8111) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit bd5952b94324d61b024ee4ac1e32e398eaa8a871 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 15:35:07 2024 -0400 chore(deps): update vitest monorepo to v1.4.0 (#8112) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 3f0d54c752a3c50a6f12365ed7d431c24f7831d3 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 15:34:12 2024 -0400 fix(deps): update server (#8067) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit dab4595a4efb958425adc85bc0092a5582cd091b Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 14:09:10 2024 -0500 chore(deps): update redis:6.2-alpine docker digest to fd35357 (#8001) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 6d9ca82b193695e4d3ba9690f52fbd1841b89683 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 14:08:01 2024 -0500 chore(deps): update web (#8066) * chore(deps): update web * fix: linting --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jason Rasmussen commit 373a03e8191d44a94ac1e2b663c1956979e916b7 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 14:06:58 2024 -0500 chore(deps): update dependency @types/node to v20.11.28 (#8110) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit d97b0259fad7c975ccb6072ffa91dd857fc0bc60 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 14:38:48 2024 -0400 chore(deps): update node.js to bf77dc2 (#8063) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 2267ca1949c4c4d548cd246794393cd61586e6ad Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 14:38:28 2024 -0400 chore(deps): update node.js to 8765147 (#8058) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 29be53e70d6c0e98d5011e03e0cd50991f26d9c9 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 14:37:22 2024 -0400 chore(deps): update prom/prometheus docker digest to 5ccad47 (#8071) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 851fe4a49f2f921405bc1ff9713464a72aee390d Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Wed Mar 20 14:33:41 2024 -0400 chore(deps): update dependency @types/node to v20.11.28 (#8064) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> commit 30f499cf2e68c5bdfde866193523dbb1232ae3b4 Author: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed Mar 20 19:32:04 2024 +0100 chore(server): use absolute import paths (#8080) update server to use absolute import paths commit 591a641d8d83e4a3c0d7fb0028b671e80a93b653 Author: Alex Date: Wed Mar 20 10:00:35 2024 -0500 chore: post release tasks commit 5b314ffd46f0438f3a60e241240d435c8598e8cf Author: Alex The Bot Date: Wed Mar 20 14:50:57 2024 +0000 Version v1.99.0 commit 0b078c9f9913b057486b033a8a9114b02428f35c Author: Alex Date: Wed Mar 20 09:46:31 2024 -0500 fix(web): Share button visible when viewing album has only shared link (#8100) commit 0d5584ecbb507332d95facc3c3e87f3f51222e8e Author: Alex Date: Wed Mar 20 09:28:19 2024 -0500 fix(web): shift-select again (#8098) commit 5e090646ba4cedf2b045677a22e1407eff27c289 Author: waclaw66 Date: Wed Mar 20 16:26:09 2024 +0200 fix(mobile): missing "Add name" translation (#8087) fix(mobile): missing "Add name" translation, positioning commit c4e910dd3d0087ca7dda2daa2a344dcc25582a97 Author: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed Mar 20 10:20:46 2024 -0400 docs(server): add documentation for prometheus metrics (#8084) * add monitoring doc * wording * indent * note instead of tip * Update docs/docs/features/monitoring.md Co-authored-by: bo0tzz * Update docs/docs/features/monitoring.md Co-authored-by: bo0tzz --------- Co-authored-by: Alex Tran Co-authored-by: bo0tzz commit 5a2394af7cdaef46a0990913a7f6f18ffbdd16da Author: Alex Date: Wed Mar 20 09:16:20 2024 -0500 fix(web): shift-select (#8093) * fix(web): shift-select * remove unused code * proper fix commit 48e32269f42d8ec4ab01111110b79605ad9c3db6 Author: Alex Date: Wed Mar 20 09:16:00 2024 -0500 chore: add prometheus.yml to release artifact (#8096) commit dd9d90d21e404f4312d58dcd48b5c424aef782fb Author: Zack Pollard Date: Wed Mar 20 06:31:52 2024 -0600 test: temporarily disable flaky audit e2e test until #7436 is fixed (#8089) commit 0544c687b939f0375c35aed49c42a2fbb336ad74 Author: Ethan Margaillan Date: Wed Mar 20 13:29:30 2024 +0100 fix(web): missing margin on people page (#8081) commit e810aae21240be2baea365a99e2b61e2eb1e0996 Author: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed Mar 20 13:24:08 2024 +0100 fix(web): show search page errors and use feature flag (#8088) commit 9c6a26de9ff7a5e796a56696e9ab8e944a95e793 Author: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed Mar 20 05:41:31 2024 +0100 chore(web): add asset store unit tests (#8077) chore(web): asset store unit tests commit e6f2bb9f89c8f9b3010eec2d31f3c684d7d3bbae Author: Jonathan Jogenfors Date: Wed Mar 20 05:40:28 2024 +0100 fix(server): use extension in originalFileName for libraries (#8083) * use file base * fix: test * fix: e2e-job tests --------- Co-authored-by: Alex Tran commit f908bd4a645306c89c5f90be41dbc7d5273fc308 Author: Ethan Margaillan Date: Wed Mar 20 05:28:13 2024 +0100 fix(web): prevent drag-n-drop upload overlay from showing when not dragging files (#8082) commit 7395b03b1f99f659b3bc20e31bc1d6bf32cc21fa Author: Thariq Shanavas Date: Tue Mar 19 22:12:36 2024 -0600 fix(docs) minor security warning raised by Borg (#8075) * Fix minor borg security warning * Update template-backup-script.md * removed one unnecessary step * Clarified optional steps * Update template-backup-script.md commit 63b4fc6f6582396918803555f34bcfce82a4ace8 Author: Alex Date: Tue Mar 19 23:07:26 2024 -0500 chore(mobile): svg logo (#8074) * chore(mobile): anti-aliasing logo * use svg * adjust height * better sizing commit f392fe7702ebb09773bf8cb6a08a369ef80f5ce5 Author: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue Mar 19 23:23:57 2024 -0400 fix(server): "view all" for cities only showing 12 cities (#8035) * view all cities * increase limit * rename endpoint * optimize query * remove pagination * update sql * linting * revert sort by count in explore page for now * fix query * fix * update sql * move to search, add partner support * update sql * pr feedback * euphemism * parameters as separate variable * move comment * update sql * linting commit 2daed747cd62a0ec86dac8a71e070d31b1a95686 Author: Mert <101130780+mertalev@users.noreply.github.com> Date: Tue Mar 19 22:42:10 2024 -0400 chore(server): change `save` -> `update` in asset repository (#8055) * `save` -> `update` * change return type * include relations * fix tests * remove when mocks * fix * stricter typing * simpler type commit 9e4bab74944273699e5db9b54d9be5b45f96cd2a Author: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue Mar 19 14:31:56 2024 +0000 feat(mobile): drag to select assets (#8004) fear(mobile): drag to select assets Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex commit 9274c0701b5656d27add8e67ba1c0aa66ad512fd Author: waclaw66 Date: Tue Mar 19 16:22:44 2024 +0200 fix(mobile): do not show hidden people (#8072) * fix(mobile): do not show hidden people * dart format fix commit 0bc773fd0094874f5b135c67339f7654ac150014 Author: Alex Date: Tue Mar 19 08:40:14 2024 -0500 refactor(mobile): backup album selection (#8053) * feat(mobile): include album with 0 assets as album option for backup * Show icon instead of thumbnail * Handle backupProgress state transition correctly to always load the backup info * remove todo comment commit c6d24085176b9da263ed0d3ad659267cc802ce8d Author: Ben Basten <45583362+ben-basten@users.noreply.github.com> Date: Tue Mar 19 12:56:41 2024 +0000 feat(web): combobox accessibility improvements (#8007) * bump skip link z index, to prevent overlap with the search box * combobox refactor initial commit * pull label into the combobox component * feat(web): combobox accessibility improvements * fix: replace crypto.randomUUID, fix border UI bug, simpler focus handling (#2) * fix: handle changes in the selected option * fix: better escape key handling in search bar * fix: remove broken tailwind classes Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * fix: remove custom "outclick" handler logic * fix: use focusout instead of custom key handlers to detect focus change * fix: move escape key handling to the window Also add escape key handling to the input box, to make sure that the "recent searches" dropdown gets closed too. * fix: better input event handling Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * fix: highlighting selected dropdown element --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> commit 033f83a55a23442185e69f84f7d5840d85fb2ad2 Author: Jan <17313367+JW-CH@users.noreply.github.com> Date: Tue Mar 19 13:47:33 2024 +0100 fix(docs): update authelia OIDC link (#8070) commit 51841d627c33f55c9113efb45efd66a26af3d487 Author: Alex Date: Mon Mar 18 22:39:49 2024 -0500 fix(web): load panorama in shared link (#8060) * fix(web): load panorama in shared link * remove console log commit 50924f0b3dddb1fa86aec8e0f57b01ff562794bb Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Mon Mar 18 19:49:31 2024 -0400 chore(deps): update dependency @types/node to v20.11.27 (#8012) * chore(deps): update dependency @types/node to v20.11.27 * fixes * fixes --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler Co-authored-by: Marty Fuhry commit 4aae1da8418e0475f4f3d8eb8cb20fd48d34d27e Author: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon Mar 18 22:56:39 2024 +0100 fix(web): repair page typo (#8051) fix typo commit 1a2554548a2ed44d619f0804773ee9b38d22f941 Author: bo0tzz Date: Mon Mar 18 22:54:30 2024 +0100 chore: Simplify install script (#8048) * chore: Simplify install script The default .env file now contains a set UPLOAD_LOCATION already * fix: Remove leftover line commit 40262c30cb7d43e059a3dbe0a146a5dd988ab0e6 Author: Jason Rasmussen Date: Mon Mar 18 15:59:53 2024 -0500 refactor(server): library service (#8050) * refactor: library service * chore: open api * fix: checks commit 761e7fdd2d914c172ef08084f286ff53483581d4 Author: Alex Date: Mon Mar 18 14:46:52 2024 -0500 feat(server): memory includes partners assets on timeline (#7993) * feat(server): memory includes partners assets on timeline * remove unsued code, generate sql * fix test * add test commit cd8a124b25429ac6a4b5598fdf3fa645a8f238fc Author: aviv926 <51673860+aviv926@users.noreply.github.com> Date: Mon Mar 18 16:00:11 2024 +0200 feat(docs): User management new options (#8029) * User Management * Add photo commit 148428a564ad5876ceb6b3991a7571e65dc15b38 Author: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sun Mar 17 20:16:02 2024 +0100 feat(server): use nestjs events to validate config (#7986) * use events for config validation * chore: better types * add unit tests --------- Co-authored-by: Jason Rasmussen commit 14da671bf9a7cab9e0d436816cca39a38bf40416 Author: Tyler Brockett Date: Sun Mar 17 11:41:55 2024 -0700 fix(docs): add microservices to IMMICH_CONFIG_FILE env var documentation (#8017) commit e8f0f82db05244fc07253ddb3b643ca8fabba89b Author: Davide <22103897+dvdblg@users.noreply.github.com> Date: Sun Mar 17 18:48:59 2024 +0100 feat(ml): add cache_dir option to OpenVINO EP (#8018) * add cache_dir option to OpenVINO EP * update provider options test to include cache_dir * use forward slash instead of string concatenation * fix cache_dir placement in provider options assertion commit b8278404a091b33d41d901255733dfe11a70ed9d Author: Alex Date: Sun Mar 17 10:46:42 2024 -0500 chore(docs): update readme (#8021) commit 45671b0b8b978470eae703248df11c64f760aae7 Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Date: Sat Mar 16 15:34:49 2024 -0500 chore(deps): update typescript-eslint monorepo to v7.2.0 (#8008) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> refactor --- server/Dockerfile | 6 +- server/package-lock.json | 139 +++ server/package.json | 10 +- server/src/interfaces/asset.interface.ts | 44 +- server/src/main.ts | 12 + server/src/prisma/find-non-deleted.ts | 28 + server/src/prisma/metrics.ts | 17 + server/src/prisma/schema.prisma | 463 +++++++++ server/src/repositories/asset.repository.ts | 937 +++++++++---------- server/src/repositories/prisma.repository.ts | 20 + server/src/services/asset-v1.service.ts | 2 +- server/src/services/asset.service.ts | 44 +- server/src/services/person.service.ts | 8 +- server/src/utils/pagination.ts | 5 +- server/src/utils/sql.ts | 2 + 15 files changed, 1201 insertions(+), 536 deletions(-) create mode 100644 server/src/prisma/find-non-deleted.ts create mode 100644 server/src/prisma/metrics.ts create mode 100644 server/src/prisma/schema.prisma create mode 100644 server/src/repositories/prisma.repository.ts diff --git a/server/Dockerfile b/server/Dockerfile index be453bba8..7d721d3f7 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -10,13 +10,17 @@ RUN npm ci && \ rm -rf node_modules/@img/sharp-libvips* && \ rm -rf node_modules/@img/sharp-linuxmusl-x64 COPY server . + +WORKDIR /usr/src/app/server +RUN npm run prisma:generate + +WORKDIR /usr/src/app ENV PATH="${PATH}:/usr/src/app/bin" \ NODE_ENV=development \ NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_VISIBLE_DEVICES=all ENTRYPOINT ["tini", "--", "/bin/sh"] - FROM dev AS prod RUN npm run build diff --git a/server/package-lock.json b/server/package-lock.json index d36229b14..917265a4d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -25,6 +25,7 @@ "@opentelemetry/auto-instrumentations-node": "^0.43.0", "@opentelemetry/exporter-prometheus": "^0.49.0", "@opentelemetry/sdk-node": "^0.49.0", + "@prisma/client": "^5.11.0", "@socket.io/postgres-adapter": "^0.3.1", "@types/picomatch": "^2.3.3", "archiver": "^7.0.0", @@ -96,6 +97,7 @@ "mock-fs": "^5.2.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^3.2.3", + "prisma": "^5.11.0", "rimraf": "^5.0.1", "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", @@ -4061,6 +4063,68 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" }, + "node_modules/@prisma/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.11.0.tgz", + "integrity": "sha512-SWshvS5FDXvgJKM/a0y9nDC1rqd7KG0Q6ZVzd+U7ZXK5soe73DJxJJgbNBt2GNXOa+ysWB4suTpdK5zfFPhwiw==", + "hasInstallScript": true, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.11.0.tgz", + "integrity": "sha512-N6yYr3AbQqaiUg+OgjkdPp3KPW1vMTAgtKX6+BiB/qB2i1TjLYCrweKcUjzOoRM5BriA4idrkTej9A9QqTfl3A==", + "devOptional": true + }, + "node_modules/@prisma/engines": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.11.0.tgz", + "integrity": "sha512-gbrpQoBTYWXDRqD+iTYMirDlF9MMlQdxskQXbhARhG6A/uFQjB7DZMYocMQLoiZXO/IskfDOZpPoZE8TBQKtEw==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "5.11.0", + "@prisma/engines-version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "@prisma/fetch-engine": "5.11.0", + "@prisma/get-platform": "5.11.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102.tgz", + "integrity": "sha512-WXCuyoymvrS4zLz4wQagSsc3/nE6CHy8znyiMv8RKazKymOMd5o9FP5RGwGHAtgoxd+aB/BWqxuP/Ckfu7/3MA==", + "devOptional": true + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.11.0.tgz", + "integrity": "sha512-994viazmHTJ1ymzvWugXod7dZ42T2ROeFuH6zHPcUfp/69+6cl5r9u3NFb6bW8lLdNjwLYEVPeu3hWzxpZeC0w==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "5.11.0", + "@prisma/engines-version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "@prisma/get-platform": "5.11.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.11.0.tgz", + "integrity": "sha512-rxtHpMLxNTHxqWuGOLzR2QOyQi79rK1u1XYAVLZxDGTLz/A+uoDnjz9veBFlicrpWjwuieM4N6jcnjj/DDoidw==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "5.11.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -11515,6 +11579,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prisma": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.11.0.tgz", + "integrity": "sha512-KCLiug2cs0Je7kGkQBN9jDWoZ90ogE/kvZTUTgz2h94FEo8pczCkPH7fPNXkD1sGU7Yh65risGGD1HQ5DF3r3g==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "5.11.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -17153,6 +17233,56 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" }, + "@prisma/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.11.0.tgz", + "integrity": "sha512-SWshvS5FDXvgJKM/a0y9nDC1rqd7KG0Q6ZVzd+U7ZXK5soe73DJxJJgbNBt2GNXOa+ysWB4suTpdK5zfFPhwiw==", + "requires": {} + }, + "@prisma/debug": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.11.0.tgz", + "integrity": "sha512-N6yYr3AbQqaiUg+OgjkdPp3KPW1vMTAgtKX6+BiB/qB2i1TjLYCrweKcUjzOoRM5BriA4idrkTej9A9QqTfl3A==", + "devOptional": true + }, + "@prisma/engines": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.11.0.tgz", + "integrity": "sha512-gbrpQoBTYWXDRqD+iTYMirDlF9MMlQdxskQXbhARhG6A/uFQjB7DZMYocMQLoiZXO/IskfDOZpPoZE8TBQKtEw==", + "devOptional": true, + "requires": { + "@prisma/debug": "5.11.0", + "@prisma/engines-version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "@prisma/fetch-engine": "5.11.0", + "@prisma/get-platform": "5.11.0" + } + }, + "@prisma/engines-version": { + "version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102.tgz", + "integrity": "sha512-WXCuyoymvrS4zLz4wQagSsc3/nE6CHy8znyiMv8RKazKymOMd5o9FP5RGwGHAtgoxd+aB/BWqxuP/Ckfu7/3MA==", + "devOptional": true + }, + "@prisma/fetch-engine": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.11.0.tgz", + "integrity": "sha512-994viazmHTJ1ymzvWugXod7dZ42T2ROeFuH6zHPcUfp/69+6cl5r9u3NFb6bW8lLdNjwLYEVPeu3hWzxpZeC0w==", + "devOptional": true, + "requires": { + "@prisma/debug": "5.11.0", + "@prisma/engines-version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "@prisma/get-platform": "5.11.0" + } + }, + "@prisma/get-platform": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.11.0.tgz", + "integrity": "sha512-rxtHpMLxNTHxqWuGOLzR2QOyQi79rK1u1XYAVLZxDGTLz/A+uoDnjz9veBFlicrpWjwuieM4N6jcnjj/DDoidw==", + "devOptional": true, + "requires": { + "@prisma/debug": "5.11.0" + } + }, "@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -22910,6 +23040,15 @@ } } }, + "prisma": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.11.0.tgz", + "integrity": "sha512-KCLiug2cs0Je7kGkQBN9jDWoZ90ogE/kvZTUTgz2h94FEo8pczCkPH7fPNXkD1sGU7Yh65risGGD1HQ5DF3r3g==", + "devOptional": true, + "requires": { + "@prisma/engines": "5.11.0" + } + }, "process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", diff --git a/server/package.json b/server/package.json index ead525a6e..747b7699e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,7 @@ { "name": "immich", "version": "1.99.0", + "version": "1.99.0", "description": "", "author": "", "private": true, @@ -29,8 +30,13 @@ "typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js", "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js", "typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'", + "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/database.config.js", + "typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js", + "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js", + "typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'", "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run", - "sql:generate": "node ./dist/utils/sql.js" + "sql:generate": "node ./dist/utils/sql.js", + "prisma:generate": "prisma generate --schema=./src/prisma/schema.prisma" }, "dependencies": { "@babel/runtime": "^7.22.11", @@ -49,6 +55,7 @@ "@opentelemetry/auto-instrumentations-node": "^0.43.0", "@opentelemetry/exporter-prometheus": "^0.49.0", "@opentelemetry/sdk-node": "^0.49.0", + "@prisma/client": "^5.11.0", "@socket.io/postgres-adapter": "^0.3.1", "@types/picomatch": "^2.3.3", "archiver": "^7.0.0", @@ -120,6 +127,7 @@ "mock-fs": "^5.2.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^3.2.3", + "prisma": "^5.11.0", "rimraf": "^5.0.1", "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 47c13041f..13cb4e049 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client'; import { AssetOrder } from 'src/entities/album.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; @@ -78,22 +79,6 @@ export interface TimeBucketItem { count: number; } -export type AssetCreate = Pick< - AssetEntity, - | 'deviceAssetId' - | 'ownerId' - | 'libraryId' - | 'deviceId' - | 'type' - | 'originalPath' - | 'fileCreatedAt' - | 'localDateTime' - | 'fileModifiedAt' - | 'checksum' - | 'originalFileName' -> & - Partial; - export type AssetWithoutRelations = Omit< AssetEntity, | 'livePhotoVideo' @@ -109,6 +94,22 @@ export type AssetWithoutRelations = Omit< | 'tags' >; +export type AssetCreate = Pick< + AssetEntity, + | 'deviceAssetId' + | 'ownerId' + | 'libraryId' + | 'deviceId' + | 'type' + | 'originalPath' + | 'fileCreatedAt' + | 'localDateTime' + | 'fileModifiedAt' + | 'checksum' + | 'originalFileName' +> & + Partial; + export type AssetUpdateOptions = Pick & Partial; export type AssetUpdateAllOptions = Omit, 'id'>; @@ -139,18 +140,13 @@ export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { create(asset: AssetCreate): Promise; - getByDate(ownerId: string, date: Date): Promise; - getByIds( - ids: string[], - relations?: FindOptionsRelations, - select?: FindOptionsSelect, - ): Promise; + getByIds(ids: string[], relations?: Prisma.AssetsInclude): Promise; getByIdsWithAllRelations(ids: string[]): Promise; getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; getByChecksum(userId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated; - getById(id: string, relations?: FindOptionsRelations): Promise; + getById(id: string, relations?: Prisma.AssetsInclude): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; getRandom(userId: string, count: number): Promise; @@ -162,7 +158,7 @@ export interface IAssetRepository { getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; getAllByDeviceId(userId: string, deviceId: string): Promise; updateAll(ids: string[], options: Partial): Promise; - update(asset: AssetUpdateOptions): Promise; + update(asset: AssetUpdateOptions): Promise; remove(asset: AssetEntity): Promise; softDeleteAll(ids: string[]): Promise; restoreAll(ids: string[]): Promise; diff --git a/server/src/main.ts b/server/src/main.ts index 3a9303868..44622c131 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -71,6 +71,18 @@ async function bootstrapApi() { logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `); } +declare global { + interface BigInt { + toJSON(): number | string; + } +} + +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); + +BigInt.prototype.toJSON = function () { + return this.valueOf() > MAX_SAFE_INTEGER ? this.toString() : Number(this.valueOf()); +}; + const immichApp = process.argv[2] || process.env.IMMICH_APP; if (process.argv[2] === immichApp) { diff --git a/server/src/prisma/find-non-deleted.ts b/server/src/prisma/find-non-deleted.ts new file mode 100644 index 000000000..db515828a --- /dev/null +++ b/server/src/prisma/find-non-deleted.ts @@ -0,0 +1,28 @@ +import { Prisma } from '@prisma/client'; + +const excludeDeleted = ({ args, query }: { args: any; query: any }) => { + if (args.where === undefined) { + args.where = { deletedAt: null }; + } else if (args.where.deletedAt === undefined) { + args.where.deletedAt = null; + } + + return query(args); +}; + +const findNonDeleted = { + findFirst: excludeDeleted, + findFirstOrThrow: excludeDeleted, + findMany: excludeDeleted, + findUnique: excludeDeleted, + findUniqueOrThrow: excludeDeleted, +}; + +export const findNonDeletedExtension = Prisma.defineExtension({ + query: { + albums: findNonDeleted, + assets: findNonDeleted, + libraries: findNonDeleted, + users: findNonDeleted, + }, +}); diff --git a/server/src/prisma/metrics.ts b/server/src/prisma/metrics.ts new file mode 100644 index 000000000..84741d61f --- /dev/null +++ b/server/src/prisma/metrics.ts @@ -0,0 +1,17 @@ +import { Prisma } from '@prisma/client'; +import util from 'node:util'; + +export const metricsExtension = Prisma.defineExtension({ + query: { + $allModels: { + async $allOperations({ operation, model, args, query }) { + const start = performance.now(); + const result = await query(args); + const end = performance.now(); + const time = end - start; + console.log(util.inspect({ model, operation, args, time }, { showHidden: false, depth: null, colors: true })); + return result; + }, + }, + }, +}); diff --git a/server/src/prisma/schema.prisma b/server/src/prisma/schema.prisma new file mode 100644 index 000000000..41f1a6ae7 --- /dev/null +++ b/server/src/prisma/schema.prisma @@ -0,0 +1,463 @@ +generator client { + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions", "relationJoins"] +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") + extensions = [cube, earthdistance, pg_trgm, unaccent, uuid_ossp(map: "uuid-ossp", schema: "public"), vectors(map: "vectors", schema: "vectors")] +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model Activity { + id String @id(map: "PK_24625a1d6b1b089c8ae206fe467") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + albumId String @db.Uuid + userId String @db.Uuid + assetId String? @db.Uuid + comment String? + isLiked Boolean @default(false) + albums Albums @relation(fields: [albumId], references: [id], onDelete: Cascade, map: "FK_1af8519996fbfb3684b58df280b") + users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_3571467bcbe021f66e2bdce96ea") + assets Assets? @relation(fields: [assetId], references: [id], onDelete: Cascade, map: "FK_8091ea76b12338cb4428d33d782") + + @@map(name: "activity") +} + +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model Albums { + id String @id(map: "PK_7f71c7b5bc7c87b8f94c9a93a00") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + ownerId String @db.Uuid + albumName String @default("Untitled Album") @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) + albumThumbnailAssetId String? @db.Uuid + updatedAt DateTime @default(now()) @db.Timestamptz(6) + description String @default("") + deletedAt DateTime? @db.Timestamptz(6) + isActivityEnabled Boolean @default(true) + order String @default("desc") @db.VarChar + activity Activity[] + assets Assets? @relation(fields: [albumThumbnailAssetId], references: [id], map: "FK_05895aa505a670300d4816debce") + users Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_b22c53f35ef20c28c21637c85f4") + albums_assets_assets AlbumsAssetsAssets[] + albums_shared_users_users AlbumsSharedUsersUsers[] + shared_links SharedLinks[] + + @@map(name: "albums") +} + +model AlbumsAssetsAssets { + albumsId String @db.Uuid + assetsId String @db.Uuid + assets Assets @relation(fields: [assetsId], references: [id], onDelete: Cascade, map: "FK_4bd1303d199f4e72ccdf998c621") + albums Albums @relation(fields: [albumsId], references: [id], onDelete: Cascade, map: "FK_e590fa396c6898fcd4a50e40927") + + @@id([albumsId, assetsId], map: "PK_c67bc36fa845fb7b18e0e398180") + @@index([assetsId], map: "IDX_4bd1303d199f4e72ccdf998c62") + @@index([albumsId], map: "IDX_e590fa396c6898fcd4a50e4092") + @@map(name: "albums_assets_assets") +} + +model AlbumsSharedUsersUsers { + albumsId String @db.Uuid + usersId String @db.Uuid + albums Albums @relation(fields: [albumsId], references: [id], onDelete: Cascade, map: "FK_427c350ad49bd3935a50baab737") + users Users @relation(fields: [usersId], references: [id], onDelete: Cascade, map: "FK_f48513bf9bccefd6ff3ad30bd06") + + @@id([albumsId, usersId], map: "PK_7df55657e0b2e8b626330a0ebc8") + @@index([albumsId], map: "IDX_427c350ad49bd3935a50baab73") + @@index([usersId], map: "IDX_f48513bf9bccefd6ff3ad30bd0") + @@map(name: "albums_shared_users_users") +} + +model ApiKeys { + name String @db.VarChar + key String @db.VarChar + userId String @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + id String @id(map: "PK_5c8a79801b44bd27b79228e1dad") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_6c2e267ae764a9413b863a29342") + + @@map(name: "api_keys") +} + +model AssetFaces { + assetId String @db.Uuid + personId String? @db.Uuid + embedding Unsupported("vector") + imageWidth Int @default(0) + imageHeight Int @default(0) + boundingBoxX1 Int @default(0) + boundingBoxY1 Int @default(0) + boundingBoxX2 Int @default(0) + boundingBoxY2 Int @default(0) + id String @id(map: "PK_6df76ab2eb6f5b57b7c2f1fc684") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, map: "FK_02a43fd0b3c50fb6d7f0cb7282c") + person Person? @relation("asset_faces_personIdToperson", fields: [personId], references: [id], map: "FK_95ad7106dd7b484275443f580f9") + person_person_faceAssetIdToasset_faces Person[] @relation("person_faceAssetIdToasset_faces") + + @@index([assetId, personId], map: "IDX_asset_faces_assetId_personId") + @@index([assetId], map: "IDX_asset_faces_on_assetId") + @@index([personId], map: "IDX_asset_faces_personId") + @@index([personId, assetId], map: "IDX_bf339a24070dac7e71304ec530") + @@index([embedding], map: "face_index") + @@map(name: "asset_faces") +} + +model AssetJobStatus { + assetId String @id(map: "PK_420bec36fc02813bddf5c8b73d4") @db.Uuid + facesRecognizedAt DateTime? @db.Timestamptz(6) + metadataExtractedAt DateTime? @db.Timestamptz(6) + assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, map: "FK_420bec36fc02813bddf5c8b73d4") + + @@map(name: "asset_job_status") +} + +model AssetStack { + id String @id(map: "PK_74a27e7fcbd5852463d0af3034b") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + primaryAssetId String @unique(map: "REL_91704e101438fd0653f582426d") @db.Uuid + primaryAsset Assets @relation("asset_stack_primaryAssetIdToassets", fields: [primaryAssetId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_91704e101438fd0653f582426dc") + assets Assets[] @relation("assets_stackIdToasset_stack") + + @@map(name: "asset_stack") +} + +/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. +model Assets { + id String @id(map: "PK_da96729a8b113377cfb6a62439c") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + deviceAssetId String @db.VarChar + ownerId String @db.Uuid + deviceId String @db.VarChar + type String @db.VarChar + originalPath String @db.VarChar + resizePath String? @db.VarChar + fileCreatedAt DateTime @db.Timestamptz(6) + fileModifiedAt DateTime @db.Timestamptz(6) + isFavorite Boolean @default(false) + duration String? @db.VarChar + webpPath String? @default("") @db.VarChar + encodedVideoPath String? @default("") @db.VarChar + checksum Bytes + isVisible Boolean @default(true) + livePhotoVideoId String? @unique(map: "UQ_16294b83fa8c0149719a1f631ef") @db.Uuid + updatedAt DateTime @default(now()) @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + isArchived Boolean @default(false) + originalFileName String @db.VarChar + sidecarPath String? @db.VarChar + isReadOnly Boolean @default(false) + thumbhash Bytes? + isOffline Boolean @default(false) + libraryId String @db.Uuid + isExternal Boolean @default(false) + deletedAt DateTime? @db.Timestamptz(6) + localDateTime DateTime @db.Timestamptz(6) + stackId String? @db.Uuid + truncatedDate DateTime @default(dbgenerated("date_trunc('day', \"localDateTime\" at time zone 'UTC') at time zone 'UTC'")) @db.Timestamptz(6) + activity Activity[] + albums Albums[] + albumsAssetsAssets AlbumsAssetsAssets[] + faces AssetFaces[] + assetJobStatus AssetJobStatus? + assetStackAssetStackPrimaryAssetIdToAssets AssetStack? @relation("asset_stack_primaryAssetIdToassets") + livePhotoVideo Assets? @relation("assetsToassets", fields: [livePhotoVideoId], references: [id], map: "FK_16294b83fa8c0149719a1f631ef") + otherAssets Assets? @relation("assetsToassets") + owner Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_2c5ac0d6fb58b238fd2068de67d") + library Libraries @relation(fields: [libraryId], references: [id], onDelete: Cascade, map: "FK_9977c3c1de01c3d848039a6b90c") + stack AssetStack? @relation("assets_stackIdToasset_stack", fields: [stackId], references: [id], map: "FK_f15d48fa3ea5e4bda05ca8ab207") + exifInfo Exif? + sharedLinks SharedLinkAsset[] + smartInfo SmartInfo? + smartSearch SmartSearch? + tags TagAsset[] + + @@unique([ownerId, libraryId, checksum], map: "UQ_assets_owner_library_checksum") + @@index([originalFileName], map: "IDX_4d66e76dada1ca180f67a205dc") + @@index([checksum], map: "IDX_8d3efe36c0755849395e6ea866") + @@index([id, stackId], map: "IDX_asset_id_stackId") + @@index([originalPath, libraryId], map: "IDX_originalPath_libraryId") + @@index([fileCreatedAt], map: "idx_asset_file_created_at") + @@map(name: "assets") +} + +model Audit { + id Int @id(map: "PK_1d3d120ddaf7bc9b1ed68ed463a") @default(autoincrement()) + entityType String @db.VarChar + entityId String @db.Uuid + action String @db.VarChar + ownerId String @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + + @@index([ownerId, createdAt], map: "IDX_ownerId_createdAt") + @@map(name: "audit") +} + +model Exif { + assetId String @id(map: "PK_c0117fdbc50b917ef9067740c44") @db.Uuid + make String? @db.VarChar + model String? @db.VarChar + exifImageWidth Int? + exifImageHeight Int? + fileSizeInByte BigInt? + orientation String? @db.VarChar + dateTimeOriginal DateTime? @db.Timestamptz(6) + modifyDate DateTime? @db.Timestamptz(6) + lensModel String? @db.VarChar + fNumber Float? + focalLength Float? + iso Int? + latitude Float? + longitude Float? + city String? @db.VarChar + state String? @db.VarChar + country String? @db.VarChar + description String @default("") + fps Float? + exposureTime String? @db.VarChar + livePhotoCID String? @db.VarChar + timeZone String? @db.VarChar + exifTextSearchableColumn Unsupported("tsvector") @default(dbgenerated("to_tsvector('english'::regconfig, (((((((((((((COALESCE(make, ''::character varying))::text || ' '::text) || (COALESCE(model, ''::character varying))::text) || ' '::text) || (COALESCE(orientation, ''::character varying))::text) || ' '::text) || (COALESCE(\"lensModel\", ''::character varying))::text) || ' '::text) || (COALESCE(city, ''::character varying))::text) || ' '::text) || (COALESCE(state, ''::character varying))::text) || ' '::text) || (COALESCE(country, ''::character varying))::text))")) + projectionType String? @db.VarChar + profileDescription String? @db.VarChar + colorspace String? @db.VarChar + bitsPerSample Int? + autoStackId String? @db.VarChar + assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_c0117fdbc50b917ef9067740c44") + + @@index([autoStackId], map: "IDX_auto_stack_id") + @@index([livePhotoCID], map: "IDX_live_photo_cid") + @@index([city], map: "exif_city") + @@map(name: "exif") +} + +/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. +model GeodataPlaces { + id Int @id(map: "PK_c29918988912ef4036f3d7fbff4") + name String @db.VarChar(200) + longitude Float + latitude Float + countryCode String @db.Char(2) + admin1Code String? @db.VarChar(20) + admin2Code String? @db.VarChar(80) + modificationDate DateTime @db.Date + earthCoord Unsupported("cube")? @default(dbgenerated("ll_to_earth(latitude, longitude)")) + admin1Name String? @db.VarChar + admin2Name String? @db.VarChar + alternateNames String? @db.VarChar + + @@index([earthCoord], map: "IDX_geodata_gist_earthcoord", type: Gist) + @@map(name: "geodata_places") +} + +model Libraries { + id String @id(map: "PK_505fedfcad00a09b3734b4223de") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + name String @db.VarChar + ownerId String @db.Uuid + type String @db.VarChar + importPaths String[] + exclusionPatterns String[] + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + deletedAt DateTime? @db.Timestamptz(6) + refreshedAt DateTime? @db.Timestamptz(6) + isVisible Boolean @default(true) + assets Assets[] + owner Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_0f6fc2fb195f24d19b0fb0d57c1") + + @@map(name: "libraries") +} + +model MoveHistory { + id String @id(map: "PK_af608f132233acf123f2949678d") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + entityId String @db.VarChar + pathType String @db.VarChar + oldPath String @db.VarChar + newPath String @unique(map: "UQ_newPath") @db.VarChar + + @@unique([entityId, pathType], map: "UQ_entityId_pathType") + @@map(name: "move_history") +} + +model Partners { + sharedById String @db.Uuid + sharedWithId String @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + inTimeline Boolean @default(false) + sharedBy Users @relation("partners_sharedByIdTousers", fields: [sharedById], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_7e077a8b70b3530138610ff5e04") + sharedWith Users @relation("partners_sharedWithIdTousers", fields: [sharedWithId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_d7e875c6c60e661723dbf372fd3") + + @@id([sharedById, sharedWithId], map: "PK_f1cc8f73d16b367f426261a8736") + @@map(name: "partners") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model Person { + id String @id(map: "PK_5fdaf670315c4b7e70cce85daa3") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + ownerId String @db.Uuid + name String @default("") @db.VarChar + thumbnailPath String @default("") @db.VarChar + isHidden Boolean @default(false) + birthDate DateTime? @db.Date + faceAssetId String? @db.Uuid + asset_faces_asset_faces_personIdToperson AssetFaces[] @relation("asset_faces_personIdToperson") + asset_faces_person_faceAssetIdToasset_faces AssetFaces? @relation("person_faceAssetIdToasset_faces", fields: [faceAssetId], references: [id], onUpdate: NoAction, map: "FK_2bbabe31656b6778c6b87b61023") + users Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_5527cc99f530a547093f9e577b6") + + @@map(name: "person") +} + +model SharedLinkAsset { + assetsId String @db.Uuid + sharedLinksId String @db.Uuid + assets Assets @relation(fields: [assetsId], references: [id], onDelete: Cascade, map: "FK_5b7decce6c8d3db9593d6111a66") + sharedLinks SharedLinks @relation(fields: [sharedLinksId], references: [id], onDelete: Cascade, map: "FK_c9fab4aa97ffd1b034f3d6581ab") + + @@id([assetsId, sharedLinksId], map: "PK_9b4f3687f9b31d1e311336b05e3") + @@index([assetsId], map: "IDX_5b7decce6c8d3db9593d6111a6") + @@index([sharedLinksId], map: "IDX_c9fab4aa97ffd1b034f3d6581a") + @@map(name: "shared_link__asset") +} + +model SharedLinks { + id String @id(map: "PK_642e2b0f619e4876e5f90a43465") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + description String? @db.VarChar + userId String @db.Uuid + key Bytes @unique(map: "UQ_sharedlink_key") + type String @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) + expiresAt DateTime? @db.Timestamptz(6) + allowUpload Boolean @default(false) + albumId String? @db.Uuid + allowDownload Boolean @default(true) + showExif Boolean @default(true) + password String? @db.VarChar + assets SharedLinkAsset[] + albums Albums? @relation(fields: [albumId], references: [id], onDelete: Cascade, map: "FK_0c6ce9058c29f07cdf7014eac66") + users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_66fe3837414c5a9f1c33ca49340") + + @@index([albumId], map: "IDX_sharedlink_albumId") + @@index([key], map: "IDX_sharedlink_key") + @@map(name: "shared_links") +} + +model SmartInfo { + assetId String @id(map: "PK_5e3753aadd956110bf3ec0244ac") @db.Uuid + tags String[] + objects String[] + smartInfoTextSearchableColumn Unsupported("tsvector") @default(dbgenerated("to_tsvector('english'::regconfig, f_concat_ws(' '::text, (COALESCE(tags, ARRAY[]::text[]) || COALESCE(objects, ARRAY[]::text[]))))")) + assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_5e3753aadd956110bf3ec0244ac") + + @@index([tags], map: "si_tags", type: Gin) + @@index([smartInfoTextSearchableColumn], map: "smart_info_text_searchable_idx", type: Gin) + @@map(name: "smart_info") +} + +model SmartSearch { + assetId String @id @db.Uuid + embedding Unsupported("vector") + assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([embedding], map: "clip_index") + @@map(name: "smart_search") +} + +model SocketIoAttachments { + id BigInt @unique @default(autoincrement()) + created_at DateTime? @default(now()) @db.Timestamptz(6) + payload Bytes? + + @@map(name: "socket_io_attachments") +} + +model SystemConfig { + key String @id(map: "PK_aab69295b445016f56731f4d535") @db.VarChar + value String? @db.VarChar + + @@map(name: "system_config") +} + +model SystemMetadata { + key String @id(map: "PK_fa94f6857470fb5b81ec6084465") @db.VarChar + value Json @default("{}") + + @@map(name: "system_metadata") +} + +model TagAsset { + assetsId String @db.Uuid + tagsId String @db.Uuid + tags Tags @relation(fields: [tagsId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_e99f31ea4cdf3a2c35c7287eb42") + assets Assets @relation(fields: [assetsId], references: [id], onDelete: Cascade, map: "FK_f8e8a9e893cb5c54907f1b798e9") + + @@id([assetsId, tagsId], map: "PK_ef5346fe522b5fb3bc96454747e") + @@index([tagsId], map: "IDX_e99f31ea4cdf3a2c35c7287eb4") + @@index([assetsId], map: "IDX_f8e8a9e893cb5c54907f1b798e") + @@index([assetsId, tagsId], map: "IDX_tag_asset_assetsId_tagsId") + @@map(name: "tag_asset") +} + +model Tags { + id String @id(map: "PK_e7dc17249a1148a1970748eda99") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + type String @db.VarChar + name String @db.VarChar + userId String @db.Uuid + renameTagId String? @db.Uuid + tags TagAsset[] + users Users @relation(fields: [userId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_92e67dc508c705dd66c94615576") + + @@unique([name, userId], map: "UQ_tag_name_userId") + @@map(name: "tags") +} + +model UserToken { + id String @id(map: "PK_48cb6b5c20faa63157b3c1baf7f") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + token String @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + userId String @db.Uuid + deviceType String @default("") @db.VarChar + deviceOS String @default("") @db.VarChar + users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_d37db50eecdf9b8ce4eedd2f918") + + @@map(name: "user_token") +} + +model Users { + id String @id(map: "PK_a3ffb1c0c8416b9fc6f907b7433") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + email String @unique(map: "UQ_97672ac88f789774dd47f7c8be3") @db.VarChar + password String @default("") @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) + profileImagePath String @default("") @db.VarChar + isAdmin Boolean @default(false) + shouldChangePassword Boolean @default(true) + deletedAt DateTime? @db.Timestamptz(6) + oauthId String @default("") @db.VarChar + updatedAt DateTime @default(now()) @db.Timestamptz(6) + storageLabel String? @unique(map: "UQ_b309cf34fa58137c416b32cea3a") @db.VarChar + memoriesEnabled Boolean @default(true) + name String @default("") @db.VarChar + avatarColor String? @db.VarChar + quotaSizeInBytes BigInt? + quotaUsageInBytes BigInt @default(0) + status String @default("active") @db.VarChar + activity Activity[] + albums Albums[] + albumsSharedUsersUsers AlbumsSharedUsersUsers[] + apiKeys ApiKeys[] + assets Assets[] + libraries Libraries[] + sharedBy Partners[] @relation("partners_sharedByIdTousers") + sharedWith Partners[] @relation("partners_sharedWithIdTousers") + person Person[] + sharedLinks SharedLinks[] + tags Tags[] + userToken UserToken[] + + @@map(name: "users") +} diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 592839254..a9c2e1230 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,15 +1,13 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { Prisma } from '@prisma/client'; import { DateTime } from 'luxon'; -import path from 'node:path'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetOrder } from 'src/entities/album.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { - AssetBuilderOptions, AssetCreate, AssetExploreFieldOptions, AssetPathEntity, @@ -30,161 +28,127 @@ import { WithoutProperty, } from 'src/interfaces/asset.interface'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; -import { OptionalBetween, searchAssetBuilder } from 'src/utils/database'; +import { searchAssetBuilder } from 'src/utils/database'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; -import { - Brackets, - FindOptionsRelations, - FindOptionsSelect, - FindOptionsWhere, - In, - IsNull, - Not, - Repository, -} from 'typeorm'; - -const truncateMap: Record = { - [TimeBucketSize.DAY]: 'day', - [TimeBucketSize.MONTH]: 'month', -}; - -const dateTrunc = (options: TimeBucketOptions) => - `(date_trunc('${ - truncateMap[options.size] - }', (asset."localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`; +import { Paginated, PaginationMode, PaginationOptions, paginatedBuilder, paginationHelper } from 'src/utils/pagination'; +import { Repository } from 'typeorm'; +import { PrismaRepository } from './prisma.repository'; @Instrumentation() @Injectable() export class AssetRepository implements IAssetRepository { constructor( @InjectRepository(AssetEntity) private repository: Repository, - @InjectRepository(ExifEntity) private exifRepository: Repository, - @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, - @InjectRepository(SmartInfoEntity) private smartInfoRepository: Repository, + @Inject(PrismaRepository) private prismaRepository: PrismaRepository, ) {} - async upsertExif(exif: Partial): Promise { - await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] }); + async upsertExif(exif: Partial & { assetId: string }): Promise { + await this.prismaRepository.exif.upsert({ update: exif, create: exif, where: { assetId: exif.assetId } }); } - async upsertJobStatus(jobStatus: Partial): Promise { - await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] }); + async upsertJobStatus(jobStatus: Partial & { assetId: string }): Promise { + await this.prismaRepository.assetJobStatus.upsert({ + update: jobStatus, + create: jobStatus, + where: { assetId: jobStatus.assetId }, + }); } - create(asset: AssetCreate): Promise { - return this.repository.save(asset); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] }) - getByDate(ownerId: string, date: Date): Promise { - // For reference of a correct approach although slower - - // let builder = this.repository - // .createQueryBuilder('asset') - // .leftJoin('asset.exifInfo', 'exifInfo') - // .where('asset.ownerId = :ownerId', { ownerId }) - // .andWhere( - // `coalesce(date_trunc('day', asset."fileCreatedAt", "exifInfo"."timeZone") at TIME ZONE "exifInfo"."timeZone", date_trunc('day', asset."fileCreatedAt")) IN (:date)`, - // { date }, - // ) - // .andWhere('asset.isVisible = true') - // .andWhere('asset.isArchived = false') - // .orderBy('asset.fileCreatedAt', 'DESC'); - - // return builder.getMany(); - - return this.repository.find({ - where: { - ownerId, - isVisible: true, - isArchived: false, - resizePath: Not(IsNull()), - fileCreatedAt: OptionalBetween(date, DateTime.fromJSDate(date).plus({ day: 1 }).toJSDate()), - }, - relations: { - exifInfo: true, - }, - order: { - fileCreatedAt: 'DESC', + async create(asset: AssetCreate): Promise { + const { ownerId, libraryId, livePhotoVideoId, stackId, ...assetData } = asset; + const res = await this.prismaRepository.assets.create({ + data: { + ...assetData, + livePhotoVideo: livePhotoVideoId ? { connect: { id: livePhotoVideoId } } : undefined, + stack: stackId ? { connect: { id: stackId } } : undefined, + library: { connect: { id: libraryId } }, + owner: { connect: { id: ownerId } }, }, }); + return res as any as AssetEntity; } @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) - getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { - return this.repository - .createQueryBuilder('entity') - .where( - `entity.ownerId IN (:...ownerIds) - AND entity.isVisible = true - AND entity.isArchived = false - AND entity.resizePath IS NOT NULL - AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day - AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`, - { - ownerIds, - day, - month, + async getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { + const date = DateTime.utc().set({ day, month }); + const res = await this.prismaRepository.assets.findMany({ + where: { + ownerId: { in: ownerIds }, + isVisible: true, + isArchived: false, + resizePath: { not: null }, + localDateTime: { + gte: date.startOf('day').toJSDate(), + lte: date.endOf('day').toJSDate(), }, - ) - .leftJoinAndSelect('entity.exifInfo', 'exifInfo') - .orderBy('entity.localDateTime', 'DESC') - .getMany(); - } - - @GenerateSql({ params: [[DummyValue.UUID]] }) - @ChunkedArray() - getByIds( - ids: string[], - relations?: FindOptionsRelations, - select?: FindOptionsSelect, - ): Promise { - return this.repository.find({ - where: { id: In(ids) }, - relations, - select, - withDeleted: true, + }, + include: { + exifInfo: true, + }, + orderBy: { + localDateTime: 'desc', + }, }); + + return res as any as AssetEntity[]; } @GenerateSql({ params: [[DummyValue.UUID]] }) @ChunkedArray() - getByIdsWithAllRelations(ids: string[]): Promise { - return this.repository.find({ - where: { id: In(ids) }, - relations: { + async getByIds(ids: string[], relations?: Prisma.AssetsInclude): Promise { + const res = await this.prismaRepository.assets.findMany({ + where: { id: { in: ids } }, + include: { + ...relations, + library: relations?.library ? { include: { assets: true, owner: true } } : undefined, + }, + }); + return res as any as AssetEntity[]; // typeorm type assumes arbitrary level of recursion + } + + @GenerateSql({ params: [[DummyValue.UUID]] }) + @ChunkedArray() + async getByIdsWithAllRelations(ids: string[]): Promise { + const res = await this.prismaRepository.assets.findMany({ + where: { id: { in: ids } }, + include: { exifInfo: true, smartInfo: true, tags: true, faces: { - person: true, - }, - stack: { - assets: true, + include: { + person: true, + }, }, + stack: { include: { assets: true } }, }, - withDeleted: true, }); + + return res as any as AssetEntity[]; } @GenerateSql({ params: [DummyValue.UUID] }) async deleteAll(ownerId: string): Promise { - await this.repository.delete({ ownerId }); + await this.prismaRepository.assets.deleteMany({ where: { ownerId } }); } - getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated { - return paginate(this.repository, pagination, { + async getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated { + const items = await this.prismaRepository.assets.findMany({ where: { albums: { - id: albumId, + some: { + id: albumId, + }, }, }, - relations: { - albums: true, - exifInfo: true, + orderBy: { + fileCreatedAt: 'desc', }, + skip: pagination.skip, + take: pagination.take + 1, }); + + return paginationHelper(items as any as AssetEntity[], pagination.take); } getByUserId( @@ -196,41 +160,22 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [[DummyValue.UUID]] }) - getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { - return paginate(this.repository, pagination, { + async getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { + const items = await this.prismaRepository.assets.findMany({ + where: { libraryId }, select: { id: true, originalPath: true, isOffline: true }, - where: { library: { id: libraryId } }, + orderBy: { fileCreatedAt: 'desc' }, + skip: pagination.skip, + take: pagination.take + 1, }); + + return paginationHelper(items as any as AssetPathEntity[], pagination.take); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { - return this.repository.findOne({ - where: { library: { id: libraryId }, originalPath: originalPath }, - }); - } - - @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) - @ChunkedArray({ paramIndex: 1 }) - async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise { - const result = await this.repository.query( - ` - WITH paths AS (SELECT unnest($2::text[]) AS path) - SELECT path FROM paths - WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); - `, - [libraryId, originalPaths], - ); - return result.map((row: { path: string }) => row.path); - } - - @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) - @ChunkedArray({ paramIndex: 1 }) - async updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise { - await this.repository.update( - { library: { id: libraryId }, originalPath: Not(In(originalPaths)), isOffline: false }, - { isOffline: true }, - ); + async getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { + const res = await this.prismaRepository.assets.findFirst({ where: { libraryId, originalPath } }); + return res as AssetEntity | null; } getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { @@ -253,74 +198,90 @@ export class AssetRepository implements IAssetRepository { */ @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) async getAllByDeviceId(ownerId: string, deviceId: string): Promise { - const items = await this.repository.find({ - select: { deviceAssetId: true }, + const items = await this.prismaRepository.assets.findMany({ where: { ownerId, deviceId, isVisible: true, }, - withDeleted: true, + select: { + deviceAssetId: true, + }, }); return items.map((asset) => asset.deviceAssetId); } @GenerateSql({ params: [DummyValue.UUID] }) - getById(id: string, relations: FindOptionsRelations): Promise { - return this.repository.findOne({ - where: { id }, - relations, - // We are specifically asking for this asset. Return it even if it is soft deleted - withDeleted: true, - }); + async getById(id: string, relations: Prisma.AssetsInclude): Promise { + const items = await this.prismaRepository.assets.findFirst({ where: { id }, include: relations }); + return items as any as AssetEntity | null; } @GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] }) @Chunked() async updateAll(ids: string[], options: AssetUpdateAllOptions): Promise { - await this.repository.update({ id: In(ids) }, options); + await this.prismaRepository.assets.updateMany({ where: { id: { in: ids } }, data: options }); } @Chunked() async softDeleteAll(ids: string[]): Promise { - await this.repository.softDelete({ id: In(ids), isExternal: false }); + await this.prismaRepository.assets.updateMany({ where: { id: { in: ids } }, data: { deletedAt: new Date() } }); } @Chunked() async restoreAll(ids: string[]): Promise { - await this.repository.restore({ id: In(ids) }); + await this.prismaRepository.assets.updateMany({ where: { id: { in: ids } }, data: { deletedAt: null } }); } - async update(asset: AssetUpdateOptions): Promise { - await this.repository.update(asset.id, asset); + async update(asset: AssetUpdateOptions): Promise { + const { ownerId, libraryId, livePhotoVideoId, stackId, ...assetData } = asset; + + const res = await this.prismaRepository.assets.update({ + data: assetData, + where: { id: asset.id }, + include: { + exifInfo: true, + smartInfo: true, + tags: true, + faces: { + include: { + person: true, + }, + }, + }, + }); + + return res as any as AssetEntity; // typeorm type assumes all relations are included } async remove(asset: AssetEntity): Promise { - await this.repository.remove(asset); + await this.prismaRepository.assets.delete({ where: { id: asset.id } }); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) - getByChecksum(userId: string, checksum: Buffer): Promise { - return this.repository.findOne({ where: { ownerId: userId, checksum } }); + async getByChecksum(userId: string, checksum: Buffer): Promise { + const item = await this.prismaRepository.assets.findFirst({ where: { ownerId: userId, checksum: checksum } }); + return item as AssetEntity | null; } - findLivePhotoMatch(options: LivePhotoSearchOptions): Promise { + async findLivePhotoMatch(options: LivePhotoSearchOptions): Promise { const { ownerId, otherAssetId, livePhotoCID, type } = options; - return this.repository.findOne({ + const item = await this.prismaRepository.assets.findFirst({ where: { - id: Not(otherAssetId), + id: { not: otherAssetId }, ownerId, type, exifInfo: { livePhotoCID, }, }, - relations: { + include: { exifInfo: true, }, }); + return item as AssetEntity | null; } @GenerateSql( @@ -331,68 +292,53 @@ export class AssetRepository implements IAssetRepository { params: [DummyValue.PAGINATION, property], })), ) - getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { - let relations: FindOptionsRelations = {}; - let where: FindOptionsWhere | FindOptionsWhere[] = {}; + async getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { + let relations: Prisma.AssetsInclude = {}; + let where: Prisma.AssetsWhereInput = {}; switch (property) { case WithoutProperty.THUMBNAIL: { - where = [ - { resizePath: IsNull(), isVisible: true }, - { resizePath: '', isVisible: true }, - { webpPath: IsNull(), isVisible: true }, - { webpPath: '', isVisible: true }, - { thumbhash: IsNull(), isVisible: true }, - ]; + where = { + OR: [ + { resizePath: null, isVisible: true }, + { resizePath: '', isVisible: true }, + { webpPath: null, isVisible: true }, + { webpPath: '', isVisible: true }, + { thumbhash: null, isVisible: true }, + ], + }; break; } case WithoutProperty.ENCODED_VIDEO: { - where = [ - { type: AssetType.VIDEO, encodedVideoPath: IsNull() }, - { type: AssetType.VIDEO, encodedVideoPath: '' }, - ]; + where = { + OR: [ + { type: AssetType.VIDEO, encodedVideoPath: null }, + { type: AssetType.VIDEO, encodedVideoPath: '' }, + ], + }; break; } case WithoutProperty.EXIF: { relations = { exifInfo: true, - jobStatus: true, + assetJobStatus: true, }; where = { isVisible: true, - jobStatus: { - metadataExtractedAt: IsNull(), + assetJobStatus: { + metadataExtractedAt: null, }, }; break; } case WithoutProperty.SMART_SEARCH: { - relations = { - smartSearch: true, - }; where = { isVisible: true, - resizePath: Not(IsNull()), - smartSearch: { - embedding: IsNull(), - }, - }; - break; - } - - case WithoutProperty.OBJECT_TAGS: { - relations = { - smartInfo: true, - }; - where = { - resizePath: Not(IsNull()), - isVisible: true, - smartInfo: { - tags: IsNull(), - }, + resizePath: { not: null }, + smartSearch: null, }; break; } @@ -400,17 +346,18 @@ export class AssetRepository implements IAssetRepository { case WithoutProperty.FACES: { relations = { faces: true, - jobStatus: true, + assetJobStatus: true, }; where = { - resizePath: Not(IsNull()), + resizePath: { not: null }, isVisible: true, faces: { - assetId: IsNull(), - personId: IsNull(), + some: { + person: null, + }, }, - jobStatus: { - facesRecognizedAt: IsNull(), + assetJobStatus: { + facesRecognizedAt: null, }, }; break; @@ -421,21 +368,24 @@ export class AssetRepository implements IAssetRepository { faces: true, }; where = { - resizePath: Not(IsNull()), + resizePath: { not: null }, isVisible: true, faces: { - assetId: Not(IsNull()), - personId: IsNull(), + some: { + person: null, + }, }, }; break; } case WithoutProperty.SIDECAR: { - where = [ - { sidecarPath: IsNull(), isVisible: true }, - { sidecarPath: '', isVisible: true }, - ]; + where = { + OR: [ + { sidecarPath: null, isVisible: true }, + { sidecarPath: '', isVisible: true }, + ], + }; break; } @@ -444,29 +394,33 @@ export class AssetRepository implements IAssetRepository { } } - return paginate(this.repository, pagination, { - relations, + const items = await this.prismaRepository.assets.findMany({ where, - order: { + orderBy: { // Ensures correct order when paginating - createdAt: 'ASC', + createdAt: 'asc', }, + skip: pagination.skip, + take: pagination.take + 1, + include: relations, }); + + return paginationHelper(items as any as AssetEntity[], pagination.take); } - getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated { - let where: FindOptionsWhere | FindOptionsWhere[] = {}; + async getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated { + let where: Prisma.AssetsWhereInput = {}; switch (property) { case WithProperty.SIDECAR: { - where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; + where = { sidecarPath: { not: null }, isVisible: true }; break; } case WithProperty.IS_OFFLINE: { if (!libraryId) { throw new Error('Library id is required when finding offline assets'); } - where = [{ isOffline: true, libraryId: libraryId }]; + where = { isOffline: true, libraryId: libraryId }; break; } @@ -475,59 +429,84 @@ export class AssetRepository implements IAssetRepository { } } - return paginate(this.repository, pagination, { + const items = await this.prismaRepository.assets.findMany({ where, - order: { + orderBy: { // Ensures correct order when paginating - createdAt: 'ASC', + createdAt: 'asc', + }, + skip: pagination.skip, + take: pagination.take + 1, + }); + + return paginationHelper(items as any as AssetEntity[], pagination.take); + } + + async getFirstAssetForAlbumId(albumId: string): Promise { + const items = await this.prismaRepository.assets.findFirst({ + where: { + albums: { + some: { + id: albumId, + }, + }, + }, + orderBy: { + fileCreatedAt: 'desc', }, }); + + return items as AssetEntity | null; } - getFirstAssetForAlbumId(albumId: string): Promise { - return this.repository.findOne({ - where: { albums: { id: albumId } }, - order: { fileCreatedAt: 'DESC' }, + async getLastUpdatedAssetForAlbumId(albumId: string): Promise { + const items = await this.prismaRepository.assets.findFirst({ + where: { + albums: { + some: { + id: albumId, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, }); - } - getLastUpdatedAssetForAlbumId(albumId: string): Promise { - return this.repository.findOne({ - where: { albums: { id: albumId } }, - order: { updatedAt: 'DESC' }, - }); + return items as AssetEntity | null; } async getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; - const assets = await this.repository.find({ + const assets = await this.prismaRepository.assets.findMany({ select: { id: true, exifInfo: { - city: true, - state: true, - country: true, - latitude: true, - longitude: true, + select: { + city: true, + state: true, + country: true, + latitude: true, + longitude: true, + }, }, }, where: { - ownerId: In([...ownerIds]), + ownerId: { + in: ownerIds, + }, isVisible: true, isArchived, exifInfo: { - latitude: Not(IsNull()), - longitude: Not(IsNull()), + latitude: { not: null }, + longitude: { not: null }, }, isFavorite, - fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore), + fileCreatedAt: { gte: fileCreatedAfter, lte: fileCreatedBefore }, }, - relations: { - exifInfo: true, - }, - order: { - fileCreatedAt: 'DESC', + orderBy: { + fileCreatedAt: 'desc', }, }); @@ -541,29 +520,20 @@ export class AssetRepository implements IAssetRepository { })); } - async getStatistics(ownerId: string, options: AssetStatsOptions): Promise { - let builder = this.repository - .createQueryBuilder('asset') - .select(`COUNT(asset.id)`, 'count') - .addSelect(`asset.type`, 'type') - .where('"ownerId" = :ownerId', { ownerId }) - .andWhere('asset.isVisible = true') - .groupBy('asset.type'); - - const { isArchived, isFavorite, isTrashed } = options; - if (isArchived !== undefined) { - builder = builder.andWhere(`asset.isArchived = :isArchived`, { isArchived }); - } - - if (isFavorite !== undefined) { - builder = builder.andWhere(`asset.isFavorite = :isFavorite`, { isFavorite }); - } - - if (isTrashed !== undefined) { - builder = builder.withDeleted().andWhere(`asset.deletedAt is not null`); - } - - const items = await builder.getRawMany(); + async getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise { + const items = await this.prismaRepository.assets.groupBy({ + by: 'type', + where: { + ownerId, + isVisible: true, + isArchived, + isFavorite, + deletedAt: isTrashed ? { not: null } : null, + }, + _count: { + id: true, + }, + }); const result: AssetStats = { [AssetType.AUDIO]: 0, @@ -573,46 +543,81 @@ export class AssetRepository implements IAssetRepository { }; for (const item of items) { - result[item.type as AssetType] = Number(item.count) || 0; + result[item.type as AssetType] = item._count.id; } return result; } - getRandom(ownerId: string, count: number): Promise { - // can't use queryBuilder because of custom OFFSET clause - return this.repository.query( - `SELECT * - FROM assets - WHERE "ownerId" = $1 - OFFSET FLOOR(RANDOM() * (SELECT GREATEST(COUNT(*) - $2, 0) FROM ASSETS WHERE "ownerId" = $1)) LIMIT $2`, - [ownerId, count], - ); + async getRandom(ownerId: string, take: number): Promise { + const where = { + ownerId, + isVisible: true, + }; + + const count = await this.prismaRepository.assets.count({ where }); + const skip = Math.floor(Math.random() * Math.max(count - take, 0)); + const items = await this.prismaRepository.assets.findMany({ where, take, skip }); + + return items as any as AssetEntity[]; } @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) - getTimeBuckets(options: TimeBucketOptions): Promise { - const truncated = dateTrunc(options); - return this.getBuilder(options) - .select(`COUNT(asset.id)::int`, 'count') - .addSelect(truncated, 'timeBucket') - .groupBy(truncated) - .orderBy(truncated, options.order === AssetOrder.ASC ? 'ASC' : 'DESC') - .getRawMany(); + async getTimeBuckets(options: TimeBucketOptions): Promise { + const items = await this.prismaRepository.assets.groupBy({ + by: 'truncatedDate', + where: { + ownerId: { + in: options.userIds, + }, + isVisible: true, + isArchived: options.isArchived, + isFavorite: options.isFavorite, + deletedAt: options.isTrashed ? { not: null } : null, + albums: options.albumId ? { some: { id: options.albumId } } : undefined, + faces: options.personId ? { some: { personId: options.personId } } : undefined, + type: options.assetType, + }, + _count: { + id: true, + }, + orderBy: { + truncatedDate: 'desc', + }, + }); + + return items.map((item) => ({ + timeBucket: item.truncatedDate.toISOString(), + count: item._count.id, + })); } @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH }] }) - getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { - const truncated = dateTrunc(options); - return ( - this.getBuilder(options) - .andWhere(`${truncated} = :timeBucket`, { timeBucket: timeBucket.replace(/^[+-]/, '') }) - // First sort by the day in localtime (put it in the right bucket) - .orderBy(truncated, 'DESC') - // and then sort by the actual time - .addOrderBy('asset.fileCreatedAt', options.order === AssetOrder.ASC ? 'ASC' : 'DESC') - .getMany() - ); + async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { + const items = await this.prismaRepository.assets.findMany({ + where: { + ownerId: { + in: options.userIds, + }, + isVisible: true, + isArchived: options.isArchived, + isFavorite: options.isFavorite, + deletedAt: options.isTrashed ? { not: null } : null, + truncatedDate: timeBucket.replace(/^[+-]/, ''), + albums: options.albumId ? { some: { id: options.albumId } } : undefined, + faces: options.personId ? { some: { personId: options.personId } } : undefined, + type: options.assetType, + }, + orderBy: { fileCreatedAt: options.order === AssetOrder.ASC ? 'asc' : 'desc' }, + include: { + owner: true, + exifInfo: options.exifInfo, + stack: options.withStacked ? { include: { assets: true } } : undefined, + }, + relationLoadStrategy: 'query', // this seems faster than 'join' in this case + }); + + return items as any as AssetEntity[]; } @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) @@ -620,28 +625,44 @@ export class AssetRepository implements IAssetRepository { ownerId: string, { minAssetsPerField, maxFields }: AssetExploreFieldOptions, ): Promise> { - const cte = this.exifRepository - .createQueryBuilder('e') - .select('city') - .groupBy('city') - .having('count(city) >= :minAssetsPerField', { minAssetsPerField }); + const res = await this.prismaRepository.exif.groupBy({ + by: 'city', + where: { + assets: { ownerId, isVisible: true, isArchived: false, type: AssetType.IMAGE }, + city: { not: null }, + }, + having: { + assetId: { + _count: { + gte: minAssetsPerField, + }, + }, + }, + take: maxFields, + orderBy: { + city: 'desc', + }, + }); - const items = await this.getBuilder({ - userIds: [ownerId], - exifInfo: false, - assetType: AssetType.IMAGE, - isArchived: false, - }) - .select('c.city', 'value') - .addSelect('asset.id', 'data') - .distinctOn(['c.city']) - .innerJoin('exif', 'e', 'asset.id = e."assetId"') - .addCommonTableExpression(cte, 'cities') - .innerJoin('cities', 'c', 'c.city = e.city') - .limit(maxFields) - .getRawMany(); + const cities = res.map((item) => item.city!); - return { fieldName: 'exifInfo.city', items }; + const items = await this.prismaRepository.exif.findMany({ + where: { + city: { + in: cities, + }, + }, + select: { + city: true, + assetId: true, + }, + distinct: ['city'], + }); + + return { + fieldName: 'exifInfo.city', + items: items.map((item) => ({ value: item.city!, data: item.assetId })), + }; } @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) @@ -649,85 +670,42 @@ export class AssetRepository implements IAssetRepository { ownerId: string, { minAssetsPerField, maxFields }: AssetExploreFieldOptions, ): Promise> { - const cte = this.smartInfoRepository - .createQueryBuilder('si') - .select('unnest(tags)', 'tag') - .groupBy('tag') - .having('count(*) >= :minAssetsPerField', { minAssetsPerField }); + const res = await this.prismaRepository.smartInfo.groupBy({ + by: 'tags', + where: { + assets: { ownerId, isVisible: true, isArchived: false, type: AssetType.IMAGE }, + }, + having: { + assetId: { + _count: { + gte: minAssetsPerField, + }, + }, + }, + take: maxFields, + orderBy: { + tags: 'desc', + }, + }); - const items = await this.getBuilder({ - userIds: [ownerId], - exifInfo: false, - assetType: AssetType.IMAGE, - isArchived: false, - }) - .select('unnest(si.tags)', 'value') - .addSelect('asset.id', 'data') - .distinctOn(['unnest(si.tags)']) - .innerJoin('smart_info', 'si', 'asset.id = si."assetId"') - .addCommonTableExpression(cte, 'random_tags') - .innerJoin('random_tags', 't', 'si.tags @> ARRAY[t.tag]') - .limit(maxFields) - .getRawMany(); + const tags = res.flatMap((item) => item.tags!); - return { fieldName: 'smartInfo.tags', items }; - } + const items = await this.prismaRepository.smartInfo.findMany({ + where: { + tags: { + hasSome: tags, + }, + }, + select: { + tags: true, + assetId: true, + }, + }); - private getBuilder(options: AssetBuilderOptions) { - const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked, exifInfo, assetType } = options; - - let builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true'); - if (assetType !== undefined) { - builder = builder.andWhere('asset.type = :assetType', { assetType }); - } - - let stackJoined = false; - - if (exifInfo !== false) { - stackJoined = true; - builder = builder - .leftJoinAndSelect('asset.exifInfo', 'exifInfo') - .leftJoinAndSelect('asset.stack', 'stack') - .leftJoinAndSelect('stack.assets', 'stackedAssets'); - } - - if (albumId) { - builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId }); - } - - if (userIds) { - builder = builder.andWhere('asset.ownerId IN (:...userIds )', { userIds }); - } - - if (isArchived !== undefined) { - builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived }); - } - - if (isFavorite !== undefined) { - builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite }); - } - - if (isTrashed !== undefined) { - builder = builder.andWhere(`asset.deletedAt ${isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); - } - - if (personId !== undefined) { - builder = builder - .innerJoin('asset.faces', 'faces') - .innerJoin('faces.person', 'person') - .andWhere('person.id = :personId', { personId }); - } - - if (withStacked) { - if (!stackJoined) { - builder = builder.leftJoinAndSelect('asset.stack', 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets'); - } - builder = builder.andWhere( - new Brackets((qb) => qb.where('stack.primaryAssetId = asset.id').orWhere('asset.stackId IS NULL')), - ); - } - - return builder; + return { + fieldName: 'smartInfo.tags', + items: items.map((item) => ({ value: item.tags![0], data: item.assetId })), + }; } @GenerateSql({ params: [DummyValue.STRING, [DummyValue.UUID], { numResults: 250 }] }) @@ -736,85 +714,80 @@ export class AssetRepository implements IAssetRepository { userIds: string[], { numResults }: MetadataSearchOptions, ): Promise { - const rows = await this.getBuilder({ - userIds: userIds, - exifInfo: false, - isArchived: false, - }) - .select('asset.*') - .addSelect('e.*') - .addSelect('COALESCE(si.tags, array[]::text[])', 'tags') - .addSelect('COALESCE(si.objects, array[]::text[])', 'objects') - .innerJoin('exif', 'e', 'asset."id" = e."assetId"') - .leftJoin('smart_info', 'si', 'si."assetId" = asset."id"') - .andWhere( - new Brackets((qb) => { - qb.where( - `(e."exifTextSearchableColumn" || COALESCE(si."smartInfoTextSearchableColumn", to_tsvector('english', ''))) - @@ PLAINTO_TSQUERY('english', :query)`, - { query }, - ).orWhere('asset."originalFileName" = :path', { path: path.parse(query).name }); - }), - ) - .addOrderBy('asset.fileCreatedAt', 'DESC') - .limit(numResults) - .getRawMany(); + const items = await this.prismaRepository.assets.findMany({ + where: { + ownerId: { + in: userIds, + }, + isVisible: true, + isArchived: false, + OR: [ + { + originalFileName: { + contains: query, + }, + }, + { + exifInfo: { + city: { + contains: query, + }, + }, + }, + { + exifInfo: { + description: { + contains: query, + }, + }, + }, + { + exifInfo: { + lensModel: { + contains: query, + }, + }, + }, + { + exifInfo: { + make: { + contains: query, + }, + }, + }, + { + exifInfo: { + model: { + contains: query, + }, + }, + }, + { + exifInfo: { + state: { + contains: query, + }, + }, + }, + { + exifInfo: { + country: { + contains: query, + }, + }, + }, + ], + }, + orderBy: { + fileCreatedAt: 'desc', + }, + take: numResults, + include: { + exifInfo: true, + smartInfo: true, + }, + }); - return rows.map( - ({ - tags, - objects, - country, - state, - city, - description, - model, - make, - dateTimeOriginal, - exifImageHeight, - exifImageWidth, - exposureTime, - fNumber, - fileSizeInByte, - focalLength, - iso, - latitude, - lensModel, - longitude, - modifyDate, - projectionType, - timeZone, - ...assetInfo - }) => - ({ - exifInfo: { - city, - country, - dateTimeOriginal, - description, - exifImageHeight, - exifImageWidth, - exposureTime, - fNumber, - fileSizeInByte, - focalLength, - iso, - latitude, - lensModel, - longitude, - make, - model, - modifyDate, - projectionType, - state, - timeZone, - }, - smartInfo: { - tags, - objects, - }, - ...assetInfo, - }) as AssetEntity, - ); + return items as any as AssetEntity[]; } } diff --git a/server/src/repositories/prisma.repository.ts b/server/src/repositories/prisma.repository.ts new file mode 100644 index 000000000..e362abd70 --- /dev/null +++ b/server/src/repositories/prisma.repository.ts @@ -0,0 +1,20 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { findNonDeletedExtension } from '../prisma/find-non-deleted'; +import { metricsExtension } from '../prisma/metrics'; + +@Injectable() +export class PrismaRepository extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + super(); + return this.$extends(metricsExtension).$extends(findNonDeletedExtension) as this; + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/server/src/services/asset-v1.service.ts b/server/src/services/asset-v1.service.ts index a24ddbd69..3a0437629 100644 --- a/server/src/services/asset-v1.service.ts +++ b/server/src/services/asset-v1.service.ts @@ -341,7 +341,7 @@ export class AssetServiceV1 { isArchived: dto.isArchived ?? false, duration: dto.duration || null, isVisible: dto.isVisible ?? true, - livePhotoVideo: livePhotoAssetId === null ? null : ({ id: livePhotoAssetId } as AssetEntity), + livePhotoVideoId: livePhotoAssetId, originalFileName: file.originalName, sidecarPath: sidecarPath || null, isReadOnly: dto.isReadOnly ?? false, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 7020d5061..a8890a95d 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -6,11 +6,19 @@ import sanitize from 'sanitize-filename'; import { AccessCore, Permission } from 'src/cores/access.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, MemoryLaneResponseDto, SanitizedAssetResponseDto, mapAsset, +} from 'src/dtos/asset-response.dto'; + AssetResponseDto, + MemoryLaneResponseDto, + SanitizedAssetResponseDto, + mapAsset, } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, @@ -20,6 +28,7 @@ import { AssetStatsDto, UpdateAssetDto, UploadFieldName, + UploadFieldName, mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -280,11 +289,15 @@ export class AssetService { smartInfo: true, owner: true, faces: { - person: true, + include: { person: true }, }, stack: { - assets: { - exifInfo: true, + include: { + assets: { + include: { + exifInfo: true, + }, + }, }, }, }); @@ -316,16 +329,7 @@ export class AssetService { const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto; await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude }); - await this.assetRepository.update({ id, ...rest }); - const asset = await this.assetRepository.getById(id, { - exifInfo: true, - owner: true, - smartInfo: true, - tags: true, - faces: { - person: true, - }, - }); + const asset = await this.assetRepository.update({ id, ...rest }); if (!asset) { throw new BadRequestException('Asset not found'); } @@ -351,14 +355,16 @@ export class AssetService { } else if (options.stackParentId) { //Creating new stack if parent doesn't have one already. If it does, then we add to the existing stack await this.access.requirePermission(auth, Permission.ASSET_UPDATE, options.stackParentId); - const primaryAsset = await this.assetRepository.getById(options.stackParentId, { stack: { assets: true } }); + const primaryAsset = await this.assetRepository.getById(options.stackParentId, { + stack: { include: { assets: true } }, + }); if (!primaryAsset) { throw new BadRequestException('Asset not found for given stackParentId'); } let stack = primaryAsset.stack; ids.push(options.stackParentId); - const assets = await this.assetRepository.getByIds(ids, { stack: { assets: true } }); + const assets = await this.assetRepository.getByIds(ids, { stack: { include: { assets: true } } }); stackIdsToCheckForDelete.push( ...new Set(assets.filter((a) => !!a.stackId && stack?.id !== a.stackId).map((a) => a.stackId!)), ); @@ -422,10 +428,10 @@ export class AssetService { const asset = await this.assetRepository.getById(id, { faces: { - person: true, + include: { person: true }, }, library: true, - stack: { assets: true }, + stack: { include: { assets: true } }, exifInfo: true, }); @@ -494,11 +500,11 @@ export class AssetService { const childIds: string[] = []; const oldParent = await this.assetRepository.getById(oldParentId, { faces: { - person: true, + include: { person: true }, }, library: true, stack: { - assets: true, + include: { assets: true }, }, }); if (!oldParent?.stackId) { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 504716a55..88b8ea601 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -308,13 +308,7 @@ export class PersonService { return JobStatus.SKIPPED; } - const relations = { - exifInfo: true, - faces: { - person: false, - }, - }; - const [asset] = await this.assetRepository.getByIds([id], relations); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, faces: true }); if (!asset || !asset.resizePath || asset.faces?.length > 0) { return JobStatus.FAILED; } diff --git a/server/src/utils/pagination.ts b/server/src/utils/pagination.ts index dec1a9de0..6ca276764 100644 --- a/server/src/utils/pagination.ts +++ b/server/src/utils/pagination.ts @@ -37,7 +37,10 @@ export async function* usePagination( } } -function paginationHelper(items: Entity[], take: number): PaginationResult { +export function paginationHelper( + items: Entity[], + take: number, +): PaginationResult { const hasNextPage = items.length > take; items.splice(take); diff --git a/server/src/utils/sql.ts b/server/src/utils/sql.ts index 1afe4d5a8..c126c54f3 100644 --- a/server/src/utils/sql.ts +++ b/server/src/utils/sql.ts @@ -19,6 +19,7 @@ import { LibraryRepository } from 'src/repositories/library.repository'; import { MoveRepository } from 'src/repositories/move.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PrismaRepository } from 'src/repositories/prisma.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { SystemConfigRepository } from 'src/repositories/system-config.repository'; @@ -62,6 +63,7 @@ const repositories = [ MoveRepository, PartnerRepository, PersonRepository, + PrismaRepository, SharedLinkRepository, SearchRepository, SystemConfigRepository,