Compare commits

...

26 Commits

Author SHA1 Message Date
Alex Tran
edac67acd2 update e2e package 2025-04-10 09:29:32 -05:00
Alex Tran
f410b58035 chore: update exiftool-vendor 2025-04-09 13:47:16 -05:00
Jason Rasmussen
8943ec23ba refactor: more database types (#17490) 2025-04-09 10:24:38 -04:00
Gagan Yadav
04b03f2924 fix(mobile): asset grid will infinitely scroll on iOS when select and… (#17469)
fix(mobile): asset grid will infinitely scroll on iOS when select and drag
2025-04-09 08:36:27 -05:00
Jason Rasmussen
cf2c0260a6 refactor: activity item (#17470)
* refactor: activity item

* fix query

* qualified columns

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-04-09 08:35:20 -04:00
Alex
ae8af84101 fix: no thumbnail generated for motion assets (#17472) 2025-04-08 16:07:10 -05:00
Jason Rasmussen
4794eeca88 refactor: database types (#17468) 2025-04-08 12:40:03 -04:00
Gagan Yadav
ac65d46ec6 fix(mobile): adds support for Internationalized Domain Name (IDN) (#17461) 2025-04-08 11:04:42 -05:00
Alex
e5ca79dd44 refactor: remove session entity (#17466)
* refactor: remove session entity

* fix: test

* update sql

* remote export
2025-04-08 16:04:07 +00:00
Jason Rasmussen
49be6d7fd8 refactor: more database enums (#17465) 2025-04-08 12:02:05 -04:00
Daniel Dietzler
15c6506aee fix: broken start/end dates on album update (#17467) 2025-04-08 15:47:44 +00:00
Jason Rasmussen
2c31a11e41 chore: replace generated enums with actual types (#17463) 2025-04-08 11:13:46 -04:00
Jason Rasmussen
b6c5a03533 refactor: remove tag entity (#17462) 2025-04-08 10:52:54 -04:00
Gagan Yadav
75bc32b47b fix(mobile): hide asset description text field if user is not owner (#17442)
* fix(mobile): hide asset description text field if user is not owner

* If user is not the owner and asset has no description then hide the text field

* Apply suggestions from code review

Co-authored-by: Alex <alex.tran1502@gmail.com>

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-04-08 09:18:33 -05:00
Jason Rasmussen
fdbe6d649f refactor: remove smart search entity (#17447)
refactor: smart search entity
2025-04-08 09:56:45 -04:00
Aleksandr
2b131fe935 feat: opt-in sync of deletes and restores from web to Android (#16732)
* Features: Local file movement to trash and restoration back to the album added. (Android)

* Comments fixes

* settings button marked as [EXPERIMENTAL]

* _moveToTrashMatchedAssets refactored, moveToTrash renamed.

* fix: bad merge

* Permission check and request for local storage added.

* Permission request added on settings switcher

* Settings button logic changed

* Method channel file_trash moved to BackgroundServicePlugin

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-04-08 08:50:40 -05:00
snek
6ae24fbbd4 feat(web): improve individual share ux (#17430) 2025-04-08 09:11:37 -04:00
renovate[bot]
7f116d8e98 chore(deps): update mcr.microsoft.com/devcontainers/typescript-node:22 docker digest to b0b88ef (#17453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 13:32:14 +01:00
renovate[bot]
bd0840c411 chore(deps): update github/codeql-action digest to 45775bd (#17452)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 13:31:57 +01:00
renovate[bot]
a5123dec1a chore(deps): update grafana/grafana docker tag to v11.6.0 (#17460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 13:31:46 +01:00
renovate[bot]
ffd18c5459 chore(deps): update dependency @types/node to ^22.14.0 (#17459)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 12:14:30 +02:00
PyKen
8242ff9bab fix(server): Exclude album assets in shared link payload (#17207)
* fix(server): Exclude album assets in shared link payload

* Fix e2e test
2025-04-08 00:19:06 -04:00
Jason Rasmussen
8203b6c450 refactor: stop using geodata entity type (#17444) 2025-04-08 00:15:43 -04:00
Jason Rasmussen
b352cf3336 refactor: remove natural earth countries enity (#17445) 2025-04-08 00:15:16 -04:00
bo0tzz
96ed9a8c4a fix: restore mangled footnotes (#17446)
I broke this in #17257
2025-04-07 18:03:32 -04:00
Jason Rasmussen
e7a5b96ed0 feat: extension, triggers, functions, comments, parameters management in sql-tools (#17269)
feat: sql-tools extension, triggers, functions, comments, parameters
2025-04-07 15:12:12 -04:00
249 changed files with 6512 additions and 2954 deletions

View File

@@ -1,4 +1,4 @@
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:2ef23730ec68d8511ec8e6e0b82550ca728b256805d81f60ed890f3bfb21cfb9 ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:b0b88ef6a5abf21194343d2c5b2829dddd9be1142f65f6a5e4390a51d5a70dd8
FROM ${BASEIMAGE} FROM ${BASEIMAGE}
# Flutter SDK # Flutter SDK

View File

@@ -46,7 +46,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -59,7 +59,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -72,6 +72,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View File

@@ -518,7 +518,7 @@ jobs:
run: npm run build run: npm run build
- name: Run existing migrations - name: Run existing migrations
run: npm run typeorm:migrations:run run: npm run migrations:run
- name: Test npm run schema:reset command works - name: Test npm run schema:reset command works
run: npm run typeorm:schema:reset run: npm run typeorm:schema:reset
@@ -532,7 +532,7 @@ jobs:
id: verify-changed-files id: verify-changed-files
with: with:
files: | files: |
server/src/migrations/ server/src
- name: Verify migration files have not changed - name: Verify migration files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true' if: steps.verify-changed-files.outputs.files_changed == 'true'
run: | run: |

18
cli/package-lock.json generated
View File

@@ -27,7 +27,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@@ -61,7 +61,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -1362,13 +1362,13 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.15", "version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==", "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.20.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/normalize-package-data": { "node_modules/@types/normalize-package-data": {
@@ -4073,9 +4073,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },

View File

@@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",

View File

@@ -102,7 +102,7 @@ services:
command: [ './run.sh', '-disable-reporting' ] command: [ './run.sh', '-disable-reporting' ]
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:11.5.2-ubuntu@sha256:8b5858c447e06fd7a89006b562ba7bba7c4d5813600c7982374c41852adefaeb image: grafana/grafana:11.6.0-ubuntu@sha256:fd8fa48213c624e1a95122f1d93abbf1cf1cbe85fc73212c1e599dbd76c63ff8
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana

View File

@@ -70,3 +70,6 @@ If you get an error `can't set healthcheck.start_interval as feature require Doc
## Next Steps ## Next Steps
Read the [Post Installation](/docs/install/post-install.mdx) steps and [upgrade instructions](/docs/install/upgrading.md). Read the [Post Installation](/docs/install/post-install.mdx) steps and [upgrade instructions](/docs/install/upgrading.md).
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env

View File

@@ -24,9 +24,6 @@ To clean up disk space, the old version's obsolete container images can be delet
docker image prune docker image prune
``` ```
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env
[watchtower]: https://containrrr.dev/watchtower/ [watchtower]: https://containrrr.dev/watchtower/
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created [breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
[container-auth]: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry
[releases]: https://github.com/immich-app/immich/releases [releases]: https://github.com/immich-app/immich/releases

61
e2e/package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@@ -25,7 +25,7 @@
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^57.0.0", "eslint-plugin-unicorn": "^57.0.0",
"exiftool-vendored": "^28.3.1", "exiftool-vendored": "^29.3.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jose": "^5.6.3", "jose": "^5.6.3",
"luxon": "^3.4.4", "luxon": "^3.4.4",
@@ -66,7 +66,7 @@
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9", "@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
@@ -100,7 +100,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -1071,9 +1071,9 @@
} }
}, },
"node_modules/@photostructure/tz-lookup": { "node_modules/@photostructure/tz-lookup": {
"version": "11.0.0", "version": "11.2.0",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.0.0.tgz", "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.2.0.tgz",
"integrity": "sha512-QMV5/dWtY/MdVPXZs/EApqzyhnqDq1keYEqpS+Xj2uidyaqw2Nk/fWcsszdruIXjdqp1VoWNzsgrO6bUHU1mFw==", "integrity": "sha512-DwrvodcXHNSdGdeSF7SBL5o8aBlsaeuCuG7633F04nYsL3hn5Hxe3z/5kCqxv61J1q7ggKZ27GPylR3x0cPNXQ==",
"dev": true, "dev": true,
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
@@ -1585,13 +1585,13 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.15", "version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==", "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.20.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/normalize-package-data": { "node_modules/@types/normalize-package-data": {
@@ -3312,27 +3312,27 @@
} }
}, },
"node_modules/exiftool-vendored": { "node_modules/exiftool-vendored": {
"version": "28.8.0", "version": "29.3.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-29.3.0.tgz",
"integrity": "sha512-R7tirJLr9fWuH9JS/KFFLB+O7jNGKuPXGxREc6YybYangEudGb+X8ERsYXk9AifMiAWh/2agNfbgkbcQcF+MxA==", "integrity": "sha512-2N+QvQH3mH0yb89vpxJXaD+SXa8GXvDigytS6cro6FOrTx9Opav4H0QPP0V4r9dBhXy5poON7qo+p1KZv5wZqQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@photostructure/tz-lookup": "^11.0.0", "@photostructure/tz-lookup": "^11.2.0",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.6.0",
"batch-cluster": "^13.0.0", "batch-cluster": "^13.0.0",
"he": "^1.2.0", "he": "^1.2.0",
"luxon": "^3.5.0" "luxon": "^3.6.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"exiftool-vendored.exe": "13.0.0", "exiftool-vendored.exe": "13.26.0",
"exiftool-vendored.pl": "13.0.1" "exiftool-vendored.pl": "13.26.0"
} }
}, },
"node_modules/exiftool-vendored.exe": { "node_modules/exiftool-vendored.exe": {
"version": "13.0.0", "version": "13.26.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.0.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.26.0.tgz",
"integrity": "sha512-4zAMuFGgxZkOoyQIzZMHv1HlvgyJK3AkNqjAgm8A8V0UmOZO7yv3pH49cDV1OduzFJqgs6yQ6eG4OGydhKtxlg==", "integrity": "sha512-y5mLmNAeABbXhb1a77EeR+CYDELI64DawnNywhMiivIYIdBPXf/81UcBd3SQlcTAHJjJjKt20qdmdL4loMkRDA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -3341,15 +3341,18 @@
] ]
}, },
"node_modules/exiftool-vendored.pl": { "node_modules/exiftool-vendored.pl": {
"version": "13.0.1", "version": "13.26.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.0.1.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.26.0.tgz",
"integrity": "sha512-+BRRzjselpWudKR0ltAW5SUt9T82D+gzQN8DdOQUgnSVWWp7oLCeTGBRptbQz+436Ihn/mPzmo/xnf0cv/Qw1A==", "integrity": "sha512-4L8b6TrZcrd/dOnoeyCgsIa4WgFygucd0KQzB7xgWpgeMDQ2xYeqAYoHTeKmLAv4DogvaVkZQgDNogscuKuM+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"!win32" "!win32"
] ],
"bin": {
"exiftool": "bin/exiftool"
}
}, },
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.2.1", "version": "1.2.1",
@@ -6316,9 +6319,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },

View File

@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@@ -35,7 +35,7 @@
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^57.0.0", "eslint-plugin-unicorn": "^57.0.0",
"exiftool-vendored": "^28.3.1", "exiftool-vendored": "^29.3.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jose": "^5.6.3", "jose": "^5.6.3",
"luxon": "^3.4.4", "luxon": "^3.4.4",

View File

@@ -246,15 +246,7 @@ describe('/shared-links', () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key }); const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200); expect(status).toBe(200);
expect(body.assets).toHaveLength(1); expect(body.assets).toHaveLength(0);
expect(body.assets[0]).toEqual(
expect.objectContaining({
originalFileName: 'example.png',
localDateTime: expect.any(String),
fileCreatedAt: expect.any(String),
exifInfo: expect.any(Object),
}),
);
expect(body.album).toBeDefined(); expect(body.album).toBeDefined();
}); });

View File

@@ -6,6 +6,7 @@
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" /> <uses-permission android:name="android.permission.MANAGE_MEDIA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
@@ -124,4 +125,4 @@
<data android:scheme="geo" /> <data android:scheme="geo" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@@ -1,25 +1,40 @@
package app.alextran.immich package app.alextran.immich
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import java.security.MessageDigest import java.security.MessageDigest
import java.io.FileInputStream import java.io.FileInputStream
import kotlinx.coroutines.* import kotlinx.coroutines.*
/** /**
* Android plugin for Dart `BackgroundService` * Android plugin for Dart `BackgroundService` and file trash operations
*
* Receives messages/method calls from the foreground Dart side to manage
* the background service, e.g. start (enqueue), stop (cancel)
*/ */
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
private var methodChannel: MethodChannel? = null private var methodChannel: MethodChannel? = null
private var fileTrashChannel: MethodChannel? = null
private var context: Context? = null private var context: Context? = null
private var pendingResult: Result? = null
private val PERMISSION_REQUEST_CODE = 1001
private var activityBinding: ActivityPluginBinding? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@@ -29,6 +44,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
context = ctx context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel") methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this) methodChannel?.setMethodCallHandler(this)
// Add file trash channel
fileTrashChannel = MethodChannel(messenger, "file_trash")
fileTrashChannel?.setMethodCallHandler(this)
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -38,11 +57,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private fun onDetachedFromEngine() { private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null) methodChannel?.setMethodCallHandler(null)
methodChannel = null methodChannel = null
fileTrashChannel?.setMethodCallHandler(null)
fileTrashChannel = null
} }
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: Result) {
val ctx = context!! val ctx = context!!
when (call.method) { when (call.method) {
// Existing BackgroundService methods
"enable" -> { "enable" -> {
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
@@ -114,10 +136,180 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
} }
} }
// File Trash methods moved from MainActivity
"moveToTrash" -> {
val fileName = call.argument<String>("fileName")
if (fileName != null) {
if (hasManageStoragePermission()) {
val success = moveToTrash(fileName)
result.success(success)
} else {
result.error("PERMISSION_DENIED", "Storage permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
}
"restoreFromTrash" -> {
val fileName = call.argument<String>("fileName")
if (fileName != null) {
if (hasManageStoragePermission()) {
val success = untrashImage(fileName)
result.success(success)
} else {
result.error("PERMISSION_DENIED", "Storage permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
}
"requestManageStoragePermission" -> {
if (!hasManageStoragePermission()) {
requestManageStoragePermission(result)
} else {
Log.e("Manage storage permission", "Permission already granted")
result.success(true)
}
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
// File Trash methods moved from MainActivity
private fun hasManageStoragePermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
true
}
}
private fun requestManageStoragePermission(result: Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
pendingResult = result // Store the result callback
val activity = activityBinding?.activity ?: return
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:${activity.packageName}")
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
} else {
result.success(true)
}
}
private fun moveToTrash(fileName: String): Boolean {
val contentResolver = context?.contentResolver ?: return false
val uri = getFileUri(fileName)
Log.e("FILE_URI", uri.toString())
return uri?.let { moveToTrash(it) } ?: false
}
private fun moveToTrash(contentUri: Uri): Boolean {
val contentResolver = context?.contentResolver ?: return false
return try {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.IS_TRASHED, 1) // Move to trash
}
val updated = contentResolver.update(contentUri, values, null, null)
updated > 0
} catch (e: Exception) {
Log.e("TrashError", "Error moving to trash", e)
false
}
}
private fun getFileUri(fileName: String): Uri? {
val contentResolver = context?.contentResolver ?: return null
val contentUri = MediaStore.Files.getContentUri("external")
val projection = arrayOf(MediaStore.Images.Media._ID)
val selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?"
val selectionArgs = arrayOf(fileName)
var fileUri: Uri? = null
contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
fileUri = ContentUris.withAppendedId(contentUri, id)
}
}
return fileUri
}
private fun untrashImage(name: String): Boolean {
val contentResolver = context?.contentResolver ?: return false
val uri = getTrashedFileUri(contentResolver, name)
Log.e("FILE_URI", uri.toString())
return uri?.let { untrashImage(it) } ?: false
}
private fun untrashImage(contentUri: Uri): Boolean {
val contentResolver = context?.contentResolver ?: return false
return try {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.IS_TRASHED, 0) // Restore file
}
val updated = contentResolver.update(contentUri, values, null, null)
updated > 0
} catch (e: Exception) {
Log.e("TrashError", "Error restoring file", e)
false
}
}
private fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? {
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
val queryArgs = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?")
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
}
contentResolver.query(contentUri, projection, queryArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
return ContentUris.withAppendedId(contentUri, id)
}
}
return null
}
// ActivityAware implementation
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivity() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
// ActivityResultListener implementation
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == PERMISSION_REQUEST_CODE) {
val granted = hasManageStoragePermission()
pendingResult?.success(granted)
pendingResult = null
return true
}
return false
}
} }
private const val TAG = "BackgroundServicePlugin" private const val TAG = "BackgroundServicePlugin"
private const val BUFFER_SIZE = 2 * 1024 * 1024; private const val BUFFER_SIZE = 2 * 1024 * 1024

View File

@@ -2,14 +2,12 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle import androidx.annotation.NonNull
import android.content.Intent
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin()) flutterEngine.plugins.add(BackgroundServicePlugin())
// No need to set up method channel here as it's now handled in the plugin
} }
} }

View File

@@ -23,6 +23,8 @@
"advanced_settings_tile_title": "Advanced", "advanced_settings_tile_title": "Advanced",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Troubleshooting", "advanced_settings_troubleshooting_title": "Troubleshooting",
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
"album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_excluded": "EXCLUDED",
"album_info_card_backup_album_included": "INCLUDED", "album_info_card_backup_album_included": "INCLUDED",
"albums": "Albums", "albums": "Albums",

View File

@@ -65,6 +65,7 @@ enum StoreKey<T> {
// Video settings // Video settings
loadOriginalVideo<bool>._(136), loadOriginalVideo<bool>._(136),
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff // Experimental stuff
photoManagerCustomFilter<bool>._(1000); photoManagerCustomFilter<bool>._(1000);

View File

@@ -0,0 +1,5 @@
abstract interface class ILocalFilesManager {
Future<bool> moveToTrash(String fileName);
Future<bool> restoreFromTrash(String fileName);
Future<bool> requestManageStoragePermission();
}

View File

@@ -23,6 +23,7 @@ enum PendingAction {
assetDelete, assetDelete,
assetUploaded, assetUploaded,
assetHidden, assetHidden,
assetTrash,
} }
class PendingChange { class PendingChange {
@@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_upload_success', _handleOnUploadSuccess); socket.on('on_upload_success', _handleOnUploadSuccess);
socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_asset_delete', _handleOnAssetDelete); socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleServerUpdates); socket.on('on_asset_trash', _handleOnAssetTrash);
socket.on('on_asset_restore', _handleServerUpdates); socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates); socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates); socket.on('on_asset_stack_update', _handleServerUpdates);
@@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_debounce.run(handlePendingChanges); _debounce.run(handlePendingChanges);
} }
Future<void> _handlePendingTrashes() async {
final trashChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetTrash)
.toList();
if (trashChanges.isNotEmpty) {
List<String> remoteIds = trashChanges
.expand((a) => (a.value as List).map((e) => e.toString()))
.toList();
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
await _ref.read(assetProvider.notifier).getAllAsset();
state = state.copyWith(
pendingChanges: state.pendingChanges
.whereNot((c) => trashChanges.contains(c))
.toList(),
);
}
}
Future<void> _handlePendingDeletes() async { Future<void> _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete) .where((c) => c.action == PendingAction.assetDelete)
@@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
await _handlePendingUploaded(); await _handlePendingUploaded();
await _handlePendingDeletes(); await _handlePendingDeletes();
await _handlingPendingHidden(); await _handlingPendingHidden();
await _handlePendingTrashes();
} }
void _handleOnConfigUpdate(dynamic _) { void _handleOnConfigUpdate(dynamic _) {
@@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void _handleOnAssetDelete(dynamic data) => void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data); addPendingChange(PendingAction.assetDelete, data);
void _handleOnAssetTrash(dynamic data) {
addPendingChange(PendingAction.assetTrash, data);
}
void _handleOnAssetHidden(dynamic data) => void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, data); addPendingChange(PendingAction.assetHidden, data);

View File

@@ -0,0 +1,23 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/utils/local_files_manager.dart';
final localFilesManagerRepositoryProvider =
Provider((ref) => LocalFilesManagerRepository());
class LocalFilesManagerRepository implements ILocalFilesManager {
@override
Future<bool> moveToTrash(String fileName) async {
return await LocalFilesManager.moveToTrash(fileName);
}
@override
Future<bool> restoreFromTrash(String fileName) async {
return await LocalFilesManager.restoreFromTrash(fileName);
}
@override
Future<bool> requestManageStoragePermission() async {
return await LocalFilesManager.requestManageStoragePermission();
}
}

View File

@@ -61,6 +61,7 @@ enum AppSettingsEnum<T> {
0, 0,
), ),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false), advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5 logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false), preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true), loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -16,6 +17,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
@@ -25,6 +28,8 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/repositories/partner.repository.dart'; import 'package:immich_mobile/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/entity.service.dart';
@@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
ref.watch(userRepositoryProvider), ref.watch(userRepositoryProvider),
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
ref.watch(etagRepositoryProvider), ref.watch(etagRepositoryProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(localFilesManagerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider), ref.watch(partnerApiRepositoryProvider),
ref.watch(userApiRepositoryProvider), ref.watch(userApiRepositoryProvider),
), ),
@@ -69,6 +76,8 @@ class SyncService {
final IUserApiRepository _userApiRepository; final IUserApiRepository _userApiRepository;
final AsyncMutex _lock = AsyncMutex(); final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService'); final Logger _log = Logger('SyncService');
final AppSettingsService _appSettingsService;
final ILocalFilesManager _localFilesManager;
SyncService( SyncService(
this._hashService, this._hashService,
@@ -82,6 +91,8 @@ class SyncService {
this._userRepository, this._userRepository,
this._userService, this._userService,
this._eTagRepository, this._eTagRepository,
this._appSettingsService,
this._localFilesManager,
this._partnerApiRepository, this._partnerApiRepository,
this._userApiRepository, this._userApiRepository,
); );
@@ -238,8 +249,19 @@ class SyncService {
return null; return null;
} }
Future<void> _moveToTrashMatchedAssets(Iterable<String> idsToDelete) async {
final List<Asset> localAssets = await _assetRepository.getAllLocal();
final List<Asset> matchedAssets = localAssets
.where((asset) => idsToDelete.contains(asset.remoteId))
.toList();
for (var asset in matchedAssets) {
_localFilesManager.moveToTrash(asset.fileName);
}
}
/// Deletes remote-only assets, updates merged assets to be local-only /// Deletes remote-only assets, updates merged assets to be local-only
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) { Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) async {
return _assetRepository.transaction(() async { return _assetRepository.transaction(() async {
await _assetRepository.deleteAllByRemoteId( await _assetRepository.deleteAllByRemoteId(
idsToDelete, idsToDelete,
@@ -249,6 +271,12 @@ class SyncService {
idsToDelete, idsToDelete,
state: AssetState.merged, state: AssetState.merged,
); );
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
await _moveToTrashMatchedAssets(idsToDelete);
}
if (merged.isEmpty) return; if (merged.isEmpty) return;
for (final Asset asset in merged) { for (final Asset asset in merged) {
asset.remoteId = null; asset.remoteId = null;
@@ -790,9 +818,27 @@ class SyncService {
return (existing, toUpsert); return (existing, toUpsert);
} }
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
for (var asset in assetsList) {
if (asset.isTrashed) {
_localFilesManager.moveToTrash(asset.fileName);
} else {
_localFilesManager.restoreFromTrash(asset.fileName);
}
}
}
/// Inserts or updates the assets in the database with their ExifInfo (if any) /// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> upsertAssetsWithExif(List<Asset> assets) async { Future<void> upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) return; if (assets.isEmpty) return;
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
_toggleTrashStatusForAssets(assets);
}
final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList(); final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList();
try { try {
await _assetRepository.transaction(() async { await _assetRepository.transaction(() async {

View File

@@ -0,0 +1,39 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class LocalFilesManager {
static const MethodChannel _channel = MethodChannel('file_trash');
static Future<bool> moveToTrash(String fileName) async {
try {
final bool success =
await _channel.invokeMethod('moveToTrash', {'fileName': fileName});
return success;
} on PlatformException catch (e) {
debugPrint('Error moving to trash: ${e.message}');
return false;
}
}
static Future<bool> restoreFromTrash(String fileName) async {
try {
final bool success = await _channel
.invokeMethod('restoreFromTrash', {'fileName': fileName});
return success;
} on PlatformException catch (e) {
debugPrint('Error restoring file: ${e.message}');
return false;
}
}
static Future<bool> requestManageStoragePermission() async {
try {
final bool success =
await _channel.invokeMethod('requestManageStoragePermission');
return success;
} on PlatformException catch (e) {
debugPrint('Error requesting permission: ${e.message}');
return false;
}
}
}

View File

@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:punycode/punycode.dart';
String sanitizeUrl(String url) { String sanitizeUrl(String url) {
// Add schema if none is set // Add schema if none is set
@@ -11,13 +12,80 @@ String sanitizeUrl(String url) {
} }
String? getServerUrl() { String? getServerUrl() {
final serverUrl = Store.tryGet(StoreKey.serverEndpoint); final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null; final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null) { if (serverUri == null) {
return null; return null;
} }
return serverUri.hasPort return Uri.decodeFull(
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}" serverUri.hasPort
: "${serverUri.scheme}://${serverUri.host}"; ? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
: "${serverUri.scheme}://${serverUri.host}",
);
}
/// Converts a Unicode URL to its ASCII-compatible encoding (Punycode).
///
/// This is especially useful for internationalized domain names (IDNs),
/// where parts of the URL (typically the host) contain non-ASCII characters.
///
/// Example:
/// ```dart
/// final encodedUrl = punycodeEncodeUrl('https://bücher.de');
/// print(encodedUrl); // Outputs: https://xn--bcher-kva.de
/// ```
///
/// Notes:
/// - If the input URL is invalid, an empty string is returned.
/// - Only the host part of the URL is converted to Punycode; the scheme,
/// path, and port remain unchanged.
///
String punycodeEncodeUrl(String serverUrl) {
final serverUri = Uri.tryParse(serverUrl);
if (serverUri == null || serverUri.host.isEmpty) return '';
final encodedHost = Uri.decodeComponent(serverUri.host).split('.').map(
(segment) {
// If segment is already ASCII, then return as it is.
if (segment.runes.every((c) => c < 0x80)) return segment;
return 'xn--${punycodeEncode(segment)}';
},
).join('.');
return serverUri.replace(host: encodedHost).toString();
}
/// Decodes an ASCII-compatible (Punycode) URL back to its original Unicode representation.
///
/// This method is useful for converting internationalized domain names (IDNs)
/// that were previously encoded with Punycode back to their human-readable Unicode form.
///
/// Example:
/// ```dart
/// final decodedUrl = punycodeDecodeUrl('https://xn--bcher-kva.de');
/// print(decodedUrl); // Outputs: https://bücher.de
/// ```
///
/// Notes:
/// - If the input URL is invalid the method returns `null`.
/// - Only the host part of the URL is decoded. The scheme and port (if any) are preserved.
/// - The method assumes that the input URL only contains: scheme, host, port (optional).
/// - Query parameters, fragments, and user info are not handled (by design, as per constraints).
///
String? punycodeDecodeUrl(String? serverUrl) {
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null || serverUri.host.isEmpty) return null;
final decodedHost = serverUri.host.split('.').map(
(segment) {
if (segment.toLowerCase().startsWith('xn--')) {
return punycodeDecode(segment.substring(4));
}
// If segment is not punycode encoded, then return as it is.
return segment;
},
).join('.');
return Uri.decodeFull(serverUri.replace(host: decodedHost).toString());
} }

View File

@@ -8,25 +8,25 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart'; import 'asset_grid_data_structure.dart';
@@ -107,6 +107,8 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
final Set<Asset> _draggedAssets = final Set<Asset> _draggedAssets =
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
ScrollPhysics? _scrollPhysics;
Set<Asset> _getSelectedAssets() { Set<Asset> _getSelectedAssets() {
return Set.from(_selectedAssets); return Set.from(_selectedAssets);
} }
@@ -265,6 +267,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
), ),
itemBuilder: _itemBuilder, itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener, itemPositionsListener: _itemPositionsListener,
physics: _scrollPhysics,
itemScrollController: _itemScrollController, itemScrollController: _itemScrollController,
scrollOffsetController: _scrollOffsetController, scrollOffsetController: _scrollOffsetController,
itemCount: widget.renderList.elements.length + itemCount: widget.renderList.elements.length +
@@ -439,6 +442,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
void _setDragStartIndex(AssetIndex index) { void _setDragStartIndex(AssetIndex index) {
setState(() { setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorAssetIndex = index.rowIndex; _dragAnchorAssetIndex = index.rowIndex;
_dragAnchorSectionIndex = index.sectionIndex; _dragAnchorSectionIndex = index.sectionIndex;
_dragging = true; _dragging = true;
@@ -446,6 +450,12 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
} }
void _stopDrag() { void _stopDrag() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS.
setState(() {
_scrollPhysics = null;
});
});
setState(() { setState(() {
_dragging = false; _dragging = false;
_draggedAssets.clear(); _draggedAssets.clear();

View File

@@ -34,17 +34,24 @@ class DescriptionInput extends HookConsumerWidget {
final owner = ref.watch(currentUserProvider); final owner = ref.watch(currentUserProvider);
final hasError = useState(false); final hasError = useState(false);
final assetWithExif = ref.watch(assetDetailProvider(asset)); final assetWithExif = ref.watch(assetDetailProvider(asset));
final hasDescription = useState(false);
final isOwner = fastHash(owner?.id ?? '') == asset.ownerId;
useEffect( useEffect(
() { () {
assetService assetService.getDescription(asset).then((value) {
.getDescription(asset) controller.text = value;
.then((value) => controller.text = value); hasDescription.value = value.isNotEmpty;
});
return null; return null;
}, },
[assetWithExif.value], [assetWithExif.value],
); );
if (!isOwner && !hasDescription.value) {
return const SizedBox.shrink();
}
submitDescription(String description) async { submitDescription(String description) async {
hasError.value = false; hasError.value = false;
try { try {
@@ -82,7 +89,7 @@ class DescriptionInput extends HookConsumerWidget {
} }
return TextField( return TextField(
enabled: fastHash(owner?.id ?? '') == asset.ownerId, enabled: isOwner,
focusNode: focusNode, focusNode: focusNode,
onTap: () => isFocus.value = true, onTap: () => isFocus.value = true,
onChanged: (value) { onChanged: (value) {

View File

@@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -7,18 +8,18 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart'; import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart'; import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart'; import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart'; import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart'; import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart'; import 'package:immich_mobile/widgets/forms/login/login_button.dart';
@@ -82,7 +83,8 @@ class LoginForm extends HookConsumerWidget {
/// Fetch the server login credential and enables oAuth login if necessary /// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise /// Returns true if successful, false otherwise
Future<void> getServerAuthSettings() async { Future<void> getServerAuthSettings() async {
final serverUrl = sanitizeUrl(serverEndpointController.text); final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
// Guard empty URL // Guard empty URL
if (serverUrl.isEmpty) { if (serverUrl.isEmpty) {

View File

@@ -1,11 +1,13 @@
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget {
final advancedTroubleshooting = final advancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid =
useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert = final allowSelfSignedSSLCert =
@@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()), LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
); );
Future<bool> checkAndroidVersion() async {
if (Platform.isAndroid) {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
int sdkVersion = androidInfo.version.sdkInt;
return sdkVersion >= 30;
}
return false;
}
final advancedSettings = [ final advancedSettings = [
SettingsSwitchListTile( SettingsSwitchListTile(
enabled: true, enabled: true,
@@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(), title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(), subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
), ),
FutureBuilder<bool>(
future: checkAndroidVersion(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref
.read(localFilesManagerRepositoryProvider)
.requestManageStoragePermission();
manageLocalMediaAndroid.value = result;
}
},
);
} else {
return const SizedBox.shrink();
}
},
),
SettingsSliderListTile( SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(args: [logLevel]), text: "advanced_settings_log_level_title".tr(args: [logLevel]),
valueNotifier: levelId, valueNotifier: levelId,

View File

@@ -51,6 +51,7 @@ dependencies:
permission_handler: ^11.4.0 permission_handler: ^11.4.0
photo_manager: ^3.6.4 photo_manager: ^3.6.4
photo_manager_image_provider: ^2.2.0 photo_manager_image_provider: ^2.2.0
punycode: ^1.0.0
riverpod_annotation: ^2.6.1 riverpod_annotation: ^2.6.1
scrollable_positioned_list: ^0.3.8 scrollable_positioned_list: ^0.3.8
share_handler: ^0.0.22 share_handler: ^0.0.22

View File

@@ -60,6 +60,9 @@ void main() {
final MockAlbumMediaRepository albumMediaRepository = final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository(); MockAlbumMediaRepository();
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
final MockAppSettingService appSettingService = MockAppSettingService();
final MockLocalFilesManagerRepository localFilesManagerRepository =
MockLocalFilesManagerRepository();
final MockPartnerApiRepository partnerApiRepository = final MockPartnerApiRepository partnerApiRepository =
MockPartnerApiRepository(); MockPartnerApiRepository();
final MockUserApiRepository userApiRepository = MockUserApiRepository(); final MockUserApiRepository userApiRepository = MockUserApiRepository();
@@ -106,6 +109,8 @@ void main() {
userRepository, userRepository,
userService, userService,
eTagRepository, eTagRepository,
appSettingService,
localFilesManagerRepository,
partnerApiRepository, partnerApiRepository,
userApiRepository, userApiRepository,
); );

View File

@@ -0,0 +1,138 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/url_helper.dart';
void main() {
group('punycodeEncodeUrl', () {
test('should return empty string for invalid URL', () {
expect(punycodeEncodeUrl('not a url'), equals(''));
});
test('should handle empty input', () {
expect(punycodeEncodeUrl(''), equals(''));
});
test('should return ASCII-only URL unchanged', () {
const url = 'https://example.com';
expect(punycodeEncodeUrl(url), equals(url));
});
test('should encode single-segment Unicode host', () {
const url = 'https://bücher';
const expected = 'https://xn--bcher-kva';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should encode multi-segment Unicode host', () {
const url = 'https://bücher.de';
const expected = 'https://xn--bcher-kva.de';
expect(punycodeEncodeUrl(url), equals(expected));
});
test(
'should encode multi-segment Unicode host with multiple non-ASCII segments',
() {
const url = 'https://bücher.münchen';
const expected = 'https://xn--bcher-kva.xn--mnchen-3ya';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should handle URL with port', () {
const url = 'https://bücher.de:8080';
const expected = 'https://xn--bcher-kva.de:8080';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should handle URL with path', () {
const url = 'https://bücher.de/path/to/resource';
const expected = 'https://xn--bcher-kva.de/path/to/resource';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should handle URL with port and path', () {
const url = 'https://bücher.de:3000/path';
const expected = 'https://xn--bcher-kva.de:3000/path';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should not encode ASCII segment in multi-segment host', () {
const url = 'https://shop.bücher.de';
const expected = 'https://shop.xn--bcher-kva.de';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should handle host with hyphen in Unicode segment', () {
const url = 'https://bü-cher.de';
const expected = 'https://xn--b-cher-3ya.de';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should handle host with numbers in Unicode segment', () {
const url = 'https://bücher123.de';
const expected = 'https://xn--bcher123-65a.de';
expect(punycodeEncodeUrl(url), equals(expected));
});
test('should encode the domain of the original issue poster :)', () {
const url = 'https://фото.большойчлен.рф/';
const expected = 'https://xn--n1aalg.xn--90ailhbncb6fh7b.xn--p1ai/';
expect(punycodeEncodeUrl(url), expected);
});
});
group('punycodeDecodeUrl', () {
test('should return null for null input', () {
expect(punycodeDecodeUrl(null), isNull);
});
test('should return null for an invalid URL', () {
// "not a url" should fail to parse.
expect(punycodeDecodeUrl('not a url'), isNull);
});
test('should return null for a URL with empty host', () {
// "https://" is a valid scheme but with no host.
expect(punycodeDecodeUrl('https://'), isNull);
});
test('should return ASCII-only URL unchanged', () {
const url = 'https://example.com';
expect(punycodeDecodeUrl(url), equals(url));
});
test('should decode a single-segment Punycode domain', () {
const input = 'https://xn--bcher-kva.de';
const expected = 'https://bücher.de';
expect(punycodeDecodeUrl(input), equals(expected));
});
test('should decode a multi-segment Punycode domain', () {
const input = 'https://shop.xn--bcher-kva.de';
const expected = 'https://shop.bücher.de';
expect(punycodeDecodeUrl(input), equals(expected));
});
test('should decode URL with port', () {
const input = 'https://xn--bcher-kva.de:8080';
const expected = 'https://bücher.de:8080';
expect(punycodeDecodeUrl(input), equals(expected));
});
test('should decode domains with uppercase punycode prefix correctly', () {
const input = 'https://XN--BCHER-KVA.de';
const expected = 'https://bücher.de';
expect(punycodeDecodeUrl(input), equals(expected));
});
test('should handle mixed segments with no punycode in some parts', () {
const input = 'https://news.xn--bcher-kva.de';
const expected = 'https://news.bücher.de';
expect(punycodeDecodeUrl(input), equals(expected));
});
test('should decode the domain of the original issue poster :)', () {
const url = 'https://xn--n1aalg.xn--90ailhbncb6fh7b.xn--p1ai/';
const expected = 'https://фото.большойчлен.рф/';
expect(punycodeDecodeUrl(url), expected);
});
});
}

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
@@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
class MockAuthRepository extends Mock implements IAuthRepository {} class MockAuthRepository extends Mock implements IAuthRepository {}
class MockPartnerRepository extends Mock implements IPartnerRepository {}
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {} class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
class MockPartnerRepository extends Mock implements IPartnerRepository {} class MockLocalFilesManagerRepository extends Mock
implements ILocalFilesManager {}

View File

@@ -1,5 +1,6 @@
import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/entity.service.dart';
@@ -25,4 +26,7 @@ class MockNetworkService extends Mock implements NetworkService {}
class MockSearchApi extends Mock implements SearchApi {} class MockSearchApi extends Mock implements SearchApi {}
class MockAppSettingService extends Mock implements AppSettingsService {}
class MockBackgroundService extends Mock implements BackgroundService {} class MockBackgroundService extends Mock implements BackgroundService {}

View File

@@ -12,7 +12,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -23,13 +23,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.15", "version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==", "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.20.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
@@ -47,9 +47,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
} }

View File

@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"repository": { "repository": {

View File

@@ -34,7 +34,7 @@
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"exiftool-vendored": "^28.3.1", "exiftool-vendored": "^29.3.0",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0", "geo-tz": "^8.0.0",
@@ -90,7 +90,7 @@
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0", "@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
@@ -4479,9 +4479,9 @@
} }
}, },
"node_modules/@photostructure/tz-lookup": { "node_modules/@photostructure/tz-lookup": {
"version": "11.1.0", "version": "11.2.0",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.1.0.tgz", "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.2.0.tgz",
"integrity": "sha512-UywyhMwUdVU2aH5ls7EweTEyPpXbDkgC//Nnsm/lWfpae8WX3N33Yy0/aBmb/Pd9+qEtgcFMYTtN/Htb+cd0ZA==", "integrity": "sha512-DwrvodcXHNSdGdeSF7SBL5o8aBlsaeuCuG7633F04nYsL3hn5Hxe3z/5kCqxv61J1q7ggKZ27GPylR3x0cPNXQ==",
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
@@ -5825,12 +5825,12 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.15", "version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==", "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.20.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/node-fetch": { "node_modules/@types/node-fetch": {
@@ -9365,26 +9365,26 @@
} }
}, },
"node_modules/exiftool-vendored": { "node_modules/exiftool-vendored": {
"version": "28.8.0", "version": "29.3.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-29.3.0.tgz",
"integrity": "sha512-R7tirJLr9fWuH9JS/KFFLB+O7jNGKuPXGxREc6YybYangEudGb+X8ERsYXk9AifMiAWh/2agNfbgkbcQcF+MxA==", "integrity": "sha512-2N+QvQH3mH0yb89vpxJXaD+SXa8GXvDigytS6cro6FOrTx9Opav4H0QPP0V4r9dBhXy5poON7qo+p1KZv5wZqQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@photostructure/tz-lookup": "^11.0.0", "@photostructure/tz-lookup": "^11.2.0",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.6.0",
"batch-cluster": "^13.0.0", "batch-cluster": "^13.0.0",
"he": "^1.2.0", "he": "^1.2.0",
"luxon": "^3.5.0" "luxon": "^3.6.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"exiftool-vendored.exe": "13.0.0", "exiftool-vendored.exe": "13.26.0",
"exiftool-vendored.pl": "13.0.1" "exiftool-vendored.pl": "13.26.0"
} }
}, },
"node_modules/exiftool-vendored.exe": { "node_modules/exiftool-vendored.exe": {
"version": "13.0.0", "version": "13.26.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.0.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.26.0.tgz",
"integrity": "sha512-4zAMuFGgxZkOoyQIzZMHv1HlvgyJK3AkNqjAgm8A8V0UmOZO7yv3pH49cDV1OduzFJqgs6yQ6eG4OGydhKtxlg==", "integrity": "sha512-y5mLmNAeABbXhb1a77EeR+CYDELI64DawnNywhMiivIYIdBPXf/81UcBd3SQlcTAHJjJjKt20qdmdL4loMkRDA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -9392,14 +9392,23 @@
] ]
}, },
"node_modules/exiftool-vendored.pl": { "node_modules/exiftool-vendored.pl": {
"version": "13.0.1", "version": "13.26.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.0.1.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.26.0.tgz",
"integrity": "sha512-+BRRzjselpWudKR0ltAW5SUt9T82D+gzQN8DdOQUgnSVWWp7oLCeTGBRptbQz+436Ihn/mPzmo/xnf0cv/Qw1A==", "integrity": "sha512-4L8b6TrZcrd/dOnoeyCgsIa4WgFygucd0KQzB7xgWpgeMDQ2xYeqAYoHTeKmLAv4DogvaVkZQgDNogscuKuM+Q==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"!win32" "!win32"
] ],
"bin": {
"exiftool": "bin/exiftool"
}
},
"node_modules/exiftool-vendored/node_modules/@types/luxon": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
"license": "MIT"
}, },
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.2.0", "version": "1.2.0",
@@ -16736,9 +16745,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicorn-magic": { "node_modules/unicorn-magic": {

View File

@@ -25,10 +25,10 @@
"lifecycle": "node ./dist/utils/lifecycle.js", "lifecycle": "node ./dist/utils/lifecycle.js",
"migrations:generate": "node ./dist/bin/migrations.js generate", "migrations:generate": "node ./dist/bin/migrations.js generate",
"migrations:create": "node ./dist/bin/migrations.js create", "migrations:create": "node ./dist/bin/migrations.js create",
"typeorm:migrations:run": "typeorm migration:run -d ./dist/bin/database.js", "migrations:run": "node ./dist/bin/migrations.js run",
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/bin/database.js", "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/bin/database.js",
"typeorm:schema:drop": "typeorm query -d ./dist/bin/database.js 'DROP schema public cascade; CREATE schema public;'", "typeorm:schema:drop": "typeorm query -d ./dist/bin/database.js 'DROP schema public cascade; CREATE schema public;'",
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run", "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run migrations:run",
"kysely:codegen": "npx kysely-codegen --include-pattern=\"(public|vectors).*\" --dialect postgres --url postgres://postgres:postgres@localhost/immich --log-level debug --out-file=./src/db.d.ts", "kysely:codegen": "npx kysely-codegen --include-pattern=\"(public|vectors).*\" --dialect postgres --url postgres://postgres:postgres@localhost/immich --log-level debug --out-file=./src/db.d.ts",
"sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js", "sync:sql": "node ./dist/bin/sync-sql.js",
@@ -60,7 +60,7 @@
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"exiftool-vendored": "^28.3.1", "exiftool-vendored": "^29.3.0",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0", "geo-tz": "^8.0.0",
@@ -116,7 +116,7 @@
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^22.13.14", "@types/node": "^22.14.0",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0", "@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",

View File

@@ -1,15 +1,20 @@
#!/usr/bin/env node #!/usr/bin/env node
process.env.DB_URL = 'postgres://postgres:postgres@localhost:5432/immich'; process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
import { Kysely } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { basename, dirname, extname, join } from 'node:path';
import postgres from 'postgres'; import postgres from 'postgres';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import 'src/schema/tables'; import { DatabaseRepository } from 'src/repositories/database.repository';
import { DatabaseTable, schemaDiff, schemaFromDatabase, schemaFromDecorators } from 'src/sql-tools'; import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
const main = async () => { const main = async () => {
const command = process.argv[2]; const command = process.argv[2];
const name = process.argv[3] || 'Migration'; const path = process.argv[3] || 'src/Migration';
switch (command) { switch (command) {
case 'debug': { case 'debug': {
@@ -17,13 +22,19 @@ const main = async () => {
return; return;
} }
case 'run': {
const only = process.argv[3] as 'kysely' | 'typeorm' | undefined;
await run(only);
return;
}
case 'create': { case 'create': {
create(name, [], []); create(path, [], []);
return; return;
} }
case 'generate': { case 'generate': {
await generate(name); await generate(path);
return; return;
} }
@@ -31,32 +42,57 @@ const main = async () => {
console.log(`Usage: console.log(`Usage:
node dist/bin/migrations.js create <name> node dist/bin/migrations.js create <name>
node dist/bin/migrations.js generate <name> node dist/bin/migrations.js generate <name>
node dist/bin/migrations.js run
`); `);
} }
} }
}; };
const run = async (only?: 'kysely' | 'typeorm') => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
const logger = new LoggingRepository(undefined, configRepository);
const db = new Kysely<any>({
dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }),
log(event) {
if (event.level === 'error') {
console.error('Query failed :', {
durationMs: event.queryDurationMillis,
error: event.error,
sql: event.query.sql,
params: event.query.parameters,
});
}
},
});
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
await databaseRepository.runMigrations({ only });
};
const debug = async () => { const debug = async () => {
const { up, down } = await compare(); const { up } = await compare();
const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n'); const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n');
const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n'); // const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n');
writeFileSync('./migrations.sql', upSql + '\n\n' + downSql); writeFileSync('./migrations.sql', upSql + '\n\n');
console.log('Wrote migrations.sql'); console.log('Wrote migrations.sql');
}; };
const generate = async (name: string) => { const generate = async (path: string) => {
const { up, down } = await compare(); const { up, down } = await compare();
if (up.items.length === 0) { if (up.items.length === 0) {
console.log('No changes detected'); console.log('No changes detected');
return; return;
} }
create(name, up.asSql(), down.asSql()); create(path, up.asSql(), down.asSql());
}; };
const create = (name: string, up: string[], down: string[]) => { const create = (path: string, up: string[], down: string[]) => {
const timestamp = Date.now(); const timestamp = Date.now();
const name = basename(path, extname(path));
const filename = `${timestamp}-${name}.ts`; const filename = `${timestamp}-${name}.ts`;
const fullPath = `./src/${filename}`; const folder = dirname(path);
const fullPath = join(folder, filename);
writeFileSync(fullPath, asMigration('kysely', { name, timestamp, up, down })); writeFileSync(fullPath, asMigration('kysely', { name, timestamp, up, down }));
console.log(`Wrote ${fullPath}`); console.log(`Wrote ${fullPath}`);
}; };
@@ -66,16 +102,25 @@ const compare = async () => {
const { database } = configRepository.getEnv(); const { database } = configRepository.getEnv();
const db = postgres(database.config.kysely); const db = postgres(database.config.kysely);
const source = schemaFromDecorators(); const source = schemaFromCode();
const target = await schemaFromDatabase(db, {}); const target = await schemaFromDatabase(db, {});
const sourceParams = new Set(source.parameters.map(({ name }) => name));
target.parameters = target.parameters.filter(({ name }) => sourceParams.has(name));
const sourceTables = new Set(source.tables.map(({ name }) => name));
target.tables = target.tables.filter(({ name }) => sourceTables.has(name));
console.log(source.warnings.join('\n')); console.log(source.warnings.join('\n'));
const isIncluded = (table: DatabaseTable) => source.tables.some(({ name }) => table.name === name); const up = schemaDiff(source, target, {
target.tables = target.tables.filter((table) => isIncluded(table)); tables: { ignoreExtra: true },
functions: { ignoreExtra: false },
const up = schemaDiff(source, target, { ignoreExtraTables: true }); });
const down = schemaDiff(target, source, { ignoreExtraTables: false }); const down = schemaDiff(target, source, {
tables: { ignoreExtra: false },
functions: { ignoreExtra: false },
});
return { up, down }; return { up, down };
}; };

View File

@@ -1,5 +1,6 @@
import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { AssetStatus, AssetType, Permission, UserStatus } from 'src/enum'; import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum';
import { OnThisDayData } from 'src/types';
export type AuthUser = { export type AuthUser = {
id: string; id: string;
@@ -29,6 +30,19 @@ export type AuthApiKey = {
permissions: Permission[]; permissions: Permission[];
}; };
export type Activity = {
id: string;
createdAt: Date;
updatedAt: Date;
albumId: string;
userId: string;
user: User;
assetId: string | null;
comment: string | null;
isLiked: boolean;
updateId: string;
};
export type ApiKey = { export type ApiKey = {
id: string; id: string;
name: string; name: string;
@@ -38,6 +52,31 @@ export type ApiKey = {
permissions: Permission[]; permissions: Permission[];
}; };
export type Tag = {
id: string;
value: string;
createdAt: Date;
updatedAt: Date;
color: string | null;
parentId: string | null;
};
export type Memory = {
id: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
memoryAt: Date;
seenAt: Date | null;
showAt: Date | null;
hideAt: Date | null;
type: MemoryType;
data: OnThisDayData;
ownerId: string;
isSaved: boolean;
assets: Asset[];
};
export type User = { export type User = {
id: string; id: string;
name: string; name: string;
@@ -92,6 +131,13 @@ export type Asset = {
type: AssetType; type: AssetType;
}; };
export type SidecarWriteAsset = {
id: string;
sidecarPath: string | null;
originalPath: string;
tags: Array<{ value: string }>;
};
export type AuthSharedLink = { export type AuthSharedLink = {
id: string; id: string;
expiresAt: Date | null; expiresAt: Date | null;
@@ -117,6 +163,28 @@ export type Partner = {
inTimeline: boolean; inTimeline: boolean;
}; };
export type Place = {
admin1Code: string | null;
admin1Name: string | null;
admin2Code: string | null;
admin2Name: string | null;
alternateNames: string | null;
countryCode: string;
id: number;
latitude: number;
longitude: number;
modificationDate: Date;
name: string;
};
export type Session = {
id: string;
createdAt: Date;
updatedAt: Date;
deviceOS: string;
deviceType: string;
};
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
export const columns = { export const columns = {
@@ -140,6 +208,7 @@ export const columns = {
'shared_links.password', 'shared_links.password',
], ],
user: userColumns, user: userColumns,
userWithPrefix: ['users.id', 'users.name', 'users.email', 'users.profileImagePath', 'users.profileChangedAt'],
userAdmin: [ userAdmin: [
...userColumns, ...userColumns,
'createdAt', 'createdAt',

29
server/src/db.d.ts vendored
View File

@@ -4,7 +4,18 @@
*/ */
import type { ColumnType } from 'kysely'; import type { ColumnType } from 'kysely';
import { AssetType, MemoryType, Permission, SyncEntityType } from 'src/enum'; import {
AlbumUserRole,
AssetFileType,
AssetOrder,
AssetStatus,
AssetType,
MemoryType,
Permission,
SharedLinkType,
SourceType,
SyncEntityType,
} from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { OnThisDayData } from 'src/types'; import { OnThisDayData } from 'src/types';
@@ -12,8 +23,6 @@ export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTyp
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S[], I[], U[]> : T[]; export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S[], I[], U[]> : T[];
export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed';
export type Generated<T> = export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>; T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
@@ -31,8 +40,6 @@ export type JsonPrimitive = boolean | number | string | null;
export type JsonValue = JsonArray | JsonObject | JsonPrimitive; export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Sourcetype = 'exif' | 'machine-learning' | 'manual';
export type Timestamp = ColumnType<Date, Date | string, Date | string>; export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export interface Activity { export interface Activity {
@@ -58,7 +65,7 @@ export interface Albums {
description: Generated<string>; description: Generated<string>;
id: Generated<string>; id: Generated<string>;
isActivityEnabled: Generated<boolean>; isActivityEnabled: Generated<boolean>;
order: Generated<string>; order: Generated<AssetOrder>;
ownerId: string; ownerId: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>; updateId: Generated<string>;
@@ -72,7 +79,7 @@ export interface AlbumsAssetsAssets {
export interface AlbumsSharedUsersUsers { export interface AlbumsSharedUsersUsers {
albumsId: string; albumsId: string;
role: Generated<string>; role: Generated<AlbumUserRole>;
usersId: string; usersId: string;
} }
@@ -98,7 +105,7 @@ export interface AssetFaces {
imageHeight: Generated<number>; imageHeight: Generated<number>;
imageWidth: Generated<number>; imageWidth: Generated<number>;
personId: string | null; personId: string | null;
sourceType: Generated<Sourcetype>; sourceType: Generated<SourceType>;
} }
export interface AssetFiles { export interface AssetFiles {
@@ -106,7 +113,7 @@ export interface AssetFiles {
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
id: Generated<string>; id: Generated<string>;
path: string; path: string;
type: string; type: AssetFileType;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
updateId: Generated<string>; updateId: Generated<string>;
} }
@@ -152,7 +159,7 @@ export interface Assets {
ownerId: string; ownerId: string;
sidecarPath: string | null; sidecarPath: string | null;
stackId: string | null; stackId: string | null;
status: Generated<AssetsStatusEnum>; status: Generated<AssetStatus>;
thumbhash: Buffer | null; thumbhash: Buffer | null;
type: AssetType; type: AssetType;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
@@ -350,7 +357,7 @@ export interface SharedLinks {
key: Buffer; key: Buffer;
password: string | null; password: string | null;
showExif: Generated<boolean>; showExif: Generated<boolean>;
type: string; type: SharedLinkType;
userId: string; userId: string;
} }

View File

@@ -4,8 +4,24 @@ import _ from 'lodash';
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { EmitEvent } from 'src/repositories/event.repository'; import { EmitEvent } from 'src/repositories/event.repository';
import { immich_uuid_v7, updated_at } from 'src/schema/functions';
import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools';
import { setUnion } from 'src/utils/set'; import { setUnion } from 'src/utils/set';
const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
Column({ ...options, type: 'uuid', nullable: false, default: () => `${immich_uuid_v7.name}()` });
export const UpdateIdColumn = () => GeneratedUuidV7Column();
export const PrimaryGeneratedUuidV7Column = () => GeneratedUuidV7Column({ primary: true });
export const UpdatedAtTrigger = (name: string) =>
BeforeUpdateTrigger({
name,
scope: 'row',
function: updated_at,
});
// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
// maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching // maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching
// by a list of IDs) requires splitting the query into multiple chunks. // by a list of IDs) requires splitting the query into multiple chunks.

View File

@@ -1,8 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
import { Activity } from 'src/database';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto'; import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { ActivityItem } from 'src/types';
import { Optional, ValidateUUID } from 'src/validation'; import { Optional, ValidateUUID } from 'src/validation';
export enum ReactionType { export enum ReactionType {
@@ -68,7 +68,7 @@ export class ActivityCreateDto extends ActivityDto {
comment?: string; comment?: string;
} }
export const mapActivity = (activity: ActivityItem): ActivityResponseDto => { export const mapActivity = (activity: Activity): ActivityResponseDto => {
return { return {
id: activity.id, id: activity.id,
assetId: activity.assetId, assetId: activity.assetId,

View File

@@ -1,11 +1,11 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator'; import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { Memory } from 'src/database';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { MemoryType } from 'src/enum'; import { MemoryType } from 'src/enum';
import { MemoryItem } from 'src/types';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
class MemoryBaseDto { class MemoryBaseDto {
@@ -89,7 +89,7 @@ export class MemoryResponseDto {
assets!: AssetResponseDto[]; assets!: AssetResponseDto[];
} }
export const mapMemory = (entity: MemoryItem, auth: AuthDto): MemoryResponseDto => { export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => {
return { return {
id: entity.id, id: entity.id,
createdAt: entity.createdAt, createdAt: entity.createdAt,

View File

@@ -1,10 +1,10 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { Place } from 'src/database';
import { PropertyLifecycle } from 'src/decorators'; import { PropertyLifecycle } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { AssetOrder, AssetType } from 'src/enum'; import { AssetOrder, AssetType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
@@ -226,15 +226,16 @@ export class PlacesResponseDto {
admin2name?: string; admin2name?: string;
} }
export function mapPlaces(place: GeodataPlacesEntity): PlacesResponseDto { export function mapPlaces(place: Place): PlacesResponseDto {
return { return {
name: place.name, name: place.name,
latitude: place.latitude, latitude: place.latitude,
longitude: place.longitude, longitude: place.longitude,
admin1name: place.admin1Name, admin1name: place.admin1Name ?? undefined,
admin2name: place.admin2Name, admin2name: place.admin2Name ?? undefined,
}; };
} }
export enum SearchSuggestionType { export enum SearchSuggestionType {
COUNTRY = 'country', COUNTRY = 'country',
STATE = 'state', STATE = 'state',

View File

@@ -1,4 +1,4 @@
import { SessionItem } from 'src/types'; import { Session } from 'src/database';
export class SessionResponseDto { export class SessionResponseDto {
id!: string; id!: string;
@@ -9,7 +9,7 @@ export class SessionResponseDto {
deviceOS!: string; deviceOS!: string;
} }
export const mapSession = (entity: SessionItem, currentId?: string): SessionResponseDto => ({ export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({
id: entity.id, id: entity.id,
createdAt: entity.createdAt.toISOString(), createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(), updatedAt: entity.updatedAt.toISOString(),

View File

@@ -104,9 +104,6 @@ export class SharedLinkResponseDto {
export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto { export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
const linkAssets = sharedLink.assets || []; const linkAssets = sharedLink.assets || [];
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
return { return {
id: sharedLink.id, id: sharedLink.id,
@@ -117,7 +114,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
type: sharedLink.type, type: sharedLink.type,
createdAt: sharedLink.createdAt, createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt, expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset)), assets: linkAssets.map((asset) => mapAsset(asset)),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload, allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload, allowDownload: sharedLink.allowDownload,

View File

@@ -1,7 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
import { TagEntity } from 'src/entities/tag.entity'; import { Tag } from 'src/database';
import { TagItem } from 'src/types';
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation'; import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
export class TagCreateDto { export class TagCreateDto {
@@ -52,7 +51,7 @@ export class TagResponseDto {
color?: string; color?: string;
} }
export function mapTag(entity: TagItem | TagEntity): TagResponseDto { export function mapTag(entity: Tag): TagResponseDto {
return { return {
id: entity.id, id: entity.id,
parentId: entity.parentId ?? undefined, parentId: entity.parentId ?? undefined,

View File

@@ -1,5 +1,6 @@
import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely'; import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Tag } from 'src/database';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
@@ -7,9 +8,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { ExifEntity } from 'src/entities/exif.entity'; import { ExifEntity } from 'src/entities/exif.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { StackEntity } from 'src/entities/stack.entity'; import { StackEntity } from 'src/entities/stack.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimeBucketSize } from 'src/repositories/asset.repository';
@@ -50,8 +49,7 @@ export class AssetEntity {
originalFileName!: string; originalFileName!: string;
sidecarPath!: string | null; sidecarPath!: string | null;
exifInfo?: ExifEntity; exifInfo?: ExifEntity;
smartSearch?: SmartSearchEntity; tags?: Tag[];
tags!: TagEntity[];
sharedLinks!: SharedLinkEntity[]; sharedLinks!: SharedLinkEntity[];
albums?: AlbumEntity[]; albums?: AlbumEntity[];
faces!: AssetFaceEntity[]; faces!: AssetFaceEntity[];
@@ -97,9 +95,9 @@ export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileT
return jsonArrayFrom( return jsonArrayFrom(
eb eb
.selectFrom('asset_files') .selectFrom('asset_files')
.selectAll() .selectAll('asset_files')
.whereRef('asset_files.assetId', '=', 'assets.id') .whereRef('asset_files.assetId', '=', 'assets.id')
.$if(!!type, (qb) => qb.where('type', '=', type!)), .$if(!!type, (qb) => qb.where('asset_files.type', '=', type!)),
).as('files'); ).as('files');
} }

View File

@@ -1,13 +0,0 @@
export class GeodataPlacesEntity {
id!: number;
name!: string;
longitude!: number;
latitude!: number;
countryCode!: string;
admin1Code!: string;
admin2Code!: string;
admin1Name!: string;
admin2Name!: string;
alternateNames!: string;
modificationDate!: Date;
}

View File

@@ -1,7 +0,0 @@
export class NaturalEarthCountriesTempEntity {
id!: number;
admin!: string;
admin_a3!: string;
type!: string;
coordinates!: string;
}

View File

@@ -1,49 +0,0 @@
import { ExpressionBuilder } from 'kysely';
import { DB } from 'src/db';
import { UserEntity } from 'src/entities/user.entity';
export class SessionEntity {
id!: string;
token!: string;
userId!: string;
user!: UserEntity;
createdAt!: Date;
updatedAt!: Date;
updateId!: string;
deviceType!: string;
deviceOS!: string;
}
const userColumns = [
'id',
'email',
'createdAt',
'profileImagePath',
'isAdmin',
'shouldChangePassword',
'deletedAt',
'oauthId',
'updatedAt',
'storageLabel',
'name',
'quotaSizeInBytes',
'quotaUsageInBytes',
'status',
'profileChangedAt',
] as const;
export const withUser = (eb: ExpressionBuilder<DB, 'sessions'>) => {
return eb
.selectFrom('users')
.select(userColumns)
.select((eb) =>
eb
.selectFrom('user_metadata')
.whereRef('users.id', '=', 'user_metadata.userId')
.select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata'))
.as('metadata'),
)
.whereRef('users.id', '=', 'sessions.userId')
.where('users.deletedAt', 'is', null)
.as('user');
};

View File

@@ -1,7 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
export class SmartSearchEntity {
asset?: AssetEntity;
assetId!: string;
embedding!: string;
}

View File

@@ -1,17 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
export class TagEntity {
id!: string;
value!: string;
createdAt!: Date;
updatedAt!: Date;
updateId?: string;
color!: string | null;
parentId?: string;
parent?: TagEntity;
children?: TagEntity[];
user?: UserEntity;
userId!: string;
assets?: AssetEntity[];
}

View File

@@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class TableCleanup1743595393000 implements MigrationInterface {
name = 'TableCleanup1743595393000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "system_config"`);
await queryRunner.query(`DROP TABLE IF EXISTS "socket_io_attachments"`);
}
public async down(): Promise<void> {}
}

View File

@@ -3,6 +3,38 @@
-- ActivityRepository.search -- ActivityRepository.search
select select
"activity".*, "activity".*,
to_json("user") as "user"
from
"activity"
inner join "users" on "users"."id" = "activity"."userId"
and "users"."deletedAt" is null
inner join lateral (
select
"users"."id",
"users"."name",
"users"."email",
"users"."profileImagePath",
"users"."profileChangedAt"
from
(
select
1
) as "dummy"
) as "user" on true
left join "assets" on "assets"."id" = "activity"."assetId"
and "assets"."deletedAt" is null
where
"activity"."albumId" = $1
order by
"activity"."createdAt" asc
-- ActivityRepository.create
insert into
"activity" ("albumId", "userId")
values
($1, $2)
returning
*,
( (
select select
to_json(obj) to_json(obj)
@@ -18,17 +50,13 @@ select
"users" "users"
where where
"users"."id" = "activity"."userId" "users"."id" = "activity"."userId"
and "users"."deletedAt" is null
) as obj ) as obj
) as "user" ) as "user"
from
"activity" -- ActivityRepository.delete
left join "assets" on "assets"."id" = "activity"."assetId" delete from "activity"
and "assets"."deletedAt" is null
where where
"activity"."albumId" = $1 "id" = $1::uuid
order by
"activity"."createdAt" asc
-- ActivityRepository.getStatistics -- ActivityRepository.getStatistics
select select

View File

@@ -179,6 +179,63 @@ from
where where
"livePhotoVideoId" = $1::uuid "livePhotoVideoId" = $1::uuid
-- AssetRepository.getAssetForSearchDuplicatesJob
select
"id",
"type",
"ownerId",
"duplicateId",
"stackId",
"isVisible",
"smart_search"."embedding",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_files".*
from
"asset_files"
where
"asset_files"."assetId" = "assets"."id"
and "asset_files"."type" = $1
) as agg
) as "files"
from
"assets"
left join "smart_search" on "assets"."id" = "smart_search"."assetId"
where
"assets"."id" = $2::uuid
limit
$3
-- AssetRepository.getAssetForSidecarWriteJob
select
"id",
"sidecarPath",
"originalPath",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"tags"."value"
from
"tags"
inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId"
where
"assets"."id" = "tag_asset"."assetsId"
) as agg
) as "tags"
from
"assets"
where
"assets"."id" = $1::uuid
limit
$2
-- AssetRepository.getById -- AssetRepository.getById
select select
"assets".* "assets".*

View File

@@ -38,41 +38,11 @@ where
-- SessionRepository.getByUserId -- SessionRepository.getByUserId
select select
"sessions".*, "sessions".*
to_json("user") as "user"
from from
"sessions" "sessions"
inner join lateral ( inner join "users" on "users"."id" = "sessions"."userId"
select and "users"."deletedAt" is null
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt",
(
select
array_agg("user_metadata") as "metadata"
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as "metadata"
from
"users"
where
"users"."id" = "sessions"."userId"
and "users"."deletedAt" is null
) as "user" on true
where where
"sessions"."userId" = $1 "sessions"."userId" = $1
order by order by

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely } from 'kysely'; import { Insertable, Kysely, NotNull, sql } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { columns } from 'src/database';
@@ -14,16 +14,6 @@ export interface ActivitySearch {
isLiked?: boolean; isLiked?: boolean;
} }
const withUser = (eb: ExpressionBuilder<DB, 'activity'>) => {
return jsonObjectFrom(
eb
.selectFrom('users')
.select(columns.user)
.whereRef('users.id', '=', 'activity.userId')
.where('users.deletedAt', 'is', null),
).as('user');
};
@Injectable() @Injectable()
export class ActivityRepository { export class ActivityRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -35,7 +25,16 @@ export class ActivityRepository {
return this.db return this.db
.selectFrom('activity') .selectFrom('activity')
.selectAll('activity') .selectAll('activity')
.select(withUser) .innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null))
.innerJoinLateral(
(eb) =>
eb
.selectFrom(sql`(select 1)`.as('dummy'))
.select(columns.userWithPrefix)
.as('user'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('user').as('user'))
.leftJoin('assets', (join) => join.onRef('assets.id', '=', 'activity.assetId').on('assets.deletedAt', 'is', null)) .leftJoin('assets', (join) => join.onRef('assets.id', '=', 'activity.assetId').on('assets.deletedAt', 'is', null))
.$if(!!userId, (qb) => qb.where('activity.userId', '=', userId!)) .$if(!!userId, (qb) => qb.where('activity.userId', '=', userId!))
.$if(assetId === null, (qb) => qb.where('assetId', 'is', null)) .$if(assetId === null, (qb) => qb.where('assetId', 'is', null))
@@ -46,10 +45,22 @@ export class ActivityRepository {
.execute(); .execute();
} }
@GenerateSql({ params: [{ albumId: DummyValue.UUID, userId: DummyValue.UUID }] })
async create(activity: Insertable<Activity>) { async create(activity: Insertable<Activity>) {
return this.save(activity); return this.db
.insertInto('activity')
.values(activity)
.returningAll()
.returning((eb) =>
jsonObjectFrom(eb.selectFrom('users').whereRef('users.id', '=', 'activity.userId').select(columns.user)).as(
'user',
),
)
.$narrowType<{ user: NotNull }>()
.executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string) { async delete(id: string) {
await this.db.deleteFrom('activity').where('id', '=', asUuid(id)).execute(); await this.db.deleteFrom('activity').where('id', '=', asUuid(id)).execute();
} }
@@ -72,15 +83,4 @@ export class ActivityRepository {
return count as number; return count as number;
} }
private async save(entity: Insertable<Activity>) {
const { id } = await this.db.insertInto('activity').values(entity).returning('id').executeTakeFirstOrThrow();
return this.db
.selectFrom('activity')
.selectAll('activity')
.select(withUser)
.where('activity.id', '=', asUuid(id))
.executeTakeFirstOrThrow();
}
} }

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely'; import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash'; import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
@@ -475,6 +476,47 @@ export class AssetRepository {
return count as number; return count as number;
} }
@GenerateSql({ params: [DummyValue.UUID] })
getAssetForSearchDuplicatesJob(id: string) {
return this.db
.selectFrom('assets')
.where('assets.id', '=', asUuid(id))
.leftJoin('smart_search', 'assets.id', 'smart_search.assetId')
.select((eb) => [
'id',
'type',
'ownerId',
'duplicateId',
'stackId',
'isVisible',
'smart_search.embedding',
withFiles(eb, AssetFileType.PREVIEW),
])
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getAssetForSidecarWriteJob(id: string) {
return this.db
.selectFrom('assets')
.where('assets.id', '=', asUuid(id))
.select((eb) => [
'id',
'sidecarPath',
'originalPath',
jsonArrayFrom(
eb
.selectFrom('tags')
.select(['tags.value'])
.innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId')
.whereRef('assets.id', '=', 'tag_asset.assetsId'),
).as('tags'),
])
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getById( getById(
id: string, id: string,

View File

@@ -197,58 +197,62 @@ export class DatabaseRepository {
return dimSize; return dimSize;
} }
async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise<void> { async runMigrations(options?: { transaction?: 'all' | 'none' | 'each'; only?: 'kysely' | 'typeorm' }): Promise<void> {
const { database } = this.configRepository.getEnv(); const { database } = this.configRepository.getEnv();
const dataSource = new DataSource(database.config.typeorm); if (options?.only !== 'kysely') {
const dataSource = new DataSource(database.config.typeorm);
this.logger.log('Running migrations, this may take a while'); this.logger.log('Running migrations, this may take a while');
this.logger.debug('Running typeorm migrations'); this.logger.debug('Running typeorm migrations');
await dataSource.initialize(); await dataSource.initialize();
await dataSource.runMigrations(options); await dataSource.runMigrations(options);
await dataSource.destroy(); await dataSource.destroy();
this.logger.debug('Finished running typeorm migrations'); this.logger.debug('Finished running typeorm migrations');
// eslint-disable-next-line unicorn/prefer-module
const migrationFolder = join(__dirname, '..', 'schema/migrations');
// TODO remove after we have at least one kysely migration
if (!existsSync(migrationFolder)) {
return;
} }
this.logger.debug('Running kysely migrations'); if (options?.only !== 'typeorm') {
const migrator = new Migrator({ // eslint-disable-next-line unicorn/prefer-module
db: this.db, const migrationFolder = join(__dirname, '..', 'schema/migrations');
migrationLockTableName: 'kysely_migrations_lock',
migrationTableName: 'kysely_migrations',
provider: new FileMigrationProvider({
fs: { readdir },
path: { join },
migrationFolder,
}),
});
const { error, results } = await migrator.migrateToLatest(); // TODO remove after we have at least one kysely migration
if (!existsSync(migrationFolder)) {
for (const result of results ?? []) { return;
if (result.status === 'Success') {
this.logger.log(`Migration "${result.migrationName}" succeeded`);
} }
if (result.status === 'Error') { this.logger.debug('Running kysely migrations');
this.logger.warn(`Migration "${result.migrationName}" failed`); const migrator = new Migrator({
db: this.db,
migrationLockTableName: 'kysely_migrations_lock',
migrationTableName: 'kysely_migrations',
provider: new FileMigrationProvider({
fs: { readdir },
path: { join },
migrationFolder,
}),
});
const { error, results } = await migrator.migrateToLatest();
for (const result of results ?? []) {
if (result.status === 'Success') {
this.logger.log(`Migration "${result.migrationName}" succeeded`);
}
if (result.status === 'Error') {
this.logger.warn(`Migration "${result.migrationName}" failed`);
}
} }
}
if (error) { if (error) {
this.logger.error(`Kysely migrations failed: ${error}`); this.logger.error(`Kysely migrations failed: ${error}`);
throw error; throw error;
} }
this.logger.debug('Finished running kysely migrations'); this.logger.debug('Finished running kysely migrations');
}
} }
async withLock<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> { async withLock<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> {

View File

@@ -1,4 +1,4 @@
import { ConsoleLogger, Injectable, Scope } from '@nestjs/common'; import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common';
import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util'; import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { Telemetry } from 'src/decorators'; import { Telemetry } from 'src/decorators';
@@ -26,7 +26,7 @@ export class MyConsoleLogger extends ConsoleLogger {
private isColorEnabled: boolean; private isColorEnabled: boolean;
constructor( constructor(
private cls: ClsService, private cls: ClsService | undefined,
options?: { color?: boolean; context?: string }, options?: { color?: boolean; context?: string },
) { ) {
super(options?.context || MyConsoleLogger.name); super(options?.context || MyConsoleLogger.name);
@@ -74,7 +74,7 @@ export class MyConsoleLogger extends ConsoleLogger {
export class LoggingRepository { export class LoggingRepository {
private logger: MyConsoleLogger; private logger: MyConsoleLogger;
constructor(cls: ClsService, configRepository: ConfigRepository) { constructor(@Inject(ClsService) cls: ClsService | undefined, configRepository: ConfigRepository) {
const { noColor } = configRepository.getEnv(); const { noColor } = configRepository.getEnv();
this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor }); this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor });
} }

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { getName } from 'i18n-iso-countries'; import { getName } from 'i18n-iso-countries';
import { Expression, Kysely, sql, SqlBool } from 'kysely'; import { Expression, Insertable, Kysely, sql, SqlBool } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { createReadStream, existsSync } from 'node:fs'; import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
@@ -8,7 +8,6 @@ import readLine from 'node:readline';
import { citiesFile } from 'src/constants'; import { citiesFile } from 'src/constants';
import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
import { SystemMetadataKey } from 'src/enum'; import { SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -182,11 +181,11 @@ export class MapRepository {
return; return;
} }
const entities: Omit<NaturalEarthCountriesTempEntity, 'id'>[] = []; const entities: Insertable<NaturalearthCountries>[] = [];
for (const feature of geoJSONData.features) { for (const feature of geoJSONData.features) {
for (const entry of feature.geometry.coordinates) { for (const entry of feature.geometry.coordinates) {
const coordinates: number[][][] = feature.geometry.type === 'MultiPolygon' ? entry[0] : entry; const coordinates: number[][][] = feature.geometry.type === 'MultiPolygon' ? entry[0] : entry;
const featureRecord: Omit<NaturalEarthCountriesTempEntity, 'id'> = { const featureRecord: Insertable<NaturalearthCountries> = {
admin: feature.properties.ADMIN, admin: feature.properties.ADMIN,
admin_a3: feature.properties.ADM0_A3, admin_a3: feature.properties.ADM0_A3,
type: feature.properties.TYPE, type: feature.properties.TYPE,

View File

@@ -5,7 +5,6 @@ import { randomUUID } from 'node:crypto';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity, searchAssetBuilder } from 'src/entities/asset.entity'; import { AssetEntity, searchAssetBuilder } from 'src/entities/asset.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { AssetStatus, AssetType } from 'src/enum'; import { AssetStatus, AssetType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { anyUuid, asUuid } from 'src/utils/database'; import { anyUuid, asUuid } from 'src/utils/database';
@@ -372,7 +371,7 @@ export class SearchRepository {
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> { searchPlaces(placeName: string) {
return this.db return this.db
.selectFrom('geodata_places') .selectFrom('geodata_places')
.selectAll() .selectAll()
@@ -395,7 +394,7 @@ export class SearchRepository {
`, `,
) )
.limit(20) .limit(20)
.execute() as Promise<GeodataPlacesEntity[]>; .execute();
} }
@GenerateSql({ params: [[DummyValue.UUID]] }) @GenerateSql({ params: [[DummyValue.UUID]] })

View File

@@ -5,7 +5,6 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database'; import { columns } from 'src/database';
import { DB, Sessions } from 'src/db'; import { DB, Sessions } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { withUser } from 'src/entities/session.entity';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
export type SessionSearchOptions = { updatedBefore: Date }; export type SessionSearchOptions = { updatedBefore: Date };
@@ -45,9 +44,8 @@ export class SessionRepository {
getByUserId(userId: string) { getByUserId(userId: string) {
return this.db return this.db
.selectFrom('sessions') .selectFrom('sessions')
.innerJoinLateral(withUser, (join) => join.onTrue()) .innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null))
.selectAll('sessions') .selectAll('sessions')
.select((eb) => eb.fn.toJson('user').as('user'))
.where('sessions.userId', '=', userId) .where('sessions.userId', '=', userId)
.orderBy('sessions.updatedAt', 'desc') .orderBy('sessions.updatedAt', 'desc')
.orderBy('sessions.createdAt', 'desc') .orderBy('sessions.createdAt', 'desc')

View File

@@ -0,0 +1,12 @@
import { AssetStatus, SourceType } from 'src/enum';
import { registerEnum } from 'src/sql-tools';
export const assets_status_enum = registerEnum({
name: 'assets_status_enum',
values: Object.values(AssetStatus),
});
export const asset_face_source_type = registerEnum({
name: 'sourcetype',
values: Object.values(SourceType),
});

View File

@@ -0,0 +1,116 @@
import { registerFunction } from 'src/sql-tools';
export const immich_uuid_v7 = registerFunction({
name: 'immich_uuid_v7',
arguments: ['p_timestamp timestamp with time zone default clock_timestamp()'],
returnType: 'uuid',
language: 'SQL',
behavior: 'volatile',
body: `
SELECT encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid())
placing substring(int8send(floor(extract(epoch from p_timestamp) * 1000)::bigint) from 3)
from 1 for 6
),
52, 1
),
53, 1
),
'hex')::uuid;
`,
synchronize: false,
});
export const updated_at = registerFunction({
name: 'updated_at',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
DECLARE
clock_timestamp TIMESTAMP := clock_timestamp();
BEGIN
new."updatedAt" = clock_timestamp;
new."updateId" = immich_uuid_v7(clock_timestamp);
return new;
END;`,
synchronize: false,
});
export const f_concat_ws = registerFunction({
name: 'f_concat_ws',
arguments: ['text', 'text[]'],
returnType: 'text',
language: 'SQL',
parallel: 'safe',
behavior: 'immutable',
body: `SELECT array_to_string($2, $1)`,
synchronize: false,
});
export const f_unaccent = registerFunction({
name: 'f_unaccent',
arguments: ['text'],
returnType: 'text',
language: 'SQL',
parallel: 'safe',
strict: true,
behavior: 'immutable',
return: `unaccent('unaccent', $1)`,
synchronize: false,
});
export const ll_to_earth_public = registerFunction({
name: 'll_to_earth_public',
arguments: ['latitude double precision', 'longitude double precision'],
returnType: 'public.earth',
language: 'SQL',
parallel: 'safe',
strict: true,
behavior: 'immutable',
body: `SELECT public.cube(public.cube(public.cube(public.earth()*cos(radians(latitude))*cos(radians(longitude))),public.earth()*cos(radians(latitude))*sin(radians(longitude))),public.earth()*sin(radians(latitude)))::public.earth`,
synchronize: false,
});
export const users_delete_audit = registerFunction({
name: 'users_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO users_audit ("userId")
SELECT "id"
FROM OLD;
RETURN NULL;
END`,
synchronize: false,
});
export const partners_delete_audit = registerFunction({
name: 'partners_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO partners_audit ("sharedById", "sharedWithId")
SELECT "sharedById", "sharedWithId"
FROM OLD;
RETURN NULL;
END`,
synchronize: false,
});
export const assets_delete_audit = registerFunction({
name: 'assets_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO assets_audit ("assetId", "ownerId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
synchronize: false,
});

109
server/src/schema/index.ts Normal file
View File

@@ -0,0 +1,109 @@
import { asset_face_source_type, assets_status_enum } from 'src/schema/enums';
import {
assets_delete_audit,
f_concat_ws,
f_unaccent,
immich_uuid_v7,
ll_to_earth_public,
partners_delete_audit,
updated_at,
users_delete_audit,
} from 'src/schema/functions';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { APIKeyTable } from 'src/schema/tables/api-key.table';
import { AssetAuditTable } from 'src/schema/tables/asset-audit.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetFileTable } from 'src/schema/tables/asset-files.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table';
import { ExifTable } from 'src/schema/tables/exif.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table';
import { MoveTable } from 'src/schema/tables/move.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { SmartSearchTable } from 'src/schema/tables/smart-search.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table';
import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table';
import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
import { TagClosureTable } from 'src/schema/tables/tag-closure.table';
import { UserAuditTable } from 'src/schema/tables/user-audit.table';
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'vectors', 'plpgsql'])
@ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' })
@ConfigurationParameter({
name: 'vectors.pgvector_compatibility',
value: () => 'on',
scope: 'user',
synchronize: false,
})
@Database({ name: 'immich' })
export class ImmichDatabase {
tables = [
ActivityTable,
AlbumAssetTable,
AlbumUserTable,
AlbumTable,
APIKeyTable,
AssetAuditTable,
AssetFaceTable,
AssetJobStatusTable,
AssetTable,
AssetFileTable,
AuditTable,
ExifTable,
FaceSearchTable,
GeodataPlacesTable,
LibraryTable,
MemoryAssetTable,
MemoryTable,
MoveTable,
NaturalEarthCountriesTable,
PartnerAuditTable,
PartnerTable,
PersonTable,
SessionTable,
SharedLinkAssetTable,
SharedLinkTable,
SmartSearchTable,
StackTable,
SessionSyncCheckpointTable,
SystemMetadataTable,
TagAssetTable,
TagClosureTable,
UserAuditTable,
UserMetadataTable,
UserTable,
VersionHistoryTable,
];
functions = [
immich_uuid_v7,
updated_at,
f_concat_ws,
f_unaccent,
ll_to_earth_public,
users_delete_audit,
partners_delete_audit,
assets_delete_audit,
];
enum = [assets_status_enum, asset_face_source_type];
}

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumTable } from 'src/schema/tables/album.table'; import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
@@ -11,10 +12,10 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('activity') @Table('activity')
@UpdatedAtTrigger('activity_updated_at')
@Index({ @Index({
name: 'IDX_activity_like', name: 'IDX_activity_like',
columns: ['assetId', 'userId', 'albumId'], columns: ['assetId', 'userId', 'albumId'],
@@ -35,9 +36,14 @@ export class ActivityTable {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@ColumnIndex('IDX_activity_update_id') @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@UpdateIdColumn() albumId!: string;
updateId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
userId!: string;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
assetId!: string | null;
@Column({ type: 'text', default: null }) @Column({ type: 'text', default: null })
comment!: string | null; comment!: string | null;
@@ -45,12 +51,7 @@ export class ActivityTable {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isLiked!: boolean; isLiked!: boolean;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) @ColumnIndex('IDX_activity_update_id')
assetId!: string | null; @UpdateIdColumn()
updateId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
userId!: string;
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
albumId!: string;
} }

View File

@@ -4,15 +4,6 @@ import { ColumnIndex, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-
@Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' }) @Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' })
export class AlbumAssetTable { export class AlbumAssetTable {
@ForeignKeyColumn(() => AssetTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
@ColumnIndex()
assetsId!: string;
@ForeignKeyColumn(() => AlbumTable, { @ForeignKeyColumn(() => AlbumTable, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
@@ -22,6 +13,15 @@ export class AlbumAssetTable {
@ColumnIndex() @ColumnIndex()
albumsId!: string; albumsId!: string;
@ForeignKeyColumn(() => AssetTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
@ColumnIndex()
assetsId!: string;
@CreateDateColumn() @CreateDateColumn()
createdAt!: Date; createdAt!: Date;
} }

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetOrder } from 'src/enum'; import { AssetOrder } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
@@ -10,10 +11,10 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' }) @Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' })
@UpdatedAtTrigger('albums_updated_at')
export class AlbumTable { export class AlbumTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: string; id!: string;
@@ -24,28 +25,33 @@ export class AlbumTable {
@Column({ default: 'Untitled Album' }) @Column({ default: 'Untitled Album' })
albumName!: string; albumName!: string;
@Column({ type: 'text', default: '' })
description!: string;
@CreateDateColumn() @CreateDateColumn()
createdAt!: Date; createdAt!: Date;
@ForeignKeyColumn(() => AssetTable, {
nullable: true,
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
comment: 'Asset ID to be used as thumbnail',
})
albumThumbnailAssetId!: string;
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@ColumnIndex('IDX_albums_update_id') @Column({ type: 'text', default: '' })
@UpdateIdColumn() description!: string;
updateId?: string;
@DeleteDateColumn() @DeleteDateColumn()
deletedAt!: Date | null; deletedAt!: Date | null;
@ForeignKeyColumn(() => AssetTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
albumThumbnailAssetId!: string;
@Column({ type: 'boolean', default: true }) @Column({ type: 'boolean', default: true })
isActivityEnabled!: boolean; isActivityEnabled!: boolean;
@Column({ default: AssetOrder.DESC }) @Column({ default: AssetOrder.DESC })
order!: AssetOrder; order!: AssetOrder;
@ColumnIndex('IDX_albums_update_id')
@UpdateIdColumn()
updateId?: string;
} }

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { import {
@@ -8,22 +9,19 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('api_keys') @Table('api_keys')
@UpdatedAtTrigger('api_keys_updated_at')
export class APIKeyTable { export class APIKeyTable {
@PrimaryGeneratedColumn()
id!: string;
@Column() @Column()
name!: string; name!: string;
@Column() @Column()
key!: string; key!: string;
@Column({ array: true, type: 'character varying' }) @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
permissions!: Permission[]; userId!: string;
@CreateDateColumn() @CreateDateColumn()
createdAt!: Date; createdAt!: Date;
@@ -31,10 +29,13 @@ export class APIKeyTable {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@PrimaryGeneratedColumn()
id!: string;
@Column({ array: true, type: 'character varying' })
permissions!: Permission[];
@ColumnIndex({ name: 'IDX_api_keys_update_id' }) @ColumnIndex({ name: 'IDX_api_keys_update_id' })
@UpdateIdColumn() @UpdateIdColumn()
updateId?: string; updateId?: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
} }

View File

@@ -1,8 +1,9 @@
import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools';
@Table('assets_audit') @Table('assets_audit')
export class AssetAuditTable { export class AssetAuditTable {
@PrimaryGeneratedColumn({ type: 'v7' }) @PrimaryGeneratedUuidV7Column()
id!: string; id!: string;
@ColumnIndex('IDX_assets_audit_asset_id') @ColumnIndex('IDX_assets_audit_asset_id')

View File

@@ -1,4 +1,5 @@
import { SourceType } from 'src/enum'; import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { PersonTable } from 'src/schema/tables/person.table'; import { PersonTable } from 'src/schema/tables/person.table';
import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@@ -7,8 +8,11 @@ import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColu
@Index({ name: 'IDX_asset_faces_assetId_personId', columns: ['assetId', 'personId'] }) @Index({ name: 'IDX_asset_faces_assetId_personId', columns: ['assetId', 'personId'] })
@Index({ columns: ['personId', 'assetId'] }) @Index({ columns: ['personId', 'assetId'] })
export class AssetFaceTable { export class AssetFaceTable {
@PrimaryGeneratedColumn() @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
id!: string; assetId!: string;
@ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true })
personId!: string | null;
@Column({ default: 0, type: 'integer' }) @Column({ default: 0, type: 'integer' })
imageWidth!: number; imageWidth!: number;
@@ -28,15 +32,12 @@ export class AssetFaceTable {
@Column({ default: 0, type: 'integer' }) @Column({ default: 0, type: 'integer' })
boundingBoxY2!: number; boundingBoxY2!: number;
@Column({ default: SourceType.MACHINE_LEARNING, enumName: 'sourcetype', enum: SourceType }) @PrimaryGeneratedColumn()
id!: string;
@Column({ default: SourceType.MACHINE_LEARNING, enum: asset_face_source_type })
sourceType!: SourceType; sourceType!: SourceType;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
assetId!: string;
@ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true })
personId!: string | null;
@DeleteDateColumn() @DeleteDateColumn()
deletedAt!: Date | null; deletedAt!: Date | null;
} }

View File

@@ -1,5 +1,6 @@
import { AssetEntity } from 'src/entities/asset.entity'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetFileType } from 'src/enum'; import { AssetFileType } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table';
import { import {
Column, Column,
ColumnIndex, ColumnIndex,
@@ -9,18 +10,18 @@ import {
Table, Table,
Unique, Unique,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Unique({ name: 'UQ_assetId_type', columns: ['assetId', 'type'] })
@Table('asset_files') @Table('asset_files')
@Unique({ name: 'UQ_assetId_type', columns: ['assetId', 'type'] })
@UpdatedAtTrigger('asset_files_updated_at')
export class AssetFileTable { export class AssetFileTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: string; id!: string;
@ColumnIndex('IDX_asset_files_assetId') @ColumnIndex('IDX_asset_files_assetId')
@ForeignKeyColumn(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
assetId?: AssetEntity; assetId?: string;
@CreateDateColumn() @CreateDateColumn()
createdAt!: Date; createdAt!: Date;
@@ -28,13 +29,13 @@ export class AssetFileTable {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@ColumnIndex('IDX_asset_files_update_id')
@UpdateIdColumn()
updateId?: string;
@Column() @Column()
type!: AssetFileType; type!: AssetFileType;
@Column() @Column()
path!: string; path!: string;
@ColumnIndex('IDX_asset_files_update_id')
@UpdateIdColumn()
updateId?: string;
} }

View File

@@ -1,9 +1,13 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity';
import { AssetStatus, AssetType } from 'src/enum'; import { AssetStatus, AssetType } from 'src/enum';
import { assets_status_enum } from 'src/schema/enums';
import { assets_delete_audit } from 'src/schema/functions';
import { LibraryTable } from 'src/schema/tables/library.table'; import { LibraryTable } from 'src/schema/tables/library.table';
import { StackTable } from 'src/schema/tables/stack.table'; import { StackTable } from 'src/schema/tables/stack.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { import {
AfterDeleteTrigger,
Column, Column,
ColumnIndex, ColumnIndex,
CreateDateColumn, CreateDateColumn,
@@ -13,10 +17,17 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('assets') @Table('assets')
@UpdatedAtTrigger('assets_updated_at')
@AfterDeleteTrigger({
name: 'assets_delete_audit',
scope: 'statement',
function: assets_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
// Checksums must be unique per user and library // Checksums must be unique per user and library
@Index({ @Index({
name: ASSET_CHECKSUM_CONSTRAINT, name: ASSET_CHECKSUM_CONSTRAINT,
@@ -30,7 +41,11 @@ import {
unique: true, unique: true,
where: '("libraryId" IS NOT NULL)', where: '("libraryId" IS NOT NULL)',
}) })
@Index({ name: 'idx_local_date_time', expression: `(("localDateTime" AT TIME ZONE 'UTC'::text))::date` }) @Index({
name: 'idx_local_date_time',
expression: `(("localDateTime" at time zone 'UTC')::date)`,
synchronize: false,
})
@Index({ @Index({
name: 'idx_local_date_time_month', name: 'idx_local_date_time_month',
expression: `(date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text)`, expression: `(date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text)`,
@@ -38,9 +53,10 @@ import {
@Index({ name: 'IDX_originalPath_libraryId', columns: ['originalPath', 'libraryId'] }) @Index({ name: 'IDX_originalPath_libraryId', columns: ['originalPath', 'libraryId'] })
@Index({ name: 'IDX_asset_id_stackId', columns: ['id', 'stackId'] }) @Index({ name: 'IDX_asset_id_stackId', columns: ['id', 'stackId'] })
@Index({ @Index({
name: 'idx_originalFileName_trigram', name: 'idx_originalfilename_trigram',
using: 'gin', using: 'gin',
expression: 'f_unaccent(("originalFileName")::text)', expression: 'f_unaccent("originalFileName") gin_trgm_ops',
synchronize: false,
}) })
// For all assets, each originalpath must be unique per user and library // For all assets, each originalpath must be unique per user and library
export class AssetTable { export class AssetTable {
@@ -53,75 +69,50 @@ export class AssetTable {
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string; ownerId!: string;
@ForeignKeyColumn(() => LibraryTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
libraryId?: string | null;
@Column() @Column()
deviceId!: string; deviceId!: string;
@Column() @Column()
type!: AssetType; type!: AssetType;
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE })
status!: AssetStatus;
@Column() @Column()
originalPath!: string; originalPath!: string;
@Column({ type: 'bytea', nullable: true })
thumbhash!: Buffer | null;
@Column({ type: 'character varying', nullable: true, default: '' })
encodedVideoPath!: string | null;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_assets_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn()
deletedAt!: Date | null;
@ColumnIndex('idx_asset_file_created_at') @ColumnIndex('idx_asset_file_created_at')
@Column({ type: 'timestamp with time zone', default: null }) @Column({ type: 'timestamp with time zone', default: null })
fileCreatedAt!: Date; fileCreatedAt!: Date;
@Column({ type: 'timestamp with time zone', default: null })
localDateTime!: Date;
@Column({ type: 'timestamp with time zone', default: null }) @Column({ type: 'timestamp with time zone', default: null })
fileModifiedAt!: Date; fileModifiedAt!: Date;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isFavorite!: boolean; isFavorite!: boolean;
@Column({ type: 'boolean', default: false }) @Column({ type: 'character varying', nullable: true })
isArchived!: boolean; duration!: string | null;
@Column({ type: 'boolean', default: false }) @Column({ type: 'character varying', nullable: true, default: '' })
isExternal!: boolean; encodedVideoPath!: string | null;
@Column({ type: 'boolean', default: false })
isOffline!: boolean;
@Column({ type: 'bytea' }) @Column({ type: 'bytea' })
@ColumnIndex() @ColumnIndex()
checksum!: Buffer; // sha1 checksum checksum!: Buffer; // sha1 checksum
@Column({ type: 'character varying', nullable: true })
duration!: string | null;
@Column({ type: 'boolean', default: true }) @Column({ type: 'boolean', default: true })
isVisible!: boolean; isVisible!: boolean;
@ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' }) @ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
livePhotoVideoId!: string | null; livePhotoVideoId!: string | null;
@UpdateDateColumn()
updatedAt!: Date;
@CreateDateColumn()
createdAt!: Date;
@Column({ type: 'boolean', default: false })
isArchived!: boolean;
@Column() @Column()
@ColumnIndex() @ColumnIndex()
originalFileName!: string; originalFileName!: string;
@@ -129,10 +120,35 @@ export class AssetTable {
@Column({ nullable: true }) @Column({ nullable: true })
sidecarPath!: string | null; sidecarPath!: string | null;
@Column({ type: 'bytea', nullable: true })
thumbhash!: Buffer | null;
@Column({ type: 'boolean', default: false })
isOffline!: boolean;
@ForeignKeyColumn(() => LibraryTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
libraryId?: string | null;
@Column({ type: 'boolean', default: false })
isExternal!: boolean;
@DeleteDateColumn()
deletedAt!: Date | null;
@Column({ type: 'timestamp with time zone', default: null })
localDateTime!: Date;
@ForeignKeyColumn(() => StackTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) @ForeignKeyColumn(() => StackTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
stackId?: string | null; stackId?: string | null;
@ColumnIndex('IDX_assets_duplicateId') @ColumnIndex('IDX_assets_duplicateId')
@Column({ type: 'uuid', nullable: true }) @Column({ type: 'uuid', nullable: true })
duplicateId!: string | null; duplicateId!: string | null;
@Column({ enum: assets_status_enum, default: AssetStatus.ACTIVE })
status!: AssetStatus;
@ColumnIndex('IDX_assets_update_id')
@UpdateIdColumn()
updateId?: string;
} }

View File

@@ -4,7 +4,7 @@ import { Column, CreateDateColumn, Index, PrimaryColumn, Table } from 'src/sql-t
@Table('audit') @Table('audit')
@Index({ name: 'IDX_ownerId_createdAt', columns: ['ownerId', 'createdAt'] }) @Index({ name: 'IDX_ownerId_createdAt', columns: ['ownerId', 'createdAt'] })
export class AuditTable { export class AuditTable {
@PrimaryColumn({ type: 'integer', default: 'increment', synchronize: false }) @PrimaryColumn({ type: 'serial', synchronize: false })
id!: number; id!: number;
@Column() @Column()

View File

@@ -1,21 +1,18 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn, UpdateIdColumn } from 'src/sql-tools'; import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools';
@Table('exif') @Table('exif')
@UpdatedAtTrigger('asset_exif_updated_at')
export class ExifTable { export class ExifTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true }) @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true })
assetId!: string; assetId!: string;
@UpdateDateColumn({ default: () => 'clock_timestamp()' }) @Column({ type: 'character varying', nullable: true })
updatedAt?: Date; make!: string | null;
@ColumnIndex('IDX_asset_exif_update_id') @Column({ type: 'character varying', nullable: true })
@UpdateIdColumn() model!: string | null;
updateId?: string;
/* General info */
@Column({ type: 'text', default: '' })
description!: string; // or caption
@Column({ type: 'integer', nullable: true }) @Column({ type: 'integer', nullable: true })
exifImageWidth!: number | null; exifImageWidth!: number | null;
@@ -35,43 +32,6 @@ export class ExifTable {
@Column({ type: 'timestamp with time zone', nullable: true }) @Column({ type: 'timestamp with time zone', nullable: true })
modifyDate!: Date | null; modifyDate!: Date | null;
@Column({ type: 'character varying', nullable: true })
timeZone!: string | null;
@Column({ type: 'double precision', nullable: true })
latitude!: number | null;
@Column({ type: 'double precision', nullable: true })
longitude!: number | null;
@Column({ type: 'character varying', nullable: true })
projectionType!: string | null;
@ColumnIndex('exif_city')
@Column({ type: 'character varying', nullable: true })
city!: string | null;
@ColumnIndex('IDX_live_photo_cid')
@Column({ type: 'character varying', nullable: true })
livePhotoCID!: string | null;
@ColumnIndex('IDX_auto_stack_id')
@Column({ type: 'character varying', nullable: true })
autoStackId!: string | null;
@Column({ type: 'character varying', nullable: true })
state!: string | null;
@Column({ type: 'character varying', nullable: true })
country!: string | null;
/* Image info */
@Column({ type: 'character varying', nullable: true })
make!: string | null;
@Column({ type: 'character varying', nullable: true })
model!: string | null;
@Column({ type: 'character varying', nullable: true }) @Column({ type: 'character varying', nullable: true })
lensModel!: string | null; lensModel!: string | null;
@@ -84,9 +44,41 @@ export class ExifTable {
@Column({ type: 'integer', nullable: true }) @Column({ type: 'integer', nullable: true })
iso!: number | null; iso!: number | null;
@Column({ type: 'double precision', nullable: true })
latitude!: number | null;
@Column({ type: 'double precision', nullable: true })
longitude!: number | null;
@ColumnIndex('exif_city')
@Column({ type: 'character varying', nullable: true })
city!: string | null;
@Column({ type: 'character varying', nullable: true })
state!: string | null;
@Column({ type: 'character varying', nullable: true })
country!: string | null;
@Column({ type: 'text', default: '' })
description!: string; // or caption
@Column({ type: 'double precision', nullable: true })
fps?: number | null;
@Column({ type: 'character varying', nullable: true }) @Column({ type: 'character varying', nullable: true })
exposureTime!: string | null; exposureTime!: string | null;
@ColumnIndex('IDX_live_photo_cid')
@Column({ type: 'character varying', nullable: true })
livePhotoCID!: string | null;
@Column({ type: 'character varying', nullable: true })
timeZone!: string | null;
@Column({ type: 'character varying', nullable: true })
projectionType!: string | null;
@Column({ type: 'character varying', nullable: true }) @Column({ type: 'character varying', nullable: true })
profileDescription!: string | null; profileDescription!: string | null;
@@ -96,10 +88,17 @@ export class ExifTable {
@Column({ type: 'integer', nullable: true }) @Column({ type: 'integer', nullable: true })
bitsPerSample!: number | null; bitsPerSample!: number | null;
@ColumnIndex('IDX_auto_stack_id')
@Column({ type: 'character varying', nullable: true })
autoStackId!: string | null;
@Column({ type: 'integer', nullable: true }) @Column({ type: 'integer', nullable: true })
rating!: number | null; rating!: number | null;
/* Video info */ @UpdateDateColumn({ default: () => 'clock_timestamp()' })
@Column({ type: 'double precision', nullable: true }) updatedAt?: Date;
fps?: number | null;
@ColumnIndex('IDX_asset_exif_update_id')
@UpdateIdColumn()
updateId?: string;
} }

View File

@@ -1,7 +1,14 @@
import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
@Table({ name: 'face_search', primaryConstraintName: 'face_search_pkey' }) @Table({ name: 'face_search', primaryConstraintName: 'face_search_pkey' })
@Index({
name: 'face_index',
using: 'hnsw',
expression: `embedding vector_cosine_ops`,
with: 'ef_construction = 300, m = 16',
synchronize: false,
})
export class FaceSearchTable { export class FaceSearchTable {
@ForeignKeyColumn(() => AssetFaceTable, { @ForeignKeyColumn(() => AssetFaceTable, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
@@ -10,7 +17,6 @@ export class FaceSearchTable {
}) })
faceId!: string; faceId!: string;
@ColumnIndex({ name: 'face_index', synchronize: false }) @Column({ type: 'vector', length: 512, synchronize: false })
@Column({ type: 'vector', array: true, length: 512, synchronize: false })
embedding!: string; embedding!: string;
} }

View File

@@ -1,10 +1,35 @@
import { Column, Index, PrimaryColumn, Table } from 'src/sql-tools'; import { Column, Index, PrimaryColumn, Table } from 'src/sql-tools';
@Index({ name: 'idx_geodata_places_alternate_names', expression: 'f_unaccent("alternateNames") gin_trgm_ops' }) @Table({ name: 'geodata_places' })
@Index({ name: 'idx_geodata_places_admin1_name', expression: 'f_unaccent("admin1Name") gin_trgm_ops' }) @Index({
@Index({ name: 'idx_geodata_places_admin2_name', expression: 'f_unaccent("admin2Name") gin_trgm_ops' }) name: 'idx_geodata_places_alternate_names',
@Index({ name: 'idx_geodata_places_name', expression: 'f_unaccent("name") gin_trgm_ops' }) using: 'gin',
@Index({ name: 'idx_geodata_places_gist_earthcoord', expression: 'll_to_earth_public(latitude, longitude)' }) expression: 'f_unaccent("alternateNames") gin_trgm_ops',
synchronize: false,
})
@Index({
name: 'idx_geodata_places_admin1_name',
using: 'gin',
expression: 'f_unaccent("admin1Name") gin_trgm_ops',
synchronize: false,
})
@Index({
name: 'idx_geodata_places_admin2_name',
using: 'gin',
expression: 'f_unaccent("admin2Name") gin_trgm_ops',
synchronize: false,
})
@Index({
name: 'idx_geodata_places_name',
using: 'gin',
expression: 'f_unaccent("name") gin_trgm_ops',
synchronize: false,
})
@Index({
name: 'idx_geodata_places_gist_earthcoord',
expression: 'll_to_earth_public(latitude, longitude)',
synchronize: false,
})
@Table({ name: 'idx_geodata_places', synchronize: false }) @Table({ name: 'idx_geodata_places', synchronize: false })
export class GeodataPlacesTable { export class GeodataPlacesTable {
@PrimaryColumn({ type: 'integer' }) @PrimaryColumn({ type: 'integer' })
@@ -28,41 +53,8 @@ export class GeodataPlacesTable {
@Column({ type: 'character varying', length: 80, nullable: true }) @Column({ type: 'character varying', length: 80, nullable: true })
admin2Code!: string; admin2Code!: string;
@Column({ type: 'character varying', nullable: true })
admin1Name!: string;
@Column({ type: 'character varying', nullable: true })
admin2Name!: string;
@Column({ type: 'character varying', nullable: true })
alternateNames!: string;
@Column({ type: 'date' }) @Column({ type: 'date' })
modificationDate!: Date; modificationDate!: Date;
}
@Table({ name: 'geodata_places_tmp', synchronize: false })
export class GeodataPlacesTempEntity {
@PrimaryColumn({ type: 'integer' })
id!: number;
@Column({ type: 'character varying', length: 200 })
name!: string;
@Column({ type: 'double precision' })
longitude!: number;
@Column({ type: 'double precision' })
latitude!: number;
@Column({ type: 'character', length: 2 })
countryCode!: string;
@Column({ type: 'character varying', length: 20, nullable: true })
admin1Code!: string;
@Column({ type: 'character varying', length: 80, nullable: true })
admin2Code!: string;
@Column({ type: 'character varying', nullable: true }) @Column({ type: 'character varying', nullable: true })
admin1Name!: string; admin1Name!: string;
@@ -72,7 +64,4 @@ export class GeodataPlacesTempEntity {
@Column({ type: 'character varying', nullable: true }) @Column({ type: 'character varying', nullable: true })
alternateNames!: string; alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
} }

View File

@@ -1,73 +1,35 @@
import { ActivityTable } from 'src/schema/tables/activity.table'; import 'src/schema/tables/activity.table';
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; import 'src/schema/tables/album-asset.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table'; import 'src/schema/tables/album.table';
import { APIKeyTable } from 'src/schema/tables/api-key.table'; import 'src/schema/tables/api-key.table';
import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; import 'src/schema/tables/asset-audit.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import 'src/schema/tables/asset-face.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; import 'src/schema/tables/asset-files.table';
import { AssetTable } from 'src/schema/tables/asset.table'; import 'src/schema/tables/asset-job-status.table';
import { AuditTable } from 'src/schema/tables/audit.table'; import 'src/schema/tables/asset.table';
import { ExifTable } from 'src/schema/tables/exif.table'; import 'src/schema/tables/audit.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import 'src/schema/tables/exif.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; import 'src/schema/tables/face-search.table';
import { LibraryTable } from 'src/schema/tables/library.table'; import 'src/schema/tables/geodata-places.table';
import { MemoryTable } from 'src/schema/tables/memory.table'; import 'src/schema/tables/library.table';
import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; import 'src/schema/tables/memory.table';
import { MoveTable } from 'src/schema/tables/move.table'; import 'src/schema/tables/memory_asset.table';
import { import 'src/schema/tables/move.table';
NaturalEarthCountriesTable, import 'src/schema/tables/natural-earth-countries.table';
NaturalEarthCountriesTempTable, import 'src/schema/tables/partner-audit.table';
} from 'src/schema/tables/natural-earth-countries.table'; import 'src/schema/tables/partner.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import 'src/schema/tables/person.table';
import { PartnerTable } from 'src/schema/tables/partner.table'; import 'src/schema/tables/session.table';
import { PersonTable } from 'src/schema/tables/person.table'; import 'src/schema/tables/shared-link-asset.table';
import { SessionTable } from 'src/schema/tables/session.table'; import 'src/schema/tables/shared-link.table';
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; import 'src/schema/tables/smart-search.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; import 'src/schema/tables/stack.table';
import { SmartSearchTable } from 'src/schema/tables/smart-search.table'; import 'src/schema/tables/sync-checkpoint.table';
import { StackTable } from 'src/schema/tables/stack.table'; import 'src/schema/tables/system-metadata.table';
import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table'; import 'src/schema/tables/tag-asset.table';
import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table'; import 'src/schema/tables/tag-closure.table';
import { TagAssetTable } from 'src/schema/tables/tag-asset.table'; import 'src/schema/tables/user-audit.table';
import { UserAuditTable } from 'src/schema/tables/user-audit.table'; import 'src/schema/tables/user-metadata.table';
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import 'src/schema/tables/user.table';
import { UserTable } from 'src/schema/tables/user.table'; import 'src/schema/tables/version-history.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
export const tables = [
ActivityTable,
AlbumAssetTable,
AlbumUserTable,
AlbumTable,
APIKeyTable,
AssetAuditTable,
AssetFaceTable,
AssetJobStatusTable,
AssetTable,
AuditTable,
ExifTable,
FaceSearchTable,
GeodataPlacesTable,
LibraryTable,
MemoryAssetTable,
MemoryTable,
MoveTable,
NaturalEarthCountriesTable,
NaturalEarthCountriesTempTable,
PartnerAuditTable,
PartnerTable,
PersonTable,
SessionTable,
SharedLinkAssetTable,
SharedLinkTable,
SmartSearchTable,
StackTable,
SessionSyncCheckpointTable,
SystemMetadataTable,
TagAssetTable,
UserAuditTable,
UserMetadataTable,
UserTable,
VersionHistoryTable,
];

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { import {
Column, Column,
@@ -8,10 +9,10 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('libraries') @Table('libraries')
@UpdatedAtTrigger('libraries_updated_at')
export class LibraryTable { export class LibraryTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: string; id!: string;
@@ -34,13 +35,13 @@ export class LibraryTable {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@ColumnIndex('IDX_libraries_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn() @DeleteDateColumn()
deletedAt?: Date; deletedAt?: Date;
@Column({ type: 'timestamp with time zone', nullable: true }) @Column({ type: 'timestamp with time zone', nullable: true })
refreshedAt!: Date | null; refreshedAt!: Date | null;
@ColumnIndex('IDX_libraries_update_id')
@UpdateIdColumn()
updateId?: string;
} }

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { MemoryType } from 'src/enum'; import { MemoryType } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { import {
@@ -9,11 +10,11 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
import { MemoryData } from 'src/types'; import { MemoryData } from 'src/types';
@Table('memories') @Table('memories')
@UpdatedAtTrigger('memories_updated_at')
export class MemoryTable<T extends MemoryType = MemoryType> { export class MemoryTable<T extends MemoryType = MemoryType> {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: string; id!: string;
@@ -24,10 +25,6 @@ export class MemoryTable<T extends MemoryType = MemoryType> {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@ColumnIndex('IDX_memories_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn() @DeleteDateColumn()
deletedAt?: Date; deletedAt?: Date;
@@ -48,13 +45,17 @@ export class MemoryTable<T extends MemoryType = MemoryType> {
@Column({ type: 'timestamp with time zone' }) @Column({ type: 'timestamp with time zone' })
memoryAt!: Date; memoryAt!: Date;
/** when the user last viewed the memory */
@Column({ type: 'timestamp with time zone', nullable: true })
seenAt?: Date;
@Column({ type: 'timestamp with time zone', nullable: true }) @Column({ type: 'timestamp with time zone', nullable: true })
showAt?: Date; showAt?: Date;
@Column({ type: 'timestamp with time zone', nullable: true }) @Column({ type: 'timestamp with time zone', nullable: true })
hideAt?: Date; hideAt?: Date;
/** when the user last viewed the memory */ @ColumnIndex('IDX_memories_update_id')
@Column({ type: 'timestamp with time zone', nullable: true }) @UpdateIdColumn()
seenAt?: Date; updateId?: string;
} }

View File

@@ -4,11 +4,11 @@ import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('memories_assets_assets') @Table('memories_assets_assets')
export class MemoryAssetTable { export class MemoryAssetTable {
@ColumnIndex()
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
@ColumnIndex() @ColumnIndex()
@ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) @ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
memoriesId!: string; memoriesId!: string;
@ColumnIndex()
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
} }

View File

@@ -1,26 +1,8 @@
import { Column, PrimaryColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; import { Column, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@Table({ name: 'naturalearth_countries', synchronize: false }) @Table({ name: 'naturalearth_countries' })
export class NaturalEarthCountriesTable { export class NaturalEarthCountriesTable {
@PrimaryColumn({ type: 'serial' }) @PrimaryGeneratedColumn({ strategy: 'identity' })
id!: number;
@Column({ type: 'character varying', length: 50 })
admin!: string;
@Column({ type: 'character varying', length: 3 })
admin_a3!: string;
@Column({ type: 'character varying', length: 50 })
type!: string;
@Column({ type: 'polygon' })
coordinates!: string;
}
@Table({ name: 'naturalearth_countries_tmp', synchronize: false })
export class NaturalEarthCountriesTempTable {
@PrimaryGeneratedColumn()
id!: number; id!: number;
@Column({ type: 'character varying', length: 50 }) @Column({ type: 'character varying', length: 50 })

View File

@@ -1,8 +1,9 @@
import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools';
@Table('partners_audit') @Table('partners_audit')
export class PartnerAuditTable { export class PartnerAuditTable {
@PrimaryGeneratedColumn({ type: 'v7' }) @PrimaryGeneratedUuidV7Column()
id!: string; id!: string;
@ColumnIndex('IDX_partners_audit_shared_by_id') @ColumnIndex('IDX_partners_audit_shared_by_id')

View File

@@ -1,15 +1,25 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { partners_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { import {
AfterDeleteTrigger,
Column, Column,
ColumnIndex, ColumnIndex,
CreateDateColumn, CreateDateColumn,
ForeignKeyColumn, ForeignKeyColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('partners') @Table('partners')
@UpdatedAtTrigger('partners_updated_at')
@AfterDeleteTrigger({
name: 'partners_delete_audit',
scope: 'statement',
function: partners_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class PartnerTable { export class PartnerTable {
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true }) @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true })
sharedById!: string; sharedById!: string;
@@ -23,10 +33,10 @@ export class PartnerTable {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@Column({ type: 'boolean', default: false })
inTimeline!: boolean;
@ColumnIndex('IDX_partners_update_id') @ColumnIndex('IDX_partners_update_id')
@UpdateIdColumn() @UpdateIdColumn()
updateId!: string; updateId!: string;
@Column({ type: 'boolean', default: false })
inTimeline!: boolean;
} }

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { import {
@@ -9,10 +10,10 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('person') @Table('person')
@UpdatedAtTrigger('person_updated_at')
@Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` }) @Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` })
export class PersonTable { export class PersonTable {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@@ -24,31 +25,31 @@ export class PersonTable {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@ColumnIndex('IDX_person_update_id')
@UpdateIdColumn()
updateId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string; ownerId!: string;
@Column({ default: '' }) @Column({ default: '' })
name!: string; name!: string;
@Column({ type: 'date', nullable: true })
birthDate!: Date | string | null;
@Column({ default: '' }) @Column({ default: '' })
thumbnailPath!: string; thumbnailPath!: string;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
faceAssetId!: string | null;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isHidden!: boolean; isHidden!: boolean;
@Column({ type: 'date', nullable: true })
birthDate!: Date | string | null;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
faceAssetId!: string | null;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isFavorite!: boolean; isFavorite!: boolean;
@Column({ type: 'character varying', nullable: true, default: null }) @Column({ type: 'character varying', nullable: true, default: null })
color?: string | null; color?: string | null;
@ColumnIndex('IDX_person_update_id')
@UpdateIdColumn()
updateId!: string;
} }

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { import {
Column, Column,
@@ -7,10 +8,10 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table({ name: 'sessions', primaryConstraintName: 'PK_48cb6b5c20faa63157b3c1baf7f' }) @Table({ name: 'sessions', primaryConstraintName: 'PK_48cb6b5c20faa63157b3c1baf7f' })
@UpdatedAtTrigger('sessions_updated_at')
export class SessionTable { export class SessionTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: string; id!: string;
@@ -19,22 +20,22 @@ export class SessionTable {
@Column() @Column()
token!: string; token!: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@CreateDateColumn() @CreateDateColumn()
createdAt!: Date; createdAt!: Date;
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@ColumnIndex('IDX_sessions_update_id') @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
@UpdateIdColumn() userId!: string;
updateId!: string;
@Column({ default: '' }) @Column({ default: '' })
deviceType!: string; deviceType!: string;
@Column({ default: '' }) @Column({ default: '' })
deviceOS!: string; deviceOS!: string;
@ColumnIndex('IDX_sessions_update_id')
@UpdateIdColumn()
updateId!: string;
} }

View File

@@ -20,16 +20,9 @@ export class SharedLinkTable {
@Column({ type: 'character varying', nullable: true }) @Column({ type: 'character varying', nullable: true })
description!: string | null; description!: string | null;
@Column({ type: 'character varying', nullable: true })
password!: string | null;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
userId!: string; userId!: string;
@ColumnIndex('IDX_sharedlink_albumId')
@ForeignKeyColumn(() => AlbumTable, { nullable: true, onDelete: 'CASCADE', onUpdate: 'CASCADE' })
albumId!: string;
@ColumnIndex('IDX_sharedlink_key') @ColumnIndex('IDX_sharedlink_key')
@Column({ type: 'bytea' }) @Column({ type: 'bytea' })
key!: Buffer; // use to access the inidividual asset key!: Buffer; // use to access the inidividual asset
@@ -46,9 +39,16 @@ export class SharedLinkTable {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
allowUpload!: boolean; allowUpload!: boolean;
@ColumnIndex('IDX_sharedlink_albumId')
@ForeignKeyColumn(() => AlbumTable, { nullable: true, onDelete: 'CASCADE', onUpdate: 'CASCADE' })
albumId!: string;
@Column({ type: 'boolean', default: true }) @Column({ type: 'boolean', default: true })
allowDownload!: boolean; allowDownload!: boolean;
@Column({ type: 'boolean', default: true }) @Column({ type: 'boolean', default: true })
showExif!: boolean; showExif!: boolean;
@Column({ type: 'character varying', nullable: true })
password!: string | null;
} }

View File

@@ -1,7 +1,14 @@
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
@Table({ name: 'smart_search', primaryConstraintName: 'smart_search_pkey' }) @Table({ name: 'smart_search', primaryConstraintName: 'smart_search_pkey' })
@Index({
name: 'clip_index',
using: 'hnsw',
expression: `embedding vector_cosine_ops`,
with: `ef_construction = 300, m = 16`,
synchronize: false,
})
export class SmartSearchTable { export class SmartSearchTable {
@ForeignKeyColumn(() => AssetTable, { @ForeignKeyColumn(() => AssetTable, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
@@ -10,7 +17,6 @@ export class SmartSearchTable {
}) })
assetId!: string; assetId!: string;
@ColumnIndex({ name: 'clip_index', synchronize: false }) @Column({ type: 'vector', length: 512, storage: 'external', synchronize: false })
@Column({ type: 'vector', array: true, length: 512, synchronize: false })
embedding!: string; embedding!: string;
} }

View File

@@ -7,10 +7,10 @@ export class StackTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: string; id!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
ownerId!: string;
//TODO: Add constraint to ensure primary asset exists in the assets array //TODO: Add constraint to ensure primary asset exists in the assets array
@ForeignKeyColumn(() => AssetTable, { nullable: false, unique: true }) @ForeignKeyColumn(() => AssetTable, { nullable: false, unique: true })
primaryAssetId!: string; primaryAssetId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
ownerId!: string;
} }

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { SyncEntityType } from 'src/enum'; import { SyncEntityType } from 'src/enum';
import { SessionTable } from 'src/schema/tables/session.table'; import { SessionTable } from 'src/schema/tables/session.table';
import { import {
@@ -8,10 +9,10 @@ import {
PrimaryColumn, PrimaryColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('session_sync_checkpoints') @Table('session_sync_checkpoints')
@UpdatedAtTrigger('session_sync_checkpoints_updated_at')
export class SessionSyncCheckpointTable { export class SessionSyncCheckpointTable {
@ForeignKeyColumn(() => SessionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true }) @ForeignKeyColumn(() => SessionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true })
sessionId!: string; sessionId!: string;
@@ -25,10 +26,10 @@ export class SessionSyncCheckpointTable {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@Column()
ack!: string;
@ColumnIndex('IDX_session_sync_checkpoints_update_id') @ColumnIndex('IDX_session_sync_checkpoints_update_id')
@UpdateIdColumn() @UpdateIdColumn()
updateId!: string; updateId!: string;
@Column()
ack!: string;
} }

View File

@@ -1,15 +1,13 @@
import { TagTable } from 'src/schema/tables/tag.table'; import { TagTable } from 'src/schema/tables/tag.table';
import { ColumnIndex, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('tags_closure') @Table('tags_closure')
export class TagClosureTable { export class TagClosureTable {
@PrimaryColumn()
@ColumnIndex() @ColumnIndex()
@ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) @ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION' })
id_ancestor!: string; id_ancestor!: string;
@PrimaryColumn()
@ColumnIndex() @ColumnIndex()
@ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) @ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION' })
id_descendant!: string; id_descendant!: string;
} }

View File

@@ -1,3 +1,4 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { import {
Column, Column,
@@ -8,15 +9,18 @@ import {
Table, Table,
Unique, Unique,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('tags') @Table('tags')
@UpdatedAtTrigger('tags_updated_at')
@Unique({ columns: ['userId', 'value'] }) @Unique({ columns: ['userId', 'value'] })
export class TagTable { export class TagTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: string; id!: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@Column() @Column()
value!: string; value!: string;
@@ -26,16 +30,13 @@ export class TagTable {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@ColumnIndex('IDX_tags_update_id')
@UpdateIdColumn()
updateId!: string;
@Column({ type: 'character varying', nullable: true, default: null }) @Column({ type: 'character varying', nullable: true, default: null })
color!: string | null; color!: string | null;
@ForeignKeyColumn(() => TagTable, { nullable: true, onDelete: 'CASCADE' }) @ForeignKeyColumn(() => TagTable, { nullable: true, onDelete: 'CASCADE' })
parentId?: string; parentId?: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) @ColumnIndex('IDX_tags_update_id')
userId!: string; @UpdateIdColumn()
updateId!: string;
} }

View File

@@ -1,14 +1,15 @@
import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools';
@Table('users_audit') @Table('users_audit')
export class UserAuditTable { export class UserAuditTable {
@PrimaryGeneratedColumn({ type: 'v7' })
id!: string;
@Column({ type: 'uuid' }) @Column({ type: 'uuid' })
userId!: string; userId!: string;
@ColumnIndex('IDX_users_audit_deleted_at') @ColumnIndex('IDX_users_audit_deleted_at')
@CreateDateColumn({ default: () => 'clock_timestamp()' }) @CreateDateColumn({ default: () => 'clock_timestamp()' })
deletedAt!: Date; deletedAt!: Date;
@PrimaryGeneratedUuidV7Column()
id!: string;
} }

View File

@@ -1,6 +1,9 @@
import { ColumnType } from 'kysely'; import { ColumnType } from 'kysely';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserStatus } from 'src/enum'; import { UserStatus } from 'src/enum';
import { users_delete_audit } from 'src/schema/functions';
import { import {
AfterDeleteTrigger,
Column, Column,
ColumnIndex, ColumnIndex,
CreateDateColumn, CreateDateColumn,
@@ -9,7 +12,6 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
UpdateDateColumn, UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools'; } from 'src/sql-tools';
type Timestamp = ColumnType<Date, Date | string, Date | string>; type Timestamp = ColumnType<Date, Date | string, Date | string>;
@@ -17,50 +19,51 @@ type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>; T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
@Table('users') @Table('users')
@UpdatedAtTrigger('users_updated_at')
@AfterDeleteTrigger({
name: 'users_delete_audit',
scope: 'statement',
function: users_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
@Index({ name: 'IDX_users_updated_at_asc_id_asc', columns: ['updatedAt', 'id'] }) @Index({ name: 'IDX_users_updated_at_asc_id_asc', columns: ['updatedAt', 'id'] })
export class UserTable { export class UserTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: Generated<string>; id!: Generated<string>;
@Column({ unique: true })
email!: string;
@Column({ default: '' }) @Column({ default: '' })
name!: Generated<string>; password!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ default: '' })
profileImagePath!: Generated<string>;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isAdmin!: Generated<boolean>; isAdmin!: Generated<boolean>;
@Column({ unique: true }) @Column({ type: 'boolean', default: true })
email!: string; shouldChangePassword!: Generated<boolean>;
@DeleteDateColumn()
deletedAt!: Timestamp | null;
@Column({ default: '' })
oauthId!: Generated<string>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({ unique: true, nullable: true, default: null }) @Column({ unique: true, nullable: true, default: null })
storageLabel!: string | null; storageLabel!: string | null;
@Column({ default: '' }) @Column({ default: '' })
password!: Generated<string>; name!: Generated<string>;
@Column({ default: '' })
oauthId!: Generated<string>;
@Column({ default: '' })
profileImagePath!: Generated<string>;
@Column({ type: 'boolean', default: true })
shouldChangePassword!: Generated<boolean>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@DeleteDateColumn()
deletedAt!: Timestamp | null;
@Column({ type: 'character varying', default: UserStatus.ACTIVE })
status!: Generated<UserStatus>;
@ColumnIndex({ name: 'IDX_users_update_id' })
@UpdateIdColumn()
updateId!: Generated<string>;
@Column({ type: 'bigint', nullable: true }) @Column({ type: 'bigint', nullable: true })
quotaSizeInBytes!: ColumnType<number> | null; quotaSizeInBytes!: ColumnType<number> | null;
@@ -68,6 +71,13 @@ export class UserTable {
@Column({ type: 'bigint', default: 0 }) @Column({ type: 'bigint', default: 0 })
quotaUsageInBytes!: Generated<ColumnType<number>>; quotaUsageInBytes!: Generated<ColumnType<number>>;
@Column({ type: 'character varying', default: UserStatus.ACTIVE })
status!: Generated<UserStatus>;
@Column({ type: 'timestamp with time zone', default: () => 'now()' }) @Column({ type: 'timestamp with time zone', default: () => 'now()' })
profileChangedAt!: Generated<Timestamp>; profileChangedAt!: Generated<Timestamp>;
@ColumnIndex({ name: 'IDX_users_update_id' })
@UpdateIdColumn()
updateId!: Generated<string>;
} }

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Activity } from 'src/database';
import { import {
ActivityCreateDto, ActivityCreateDto,
ActivityDto, ActivityDto,
@@ -13,7 +14,6 @@ import {
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { ActivityItem } from 'src/types';
@Injectable() @Injectable()
export class ActivityService extends BaseService { export class ActivityService extends BaseService {
@@ -43,7 +43,7 @@ export class ActivityService extends BaseService {
albumId: dto.albumId, albumId: dto.albumId,
}; };
let activity: ActivityItem | undefined; let activity: Activity | undefined;
let duplicate = false; let duplicate = false;
if (dto.type === ReactionType.LIKE) { if (dto.type === ReactionType.LIKE) {

View File

@@ -140,7 +140,7 @@ export class AlbumService extends BaseService {
order: dto.order, order: dto.order,
}); });
return mapAlbumWithoutAssets(updatedAlbum); return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets });
} }
async delete(auth: AuthDto, id: string): Promise<void> { async delete(auth: AuthDto, id: string): Promise<void> {

View File

@@ -1,9 +1,9 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { ApiKey } from 'src/database';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { ApiKeyItem } from 'src/types';
import { isGranted } from 'src/utils/access'; import { isGranted } from 'src/utils/access';
@Injectable() @Injectable()
@@ -58,7 +58,7 @@ export class ApiKeyService extends BaseService {
return keys.map((key) => this.map(key)); return keys.map((key) => this.map(key));
} }
private map(entity: ApiKeyItem): APIKeyResponseDto { private map(entity: ApiKey): APIKeyResponseDto {
return { return {
id: entity.id, id: entity.id,
name: entity.name, name: entity.name,

View File

@@ -1,10 +1,10 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum'; import { AuthType, Permission } from 'src/enum';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { sessionStub } from 'test/fixtures/session.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
@@ -97,17 +97,19 @@ describe('AuthService', () => {
}); });
it('should successfully log the user in', async () => { it('should successfully log the user in', async () => {
mocks.user.getByEmail.mockResolvedValue(userStub.user1); const user = { ...factory.user(), password: 'immich_password' } as UserEntity;
mocks.session.create.mockResolvedValue(sessionStub.valid); const session = factory.session();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz', accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'user-id', userId: user.id,
userEmail: 'immich@test.com', userEmail: user.email,
name: 'immich_name', name: user.name,
profileImagePath: '', profileImagePath: user.profileImagePath,
isAdmin: false, isAdmin: user.isAdmin,
shouldChangePassword: false, shouldChangePassword: user.shouldChangePassword,
}); });
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
@@ -256,8 +258,14 @@ describe('AuthService', () => {
}); });
it('should validate using authorization header', async () => { it('should validate using authorization header', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); const session = factory.session();
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@@ -266,8 +274,8 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).resolves.toEqual({ ).resolves.toEqual({
user: userStub.user1, user: sessionWithToken.user,
session: sessionStub.valid, session: { id: session.id },
}); });
}); });
}); });
@@ -371,7 +379,14 @@ describe('AuthService', () => {
}); });
it('should return an auth dto', async () => { it('should return an auth dto', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); const session = factory.session();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@@ -380,13 +395,20 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).resolves.toEqual({ ).resolves.toEqual({
user: userStub.user1, user: sessionWithToken.user,
session: sessionStub.valid, session: { id: session.id },
}); });
}); });
it('should throw if admin route and not an admin', async () => { it('should throw if admin route and not an admin', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); const session = factory.session();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@@ -398,8 +420,15 @@ describe('AuthService', () => {
}); });
it('should update when access time exceeds an hour', async () => { it('should update when access time exceeds an hour', async () => {
mocks.session.getByToken.mockResolvedValue(sessionStub.inactive as any); const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
mocks.session.update.mockResolvedValue(sessionStub.valid); const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: factory.authUser(),
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
mocks.session.update.mockResolvedValue(session);
await expect( await expect(
sut.authenticate({ sut.authenticate({
@@ -408,7 +437,8 @@ describe('AuthService', () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).resolves.toBeDefined(); ).resolves.toBeDefined();
expect(mocks.session.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
expect(mocks.session.update).toHaveBeenCalled();
}); });
}); });
@@ -506,7 +536,7 @@ describe('AuthService', () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(userStub.user1); mocks.user.getByEmail.mockResolvedValue(userStub.user1);
mocks.user.update.mockResolvedValue(userStub.user1); mocks.user.update.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
@@ -535,7 +565,7 @@ describe('AuthService', () => {
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); mocks.session.create.mockResolvedValue(factory.session());
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
oauthResponse, oauthResponse,
@@ -550,7 +580,7 @@ describe('AuthService', () => {
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.getAdmin.mockResolvedValue(userStub.user1);
mocks.user.create.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); mocks.session.create.mockResolvedValue(factory.session());
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
@@ -572,7 +602,7 @@ describe('AuthService', () => {
it(`should use the mobile redirect override for a url of ${url}`, async () => { it(`should use the mobile redirect override for a url of ${url}`, async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(userStub.user1); mocks.user.getByOAuthId.mockResolvedValue(userStub.user1);
mocks.session.create.mockResolvedValue(sessionStub.valid); mocks.session.create.mockResolvedValue(factory.session());
await sut.callback({ url }, loginDetails); await sut.callback({ url }, loginDetails);

View File

@@ -338,7 +338,9 @@ export class AuthService extends BaseService {
return { return {
user: session.user, user: session.user,
session, session: {
id: session.id,
},
}; };
} }

Some files were not shown because too many files have changed in this diff Show More