1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 06:31:58 +00:00
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 64aac239f0
Author: Alex <alex.tran1502@gmail.com>
Date:   Thu Mar 21 18:00:22 2024 -0500

    chore: consolidate readme files (#8171)

commit d6823b128c
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 508f32c08a
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 <alex.tran1502@gmail.com>

commit 8ed6ed4d2b
Author: Ethan Margaillan <ethan.margaillan@gmail.com>
Date:   Thu Mar 21 19:39:33 2024 +0100

    feat(web): rework context menus: add icons and reorder items (#8090)

commit 1abb0bdae8
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 <alex.tran1502@gmail.com>

commit 5ef6215546
Author: martyfuhry <martyfuhry@gmail.com>
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 <alex.tran1502@gmail.com>

commit 95fb9c4365
Author: waclaw66 <waclaw66@seznam.cz>
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 fa0a5107c2
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 dc3c329431
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Thu Mar 21 09:36:10 2024 -0500

    chore: remove unused type (#8157)

commit 2a9f2b4515
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Thu Mar 21 09:08:29 2024 -0500

    refactor: app modules, main.ts (#8156)

commit 793049388b
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 382b63954c
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Thu Mar 21 08:07:47 2024 -0500

    refactor: asset v1, app.utils (#8152)

commit 87ccba7f9d
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 e21c96c0ef
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 4de0b2f44e
Author: Ethan Margaillan <ethan.margaillan@gmail.com>
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 b588a87d4a
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 44ed1f0919
Author: Alex <alex.tran1502@gmail.com>
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 16d0df796c
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Wed Mar 20 22:15:09 2024 -0500

    refactor: infra folder (#8138)

commit 9fd5d2ad9c
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 28ad004b01
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 ef4a492cb1
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 6d9e7694b1
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 0c13c63bb6
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Wed Mar 20 16:46:59 2024 -0500

    refactor: infra/domain module (#8130)

commit 907eb869bc
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Wed Mar 20 16:22:47 2024 -0500

    chore: move apps and test utils (#8129)

commit c1402eee8e
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Wed Mar 20 16:02:51 2024 -0500

    chore: migrate database files (#8126)

commit 84f7ca855a
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 2dcce03352
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 96a22ec3c1
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 4b29bccc7c
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 40e079a247
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Wed Mar 20 15:15:01 2024 -0500

    chore: move controllers and middleware (#8119)

commit 81f0265095
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Wed Mar 20 15:04:03 2024 -0500

    chore: organize config, validation, decorators (#8118)

    * refactor: validation

    * refactor: utilities

    * refactor: config

commit 92cc647cf6
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Wed Mar 20 14:50:01 2024 -0500

    chore: renovate grouping (#8113)

commit 048d437b0b
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 ec9a6bca14
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 bd5952b943
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 3f0d54c752
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 dab4595a4e
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 6d9ca82b19
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 <jrasm91@gmail.com>

commit 373a03e819
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 d97b0259fa
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 2267ca1949
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 29be53e70d
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 851fe4a49f
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 30f499cf2e
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 591a641d8d
Author: Alex <alex.tran1502@gmail.com>
Date:   Wed Mar 20 10:00:35 2024 -0500

    chore: post release tasks

commit 5b314ffd46
Author: Alex The Bot <alex.tran1502@gmail.com>
Date:   Wed Mar 20 14:50:57 2024 +0000

    Version v1.99.0

commit 0b078c9f99
Author: Alex <alex.tran1502@gmail.com>
Date:   Wed Mar 20 09:46:31 2024 -0500

    fix(web): Share button visible when viewing album has only shared link (#8100)

commit 0d5584ecbb
Author: Alex <alex.tran1502@gmail.com>
Date:   Wed Mar 20 09:28:19 2024 -0500

    fix(web): shift-select again (#8098)

commit 5e090646ba
Author: waclaw66 <waclaw66@seznam.cz>
Date:   Wed Mar 20 16:26:09 2024 +0200

    fix(mobile): missing "Add name" translation (#8087)

    fix(mobile): missing "Add name" translation, positioning

commit c4e910dd3d
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 <git@bo0tzz.me>

    * Update docs/docs/features/monitoring.md

    Co-authored-by: bo0tzz <git@bo0tzz.me>

    ---------

    Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
    Co-authored-by: bo0tzz <git@bo0tzz.me>

commit 5a2394af7c
Author: Alex <alex.tran1502@gmail.com>
Date:   Wed Mar 20 09:16:20 2024 -0500

    fix(web): shift-select (#8093)

    * fix(web): shift-select

    * remove unused code

    * proper fix

commit 48e32269f4
Author: Alex <alex.tran1502@gmail.com>
Date:   Wed Mar 20 09:16:00 2024 -0500

    chore: add prometheus.yml to release artifact (#8096)

commit dd9d90d21e
Author: Zack Pollard <zackpollard@ymail.com>
Date:   Wed Mar 20 06:31:52 2024 -0600

    test: temporarily disable flaky audit e2e test until #7436 is fixed (#8089)

commit 0544c687b9
Author: Ethan Margaillan <ethan.margaillan@gmail.com>
Date:   Wed Mar 20 13:29:30 2024 +0100

    fix(web): missing margin on people page (#8081)

commit e810aae212
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 9c6a26de9f
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 e6f2bb9f89
Author: Jonathan Jogenfors <jonathan@jogenfors.se>
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 <alex.tran1502@gmail.com>

commit f908bd4a64
Author: Ethan Margaillan <ethan.margaillan@gmail.com>
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 7395b03b1f
Author: Thariq Shanavas <thariqshanavas@gmail.com>
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 63b4fc6f65
Author: Alex <alex.tran1502@gmail.com>
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 f392fe7702
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 2daed747cd
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 9e4bab7494
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 <alex.tran1502@gmail.com>

commit 9274c0701b
Author: waclaw66 <waclaw66@seznam.cz>
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 0bc773fd00
Author: Alex <alex.tran1502@gmail.com>
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 c6d2408517
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 033f83a55a
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 51841d627c
Author: Alex <alex.tran1502@gmail.com>
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 50924f0b3d
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 <mail@ddietzler.dev>
    Co-authored-by: Marty Fuhry <martyfuhry@gmail.com>

commit 4aae1da841
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 1a2554548a
Author: bo0tzz <git@bo0tzz.me>
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 40262c30cb
Author: Jason Rasmussen <jrasm91@gmail.com>
Date:   Mon Mar 18 15:59:53 2024 -0500

    refactor(server): library service (#8050)

    * refactor: library service

    * chore: open api

    * fix: checks

commit 761e7fdd2d
Author: Alex <alex.tran1502@gmail.com>
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 cd8a124b25
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 148428a564
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 <jrasm91@gmail.com>

commit 14da671bf9
Author: Tyler Brockett <tylerbrockett@users.noreply.github.com>
Date:   Sun Mar 17 11:41:55 2024 -0700

    fix(docs): add microservices to IMMICH_CONFIG_FILE env var documentation (#8017)

commit e8f0f82db0
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 b8278404a0
Author: Alex <alex.tran1502@gmail.com>
Date:   Sun Mar 17 10:46:42 2024 -0500

    chore(docs): update readme (#8021)

commit 45671b0b8b
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

search, kysely extension

set max

use class provider directly

fix

remove deprecated endpoint

add truncated date migration

fix get by date query

fix typing

fix typing

move with* helpers

refactor timeline service

fix facial recognition

fix test

we have `withDeleted` at home

rebase
This commit is contained in:
mertalev 2024-03-17 15:30:52 -04:00
parent e54c18367b
commit fbc695b46a
No known key found for this signature in database
GPG key ID: 9181CD92C0A1C5E3
25 changed files with 4397 additions and 759 deletions

View file

@ -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" \
IMMICH_ENV=development \
NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all
ENTRYPOINT ["tini", "--", "/bin/sh"]
FROM dev AS prod
RUN npm run build

2705
server/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -32,7 +32,8 @@
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run",
"sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js",
"email:dev": "email dev -p 3050 --dir src/emails"
"email:dev": "email dev -p 3050 --dir src/emails",
"prisma:generate": "prisma generate --schema=./src/prisma/schema.prisma"
},
"dependencies": {
"@nestjs/bullmq": "^10.0.1",
@ -51,6 +52,7 @@
"@opentelemetry/exporter-prometheus": "^0.52.0",
"@opentelemetry/sdk-node": "^0.52.0",
"@react-email/components": "^0.0.19",
"@prisma/client": "^5.11.0",
"@socket.io/postgres-adapter": "^0.3.1",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
@ -69,6 +71,7 @@
"ioredis": "^5.3.2",
"joi": "^17.10.0",
"js-yaml": "^4.1.0",
"kysely": "^0.27.3",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
"mnemonist": "^0.39.8",
@ -79,6 +82,7 @@
"openid-client": "^5.4.3",
"pg": "^8.11.3",
"picomatch": "^4.0.0",
"prisma-extension-kysely": "^2.1.0",
"react-email": "^2.1.2",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
@ -122,6 +126,8 @@
"mock-fs": "^5.2.0",
"prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^3.2.3",
"prisma": "^5.11.0",
"prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1",
"source-map-support": "^0.5.21",
"sql-formatter": "^15.0.0",

View file

@ -1,10 +1,10 @@
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';
import { ExifEntity } from 'src/entities/exif.entity';
import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface';
import { Paginated, PaginationOptions } from 'src/utils/pagination';
import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm';
export type AssetStats = Record<AssetType, number>;
@ -66,22 +66,6 @@ export interface TimeBucketItem {
count: number;
}
export type AssetCreate = Pick<
AssetEntity,
| 'deviceAssetId'
| 'ownerId'
| 'libraryId'
| 'deviceId'
| 'type'
| 'originalPath'
| 'fileCreatedAt'
| 'localDateTime'
| 'fileModifiedAt'
| 'checksum'
| 'originalFileName'
> &
Partial<AssetEntity>;
export type AssetWithoutRelations = Omit<
AssetEntity,
| 'livePhotoVideo'
@ -97,10 +81,25 @@ export type AssetWithoutRelations = Omit<
| 'tags'
>;
type AssetUpdateWithoutRelations = Pick<AssetWithoutRelations, 'id'> & Partial<AssetWithoutRelations>;
type AssetUpdateWithLivePhotoRelation = Pick<AssetWithoutRelations, 'id'> & Pick<AssetEntity, 'livePhotoVideo'>;
export type AssetCreate = Pick<
AssetEntity,
| 'deviceAssetId'
| 'ownerId'
| 'libraryId'
| 'deviceId'
| 'type'
| 'originalPath'
| 'fileCreatedAt'
| 'localDateTime'
| 'fileModifiedAt'
| 'checksum'
| 'originalFileName'
> &
Partial<AssetWithoutRelations>;
export type AssetUpdateOptions = AssetUpdateWithoutRelations | AssetUpdateWithLivePhotoRelation;
type AssetUpdateWithoutRelations = Pick<AssetEntity, 'id'> & Partial<AssetWithoutRelations>;
export type AssetUpdateOptions = AssetUpdateWithoutRelations;
export type AssetUpdateAllOptions = Omit<Partial<AssetWithoutRelations>, 'id'>;
@ -139,30 +138,28 @@ export interface AssetUpdateDuplicateOptions {
duplicateIds: string[];
}
export interface AssetGetByChecksumOptions {
ownerId: string;
checksum: Buffer;
libraryId?: string;
}
export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
create(asset: AssetCreate): Promise<AssetEntity>;
getByIds(
ids: string[],
relations?: FindOptionsRelations<AssetEntity>,
select?: FindOptionsSelect<AssetEntity>,
): Promise<AssetEntity[]>;
getByIds(ids: string[], relations?: Prisma.AssetsInclude): Promise<AssetEntity[]>;
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
getByChecksum(options: { ownerId: string; checksum: Buffer; libraryId?: string }): Promise<AssetEntity | null>;
getByChecksum(options: AssetGetByChecksumOptions): Promise<AssetEntity | null>;
getByChecksums(userId: string, checksums: Buffer[]): Promise<AssetEntity[]>;
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<string[]>;
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
getById(
id: string,
relations?: FindOptionsRelations<AssetEntity>,
order?: FindOptionsOrder<AssetEntity>,
): Promise<AssetEntity | null>;
getById(id: string, relations?: Prisma.AssetsInclude): Promise<AssetEntity | null>;
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated<AssetEntity>;
getRandom(userId: string, count: number): Promise<AssetEntity[]>;
@ -176,7 +173,7 @@ export interface IAssetRepository {
getLivePhotoCount(motionId: string): Promise<number>;
updateAll(ids: string[], options: Partial<AssetUpdateAllOptions>): Promise<void>;
updateDuplicates(options: AssetUpdateDuplicateOptions): Promise<void>;
update(asset: AssetUpdateOptions): Promise<void>;
update(asset: AssetUpdateOptions): Promise<AssetEntity>;
remove(asset: AssetEntity): Promise<void>;
softDeleteAll(ids: string[]): Promise<void>;
restoreAll(ids: string[]): Promise<void>;
@ -188,7 +185,7 @@ export interface IAssetRepository {
upsertJobStatus(...jobStatus: Partial<AssetJobStatusEntity>[]): Promise<void>;
getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
getDuplicates(options: AssetBuilderOptions): Promise<AssetEntity[]>;
getDuplicates(userId: string): Promise<AssetEntity[]>;
getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]>;
getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]>;
}

View file

@ -147,13 +147,13 @@ export type SmartSearchOptions = SearchDateOptions &
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
hasPerson?: boolean;
numResults: number;
maxDistance?: number;
maxDistance: number;
}
export interface AssetDuplicateSearch {
assetId: string;
embedding: number[];
maxDistance?: number;
maxDistance: number;
type: AssetType;
userIds: string[];
}

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTruncatedDate1713654632360 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN "truncatedDate" timestamptz
GENERATED ALWAYS AS (date_trunc('day', "localDateTime" at time zone 'UTC') at time zone 'UTC') STORED`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE assets DROP COLUMN "truncatedDate"`);
}
}

View file

@ -0,0 +1,32 @@
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.OR?.some(({ deletedAt }: any) => deletedAt !== undefined) &&
!args.where.AND?.some(({ deletedAt }: any) => 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,
},
});

View file

@ -0,0 +1,294 @@
import type { ColumnType } from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export type Activity = {
id: Generated<string>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
albumId: string;
userId: string;
assetId: string | null;
comment: string | null;
isLiked: Generated<boolean>;
};
export type Albums = {
id: Generated<string>;
ownerId: string;
albumName: Generated<string>;
createdAt: Generated<Timestamp>;
albumThumbnailAssetId: string | null;
updatedAt: Generated<Timestamp>;
description: Generated<string>;
deletedAt: Timestamp | null;
isActivityEnabled: Generated<boolean>;
order: Generated<string>;
};
export type AlbumsAssetsAssets = {
albumsId: string;
assetsId: string;
};
export type AlbumsSharedUsersUsers = {
albumsId: string;
usersId: string;
};
export type ApiKeys = {
name: string;
key: string;
userId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
id: Generated<string>;
};
export type AssetFaces = {
assetId: string;
personId: string | null;
imageWidth: Generated<number>;
imageHeight: Generated<number>;
boundingBoxX1: Generated<number>;
boundingBoxY1: Generated<number>;
boundingBoxX2: Generated<number>;
boundingBoxY2: Generated<number>;
id: Generated<string>;
};
export type AssetJobStatus = {
assetId: string;
facesRecognizedAt: Timestamp | null;
metadataExtractedAt: Timestamp | null;
duplicatesDetectedAt: Timestamp | null;
};
export type Assets = {
id: Generated<string>;
deviceAssetId: string;
ownerId: string;
deviceId: string;
type: string;
originalPath: string;
previewPath: string | null;
fileCreatedAt: Timestamp;
fileModifiedAt: Timestamp;
isFavorite: Generated<boolean>;
duration: string | null;
thumbnailPath: Generated<string | null>;
encodedVideoPath: Generated<string | null>;
checksum: Buffer;
isVisible: Generated<boolean>;
livePhotoVideoId: string | null;
updatedAt: Generated<Timestamp>;
createdAt: Generated<Timestamp>;
isArchived: Generated<boolean>;
originalFileName: string;
sidecarPath: string | null;
isReadOnly: Generated<boolean>;
thumbhash: Buffer | null;
isOffline: Generated<boolean>;
libraryId: string | null;
isExternal: Generated<boolean>;
deletedAt: Timestamp | null;
localDateTime: Timestamp;
stackId: string | null;
duplicateId: string | null;
truncatedDate: Generated<Timestamp>;
};
export type AssetStack = {
id: Generated<string>;
primaryAssetId: string;
};
export type Audit = {
id: Generated<number>;
entityType: string;
entityId: string;
action: string;
ownerId: string;
createdAt: Generated<Timestamp>;
};
export type Exif = {
assetId: string;
make: string | null;
model: string | null;
exifImageWidth: number | null;
exifImageHeight: number | null;
fileSizeInByte: string | null;
orientation: string | null;
dateTimeOriginal: Timestamp | null;
modifyDate: Timestamp | null;
lensModel: string | null;
fNumber: number | null;
focalLength: number | null;
iso: number | null;
latitude: number | null;
longitude: number | null;
city: string | null;
state: string | null;
country: string | null;
description: Generated<string>;
fps: number | null;
exposureTime: string | null;
livePhotoCID: string | null;
timeZone: string | null;
projectionType: string | null;
profileDescription: string | null;
colorspace: string | null;
bitsPerSample: number | null;
autoStackId: string | null;
};
export type GeodataPlaces = {
id: number;
name: string;
longitude: number;
latitude: number;
countryCode: string;
admin1Code: string | null;
admin2Code: string | null;
modificationDate: Timestamp;
admin1Name: string | null;
admin2Name: string | null;
alternateNames: string | null;
};
export type Libraries = {
id: Generated<string>;
name: string;
ownerId: string;
type: string;
importPaths: string[];
exclusionPatterns: string[];
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
refreshedAt: Timestamp | null;
isVisible: Generated<boolean>;
};
export type MoveHistory = {
id: Generated<string>;
entityId: string;
pathType: string;
oldPath: string;
newPath: string;
};
export type Partners = {
sharedById: string;
sharedWithId: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
inTimeline: Generated<boolean>;
};
export type Person = {
id: Generated<string>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
ownerId: string;
name: Generated<string>;
thumbnailPath: Generated<string>;
isHidden: Generated<boolean>;
birthDate: Timestamp | null;
faceAssetId: string | null;
};
export type SharedLinkAsset = {
assetsId: string;
sharedLinksId: string;
};
export type SharedLinks = {
id: Generated<string>;
description: string | null;
userId: string;
key: Buffer;
type: string;
createdAt: Generated<Timestamp>;
expiresAt: Timestamp | null;
allowUpload: Generated<boolean>;
albumId: string | null;
allowDownload: Generated<boolean>;
showExif: Generated<boolean>;
password: string | null;
};
export type SmartInfo = {
assetId: string;
tags: string[];
objects: string[];
};
export type SmartSearch = {
assetId: string;
};
export type SocketIoAttachments = {
id: Generated<string>;
created_at: Generated<Timestamp | null>;
payload: Buffer | null;
};
export type SystemConfig = {
key: string;
value: string | null;
};
export type SystemMetadata = {
key: string;
value: Generated<unknown>;
};
export type TagAsset = {
assetsId: string;
tagsId: string;
};
export type Tags = {
id: Generated<string>;
type: string;
name: string;
userId: string;
renameTagId: string | null;
};
export type Users = {
id: Generated<string>;
email: string;
password: Generated<string>;
createdAt: Generated<Timestamp>;
profileImagePath: Generated<string>;
isAdmin: Generated<boolean>;
shouldChangePassword: Generated<boolean>;
deletedAt: Timestamp | null;
oauthId: Generated<string>;
updatedAt: Generated<Timestamp>;
storageLabel: string | null;
memoriesEnabled: Generated<boolean>;
name: Generated<string>;
avatarColor: string | null;
quotaSizeInBytes: string | null;
quotaUsageInBytes: Generated<string>;
status: Generated<string>;
};
export type UserToken = {
id: Generated<string>;
token: string;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
userId: string;
deviceType: Generated<string>;
deviceOS: Generated<string>;
};
export type DB = {
activity: Activity;
albums: Albums;
albums_assets_assets: AlbumsAssetsAssets;
albums_shared_users_users: AlbumsSharedUsersUsers;
api_keys: ApiKeys;
asset_faces: AssetFaces;
asset_job_status: AssetJobStatus;
asset_stack: AssetStack;
assets: Assets;
audit: Audit;
exif: Exif;
geodata_places: GeodataPlaces;
libraries: Libraries;
move_history: MoveHistory;
partners: Partners;
person: Person;
shared_link__asset: SharedLinkAsset;
shared_links: SharedLinks;
smart_info: SmartInfo;
smart_search: SmartSearch;
socket_io_attachments: SocketIoAttachments;
system_config: SystemConfig;
system_metadata: SystemMetadata;
tag_asset: TagAsset;
tags: Tags;
user_token: UserToken;
users: Users;
};

View file

@ -0,0 +1,16 @@
import { DeduplicateJoinsPlugin, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely';
import kyselyExt from 'prisma-extension-kysely';
import type { DB } from 'src/prisma/generated/types';
export const kyselyExtension = kyselyExt({
kysely: (driver) =>
new Kysely<DB>({
dialect: {
createDriver: () => driver,
createAdapter: () => new PostgresAdapter(),
createIntrospector: (db) => new PostgresIntrospector(db),
createQueryCompiler: () => new PostgresQueryCompiler(),
},
plugins: [new DeduplicateJoinsPlugin()],
}),
});

View file

@ -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;
},
},
},
});

View file

@ -0,0 +1,469 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions", "relationJoins"]
}
generator kysely {
provider = "prisma-kysely"
}
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)
duplicatesDetectedAt 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
previewPath String? @db.VarChar
fileCreatedAt DateTime @db.Timestamptz(6)
fileModifiedAt DateTime @db.Timestamptz(6)
isFavorite Boolean @default(false)
duration String? @db.VarChar
thumbnailPath 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
duplicateId 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")
}

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,7 @@ import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.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 { ServerInfoRepository } from 'src/repositories/server-info.repository';
import { SessionRepository } from 'src/repositories/session.repository';
@ -96,4 +97,5 @@ export const repositories = [
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: PrismaRepository, useClass: PrismaRepository },
];

View file

@ -0,0 +1,32 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { findNonDeletedExtension } from 'src/prisma/find-non-deleted';
import { kyselyExtension } from 'src/prisma/kysely';
import { metricsExtension } from 'src/prisma/metrics';
function extendClient(base: PrismaClient) {
return base.$extends(metricsExtension).$extends(findNonDeletedExtension).$extends(kyselyExtension);
}
class UntypedExtendedClient extends PrismaClient {
constructor(options?: ConstructorParameters<typeof PrismaClient>[0]) {
super(options);
return extendClient(this) as this;
}
}
const ExtendedPrismaClient = UntypedExtendedClient as unknown as new (
options?: ConstructorParameters<typeof PrismaClient>[0],
) => ReturnType<typeof extendClient>;
@Injectable()
export class PrismaRepository extends ExtendedPrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View file

@ -1,17 +1,14 @@
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { getVectorExtension } from 'src/database.config';
import { Kysely, OrderByDirectionExpression, sql } from 'kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { DatabaseExtension } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import {
AssetDuplicateResult,
AssetDuplicateSearch,
AssetSearchBuilderOptions,
AssetSearchOptions,
FaceEmbeddingSearch,
FaceSearchResult,
@ -19,40 +16,22 @@ import {
SearchPaginationOptions,
SmartSearchOptions,
} from 'src/interfaces/search.interface';
import { asVector, searchAssetBuilder } from 'src/utils/database';
import { DB } from 'src/prisma/generated/types';
import { PrismaRepository } from 'src/repositories/prisma.repository';
import { asVector, withExif, withFaces, withPeople, withSmartInfo } from 'src/utils/database';
import { Instrumentation } from 'src/utils/instrumentation';
import { getCLIPModelInfo } from 'src/utils/misc';
import { Paginated, PaginationMode, PaginationResult, paginatedBuilder } from 'src/utils/pagination';
import { Paginated } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation';
import { Repository, SelectQueryBuilder } from 'typeorm';
@Instrumentation()
@Injectable()
export class SearchRepository implements ISearchRepository {
private faceColumns: string[];
private assetsByCityQuery: string;
constructor(
@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
@InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository<GeodataPlacesEntity>,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
private prismaRepository: PrismaRepository,
) {
this.logger.setContext(SearchRepository.name);
this.faceColumns = this.assetFaceRepository.manager.connection
.getMetadata(AssetFaceEntity)
.ownColumns.map((column) => column.propertyName)
.filter((propertyName) => propertyName !== 'embedding');
this.assetsByCityQuery =
assetsByCityCte +
this.assetRepository
.createQueryBuilder('asset')
.innerJoinAndSelect('asset.exifInfo', 'exif')
.withDeleted()
.getQuery() +
' INNER JOIN cte ON asset.id = cte."assetId" ORDER BY exif.city';
}
async init(modelName: string): Promise<void> {
@ -80,23 +59,16 @@ export class SearchRepository implements ISearchRepository {
],
})
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
let builder = this.assetRepository.createQueryBuilder('asset');
builder = searchAssetBuilder(builder, options);
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirectionExpression;
const builder = this.searchAssetBuilder(options)
.orderBy('assets.fileCreatedAt', orderDirection)
.limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size);
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
return paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.SKIP_TAKE,
skip: (pagination.page - 1) * pagination.size,
take: pagination.size,
});
}
private createPersonFilter(builder: SelectQueryBuilder<AssetFaceEntity>, personIds: string[]) {
return builder
.select(`${builder.alias}."assetId"`)
.where(`${builder.alias}."personId" IN (:...personIds)`, { personIds })
.groupBy(`${builder.alias}."assetId"`)
.having(`COUNT(DISTINCT ${builder.alias}."personId") = :personCount`, { personCount: personIds.length });
const items = (await builder.execute()) as any as AssetEntity[];
const hasNextPage = items.length > pagination.size;
items.splice(pagination.size);
return { items, hasNextPage };
}
@GenerateSql({
@ -112,39 +84,26 @@ export class SearchRepository implements ISearchRepository {
},
],
})
async searchSmart(
pagination: SearchPaginationOptions,
{ embedding, userIds, personIds, ...options }: SmartSearchOptions,
): Paginated<AssetEntity> {
let results: PaginationResult<AssetEntity> = { items: [], hasNextPage: false };
async searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity> {
if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) {
throw new Error(`Invalid value for 'size': ${pagination.size}`);
}
await this.assetRepository.manager.transaction(async (manager) => {
let builder = manager.createQueryBuilder(AssetEntity, 'asset');
let items: AssetEntity[] = [];
await this.prismaRepository.$transaction(async (tx) => {
await tx.$queryRawUnsafe(`SET LOCAL vectors.hnsw_ef_search = ${pagination.size + 1}`);
const builder = this.searchAssetBuilder(options, tx.$kysely)
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
.orderBy(sql`smart_search.embedding <=> ${asVector(options.embedding)}::vector`)
.limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size);
if (personIds?.length) {
const assetFaceBuilder = manager.createQueryBuilder(AssetFaceEntity, 'asset_face');
const cte = this.createPersonFilter(assetFaceBuilder, personIds);
builder
.addCommonTableExpression(cte, 'asset_face_ids')
.innerJoin('asset_face_ids', 'a', 'a."assetId" = asset.id');
}
builder = searchAssetBuilder(builder, options);
builder
.innerJoin('asset.smartSearch', 'search')
.andWhere('asset.ownerId IN (:...userIds )')
.orderBy('search.embedding <=> :embedding')
.setParameters({ userIds, embedding: asVector(embedding) });
await manager.query(this.getRuntimeConfig(pagination.size));
results = await paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.LIMIT_OFFSET,
skip: (pagination.page - 1) * pagination.size,
take: pagination.size,
});
items = (await builder.execute()) as any as AssetEntity[];
});
return results;
const hasNextPage = items.length > pagination.size;
items.splice(pagination.size);
return { items, hasNextPage };
}
@GenerateSql({
@ -163,31 +122,28 @@ export class SearchRepository implements ISearchRepository {
type,
userIds,
}: AssetDuplicateSearch): Promise<AssetDuplicateResult[]> {
const cte = this.assetRepository.createQueryBuilder('asset');
cte
.select('search.assetId', 'assetId')
.addSelect('asset.duplicateId', 'duplicateId')
.addSelect(`search.embedding <=> :embedding`, 'distance')
.innerJoin('asset.smartSearch', 'search')
.where('asset.ownerId IN (:...userIds )')
.andWhere('asset.id != :assetId')
.andWhere('asset.isVisible = :isVisible')
.andWhere('asset.type = :type')
.orderBy('search.embedding <=> :embedding')
.limit(64)
.setParameters({ assetId, embedding: asVector(embedding), isVisible: true, type, userIds });
const builder = this.assetRepository.manager
.createQueryBuilder()
.addCommonTableExpression(cte, 'cte')
.from('cte', 'res')
.select('res.*');
if (maxDistance) {
builder.where('res.distance <= :maxDistance', { maxDistance });
}
return builder.getRawMany() as Promise<AssetDuplicateResult[]>;
const vector = asVector(embedding);
return this.prismaRepository.$kysely
.with('cte', (qb) =>
qb
.selectFrom('assets')
.select([
'assets.id as assetId',
'assets.duplicateId',
sql<number>`smart_search.embedding <=> ${vector}::vector`.as('distance'),
])
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
.where('assets.ownerId', '=', sql<string>`ANY(ARRAY[${userIds}]::uuid[])`)
.where('assets.isVisible', '=', true)
.where('assets.type', '=', type)
.where('assets.id', '!=', assetId)
.orderBy(sql`smart_search.embedding <=> ${vector}::vector`)
.limit(64),
)
.selectFrom('cte')
.selectAll()
.where('cte.distance', '<=', maxDistance as number)
.execute();
}
@GenerateSql({
@ -200,104 +156,107 @@ export class SearchRepository implements ISearchRepository {
},
],
})
async searchFaces({
searchFaces({
userIds,
embedding,
numResults,
maxDistance,
hasPerson,
}: FaceEmbeddingSearch): Promise<FaceSearchResult[]> {
if (!isValidInteger(numResults, { min: 1 })) {
if (!isValidInteger(numResults, { min: 1, max: 1000 })) {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
// setting this too low messes with prefilter recall
numResults = Math.max(numResults, 64);
let results: Array<AssetFaceEntity & { distance: number }> = [];
await this.assetRepository.manager.transaction(async (manager) => {
const cte = manager
.createQueryBuilder(AssetFaceEntity, 'faces')
.select('search.embedding <=> :embedding', 'distance')
.innerJoin('faces.asset', 'asset')
.innerJoin('faces.faceSearch', 'search')
.where('asset.ownerId IN (:...userIds )')
.orderBy('search.embedding <=> :embedding')
.setParameters({ userIds, embedding: asVector(embedding) });
cte.limit(numResults);
if (hasPerson) {
cte.andWhere('faces."personId" IS NOT NULL');
}
for (const col of this.faceColumns) {
cte.addSelect(`faces.${col}`, col);
}
await manager.query(this.getRuntimeConfig(numResults));
results = await manager
.createQueryBuilder()
.select('res.*')
.addCommonTableExpression(cte, 'cte')
.from('cte', 'res')
.where('res.distance <= :maxDistance', { maxDistance })
.orderBy('res.distance')
.getRawMany();
const vector = asVector(embedding);
return this.prismaRepository.$transaction(async (tx) => {
await tx.$queryRawUnsafe(`SET LOCAL vectors.hnsw_ef_search = ${numResults}`);
return tx.$kysely
.with('cte', (qb) =>
qb
.selectFrom('asset_faces')
.select([
(eb) => eb.fn.toJson(sql`asset_faces.*`).as('face'),
sql<number>`asset_faces.embedding <=> ${vector}::vector`.as('distance'),
])
.innerJoin('assets', 'assets.id', 'asset_faces.assetId')
.where('assets.ownerId', '=', sql<string>`ANY(ARRAY[${userIds}]::uuid[])`)
.$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
.orderBy(sql`asset_faces.embedding <=> ${vector}::vector`)
.limit(numResults),
)
.selectFrom('cte')
.selectAll()
.where('cte.distance', '<=', maxDistance)
.execute() as any as Array<{ face: AssetFaceEntity; distance: number }>;
});
return results.map((row) => ({
face: this.assetFaceRepository.create(row),
distance: row.distance,
}));
}
@GenerateSql({ params: [DummyValue.STRING] })
async searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
return await this.geodataPlacesRepository
.createQueryBuilder('geoplaces')
.where(`f_unaccent(name) %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("admin2Name") %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("admin1Name") %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
const contains = '%>>' as any as 'ilike';
return this.prismaRepository.$kysely
.selectFrom('geodata_places')
.selectAll()
.where((eb) =>
eb.or([
eb(eb.fn('f_unaccent', ['name']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
eb(eb.fn('f_unaccent', ['admin2Name']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
eb(eb.fn('f_unaccent', ['admin1Name']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
eb(eb.fn('f_unaccent', ['alternateNames']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
]),
)
.orderBy(
`
COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0.1) +
COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0.1) +
COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0.1) +
COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0.1)
sql`
COALESCE(f_unaccent(name) <->>> f_unaccent(${placeName}), 0.1) +
COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(${placeName}), 0.1) +
COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(${placeName}), 0.1) +
COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(${placeName}), 0.1)
`,
)
.setParameters({ placeName })
.limit(20)
.getMany();
.execute() as Promise<GeodataPlacesEntity[]>;
}
@GenerateSql({ params: [[DummyValue.UUID]] })
async getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
const parameters = [userIds, true, false, AssetType.IMAGE];
const rawRes = await this.repository.query(this.assetsByCityQuery, parameters);
getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
// the performance difference between this and the normal way is too huge to ignore, e.g. 3s vs 4ms
return this.prismaRepository.$queryRaw`WITH RECURSIVE cte AS (
(
SELECT city, "assetId"
FROM exif
INNER JOIN assets ON exif."assetId" = assets.id
WHERE "ownerId" = ANY(ARRAY[${userIds}]::uuid[]) AND "isVisible" = true AND "isArchived" = false AND type = 'IMAGE'
ORDER BY city
LIMIT 1
)
const items: AssetEntity[] = [];
for (const res of rawRes) {
const item = { exifInfo: {} as Record<string, any> } as Record<string, any>;
for (const [key, value] of Object.entries(res)) {
if (key.startsWith('exif_')) {
item.exifInfo[key.replace('exif_', '')] = value;
} else {
item[key.replace('asset_', '')] = value;
}
}
items.push(item as AssetEntity);
}
UNION ALL
return items;
SELECT l.city, l."assetId"
FROM cte c
, LATERAL (
SELECT city, "assetId"
FROM exif
INNER JOIN assets ON exif."assetId" = assets.id
WHERE city > c.city AND "ownerId" = ANY(ARRAY[${userIds}]::uuid[]) AND "isVisible" = true AND "isArchived" = false AND type = 'IMAGE'
ORDER BY city
LIMIT 1
) l
)
select "assets".*, json_strip_nulls(to_json(exif.*)) as "exifInfo"
from "assets"
inner join "exif" on "assets"."id" = "exif"."assetId"
inner join "cte" on "assets"."id" = "cte"."assetId"`;
}
async upsert(assetId: string, embedding: number[]): Promise<void> {
await this.smartSearchRepository.upsert(
{ assetId, embedding: () => asVector(embedding, true) },
{ conflictPaths: ['assetId'] },
);
await this.prismaRepository.$kysely
.insertInto('smart_search')
.values({ assetId, embedding: asVector(embedding, true) } as any)
.onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding: asVector(embedding, true) } as any))
.execute();
}
private async updateDimSize(dimSize: number): Promise<void> {
@ -312,28 +271,28 @@ export class SearchRepository implements ISearchRepository {
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
await this.smartSearchRepository.manager.transaction(async (manager) => {
await manager.clear(SmartSearchEntity);
await manager.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`);
await manager.query(`REINDEX INDEX clip_index`);
await this.prismaRepository.$transaction(async (tx) => {
await tx.$queryRawUnsafe(`TRUNCATE smart_search`);
await tx.$queryRawUnsafe(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`);
await tx.$queryRawUnsafe(`REINDEX INDEX clip_index`);
});
this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`);
}
deleteAllSearchEmbeddings(): Promise<void> {
return this.smartSearchRepository.clear();
return this.prismaRepository.$queryRawUnsafe(`TRUNCATE smart_search`);
}
private async getDimSize(): Promise<number> {
const res = await this.smartSearchRepository.manager.query(`
const res = await this.prismaRepository.$queryRaw<[{ dimsize: number }]>`
SELECT atttypmod as dimsize
FROM pg_attribute f
JOIN pg_class c ON c.oid = f.attrelid
WHERE c.relkind = 'r'::char
AND f.attnum > 0
AND c.relname = 'smart_search'
AND f.attname = 'embedding'`);
AND f.attname = 'embedding'`;
const dimSize = res[0]['dimsize'];
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
@ -342,43 +301,90 @@ export class SearchRepository implements ISearchRepository {
return dimSize;
}
private getRuntimeConfig(numResults?: number): string {
if (getVectorExtension() === DatabaseExtension.VECTOR) {
return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall
}
let runtimeConfig = 'SET LOCAL vectors.enable_prefilter=on; SET LOCAL vectors.search_mode=vbase;';
if (numResults) {
runtimeConfig += ` SET LOCAL vectors.hnsw_ef_search = ${numResults};`;
}
return runtimeConfig;
private searchAssetBuilder(options: AssetSearchBuilderOptions, kysely: Kysely<DB> = this.prismaRepository.$kysely) {
options.isArchived ??= options.withArchived ? undefined : false;
options.withDeleted ??= !!(options.trashedAfter || options.trashedBefore);
const query = kysely
.selectFrom('assets')
.selectAll('assets')
.$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore as Date))
.$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter as Date))
.$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore as Date))
.$if(!!options.updatedAfter, (qb) => qb.where('assets.updatedAt', '>=', options.updatedAfter as Date))
.$if(!!options.trashedBefore, (qb) => qb.where('assets.deletedAt', '<=', options.trashedBefore as Date))
.$if(!!options.trashedAfter, (qb) => qb.where('assets.deletedAt', '>=', options.trashedAfter as Date))
.$if(!!options.takenBefore, (qb) => qb.where('assets.fileCreatedAt', '<=', options.takenBefore as Date))
.$if(!!options.takenAfter, (qb) => qb.where('assets.fileCreatedAt', '>=', options.takenAfter as Date))
.$if(!!options.city, (qb) =>
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.city', '=', options.city as string),
)
.$if(!!options.country, (qb) =>
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.country', '=', options.country as string),
)
.$if(!!options.lensModel, (qb) =>
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.lensModel', '=', options.lensModel as string),
)
.$if(!!options.make, (qb) =>
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.make', '=', options.make as string),
)
.$if(!!options.model, (qb) =>
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.model', '=', options.model as string),
)
.$if(!!options.state, (qb) =>
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.state', '=', options.state as string),
)
.$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum as Buffer))
.$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId as string))
.$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId as string))
.$if(!!options.id, (qb) => qb.where('assets.id', '=', options.id as string))
.$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', options.libraryId as string))
.$if(!!options.userIds, (qb) =>
qb.where('assets.ownerId', '=', sql<string>`ANY(ARRAY[${options.userIds}]::uuid[])`),
)
.$if(!!options.encodedVideoPath, (qb) =>
qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath as string),
)
.$if(!!options.originalPath, (qb) => qb.where('assets.originalPath', '=', options.originalPath as string))
.$if(!!options.previewPath, (qb) => qb.where('assets.previewPath', '=', options.previewPath as string))
.$if(!!options.thumbnailPath, (qb) => qb.where('assets.thumbnailPath', '=', options.thumbnailPath as string))
.$if(!!options.originalFileName, (qb) =>
qb.where(sql`f_unaccent(assets.originalFileName)`, 'ilike', sql`f_unaccent(${options.originalFileName})`),
)
.$if(!!options.isFavorite, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite as boolean))
.$if(!!options.isOffline, (qb) => qb.where('assets.isOffline', '=', options.isOffline as boolean))
.$if(!!options.isVisible, (qb) => qb.where('assets.isVisible', '=', options.isVisible as boolean))
.$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type as AssetType))
.$if(!!options.isArchived, (qb) => qb.where('assets.isArchived', '=', options.isArchived as boolean))
.$if(!!options.isEncoded, (qb) => qb.where('assets.encodedVideoPath', 'is not', null))
.$if(!!options.isMotion, (qb) => qb.where('assets.livePhotoVideoId', 'is not', null))
.$if(!!options.isNotInAlbum, (qb) =>
qb
.leftJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
.where('albums_assets_assets.assetsId', 'is', null),
)
.$if(!!options.withExif, (qb) => qb.select((eb) => withExif(eb)))
.$if(!!options.withSmartInfo, (qb) => qb.select((eb) => withSmartInfo(eb)))
.$if(!(!options.withFaces || options.withPeople), (qb) =>
qb.select((eb) => withFaces(eb)).$if(!!options.withPeople, (qb) => qb.select((eb) => withPeople(eb) as any)),
)
.$if(!!options.personIds && options.personIds.length > 0, (qb) =>
qb.innerJoin(
(eb: any) =>
eb
.selectFrom('asset_faces')
.select('asset_faces.assetId')
.where('asset_faces.personId', '=', sql`ANY(ARRAY[${options.personIds}]::uuid[])`)
.groupBy('asset_faces.assetId')
.having(
(eb: any) => eb.fn.count('asset_faces.personId').distinct(),
'=',
(options.personIds as string[]).length,
)
.as('personAssetIds'),
(join) => join.onRef('personAssetIds.assetId' as any, '=', 'assets.id' as any),
),
)
.$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
return query;
}
}
// the performance difference between this and the normal way is too huge to ignore, e.g. 3s vs 4ms
const assetsByCityCte = `
WITH RECURSIVE cte AS (
(
SELECT city, "assetId"
FROM exif
INNER JOIN assets ON exif."assetId" = assets.id
WHERE "ownerId" = ANY($1::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
ORDER BY city
LIMIT 1
)
UNION ALL
SELECT l.city, l."assetId"
FROM cte c
, LATERAL (
SELECT city, "assetId"
FROM exif
INNER JOIN assets ON exif."assetId" = assets.id
WHERE city > c.city AND "ownerId" = ANY($1::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
ORDER BY city
LIMIT 1
) l
)
`;

View file

@ -371,7 +371,7 @@ export class AssetMediaService {
localDateTime: dto.fileCreatedAt,
duration: dto.duration || null,
livePhotoVideo: null,
livePhotoVideoId: null,
sidecarPath: sidecarPath || null,
});

View file

@ -218,14 +218,20 @@ describe(AssetService.name, () => {
it('should update the asset', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
assetMock.getById.mockResolvedValue(assetStub.image);
assetMock.update.mockResolvedValue(assetStub.image);
await sut.update(authStub.admin, 'asset-1', { isFavorite: true });
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true });
});
it('should update the exif description', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
assetMock.getById.mockResolvedValue(assetStub.image);
assetMock.update.mockResolvedValue(assetStub.image);
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
});
});

View file

@ -110,29 +110,26 @@ export class AssetService {
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await this.access.requirePermission(auth, Permission.ASSET_READ, id);
const asset = await this.assetRepository.getById(
id,
{
exifInfo: true,
tags: true,
sharedLinks: true,
smartInfo: true,
owner: true,
faces: {
person: true,
},
stack: {
const asset = await this.assetRepository.getById(id, {
exifInfo: true,
tags: true,
sharedLinks: true,
smartInfo: true,
owner: true,
faces: {
include: { person: true },
orderBy: { boundingBoxX1: 'asc' },
},
stack: {
include: {
assets: {
exifInfo: true,
include: {
exifInfo: true,
},
},
},
},
{
faces: {
boundingBoxX1: 'ASC',
},
},
);
});
if (!asset) {
throw new BadRequestException('Asset not found');
@ -161,16 +158,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');
}
@ -196,14 +184,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!)),
);
@ -273,10 +263,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,
});
@ -351,11 +341,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) {

View file

@ -37,7 +37,7 @@ export class DuplicateService {
}
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] });
const res = await this.assetRepository.getDuplicates(auth.user.id);
return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth })));
}

View file

@ -319,13 +319,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.previewPath || asset.faces?.length > 0) {
return JobStatus.FAILED;
}

View file

@ -4,6 +4,7 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { TimelineService } from 'src/services/timeline.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { partnerStub } from 'test/fixtures/partner.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
@ -52,6 +53,7 @@ describe(TimelineService.name, () => {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
albumId: 'album-id',
userIds: [authStub.admin.user.id],
});
});
@ -66,12 +68,15 @@ describe(TimelineService.name, () => {
userId: authStub.admin.user.id,
}),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
userIds: [authStub.admin.user.id],
});
expect(assetMock.getTimeBucket).toHaveBeenCalledWith(
'bucket',
expect.objectContaining({
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
userIds: [authStub.admin.user.id],
}),
);
});
it('should return the assets for a library time bucket if user has library.read', async () => {
@ -84,11 +89,14 @@ describe(TimelineService.name, () => {
userId: authStub.admin.user.id,
}),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
userIds: [authStub.admin.user.id],
});
expect(assetMock.getTimeBucket).toHaveBeenCalledWith(
'bucket',
expect.objectContaining({
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
userIds: [authStub.admin.user.id],
}),
);
});
it('should throw an error if withParners is true and isArchived true or undefined', async () => {

View file

@ -60,15 +60,6 @@ export class TimelineService {
private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) {
if (dto.albumId) {
await this.accessCore.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]);
} else {
dto.userId = dto.userId || auth.user.id;
}
if (dto.userId) {
await this.accessCore.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]);
if (dto.isArchived !== false) {
await this.accessCore.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]);
}
}
if (dto.withPartners) {

View file

@ -1,6 +1,9 @@
import { ExpressionBuilder } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetSearchBuilderOptions } from 'src/interfaces/search.interface';
import { DB } from 'src/prisma/generated/types';
import { Between, IsNull, LessThanOrEqual, MoreThanOrEqual, Not, SelectQueryBuilder } from 'typeorm';
/**
@ -142,3 +145,20 @@ export function searchAssetBuilder(
return builder;
}
export const withExif = (eb: ExpressionBuilder<DB, 'assets'>) =>
jsonObjectFrom(eb.selectFrom('exif').selectAll().whereRef('exif.assetId', '=', 'assets.id')).as('exifInfo');
export const withSmartInfo = (eb: ExpressionBuilder<DB, 'assets'>) =>
jsonObjectFrom(eb.selectFrom('smart_info').selectAll().whereRef('smart_info.assetId', '=', 'assets.id')).as(
'smartInfo',
);
export const withFaces = (eb: ExpressionBuilder<DB, 'assets'>) =>
jsonArrayFrom(eb.selectFrom('asset_faces').selectAll().whereRef('asset_faces.assetId', '=', 'assets.id')).as('faces');
export const withPeople = (eb: ExpressionBuilder<DB, 'assets' | 'asset_faces'>) =>
jsonObjectFrom(eb.selectFrom('person').selectAll().whereRef('asset_faces.personId', '=', 'person.id')).as('people');
export const withOwner = (eb: ExpressionBuilder<DB, 'assets'>) =>
jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner');

View file

@ -37,7 +37,10 @@ export async function* usePagination<T>(
}
}
function paginationHelper<Entity extends ObjectLiteral>(items: Entity[], take: number): PaginationResult<Entity> {
export function paginationHelper<Entity extends ObjectLiteral>(
items: Entity[],
take: number,
): PaginationResult<Entity> {
const hasNextPage = items.length > take;
items.splice(take);

View file

@ -18,7 +18,8 @@
"preserveWatchOutput": true,
"baseUrl": "./",
"jsx": "react",
"types": ["vitest/globals"]
"types": ["vitest/globals"],
"noErrorTruncation": true
},
"exclude": ["dist", "node_modules", "upload"]
}