Compare commits

...

49 Commits

Author SHA1 Message Date
Alex 73ea1e8fb5 refactor: using recommended api to get device albums 2025-04-11 13:14:11 -05:00
Jason Rasmussen ae6653392e feat: view qr code from share modal (#17544) 2025-04-11 14:02:07 -04:00
Etienne d7a782da34 feat: sync pictureFile with oidc if it isn't set already (#17397)
* feat: sync pictureFile with oidc if it isn't set already

fix: move picture writer to get userId

fix: move await promise to the top of the setPicure function before checking its value and automatically create the user folder

chore: code cleanup

* fix: extension double dot

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-04-11 13:00:39 -05:00
renovate[bot] 08b5952c87 chore(deps): update dependency vite to v6.2.6 [security] (#17541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-11 13:56:01 -04:00
Jason Rasmussen 584e5894bf refactor: user factories instead of stubs (#17540) 2025-04-11 11:53:37 -04:00
Yaros 52d4b2fe57 fix(mobile): remove locate asset button from trashed asset (#17503)
* fix: remove locate asset button from trashed asset

* chore: refactor code
2025-04-11 09:41:10 -05:00
Ben 92f0973a46 fix(web): reset search history after logout (#17534)
fix(web): reset search suggestions after logout
2025-04-10 20:34:45 +00:00
Jason Rasmussen 75c83cb704 refactor: metadata stub (#17532) 2025-04-10 21:58:55 +02:00
Jason Rasmussen 0b22d3348e refactor: count all return type (#17529) 2025-04-10 14:38:49 -04:00
Alex abde0fbe60 fix(web): mobile view double scroll layer (#17528) 2025-04-10 13:50:05 -04:00
Jason Rasmussen eaa0e07329 refactor: asset files entity (#17527) 2025-04-10 13:26:27 -04:00
Mitchell Pleune 9fd2c5220d fix: test_sets_default_sess_options fails if compiling with globally enabled cuda (#17516)
Coming from nixos, this test fails when cuda is enabled. https://github.com/NixOS/nixpkgs/pull/382896

Solution as proposed by @mertalev
2025-04-10 13:06:33 -04:00
Snowknight26 7fcab4b251 feat(server): read additional lens exif tags (#17125)
* fix(server): read additional lens exif tags

* Update order of read tags

* Fix e2e test

* Fix e2e test

* Fix e2e test

* Fix e2e test

* Update test

* Filter unknown lens exif data

* Formatting fixes
2025-04-10 12:02:41 -05:00
Ben e3995fb5f4 fix(web): increase sidebar breakpoint (#17436) 2025-04-10 12:00:30 -05:00
Alex 6d3f3d8616 refactor: convert download manager into a state class (#17491)
* fix(web): download progress bar not functioning

* remove unused method
2025-04-10 16:48:21 +00:00
Jason Rasmussen 4412680679 refactor: remove unused shared users list (#17526) 2025-04-10 11:44:47 -05:00
Brandon Wees 7df2c9c905 fix: patch-package install in docker build and better postgres patch (#17523)
* always patch package when running npm i, install immich CLI outside of directory so post install doesnt run

* handles case where query is an object and defined but origin is not.

* move patch-package from a dev dependency to a normal dependency. Also copy the patches folder for the docker build to use and patch with

* fix Dockerfile

* use query.reject instead of throw for queryError

* package-lock to reflect the dev dependency change

* dont throw the error, just provide an empty string for query.origin if it does not exist

* remove npm link and demote patch-package back to a dev dependency

* modify patch to add defensive check to catch queries that will fail to parse and reject
2025-04-10 12:43:35 -04:00
Daniel Dietzler 7a1e8ce6d8 chore: remove exif entity (#17499) 2025-04-10 12:36:29 -04:00
Jason Rasmussen 8aea07b750 refactor: album user entity (#17524) 2025-04-10 11:53:21 -04:00
Zack Pollard 94dba29298 refactor: remove user entity (#17498) 2025-04-10 10:53:21 -04:00
Rudhra Raveendran 9e49783e49 feat: use browser download manager for single file downloads (#17507)
* Fix download panel reactivity

* Directly download individual files without buffering in memory

* Fix shared link e2e download tests
2025-04-10 09:13:50 -05:00
renovate[bot] 43e3075f93 fix(deps): update machine-learning (#17455) 2025-04-09 16:20:11 +00:00
Zack Pollard d03647904b refactor: remove move entity (#17489) 2025-04-09 11:54:20 -04:00
Jason Rasmussen 206545356d refactor: metadata entity (#17492) 2025-04-09 11:45:30 -04:00
renovate[bot] 3e372500b0 fix(deps): update typescript-projects (#17456)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-04-09 14:47:29 +00: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
336 changed files with 8330 additions and 4699 deletions
+1 -1
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
+3 -3
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}}'
+2 -2
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: |
+24 -24
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"
} }
}, },
@@ -1012,9 +1012,9 @@
} }
}, },
"node_modules/@pkgr/core": { "node_modules/@pkgr/core": {
"version": "0.2.0", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.1.tgz",
"integrity": "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==", "integrity": "sha512-VzgHzGblFmUeBmmrk55zPyrQIArQN4vujc9shWytaPdB3P7qhi0cpaiKIr7tlCmFv2lYUwnLospIqjL9ZSAhhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -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": {
@@ -2297,14 +2297,14 @@
} }
}, },
"node_modules/eslint-plugin-prettier": { "node_modules/eslint-plugin-prettier": {
"version": "5.2.5", "version": "5.2.6",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.5.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz",
"integrity": "sha512-IKKP8R87pJyMl7WWamLgPkloB16dagPIdd2FjBDbyRYPKo93wS/NbCOPh6gH+ieNLC+XZrhJt/kWj0PS/DFdmg==", "integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"prettier-linter-helpers": "^1.0.0", "prettier-linter-helpers": "^1.0.0",
"synckit": "^0.10.2" "synckit": "^0.11.0"
}, },
"engines": { "engines": {
"node": "^14.18.0 || >=16.0.0" "node": "^14.18.0 || >=16.0.0"
@@ -3884,20 +3884,20 @@
} }
}, },
"node_modules/synckit": { "node_modules/synckit": {
"version": "0.10.3", "version": "0.11.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.10.3.tgz", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz",
"integrity": "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==", "integrity": "sha512-szhWDqNNI9etJUvbZ1/cx1StnZx8yMmFxme48SwR4dty4ioSY50KEZlpv0qAfgc1fpRzuh9hBXEzoCpJ779dLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@pkgr/core": "^0.2.0", "@pkgr/core": "^0.2.1",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"engines": { "engines": {
"node": "^14.18.0 || >=16.0.0" "node": "^14.18.0 || >=16.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/unts" "url": "https://opencollective.com/synckit"
} }
}, },
"node_modules/test-exclude": { "node_modules/test-exclude": {
@@ -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"
}, },
@@ -4144,9 +4144,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.2.5", "version": "6.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
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",
+1 -1
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
+3
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
-3
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
+25 -25
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",
@@ -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"
} }
}, },
@@ -1088,9 +1088,9 @@
} }
}, },
"node_modules/@pkgr/core": { "node_modules/@pkgr/core": {
"version": "0.2.0", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.1.tgz",
"integrity": "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==", "integrity": "sha512-VzgHzGblFmUeBmmrk55zPyrQIArQN4vujc9shWytaPdB3P7qhi0cpaiKIr7tlCmFv2lYUwnLospIqjL9ZSAhhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1566,9 +1566,9 @@
} }
}, },
"node_modules/@types/luxon": { "node_modules/@types/luxon": {
"version": "3.6.0", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-RtEj20xRyG7cRp142MkQpV3GRF8Wo2MtDkKLz65MQs7rM1Lh8bz+HtfPXCCJEYpnDFu6VwAq/Iv2Ikyp9Jw/hw==", "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -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": {
@@ -3094,14 +3094,14 @@
} }
}, },
"node_modules/eslint-plugin-prettier": { "node_modules/eslint-plugin-prettier": {
"version": "5.2.5", "version": "5.2.6",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.5.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz",
"integrity": "sha512-IKKP8R87pJyMl7WWamLgPkloB16dagPIdd2FjBDbyRYPKo93wS/NbCOPh6gH+ieNLC+XZrhJt/kWj0PS/DFdmg==", "integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"prettier-linter-helpers": "^1.0.0", "prettier-linter-helpers": "^1.0.0",
"synckit": "^0.10.2" "synckit": "^0.11.0"
}, },
"engines": { "engines": {
"node": "^14.18.0 || >=16.0.0" "node": "^14.18.0 || >=16.0.0"
@@ -6039,20 +6039,20 @@
} }
}, },
"node_modules/synckit": { "node_modules/synckit": {
"version": "0.10.3", "version": "0.11.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.10.3.tgz", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz",
"integrity": "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==", "integrity": "sha512-szhWDqNNI9etJUvbZ1/cx1StnZx8yMmFxme48SwR4dty4ioSY50KEZlpv0qAfgc1fpRzuh9hBXEzoCpJ779dLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@pkgr/core": "^0.2.0", "@pkgr/core": "^0.2.1",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"engines": { "engines": {
"node": "^14.18.0 || >=16.0.0" "node": "^14.18.0 || >=16.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/unts" "url": "https://opencollective.com/synckit"
} }
}, },
"node_modules/tar": { "node_modules/tar": {
@@ -6316,9 +6316,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"
}, },
+1 -1
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",
+3 -3
View File
@@ -1141,7 +1141,7 @@ describe('/asset', () => {
fNumber: 8, fNumber: 8,
focalLength: 97, focalLength: 97,
iso: 100, iso: 100,
lensModel: 'E PZ 18-105mm F4 G OSS', lensModel: 'Sony E PZ 18-105mm F4 G OSS',
fileSizeInByte: 25_001_984, fileSizeInByte: 25_001_984,
dateTimeOriginal: '2016-09-27T10:51:44+00:00', dateTimeOriginal: '2016-09-27T10:51:44+00:00',
orientation: '1', orientation: '1',
@@ -1163,7 +1163,7 @@ describe('/asset', () => {
fNumber: 22, fNumber: 22,
focalLength: 25, focalLength: 25,
iso: 100, iso: 100,
lensModel: 'E 25mm F2', lensModel: 'Zeiss Batis 25mm F2',
fileSizeInByte: 49_512_448, fileSizeInByte: 49_512_448,
dateTimeOriginal: '2016-01-08T14:08:01+00:00', dateTimeOriginal: '2016-01-08T14:08:01+00:00',
orientation: '1', orientation: '1',
@@ -1234,7 +1234,7 @@ describe('/asset', () => {
focalLength: 18.3, focalLength: 18.3,
iso: 100, iso: 100,
latitude: 36.613_24, latitude: 36.613_24,
lensModel: 'GR LENS 18.3mm F2.8', lensModel: '18.3mm F2.8',
longitude: -121.897_85, longitude: -121.897_85,
make: 'RICOH IMAGING COMPANY, LTD.', make: 'RICOH IMAGING COMPANY, LTD.',
model: 'RICOH GR III', model: 'RICOH GR III',
+1 -9
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();
}); });
+2 -1
View File
@@ -48,7 +48,7 @@ test.describe('Shared Links', () => {
await page.waitForSelector('[data-group] svg'); await page.waitForSelector('[data-group] svg');
await page.getByRole('checkbox').click(); await page.getByRole('checkbox').click();
await page.getByRole('button', { name: 'Download' }).click(); await page.getByRole('button', { name: 'Download' }).click();
await page.getByText('DOWNLOADING', { exact: true }).waitFor(); await page.waitForEvent('download');
}); });
test('download all from shared link', async ({ page }) => { test('download all from shared link', async ({ page }) => {
@@ -56,6 +56,7 @@ test.describe('Shared Links', () => {
await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.getByRole('button', { name: 'Download' }).click(); await page.getByRole('button', { name: 'Download' }).click();
await page.getByText('DOWNLOADING', { exact: true }).waitFor(); await page.getByText('DOWNLOADING', { exact: true }).waitFor();
await page.waitForEvent('download');
}); });
test('enter password for a shared link', async ({ page }) => { test('enter password for a shared link', async ({ page }) => {
+1
View File
@@ -1371,6 +1371,7 @@
"view_next_asset": "View next asset", "view_next_asset": "View next asset",
"view_previous_asset": "View previous asset", "view_previous_asset": "View previous asset",
"view_stack": "View Stack", "view_stack": "View Stack",
"view_qr_code": "View QR code",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}", "visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"waiting": "Waiting", "waiting": "Waiting",
"warning": "Warning", "warning": "Warning",
+4 -4
View File
@@ -1,6 +1,6 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:ebfa8696e47a68cffebb31e370a93ce57c01bc753f246ceaaef72801d1661351 AS builder-cpu FROM python:3.11-bookworm@sha256:0a9d314ae6e976351bd37b702bf6b0a89bb58e6304e5df35b960059b12531419 AS builder-cpu
FROM builder-cpu AS builder-openvino FROM builder-cpu AS builder-openvino
@@ -54,7 +54,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update && apt-get install -y --no-install-recommends g++ RUN apt-get update && apt-get install -y --no-install-recommends g++
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:fb91e82e8643382d5bce074ba0d167677d678faff4bd518dac670476d19b159c /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest@sha256:0b6dc79013b689f3bc0cbf12807cb1c901beaafe80f2ee10a1d76aa3842afb92 /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
@@ -63,11 +63,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \ uv pip install /opt/onnxruntime_rocm-*.whl; \
fi fi
FROM python:3.11-slim-bookworm@sha256:7029b00486ac40bed03e36775b864d3f3d39dcbdf19cd45e6a52d541e6c178f0 AS prod-cpu FROM python:3.11-slim-bookworm@sha256:49d73c49616929b0a4f37c50fee0056eb4b0f15de624591e8d9bf84b4dfdd3ce AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
FROM python:3.11-slim-bookworm@sha256:7029b00486ac40bed03e36775b864d3f3d39dcbdf19cd45e6a52d541e6c178f0 AS prod-openvino FROM python:3.11-slim-bookworm@sha256:49d73c49616929b0a4f37c50fee0056eb4b0f15de624591e8d9bf84b4dfdd3ce AS prod-openvino
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
+2 -2
View File
@@ -278,8 +278,8 @@ class TestOrtSession:
assert session.provider_options == [] assert session.provider_options == []
def test_sets_default_sess_options(self) -> None: def test_sets_default_sess_options_if_cpu(self) -> None:
session = OrtSession("ViT-B-32__openai") session = OrtSession("ViT-B-32__openai", providers=["CPUExecutionProvider"])
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
assert session.sess_options.inter_op_num_threads == 1 assert session.sess_options.inter_op_num_threads == 1
+106 -106
View File
@@ -876,7 +876,7 @@ wheels = [
[[package]] [[package]]
name = "huggingface-hub" name = "huggingface-hub"
version = "0.29.3" version = "0.30.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "filelock" }, { name = "filelock" },
@@ -887,9 +887,9 @@ dependencies = [
{ name = "tqdm" }, { name = "tqdm" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e5/f9/851f34b02970e8143d41d4001b2d49e54ef113f273902103823b8bc95ada/huggingface_hub-0.29.3.tar.gz", hash = "sha256:64519a25716e0ba382ba2d3fb3ca082e7c7eb4a2fc634d200e8380006e0760e5", size = 390123 } sdist = { url = "https://files.pythonhosted.org/packages/df/22/8eb91736b1dcb83d879bd49050a09df29a57cc5cd9f38e48a4b1c45ee890/huggingface_hub-0.30.2.tar.gz", hash = "sha256:9a7897c5b6fd9dad3168a794a8998d6378210f5b9688d0dfc180b1a228dc2466", size = 400868 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/0c/37d380846a2e5c9a3c6a73d26ffbcfdcad5fc3eacf42fdf7cff56f2af634/huggingface_hub-0.29.3-py3-none-any.whl", hash = "sha256:0b25710932ac649c08cdbefa6c6ccb8e88eef82927cacdb048efb726429453aa", size = 468997 }, { url = "https://files.pythonhosted.org/packages/93/27/1fb384a841e9661faad1c31cbfa62864f59632e876df5d795234da51c395/huggingface_hub-0.30.2-py3-none-any.whl", hash = "sha256:68ff05969927058cfa41df4f2155d4bb48f5f54f719dd0390103eefa9b191e28", size = 481433 },
] ]
[[package]] [[package]]
@@ -1789,7 +1789,7 @@ wheels = [
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.11.1" version = "2.11.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-types" }, { name = "annotated-types" },
@@ -1797,96 +1797,96 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/93/a3/698b87a4d4d303d7c5f62ea5fbf7a79cab236ccfbd0a17847b7f77f8163e/pydantic-2.11.1.tar.gz", hash = "sha256:442557d2910e75c991c39f4b4ab18963d57b9b55122c8b2a9cd176d8c29ce968", size = 782817 } sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/12/f9221a949f2419e2e23847303c002476c26fbcfd62dc7f3d25d0bec5ca99/pydantic-2.11.1-py3-none-any.whl", hash = "sha256:5b6c415eee9f8123a14d859be0c84363fec6b1feb6b688d6435801230b56e0b8", size = 442648 }, { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591 },
] ]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.33.0" version = "2.33.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b9/05/91ce14dfd5a3a99555fce436318cc0fd1f08c4daa32b3248ad63669ea8b4/pydantic_core-2.33.0.tar.gz", hash = "sha256:40eb8af662ba409c3cbf4a8150ad32ae73514cd7cb1f1a2113af39763dd616b3", size = 434080 } sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/43/0649ad07e66b36a3fb21442b425bd0348ac162c5e686b36471f363201535/pydantic_core-2.33.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71dffba8fe9ddff628c68f3abd845e91b028361d43c5f8e7b3f8b91d7d85413e", size = 2042968 }, { url = "https://files.pythonhosted.org/packages/38/ea/5f572806ab4d4223d11551af814d243b0e3e02cc6913def4d1fe4a5ca41c/pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26", size = 2044021 },
{ url = "https://files.pythonhosted.org/packages/a0/a6/975fea4774a459e495cb4be288efd8b041ac756a0a763f0b976d0861334b/pydantic_core-2.33.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:abaeec1be6ed535a5d7ffc2e6c390083c425832b20efd621562fbb5bff6dc518", size = 1860347 }, { url = "https://files.pythonhosted.org/packages/8c/d1/f86cc96d2aa80e3881140d16d12ef2b491223f90b28b9a911346c04ac359/pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927", size = 1861742 },
{ url = "https://files.pythonhosted.org/packages/aa/49/7858dadad305101a077ec4d0c606b6425a2b134ea8d858458a6d287fd871/pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:759871f00e26ad3709efc773ac37b4d571de065f9dfb1778012908bcc36b3a73", size = 1910060 }, { url = "https://files.pythonhosted.org/packages/37/08/fbd2cd1e9fc735a0df0142fac41c114ad9602d1c004aea340169ae90973b/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db", size = 1910414 },
{ url = "https://files.pythonhosted.org/packages/8d/4f/6522527911d9c5fe6d76b084d8b388d5c84b09d113247b39f91937500b34/pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcfebee69cd5e1c0b76a17e17e347c84b00acebb8dd8edb22d4a03e88e82a207", size = 1997129 }, { url = "https://files.pythonhosted.org/packages/7f/73/3ac217751decbf8d6cb9443cec9b9eb0130eeada6ae56403e11b486e277e/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48", size = 1996848 },
{ url = "https://files.pythonhosted.org/packages/75/d0/06f396da053e3d73001ea4787e56b4d7132a87c0b5e2e15a041e808c35cd/pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b1262b912435a501fa04cd213720609e2cefa723a07c92017d18693e69bf00b", size = 2140389 }, { url = "https://files.pythonhosted.org/packages/9a/f5/5c26b265cdcff2661e2520d2d1e9db72d117ea00eb41e00a76efe68cb009/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969", size = 2141055 },
{ url = "https://files.pythonhosted.org/packages/f5/6b/b9ff5b69cd4ef007cf665463f3be2e481dc7eb26c4a55b2f57a94308c31a/pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4726f1f3f42d6a25678c67da3f0b10f148f5655813c5aca54b0d1742ba821b8f", size = 2754237 }, { url = "https://files.pythonhosted.org/packages/5d/14/a9c3cee817ef2f8347c5ce0713e91867a0dceceefcb2973942855c917379/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e", size = 2753806 },
{ url = "https://files.pythonhosted.org/packages/53/80/b4879de375cdf3718d05fcb60c9aa1f119d28e261dafa51b6a69c78f7178/pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e790954b5093dff1e3a9a2523fddc4e79722d6f07993b4cd5547825c3cbf97b5", size = 2007433 }, { url = "https://files.pythonhosted.org/packages/f2/68/866ce83a51dd37e7c604ce0050ff6ad26de65a7799df89f4db87dd93d1d6/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89", size = 2007777 },
{ url = "https://files.pythonhosted.org/packages/46/24/54054713dc0af98a94eab37e0f4294dfd5cd8f70b2ca9dcdccd15709fd7e/pydantic_core-2.33.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34e7fb3abe375b5c4e64fab75733d605dda0f59827752debc99c17cb2d5f3276", size = 2123980 }, { url = "https://files.pythonhosted.org/packages/b6/a8/36771f4404bb3e49bd6d4344da4dede0bf89cc1e01f3b723c47248a3761c/pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde", size = 2122803 },
{ url = "https://files.pythonhosted.org/packages/3a/4c/257c1cb89e14cfa6e95ebcb91b308eb1dd2b348340ff76a6e6fcfa9969e1/pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ecb158fb9b9091b515213bed3061eb7deb1d3b4e02327c27a0ea714ff46b0760", size = 2087433 }, { url = "https://files.pythonhosted.org/packages/18/9c/730a09b2694aa89360d20756369822d98dc2f31b717c21df33b64ffd1f50/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65", size = 2086755 },
{ url = "https://files.pythonhosted.org/packages/0c/62/927df8a39ad78ef7b82c5446e01dec9bb0043e1ad71d8f426062f5f014db/pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:4d9149e7528af8bbd76cc055967e6e04617dcb2a2afdaa3dea899406c5521faa", size = 2260242 }, { url = "https://files.pythonhosted.org/packages/54/8e/2dccd89602b5ec31d1c58138d02340ecb2ebb8c2cac3cc66b65ce3edb6ce/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc", size = 2257358 },
{ url = "https://files.pythonhosted.org/packages/74/f2/389414f7c77a100954e84d6f52a82bd1788ae69db72364376d8a73b38765/pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e81a295adccf73477220e15ff79235ca9dcbcee4be459eb9d4ce9a2763b8386c", size = 2258227 }, { url = "https://files.pythonhosted.org/packages/d1/9c/126e4ac1bfad8a95a9837acdd0963695d69264179ba4ede8b8c40d741702/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091", size = 2257916 },
{ url = "https://files.pythonhosted.org/packages/53/99/94516313e15d906a1264bb40faf24a01a4af4e2ca8a7c10dd173b6513c5a/pydantic_core-2.33.0-cp310-cp310-win32.whl", hash = "sha256:f22dab23cdbce2005f26a8f0c71698457861f97fc6318c75814a50c75e87d025", size = 1925523 }, { url = "https://files.pythonhosted.org/packages/7d/ba/91eea2047e681a6853c81c20aeca9dcdaa5402ccb7404a2097c2adf9d038/pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383", size = 1923823 },
{ url = "https://files.pythonhosted.org/packages/7d/67/cc789611c6035a0b71305a1ec6ba196256ced76eba8375f316f840a70456/pydantic_core-2.33.0-cp310-cp310-win_amd64.whl", hash = "sha256:9cb2390355ba084c1ad49485d18449b4242da344dea3e0fe10babd1f0db7dcfc", size = 1951872 }, { url = "https://files.pythonhosted.org/packages/94/c0/fcdf739bf60d836a38811476f6ecd50374880b01e3014318b6e809ddfd52/pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504", size = 1952494 },
{ url = "https://files.pythonhosted.org/packages/f0/93/9e97af2619b4026596487a79133e425c7d3c374f0a7f100f3d76bcdf9c83/pydantic_core-2.33.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a608a75846804271cf9c83e40bbb4dab2ac614d33c6fd5b0c6187f53f5c593ef", size = 2042784 }, { url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224 },
{ url = "https://files.pythonhosted.org/packages/42/b4/0bba8412fd242729feeb80e7152e24f0e1a1c19f4121ca3d4a307f4e6222/pydantic_core-2.33.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e1c69aa459f5609dec2fa0652d495353accf3eda5bdb18782bc5a2ae45c9273a", size = 1858179 }, { url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845 },
{ url = "https://files.pythonhosted.org/packages/69/1f/c1c40305d929bd08af863df64b0a26203b70b352a1962d86f3bcd52950fe/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9ec80eb5a5f45a2211793f1c4aeddff0c3761d1c70d684965c1807e923a588b", size = 1909396 }, { url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029 },
{ url = "https://files.pythonhosted.org/packages/0f/99/d2e727375c329c1e652b5d450fbb9d56e8c3933a397e4bd46e67c68c2cd5/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e925819a98318d17251776bd3d6aa9f3ff77b965762155bdad15d1a9265c4cfd", size = 1998264 }, { url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784 },
{ url = "https://files.pythonhosted.org/packages/9c/2e/3119a33931278d96ecc2e9e1b9d50c240636cfeb0c49951746ae34e4de74/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bf68bb859799e9cec3d9dd8323c40c00a254aabb56fe08f907e437005932f2b", size = 2140588 }, { url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075 },
{ url = "https://files.pythonhosted.org/packages/35/bd/9267bd1ba55f17c80ef6cb7e07b3890b4acbe8eb6014f3102092d53d9300/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b2ea72dea0825949a045fa4071f6d5b3d7620d2a208335207793cf29c5a182d", size = 2746296 }, { url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849 },
{ url = "https://files.pythonhosted.org/packages/6f/ed/ef37de6478a412ee627cbebd73e7b72a680f45bfacce9ff1199de6e17e88/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1583539533160186ac546b49f5cde9ffc928062c96920f58bd95de32ffd7bffd", size = 2005555 }, { url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794 },
{ url = "https://files.pythonhosted.org/packages/dd/84/72c8d1439585d8ee7bc35eb8f88a04a4d302ee4018871f1f85ae1b0c6625/pydantic_core-2.33.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:23c3e77bf8a7317612e5c26a3b084c7edeb9552d645742a54a5867635b4f2453", size = 2124452 }, { url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237 },
{ url = "https://files.pythonhosted.org/packages/a7/8f/cb13de30c6a3e303423751a529a3d1271c2effee4b98cf3e397a66ae8498/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7a7f2a3f628d2f7ef11cb6188bcf0b9e1558151d511b974dfea10a49afe192b", size = 2087001 }, { url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351 },
{ url = "https://files.pythonhosted.org/packages/83/d0/e93dc8884bf288a63fedeb8040ac8f29cb71ca52e755f48e5170bb63e55b/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:f1fb026c575e16f673c61c7b86144517705865173f3d0907040ac30c4f9f5915", size = 2261663 }, { url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914 },
{ url = "https://files.pythonhosted.org/packages/4c/ba/4b7739c95efa0b542ee45fd872c8f6b1884ab808cf04ce7ac6621b6df76e/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:635702b2fed997e0ac256b2cfbdb4dd0bf7c56b5d8fba8ef03489c03b3eb40e2", size = 2257786 }, { url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385 },
{ url = "https://files.pythonhosted.org/packages/cc/98/73cbca1d2360c27752cfa2fcdcf14d96230e92d7d48ecd50499865c56bf7/pydantic_core-2.33.0-cp311-cp311-win32.whl", hash = "sha256:07b4ced28fccae3f00626eaa0c4001aa9ec140a29501770a88dbbb0966019a86", size = 1925697 }, { url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765 },
{ url = "https://files.pythonhosted.org/packages/9a/26/d85a40edeca5d8830ffc33667d6fef329fd0f4bc0c5181b8b0e206cfe488/pydantic_core-2.33.0-cp311-cp311-win_amd64.whl", hash = "sha256:4927564be53239a87770a5f86bdc272b8d1fbb87ab7783ad70255b4ab01aa25b", size = 1949859 }, { url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688 },
{ url = "https://files.pythonhosted.org/packages/7e/0b/5a381605f0b9870465b805f2c86c06b0a7c191668ebe4117777306c2c1e5/pydantic_core-2.33.0-cp311-cp311-win_arm64.whl", hash = "sha256:69297418ad644d521ea3e1aa2e14a2a422726167e9ad22b89e8f1130d68e1e9a", size = 1907978 }, { url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185 },
{ url = "https://files.pythonhosted.org/packages/a9/c4/c9381323cbdc1bb26d352bc184422ce77c4bc2f2312b782761093a59fafc/pydantic_core-2.33.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6c32a40712e3662bebe524abe8abb757f2fa2000028d64cc5a1006016c06af43", size = 2025127 }, { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640 },
{ url = "https://files.pythonhosted.org/packages/6f/bd/af35278080716ecab8f57e84515c7dc535ed95d1c7f52c1c6f7b313a9dab/pydantic_core-2.33.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ec86b5baa36f0a0bfb37db86c7d52652f8e8aa076ab745ef7725784183c3fdd", size = 1851687 }, { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649 },
{ url = "https://files.pythonhosted.org/packages/12/e4/a01461225809c3533c23bd1916b1e8c2e21727f0fea60ab1acbffc4e2fca/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4deac83a8cc1d09e40683be0bc6d1fa4cde8df0a9bf0cda5693f9b0569ac01b6", size = 1892232 }, { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472 },
{ url = "https://files.pythonhosted.org/packages/51/17/3d53d62a328fb0a49911c2962036b9e7a4f781b7d15e9093c26299e5f76d/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:175ab598fb457a9aee63206a1993874badf3ed9a456e0654273e56f00747bbd6", size = 1977896 }, { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509 },
{ url = "https://files.pythonhosted.org/packages/30/98/01f9d86e02ec4a38f4b02086acf067f2c776b845d43f901bd1ee1c21bc4b/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f36afd0d56a6c42cf4e8465b6441cf546ed69d3a4ec92724cc9c8c61bd6ecf4", size = 2127717 }, { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702 },
{ url = "https://files.pythonhosted.org/packages/3c/43/6f381575c61b7c58b0fd0b92134c5a1897deea4cdfc3d47567b3ff460a4e/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a98257451164666afafc7cbf5fb00d613e33f7e7ebb322fbcd99345695a9a61", size = 2680287 }, { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428 },
{ url = "https://files.pythonhosted.org/packages/01/42/c0d10d1451d161a9a0da9bbef023b8005aa26e9993a8cc24dc9e3aa96c93/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecc6d02d69b54a2eb83ebcc6f29df04957f734bcf309d346b4f83354d8376862", size = 2008276 }, { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753 },
{ url = "https://files.pythonhosted.org/packages/20/ca/e08df9dba546905c70bae44ced9f3bea25432e34448d95618d41968f40b7/pydantic_core-2.33.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a69b7596c6603afd049ce7f3835bcf57dd3892fc7279f0ddf987bebed8caa5a", size = 2115305 }, { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849 },
{ url = "https://files.pythonhosted.org/packages/03/1f/9b01d990730a98833113581a78e595fd40ed4c20f9693f5a658fb5f91eff/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea30239c148b6ef41364c6f51d103c2988965b643d62e10b233b5efdca8c0099", size = 2068999 }, { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541 },
{ url = "https://files.pythonhosted.org/packages/20/18/fe752476a709191148e8b1e1139147841ea5d2b22adcde6ee6abb6c8e7cf/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:abfa44cf2f7f7d7a199be6c6ec141c9024063205545aa09304349781b9a125e6", size = 2241488 }, { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225 },
{ url = "https://files.pythonhosted.org/packages/81/22/14738ad0a0bf484b928c9e52004f5e0b81dd8dabbdf23b843717b37a71d1/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20d4275f3c4659d92048c70797e5fdc396c6e4446caf517ba5cad2db60cd39d3", size = 2248430 }, { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373 },
{ url = "https://files.pythonhosted.org/packages/e8/27/be7571e215ac8d321712f2433c445b03dbcd645366a18f67b334df8912bc/pydantic_core-2.33.0-cp312-cp312-win32.whl", hash = "sha256:918f2013d7eadea1d88d1a35fd4a1e16aaf90343eb446f91cb091ce7f9b431a2", size = 1908353 }, { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034 },
{ url = "https://files.pythonhosted.org/packages/be/3a/be78f28732f93128bd0e3944bdd4b3970b389a1fbd44907c97291c8dcdec/pydantic_core-2.33.0-cp312-cp312-win_amd64.whl", hash = "sha256:aec79acc183865bad120b0190afac467c20b15289050648b876b07777e67ea48", size = 1955956 }, { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848 },
{ url = "https://files.pythonhosted.org/packages/21/26/b8911ac74faa994694b76ee6a22875cc7a4abea3c381fdba4edc6c6bef84/pydantic_core-2.33.0-cp312-cp312-win_arm64.whl", hash = "sha256:5461934e895968655225dfa8b3be79e7e927e95d4bd6c2d40edd2fa7052e71b6", size = 1903259 }, { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986 },
{ url = "https://files.pythonhosted.org/packages/79/20/de2ad03ce8f5b3accf2196ea9b44f31b0cd16ac6e8cfc6b21976ed45ec35/pydantic_core-2.33.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f00e8b59e1fc8f09d05594aa7d2b726f1b277ca6155fc84c0396db1b373c4555", size = 2032214 }, { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551 },
{ url = "https://files.pythonhosted.org/packages/f9/af/6817dfda9aac4958d8b516cbb94af507eb171c997ea66453d4d162ae8948/pydantic_core-2.33.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a73be93ecef45786d7d95b0c5e9b294faf35629d03d5b145b09b81258c7cd6d", size = 1852338 }, { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785 },
{ url = "https://files.pythonhosted.org/packages/44/f3/49193a312d9c49314f2b953fb55740b7c530710977cabe7183b8ef111b7f/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff48a55be9da6930254565ff5238d71d5e9cd8c5487a191cb85df3bdb8c77365", size = 1896913 }, { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758 },
{ url = "https://files.pythonhosted.org/packages/06/e0/c746677825b2e29a2fa02122a8991c83cdd5b4c5f638f0664d4e35edd4b2/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4ea04195638dcd8c53dadb545d70badba51735b1594810e9768c2c0b4a5da", size = 1986046 }, { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109 },
{ url = "https://files.pythonhosted.org/packages/11/ec/44914e7ff78cef16afb5e5273d480c136725acd73d894affdbe2a1bbaad5/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d698dcbe12b60661f0632b543dbb119e6ba088103b364ff65e951610cb7ce0", size = 2128097 }, { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159 },
{ url = "https://files.pythonhosted.org/packages/fe/f5/c6247d424d01f605ed2e3802f338691cae17137cee6484dce9f1ac0b872b/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae62032ef513fe6281ef0009e30838a01057b832dc265da32c10469622613885", size = 2681062 }, { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222 },
{ url = "https://files.pythonhosted.org/packages/f0/85/114a2113b126fdd7cf9a9443b1b1fe1b572e5bd259d50ba9d5d3e1927fa9/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f225f3a3995dbbc26affc191d0443c6c4aa71b83358fd4c2b7d63e2f6f0336f9", size = 2007487 }, { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980 },
{ url = "https://files.pythonhosted.org/packages/e6/40/3c05ed28d225c7a9acd2b34c5c8010c279683a870219b97e9f164a5a8af0/pydantic_core-2.33.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bdd36b362f419c78d09630cbaebc64913f66f62bda6d42d5fbb08da8cc4f181", size = 2121382 }, { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840 },
{ url = "https://files.pythonhosted.org/packages/8a/22/e70c086f41eebd323e6baa92cc906c3f38ddce7486007eb2bdb3b11c8f64/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a0147c0bef783fd9abc9f016d66edb6cac466dc54a17ec5f5ada08ff65caf5d", size = 2072473 }, { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518 },
{ url = "https://files.pythonhosted.org/packages/3e/84/d1614dedd8fe5114f6a0e348bcd1535f97d76c038d6102f271433cd1361d/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c860773a0f205926172c6644c394e02c25421dc9a456deff16f64c0e299487d3", size = 2249468 }, { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025 },
{ url = "https://files.pythonhosted.org/packages/b0/c0/787061eef44135e00fddb4b56b387a06c303bfd3884a6df9bea5cb730230/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:138d31e3f90087f42aa6286fb640f3c7a8eb7bdae829418265e7e7474bd2574b", size = 2254716 }, { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991 },
{ url = "https://files.pythonhosted.org/packages/ae/e2/27262eb04963201e89f9c280f1e10c493a7a37bc877e023f31aa72d2f911/pydantic_core-2.33.0-cp313-cp313-win32.whl", hash = "sha256:d20cbb9d3e95114325780f3cfe990f3ecae24de7a2d75f978783878cce2ad585", size = 1916450 }, { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262 },
{ url = "https://files.pythonhosted.org/packages/13/8d/25ff96f1e89b19e0b70b3cd607c9ea7ca27e1dcb810a9cd4255ed6abf869/pydantic_core-2.33.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca1103d70306489e3d006b0f79db8ca5dd3c977f6f13b2c59ff745249431a606", size = 1956092 }, { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626 },
{ url = "https://files.pythonhosted.org/packages/1b/64/66a2efeff657b04323ffcd7b898cb0354d36dae3a561049e092134a83e9c/pydantic_core-2.33.0-cp313-cp313-win_arm64.whl", hash = "sha256:6291797cad239285275558e0a27872da735b05c75d5237bbade8736f80e4c225", size = 1908367 }, { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590 },
{ url = "https://files.pythonhosted.org/packages/52/54/295e38769133363d7ec4a5863a4d579f331728c71a6644ff1024ee529315/pydantic_core-2.33.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7b79af799630af263eca9ec87db519426d8c9b3be35016eddad1832bac812d87", size = 1813331 }, { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963 },
{ url = "https://files.pythonhosted.org/packages/4c/9c/0c8ea02db8d682aa1ef48938abae833c1d69bdfa6e5ec13b21734b01ae70/pydantic_core-2.33.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf946a4739b5237f4f56d77fa6668263bc466d06a8036c055587c130a46f7b", size = 1986653 }, { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896 },
{ url = "https://files.pythonhosted.org/packages/8e/4f/3fb47d6cbc08c7e00f92300e64ba655428c05c56b8ab6723bd290bae6458/pydantic_core-2.33.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8a1d581e8cdbb857b0e0e81df98603376c1a5c34dc5e54039dcc00f043df81e7", size = 1931234 }, { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810 },
{ url = "https://files.pythonhosted.org/packages/44/77/85e173b715e1a277ce934f28d877d82492df13e564fa68a01c96f36a47ad/pydantic_core-2.33.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e2762c568596332fdab56b07060c8ab8362c56cf2a339ee54e491cd503612c50", size = 2040129 }, { url = "https://files.pythonhosted.org/packages/9c/c7/8b311d5adb0fe00a93ee9b4e92a02b0ec08510e9838885ef781ccbb20604/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02", size = 2041659 },
{ url = "https://files.pythonhosted.org/packages/33/e7/33da5f8a94bbe2191cfcd15bd6d16ecd113e67da1b8c78d3cc3478112dab/pydantic_core-2.33.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bf637300ff35d4f59c006fff201c510b2b5e745b07125458a5389af3c0dff8c", size = 1872656 }, { url = "https://files.pythonhosted.org/packages/8a/d6/4f58d32066a9e26530daaf9adc6664b01875ae0691570094968aaa7b8fcc/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068", size = 1873294 },
{ url = "https://files.pythonhosted.org/packages/b4/7a/9600f222bea840e5b9ba1f17c0acc79b669b24542a78c42c6a10712c0aae/pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c151ce3d59ed56ebd7ce9ce5986a409a85db697d25fc232f8e81f195aa39a1", size = 1903731 }, { url = "https://files.pythonhosted.org/packages/f7/3f/53cc9c45d9229da427909c751f8ed2bf422414f7664ea4dde2d004f596ba/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e", size = 1903771 },
{ url = "https://files.pythonhosted.org/packages/81/d2/94c7ca4e24c5dcfb74df92e0836c189e9eb6814cf62d2f26a75ea0a906db/pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee65f0cc652261744fd07f2c6e6901c914aa6c5ff4dcfaf1136bc394d0dd26b", size = 2083966 }, { url = "https://files.pythonhosted.org/packages/f0/49/bf0783279ce674eb9903fb9ae43f6c614cb2f1c4951370258823f795368b/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe", size = 2083558 },
{ url = "https://files.pythonhosted.org/packages/b8/74/a0259989d220e8865ed6866a6d40539e40fa8f507e587e35d2414cc081f8/pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:024d136ae44d233e6322027bbf356712b3940bee816e6c948ce4b90f18471b3d", size = 2118951 }, { url = "https://files.pythonhosted.org/packages/9c/5b/0d998367687f986c7d8484a2c476d30f07bf5b8b1477649a6092bd4c540e/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1", size = 2118038 },
{ url = "https://files.pythonhosted.org/packages/13/4c/87405ed04d6d07597920b657f082a8e8e58bf3034178bb9044b4d57a91e2/pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e37f10f6d4bc67c58fbd727108ae1d8b92b397355e68519f1e4a7babb1473442", size = 2079632 }, { url = "https://files.pythonhosted.org/packages/b3/33/039287d410230ee125daee57373ac01940d3030d18dba1c29cd3089dc3ca/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7", size = 2079315 },
{ url = "https://files.pythonhosted.org/packages/5a/4c/bcb02970ef91d4cd6de7c6893101302637da456bc8b52c18ea0d047b55ce/pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:502ed542e0d958bd12e7c3e9a015bce57deaf50eaa8c2e1c439b512cb9db1e3a", size = 2250541 }, { url = "https://files.pythonhosted.org/packages/1f/85/6d8b2646d99c062d7da2d0ab2faeb0d6ca9cca4c02da6076376042a20da3/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde", size = 2249063 },
{ url = "https://files.pythonhosted.org/packages/a3/2b/dbe5450c4cd904be5da736dcc7f2357b828199e29e38de19fc81f988b288/pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:715c62af74c236bf386825c0fdfa08d092ab0f191eb5b4580d11c3189af9d330", size = 2255685 }, { url = "https://files.pythonhosted.org/packages/17/d7/c37d208d5738f7b9ad8f22ae8a727d88ebf9c16c04ed2475122cc3f7224a/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add", size = 2254631 },
{ url = "https://files.pythonhosted.org/packages/ca/a6/ca1d35f695d81f639c5617fc9efb44caad21a9463383fa45364b3044175a/pydantic_core-2.33.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bccc06fa0372151f37f6b69834181aa9eb57cf8665ed36405fb45fbf6cac3bae", size = 2082395 }, { url = "https://files.pythonhosted.org/packages/13/e0/bafa46476d328e4553b85ab9b2f7409e7aaef0ce4c937c894821c542d347/pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c", size = 2080877 },
{ url = "https://files.pythonhosted.org/packages/2b/b2/553e42762e7b08771fca41c0230c1ac276f9e79e78f57628e1b7d328551d/pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5d8dc9f63a26f7259b57f46a7aab5af86b2ad6fbe48487500bb1f4b27e051e4c", size = 2041207 }, { url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858 },
{ url = "https://files.pythonhosted.org/packages/85/81/a91a57bbf3efe53525ab75f65944b8950e6ef84fe3b9a26c1ec173363263/pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30369e54d6d0113d2aa5aee7a90d17f225c13d87902ace8fcd7bbf99b19124db", size = 1873736 }, { url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745 },
{ url = "https://files.pythonhosted.org/packages/9c/d2/5ab52e9f551cdcbc1ee99a0b3ef595f56d031f66f88e5ca6726c49f9ce65/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3eb479354c62067afa62f53bb387827bee2f75c9c79ef25eef6ab84d4b1ae3b", size = 1903794 }, { url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188 },
{ url = "https://files.pythonhosted.org/packages/2f/5f/a81742d3f3821b16f1265f057d6e0b68a3ab13a814fe4bffac536a1f26fd/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0310524c833d91403c960b8a3cf9f46c282eadd6afd276c8c5edc617bd705dc9", size = 2083457 }, { url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479 },
{ url = "https://files.pythonhosted.org/packages/b5/2f/e872005bc0fc47f9c036b67b12349a8522d32e3bda928e82d676e2a594d1/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eddb18a00bbb855325db27b4c2a89a4ba491cd6a0bd6d852b225172a1f54b36c", size = 2119537 }, { url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415 },
{ url = "https://files.pythonhosted.org/packages/d3/13/183f13ce647202eaf3dada9e42cdfc59cbb95faedd44d25f22b931115c7f/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ade5dbcf8d9ef8f4b28e682d0b29f3008df9842bb5ac48ac2c17bc55771cc976", size = 2080069 }, { url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623 },
{ url = "https://files.pythonhosted.org/packages/23/8b/b6be91243da44a26558d9c3a9007043b3750334136c6550551e8092d6d96/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2c0afd34f928383e3fd25740f2050dbac9d077e7ba5adbaa2227f4d4f3c8da5c", size = 2251618 }, { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175 },
{ url = "https://files.pythonhosted.org/packages/aa/c5/fbcf1977035b834f63eb542e74cd6c807177f383386175b468f0865bcac4/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7da333f21cd9df51d5731513a6d39319892947604924ddf2e24a4612975fb936", size = 2255374 }, { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674 },
{ url = "https://files.pythonhosted.org/packages/2f/f8/66f328e411f1c9574b13c2c28ab01f308b53688bbbe6ca8fb981e6cabc42/pydantic_core-2.33.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b6d77c75a57f041c5ee915ff0b0bb58eabb78728b69ed967bc5b780e8f701b8", size = 2082099 }, { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951 },
] ]
[[package]] [[package]]
@@ -1960,15 +1960,15 @@ wheels = [
[[package]] [[package]]
name = "pytest-cov" name = "pytest-cov"
version = "6.0.0" version = "6.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "coverage", extra = ["toml"] }, { name = "coverage", extra = ["toml"] },
{ name = "pytest" }, { name = "pytest" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 },
] ]
[[package]] [[package]]
@@ -2225,27 +2225,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.11.2" version = "0.11.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511 } sdist = { url = "https://files.pythonhosted.org/packages/e8/5b/3ae20f89777115944e89c2d8c2e795dcc5b9e04052f76d5347e35e0da66e/ruff-0.11.4.tar.gz", hash = "sha256:f45bd2fb1a56a5a85fae3b95add03fb185a0b30cf47f5edc92aa0355ca1d7407", size = 3933063 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146 }, { url = "https://files.pythonhosted.org/packages/9c/db/baee59ac88f57527fcbaad3a7b309994e42329c6bc4d4d2b681a3d7b5426/ruff-0.11.4-py3-none-linux_armv6l.whl", hash = "sha256:d9f4a761ecbde448a2d3e12fb398647c7f0bf526dbc354a643ec505965824ed2", size = 10106493 },
{ url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092 }, { url = "https://files.pythonhosted.org/packages/c1/d6/9a0962cbb347f4ff98b33d699bf1193ff04ca93bed4b4222fd881b502154/ruff-0.11.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8c1747d903447d45ca3d40c794d1a56458c51e5cc1bc77b7b64bd2cf0b1626cc", size = 10876382 },
{ url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082 }, { url = "https://files.pythonhosted.org/packages/3a/8f/62bab0c7d7e1ae3707b69b157701b41c1ccab8f83e8501734d12ea8a839f/ruff-0.11.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:51a6494209cacca79e121e9b244dc30d3414dac8cc5afb93f852173a2ecfc906", size = 10237050 },
{ url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818 }, { url = "https://files.pythonhosted.org/packages/09/96/e296965ae9705af19c265d4d441958ed65c0c58fc4ec340c27cc9d2a1f5b/ruff-0.11.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f171605f65f4fc49c87f41b456e882cd0c89e4ac9d58e149a2b07930e1d466f", size = 10424984 },
{ url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251 }, { url = "https://files.pythonhosted.org/packages/e5/56/644595eb57d855afed6e54b852e2df8cd5ca94c78043b2f29bdfb29882d5/ruff-0.11.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebf99ea9af918878e6ce42098981fc8c1db3850fef2f1ada69fb1dcdb0f8e79e", size = 9957438 },
{ url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566 }, { url = "https://files.pythonhosted.org/packages/86/83/9d3f3bed0118aef3e871ded9e5687fb8c5776bde233427fd9ce0a45db2d4/ruff-0.11.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edad2eac42279df12e176564a23fc6f4aaeeb09abba840627780b1bb11a9d223", size = 11547282 },
{ url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721 }, { url = "https://files.pythonhosted.org/packages/40/e6/0c6e4f5ae72fac5ccb44d72c0111f294a5c2c8cc5024afcb38e6bda5f4b3/ruff-0.11.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f103a848be9ff379fc19b5d656c1f911d0a0b4e3e0424f9532ececf319a4296e", size = 12182020 },
{ url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274 }, { url = "https://files.pythonhosted.org/packages/b5/92/4aed0e460aeb1df5ea0c2fbe8d04f9725cccdb25d8da09a0d3f5b8764bf8/ruff-0.11.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:193e6fac6eb60cc97b9f728e953c21cc38a20077ed64f912e9d62b97487f3f2d", size = 11679154 },
{ url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284 }, { url = "https://files.pythonhosted.org/packages/1b/d3/7316aa2609f2c592038e2543483eafbc62a0e1a6a6965178e284808c095c/ruff-0.11.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7af4e5f69b7c138be8dcffa5b4a061bf6ba6a3301f632a6bce25d45daff9bc99", size = 13905985 },
{ url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861 }, { url = "https://files.pythonhosted.org/packages/63/80/734d3d17546e47ff99871f44ea7540ad2bbd7a480ed197fe8a1c8a261075/ruff-0.11.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:126b1bf13154aa18ae2d6c3c5efe144ec14b97c60844cfa6eb960c2a05188222", size = 11348343 },
{ url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560 }, { url = "https://files.pythonhosted.org/packages/04/7b/70fc7f09a0161dce9613a4671d198f609e653d6f4ff9eee14d64c4c240fb/ruff-0.11.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8806daaf9dfa881a0ed603f8a0e364e4f11b6ed461b56cae2b1c0cab0645304", size = 10308487 },
{ url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091 }, { url = "https://files.pythonhosted.org/packages/1a/22/1cdd62dabd678d75842bf4944fd889cf794dc9e58c18cc547f9eb28f95ed/ruff-0.11.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5d94bb1cc2fc94a769b0eb975344f1b1f3d294da1da9ddbb5a77665feb3a3019", size = 9929091 },
{ url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133 }, { url = "https://files.pythonhosted.org/packages/9f/20/40e0563506332313148e783bbc1e4276d657962cc370657b2fff20e6e058/ruff-0.11.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:995071203d0fe2183fc7a268766fd7603afb9996785f086b0d76edee8755c896", size = 10924659 },
{ url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514 }, { url = "https://files.pythonhosted.org/packages/b5/41/eef9b7aac8819d9e942f617f9db296f13d2c4576806d604aba8db5a753f1/ruff-0.11.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7a37ca937e307ea18156e775a6ac6e02f34b99e8c23fe63c1996185a4efe0751", size = 11428160 },
{ url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835 }, { url = "https://files.pythonhosted.org/packages/ff/61/c488943414fb2b8754c02f3879de003e26efdd20f38167ded3fb3fc1cda3/ruff-0.11.4-py3-none-win32.whl", hash = "sha256:0e9365a7dff9b93af933dab8aebce53b72d8f815e131796268709890b4a83270", size = 10311496 },
{ url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713 }, { url = "https://files.pythonhosted.org/packages/b6/2b/2a1c8deb5f5dfa3871eb7daa41492c4d2b2824a74d2b38e788617612a66d/ruff-0.11.4-py3-none-win_amd64.whl", hash = "sha256:5a9fa1c69c7815e39fcfb3646bbfd7f528fa8e2d4bebdcf4c2bd0fa037a255fb", size = 11399146 },
{ url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990 }, { url = "https://files.pythonhosted.org/packages/4f/03/3aec4846226d54a37822e4c7ea39489e4abd6f88388fba74e3d4abe77300/ruff-0.11.4-py3-none-win_arm64.whl", hash = "sha256:d435db6b9b93d02934cf61ef332e66af82da6d8c69aefdea5994c89997c7a0fc", size = 10450306 },
] ]
[[package]] [[package]]
@@ -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>
@@ -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
@@ -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
} }
} }
+2
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",
@@ -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);
@@ -0,0 +1,5 @@
abstract interface class ILocalFilesManager {
Future<bool> moveToTrash(String fileName);
Future<bool> restoreFromTrash(String fileName);
Future<bool> requestManageStoragePermission();
}
+27 -1
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);
@@ -30,7 +30,7 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
@override @override
Future<List<String>> getAssetIds(String albumId) async { Future<List<String>> getAssetIds(String albumId) async {
final album = await AssetPathEntity.fromId(albumId); final album = await AssetPathEntity.obtainPathFromProperties(id: albumId);
final List<AssetEntity> assets = final List<AssetEntity> assets =
await album.getAssetListRange(start: 0, end: 0x7fffffffffffffff); await album.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
return assets.map((e) => e.id).toList(); return assets.map((e) => e.id).toList();
@@ -38,7 +38,7 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
@override @override
Future<int> getAssetCount(String albumId) async { Future<int> getAssetCount(String albumId) async {
final album = await AssetPathEntity.fromId(albumId); final album = await AssetPathEntity.obtainPathFromProperties(id: albumId);
return album.assetCountAsync; return album.assetCountAsync;
} }
@@ -51,9 +51,9 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
DateTime? modifiedUntil, DateTime? modifiedUntil,
bool orderByModificationDate = false, bool orderByModificationDate = false,
}) async { }) async {
final onDevice = await AssetPathEntity.fromId( final onDevice = await AssetPathEntity.obtainPathFromProperties(
albumId, id: albumId,
filterOption: FilterOptionGroup( optionGroup: FilterOptionGroup(
imageOption: const FilterOption(needTitle: true), imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true), videoOption: const FilterOption(needTitle: true),
containsPathModified: true, containsPathModified: true,
@@ -80,7 +80,8 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
DateTime? modifiedFrom, DateTime? modifiedFrom,
DateTime? modifiedUntil, DateTime? modifiedUntil,
}) async { }) async {
final assetPathEntity = await AssetPathEntity.fromId(id); final assetPathEntity =
await AssetPathEntity.obtainPathFromProperties(id: id);
return _toAlbum(assetPathEntity); return _toAlbum(assetPathEntity);
} }
@@ -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();
}
}
@@ -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),
+47 -1
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 {
+39
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;
}
}
}
+72 -4
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());
} }
@@ -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();
@@ -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) {
@@ -7,6 +7,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
class TopControlAppBar extends HookConsumerWidget { class TopControlAppBar extends HookConsumerWidget {
const TopControlAppBar({ const TopControlAppBar({
@@ -166,6 +167,9 @@ class TopControlAppBar extends HookConsumerWidget {
); );
} }
bool isInHomePage = ref.read(tabProvider.notifier).state == TabEnum.home;
bool? isInTrash = ref.read(currentAssetProvider)?.isTrashed;
return AppBar( return AppBar(
foregroundColor: Colors.grey[100], foregroundColor: Colors.grey[100],
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@@ -174,7 +178,7 @@ class TopControlAppBar extends HookConsumerWidget {
shape: const Border(), shape: const Border(),
actions: [ actions: [
if (asset.isRemote && isOwner) buildFavoriteButton(a), if (asset.isRemote && isOwner) buildFavoriteButton(a),
if (isOwner && ref.read(tabProvider.notifier).state != TabEnum.home) if (isOwner && !isInHomePage && !(isInTrash ?? false))
buildLocateButton(), buildLocateButton(),
if (asset.livePhotoVideoId != null) const MotionPhotoButton(), if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(),
@@ -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) {
@@ -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,
+1
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
@@ -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,
); );
@@ -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);
});
});
}
+5 -1
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 {}
+4
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 {}
+8 -8
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"
} }
+1 -1
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": {
+2 -1
View File
@@ -4,6 +4,7 @@ FROM ghcr.io/immich-app/base-server-dev:202503251114@sha256:10e8973e8603c5729436
RUN apt-get install --no-install-recommends -yqq tini RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./ COPY server/package.json server/package-lock.json ./
COPY server/patches ./patches
RUN npm ci && \ RUN npm ci && \
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need # exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning # they're marked as optional dependencies, so we need to copy them manually after pruning
@@ -56,7 +57,7 @@ COPY server/resources resources
COPY server/package.json server/package-lock.json ./ COPY server/package.json server/package-lock.json ./
COPY server/start*.sh ./ COPY server/start*.sh ./
COPY "docker/scripts/get-cpus.sh" ./ COPY "docker/scripts/get-cpus.sh" ./
RUN npm link && npm install -g @immich/cli && npm cache clean --force RUN npm install -g @immich/cli && npm cache clean --force
COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE COPY LICENSE /LICENSE
ENV PATH="${PATH}:/usr/src/app/bin" ENV PATH="${PATH}:/usr/src/app/bin"
+364 -457
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -25,15 +25,15 @@
"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",
"email:dev": "email dev -p 3050 --dir src/emails", "email:dev": "email dev -p 3050 --dir src/emails",
"postinstall": "[ \"$npm_config_global\" != \"true\" ] && patch-package || true" "postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^11.0.1", "@nestjs/bullmq": "^11.0.1",
@@ -49,7 +49,7 @@
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.200.0", "@opentelemetry/exporter-prometheus": "^0.200.0",
"@opentelemetry/sdk-node": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0",
"@react-email/components": "^0.0.34", "@react-email/components": "^0.0.35",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.0", "archiver": "^7.0.0",
"async-lock": "^1.4.0", "async-lock": "^1.4.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",
+21 -12
View File
@@ -1,39 +1,48 @@
diff --git a/node_modules/postgres/cf/src/connection.js b/node_modules/postgres/cf/src/connection.js diff --git a/node_modules/postgres/cf/src/connection.js b/node_modules/postgres/cf/src/connection.js
index ee8b1e6..d03b9dd 100644 index ee8b1e6..acf4566 100644
--- a/node_modules/postgres/cf/src/connection.js --- a/node_modules/postgres/cf/src/connection.js
+++ b/node_modules/postgres/cf/src/connection.js +++ b/node_modules/postgres/cf/src/connection.js
@@ -387,6 +387,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose @@ -387,8 +387,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
} }
function queryError(query, err) { function queryError(query, err) {
+ if (!query || typeof query !== 'object') throw err + if (!query || typeof query !== 'object' || !query.reject) throw err
+ +
'query' in err || 'parameters' in err || Object.defineProperties(err, { 'query' in err || 'parameters' in err || Object.defineProperties(err, {
stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
+ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug },
query: { value: query.string, enumerable: options.debug }, query: { value: query.string, enumerable: options.debug },
parameters: { value: query.parameters, enumerable: options.debug },
args: { value: query.args, enumerable: options.debug },
diff --git a/node_modules/postgres/cjs/src/connection.js b/node_modules/postgres/cjs/src/connection.js diff --git a/node_modules/postgres/cjs/src/connection.js b/node_modules/postgres/cjs/src/connection.js
index f7f58d1..8a37571 100644 index f7f58d1..b7f2d65 100644
--- a/node_modules/postgres/cjs/src/connection.js --- a/node_modules/postgres/cjs/src/connection.js
+++ b/node_modules/postgres/cjs/src/connection.js +++ b/node_modules/postgres/cjs/src/connection.js
@@ -385,6 +385,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose @@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
} }
function queryError(query, err) { function queryError(query, err) {
+ if (!query || typeof query !== 'object') throw err + if (!query || typeof query !== 'object' || !query.reject) throw err
+ +
'query' in err || 'parameters' in err || Object.defineProperties(err, { 'query' in err || 'parameters' in err || Object.defineProperties(err, {
stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
+ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug },
query: { value: query.string, enumerable: options.debug }, query: { value: query.string, enumerable: options.debug },
parameters: { value: query.parameters, enumerable: options.debug },
args: { value: query.args, enumerable: options.debug },
diff --git a/node_modules/postgres/src/connection.js b/node_modules/postgres/src/connection.js diff --git a/node_modules/postgres/src/connection.js b/node_modules/postgres/src/connection.js
index 97cc97e..58f5298 100644 index 97cc97e..26f508e 100644
--- a/node_modules/postgres/src/connection.js --- a/node_modules/postgres/src/connection.js
+++ b/node_modules/postgres/src/connection.js +++ b/node_modules/postgres/src/connection.js
@@ -385,6 +385,8 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose @@ -385,8 +385,10 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
} }
function queryError(query, err) { function queryError(query, err) {
+ if (!query || typeof query !== 'object') throw err + if (!query || typeof query !== 'object' || !query.reject) throw err
+ +
'query' in err || 'parameters' in err || Object.defineProperties(err, { 'query' in err || 'parameters' in err || Object.defineProperties(err, {
stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug }, - stack: { value: err.stack + query.origin.replace(/.*\n/, '\n'), enumerable: options.debug },
+ stack: { value: err.stack + (query.origin || '').replace(/.*\n/, '\n'), enumerable: options.debug },
query: { value: query.string, enumerable: options.debug }, query: { value: query.string, enumerable: options.debug },
parameters: { value: query.parameters, enumerable: options.debug },
args: { value: query.args, enumerable: options.debug },
+64 -19
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 };
}; };
+86 -3
View File
@@ -1,5 +1,7 @@
import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { Selectable } from 'kysely';
import { AssetStatus, AssetType, Permission, UserStatus } from 'src/enum'; import { Exif as DatabaseExif } from 'src/db';
import { AlbumUserRole, AssetFileType, AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum';
import { OnThisDayData, UserMetadataItem } from 'src/types';
export type AuthUser = { export type AuthUser = {
id: string; id: string;
@@ -10,6 +12,17 @@ export type AuthUser = {
quotaSizeInBytes: number | null; quotaSizeInBytes: number | null;
}; };
export type AlbumUser = {
user: User;
role: AlbumUserRole;
};
export type AssetFile = {
id: string;
type: AssetFileType;
path: string;
};
export type Library = { export type Library = {
id: string; id: string;
ownerId: string; ownerId: string;
@@ -29,6 +42,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 +64,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;
@@ -57,7 +108,7 @@ export type UserAdmin = User & {
quotaSizeInBytes: number | null; quotaSizeInBytes: number | null;
quotaUsageInBytes: number; quotaUsageInBytes: number;
status: UserStatus; status: UserStatus;
metadata: UserMetadataEntity[]; metadata: UserMetadataItem[];
}; };
export type Asset = { export type Asset = {
@@ -92,6 +143,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 +175,30 @@ 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;
};
export type Exif = Omit<Selectable<DatabaseExif>, 'updatedAt' | 'updateId'>;
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 +222,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',
+20 -15
View File
@@ -4,16 +4,25 @@
*/ */
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, UserMetadataItem } from 'src/types';
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>; export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
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;
} }
@@ -405,10 +412,8 @@ export interface TypeormMetadata {
value: string | null; value: string | null;
} }
export interface UserMetadata { export interface UserMetadata extends UserMetadataItem {
key: string;
userId: string; userId: string;
value: Json;
} }
export interface UsersAudit { export interface UsersAudit {
+16
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.
+3 -4
View File
@@ -1,8 +1,7 @@
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 { ActivityItem } from 'src/types';
import { Optional, ValidateUUID } from 'src/validation'; import { Optional, ValidateUUID } from 'src/validation';
export enum ReactionType { export enum ReactionType {
@@ -68,13 +67,13 @@ 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,
createdAt: activity.createdAt, createdAt: activity.createdAt,
comment: activity.comment, comment: activity.comment,
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT, type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
user: mapUser(activity.user as unknown as UserEntity), user: mapUser(activity.user),
}; };
}; };
+1 -3
View File
@@ -143,13 +143,11 @@ export class AlbumResponseDto {
} }
export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => { export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
const sharedUsers: UserResponseDto[] = [];
const albumUsers: AlbumUserResponseDto[] = []; const albumUsers: AlbumUserResponseDto[] = [];
if (entity.albumUsers) { if (entity.albumUsers) {
for (const albumUser of entity.albumUsers) { for (const albumUser of entity.albumUsers) {
const user = mapUser(albumUser.user); const user = mapUser(albumUser.user);
sharedUsers.push(user);
albumUsers.push({ albumUsers.push({
user, user,
role: albumUser.role, role: albumUser.role,
@@ -162,7 +160,7 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
const assets = entity.assets || []; const assets = entity.assets || [];
const hasSharedLink = entity.sharedLinks?.length > 0; const hasSharedLink = entity.sharedLinks?.length > 0;
const hasSharedUser = sharedUsers.length > 0; const hasSharedUser = albumUsers.length > 0;
let startDate = assets.at(0)?.localDateTime; let startDate = assets.at(0)?.localDateTime;
let endDate = assets.at(-1)?.localDateTime; let endDate = assets.at(-1)?.localDateTime;
+2 -3
View File
@@ -1,8 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database'; import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import { UserEntity } from 'src/entities/user.entity';
import { ImmichCookie } from 'src/enum'; import { ImmichCookie } from 'src/enum';
import { toEmail } from 'src/validation'; import { toEmail } from 'src/validation';
@@ -42,7 +41,7 @@ export class LoginResponseDto {
shouldChangePassword!: boolean; shouldChangePassword!: boolean;
} }
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto { export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
return { return {
accessToken, accessToken,
userId: entity.id, userId: entity.id,
+3 -3
View File
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ExifEntity } from 'src/entities/exif.entity'; import { Exif } from 'src/database';
export class ExifResponseDto { export class ExifResponseDto {
make?: string | null = null; make?: string | null = null;
@@ -28,7 +28,7 @@ export class ExifResponseDto {
rating?: number | null = null; rating?: number | null = null;
} }
export function mapExif(entity: ExifEntity): ExifResponseDto { export function mapExif(entity: Exif): ExifResponseDto {
return { return {
make: entity.make, make: entity.make,
model: entity.model, model: entity.model,
@@ -55,7 +55,7 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
}; };
} }
export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto { export function mapSanitizedExif(entity: Exif): ExifResponseDto {
return { return {
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null, fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation, orientation: entity.orientation,
+2 -2
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,
+5 -4
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',
+2 -2
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(),
+1 -4
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,
+2 -3
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,
+1 -1
View File
@@ -1,8 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsDateString, IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator'; import { IsDateString, IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator';
import { UserPreferences } from 'src/entities/user-metadata.entity';
import { UserAvatarColor } from 'src/enum'; import { UserAvatarColor } from 'src/enum';
import { UserPreferences } from 'src/types';
import { Optional, ValidateBoolean } from 'src/validation'; import { Optional, ValidateBoolean } from 'src/validation';
class AvatarUpdate { class AvatarUpdate {
+7 -7
View File
@@ -2,9 +2,8 @@ import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import { User, UserAdmin } from 'src/database'; import { User, UserAdmin } from 'src/database';
import { UserMetadataEntity, UserMetadataItem } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types';
import { getPreferences } from 'src/utils/preferences'; import { getPreferences } from 'src/utils/preferences';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
@@ -42,13 +41,13 @@ export class UserLicense {
activatedAt!: Date; activatedAt!: Date;
} }
export const mapUser = (entity: UserEntity | User): UserResponseDto => { export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
return { return {
id: entity.id, id: entity.id,
email: entity.email, email: entity.email,
name: entity.name, name: entity.name,
profileImagePath: entity.profileImagePath, profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity.email, (entity as UserEntity).metadata || []).avatar.color, avatarColor: getPreferences(entity.email, (entity as UserAdmin).metadata || []).avatar.color,
profileChangedAt: entity.profileChangedAt, profileChangedAt: entity.profileChangedAt,
}; };
}; };
@@ -142,9 +141,10 @@ export class UserAdminResponseDto extends UserResponseDto {
license!: UserLicense | null; license!: UserLicense | null;
} }
export function mapUserAdmin(entity: UserEntity | UserAdmin): UserAdminResponseDto { export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto {
const license = (entity.metadata as UserMetadataItem[])?.find( const metadata = entity.metadata || [];
(item): item is UserMetadataEntity<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE, const license = metadata.find(
(item): item is UserMetadataItem<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,
)?.value; )?.value;
return { return {
...mapUser(entity), ...mapUser(entity),
-11
View File
@@ -1,11 +0,0 @@
import { AlbumEntity } from 'src/entities/album.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AlbumUserRole } from 'src/enum';
export class AlbumUserEntity {
albumId!: string;
userId!: string;
album!: AlbumEntity;
user!: UserEntity;
role!: AlbumUserRole;
}
+3 -4
View File
@@ -1,12 +1,11 @@
import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumUser, User } from 'src/database';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AssetOrder } from 'src/enum'; import { AssetOrder } from 'src/enum';
export class AlbumEntity { export class AlbumEntity {
id!: string; id!: string;
owner!: UserEntity; owner!: User;
ownerId!: string; ownerId!: string;
albumName!: string; albumName!: string;
description!: string; description!: string;
@@ -16,7 +15,7 @@ export class AlbumEntity {
deletedAt!: Date | null; deletedAt!: Date | null;
albumThumbnailAsset!: AssetEntity | null; albumThumbnailAsset!: AssetEntity | null;
albumThumbnailAssetId!: string | null; albumThumbnailAssetId!: string | null;
albumUsers!: AlbumUserEntity[]; albumUsers!: AlbumUser[];
assets!: AssetEntity[]; assets!: AssetEntity[];
sharedLinks!: SharedLinkEntity[]; sharedLinks!: SharedLinkEntity[];
isActivityEnabled!: boolean; isActivityEnabled!: boolean;
-13
View File
@@ -1,13 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetFileType } from 'src/enum';
export class AssetFileEntity {
id!: string;
assetId!: string;
asset?: AssetEntity;
createdAt!: Date;
updatedAt!: Date;
updateId?: string;
type!: AssetFileType;
path!: string;
}
+10 -13
View File
@@ -1,16 +1,12 @@
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 { AssetFile, Exif, Tag, User } 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';
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 { 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 { 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';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
@@ -21,14 +17,14 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
export class AssetEntity { export class AssetEntity {
id!: string; id!: string;
deviceAssetId!: string; deviceAssetId!: string;
owner!: UserEntity; owner!: User;
ownerId!: string; ownerId!: string;
libraryId?: string | null; libraryId?: string | null;
deviceId!: string; deviceId!: string;
type!: AssetType; type!: AssetType;
status!: AssetStatus; status!: AssetStatus;
originalPath!: string; originalPath!: string;
files!: AssetFileEntity[]; files!: AssetFile[];
thumbhash!: Buffer | null; thumbhash!: Buffer | null;
encodedVideoPath!: string | null; encodedVideoPath!: string | null;
createdAt!: Date; createdAt!: Date;
@@ -49,9 +45,8 @@ export class AssetEntity {
livePhotoVideoId!: string | null; livePhotoVideoId!: string | null;
originalFileName!: string; originalFileName!: string;
sidecarPath!: string | null; sidecarPath!: string | null;
exifInfo?: ExifEntity; exifInfo?: Exif;
smartSearch?: SmartSearchEntity; tags?: Tag[];
tags!: TagEntity[];
sharedLinks!: SharedLinkEntity[]; sharedLinks!: SharedLinkEntity[];
albums?: AlbumEntity[]; albums?: AlbumEntity[];
faces!: AssetFaceEntity[]; faces!: AssetFaceEntity[];
@@ -68,7 +63,9 @@ export type AssetEntityPlaceholder = AssetEntity & {
}; };
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')); return qb
.leftJoin('exif', 'assets.id', 'exif.assetId')
.select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif>().as('exifInfo'));
} }
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
@@ -97,9 +94,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');
} }
-36
View File
@@ -1,36 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
export class ExifEntity {
asset?: AssetEntity;
assetId!: string;
updatedAt?: Date;
updateId?: string;
description!: string; // or caption
exifImageWidth!: number | null;
exifImageHeight!: number | null;
fileSizeInByte!: number | null;
orientation!: string | null;
dateTimeOriginal!: Date | null;
modifyDate!: Date | null;
timeZone!: string | null;
latitude!: number | null;
longitude!: number | null;
projectionType!: string | null;
city!: string | null;
livePhotoCID!: string | null;
autoStackId!: string | null;
state!: string | null;
country!: string | null;
make!: string | null;
model!: string | null;
lensModel!: string | null;
fNumber!: number | null;
focalLength!: number | null;
iso!: number | null;
exposureTime!: string | null;
profileDescription!: string | null;
colorspace!: string | null;
bitsPerSample!: number | null;
rating!: number | null;
fps?: number | null;
}
@@ -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;
}
-9
View File
@@ -1,9 +0,0 @@
import { PathType } from 'src/enum';
export class MoveEntity {
id!: string;
entityId!: string;
pathType!: PathType;
oldPath!: string;
newPath!: string;
}
@@ -1,7 +0,0 @@
export class NaturalEarthCountriesTempEntity {
id!: number;
admin!: string;
admin_a3!: string;
type!: string;
coordinates!: string;
}
-2
View File
@@ -1,5 +1,4 @@
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { UserEntity } from 'src/entities/user.entity';
export class PersonEntity { export class PersonEntity {
id!: string; id!: string;
@@ -7,7 +6,6 @@ export class PersonEntity {
updatedAt!: Date; updatedAt!: Date;
updateId?: string; updateId?: string;
ownerId!: string; ownerId!: string;
owner!: UserEntity;
name!: string; name!: string;
birthDate!: Date | string | null; birthDate!: Date | string | null;
thumbnailPath!: string; thumbnailPath!: string;
-49
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');
};
@@ -1,6 +1,5 @@
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
export class SharedLinkEntity { export class SharedLinkEntity {
@@ -8,7 +7,6 @@ export class SharedLinkEntity {
description!: string | null; description!: string | null;
password!: string | null; password!: string | null;
userId!: string; userId!: string;
user!: UserEntity;
key!: Buffer; // use to access the inidividual asset key!: Buffer; // use to access the inidividual asset
type!: SharedLinkType; type!: SharedLinkType;
createdAt!: Date; createdAt!: Date;
@@ -1,7 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
export class SmartSearchEntity {
asset?: AssetEntity;
assetId!: string;
embedding!: string;
}
-2
View File
@@ -1,9 +1,7 @@
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
export class StackEntity { export class StackEntity {
id!: string; id!: string;
owner!: UserEntity;
ownerId!: string; ownerId!: string;
assets!: AssetEntity[]; assets!: AssetEntity[];
primaryAsset!: AssetEntity; primaryAsset!: AssetEntity;
-17
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[];
}
-110
View File
@@ -1,110 +0,0 @@
import { UserEntity } from 'src/entities/user.entity';
import { UserAvatarColor, UserMetadataKey } from 'src/enum';
import { DeepPartial } from 'src/types';
import { HumanReadableSize } from 'src/utils/bytes';
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
key: T;
value: UserMetadata[T];
};
export class UserMetadataEntity<T extends keyof UserMetadata = UserMetadataKey> implements UserMetadataItem<T> {
userId!: string;
user?: UserEntity;
key!: T;
value!: UserMetadata[T];
}
export interface UserPreferences {
folders: {
enabled: boolean;
sidebarWeb: boolean;
};
memories: {
enabled: boolean;
};
people: {
enabled: boolean;
sidebarWeb: boolean;
};
ratings: {
enabled: boolean;
};
sharedLinks: {
enabled: boolean;
sidebarWeb: boolean;
};
tags: {
enabled: boolean;
sidebarWeb: boolean;
};
avatar: {
color: UserAvatarColor;
};
emailNotifications: {
enabled: boolean;
albumInvite: boolean;
albumUpdate: boolean;
};
download: {
archiveSize: number;
includeEmbeddedVideos: boolean;
};
purchase: {
showSupportBadge: boolean;
hideBuyButtonUntil: string;
};
}
export const getDefaultPreferences = (user: { email: string }): UserPreferences => {
const values = Object.values(UserAvatarColor);
const randomIndex = Math.floor(
[...user.email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length,
);
return {
folders: {
enabled: false,
sidebarWeb: false,
},
memories: {
enabled: true,
},
people: {
enabled: true,
sidebarWeb: false,
},
sharedLinks: {
enabled: true,
sidebarWeb: false,
},
ratings: {
enabled: false,
},
tags: {
enabled: false,
sidebarWeb: false,
},
avatar: {
color: values[randomIndex],
},
emailNotifications: {
enabled: true,
albumInvite: true,
albumUpdate: true,
},
download: {
archiveSize: HumanReadableSize.GiB * 4,
includeEmbeddedVideos: false,
},
purchase: {
showSupportBadge: true,
hideBuyButtonUntil: new Date(2022, 1, 12).toISOString(),
},
};
};
export interface UserMetadata extends Record<UserMetadataKey, Record<string, any>> {
[UserMetadataKey.PREFERENCES]: DeepPartial<UserPreferences>;
[UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: string };
}
-34
View File
@@ -1,34 +0,0 @@
import { ExpressionBuilder } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { DB } from 'src/db';
import { AssetEntity } from 'src/entities/asset.entity';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserStatus } from 'src/enum';
export class UserEntity {
id!: string;
name!: string;
isAdmin!: boolean;
email!: string;
storageLabel!: string | null;
password?: string;
oauthId!: string;
profileImagePath!: string;
shouldChangePassword!: boolean;
createdAt!: Date;
deletedAt!: Date | null;
status!: UserStatus;
updatedAt!: Date;
updateId?: string;
assets!: AssetEntity[];
quotaSizeInBytes!: number | null;
quotaUsageInBytes!: number;
metadata!: UserMetadataEntity[];
profileChangedAt!: Date;
}
export const withMetadata = (eb: ExpressionBuilder<DB, 'users'>) => {
return jsonArrayFrom(
eb.selectFrom('user_metadata').selectAll('user_metadata').whereRef('users.id', '=', 'user_metadata.userId'),
).as('metadata');
};
@@ -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> {}
}
+36 -8
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
+57
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".*
+3 -33
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
+57 -12
View File
@@ -24,7 +24,8 @@ select
from from
( (
select select
"user_metadata".* "user_metadata"."key",
"user_metadata"."value"
from from
"user_metadata" "user_metadata"
where where
@@ -54,7 +55,21 @@ select
"shouldChangePassword", "shouldChangePassword",
"storageLabel", "storageLabel",
"quotaSizeInBytes", "quotaSizeInBytes",
"quotaUsageInBytes" "quotaUsageInBytes",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"user_metadata"."key",
"user_metadata"."value"
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
from from
"users" "users"
where where
@@ -87,7 +102,21 @@ select
"shouldChangePassword", "shouldChangePassword",
"storageLabel", "storageLabel",
"quotaSizeInBytes", "quotaSizeInBytes",
"quotaUsageInBytes" "quotaUsageInBytes",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"user_metadata"."key",
"user_metadata"."value"
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
from from
"users" "users"
where where
@@ -135,7 +164,21 @@ select
"shouldChangePassword", "shouldChangePassword",
"storageLabel", "storageLabel",
"quotaSizeInBytes", "quotaSizeInBytes",
"quotaUsageInBytes" "quotaUsageInBytes",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"user_metadata"."key",
"user_metadata"."value"
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
from from
"users" "users"
where where
@@ -174,7 +217,8 @@ select
from from
( (
select select
"user_metadata".* "user_metadata"."key",
"user_metadata"."value"
from from
"user_metadata" "user_metadata"
where where
@@ -210,7 +254,8 @@ select
from from
( (
select select
"user_metadata".* "user_metadata"."key",
"user_metadata"."value"
from from
"user_metadata" "user_metadata"
where where
@@ -232,15 +277,15 @@ select
count(*) filter ( count(*) filter (
where where
( (
"assets"."type" = $1 "assets"."type" = 'IMAGE'
and "assets"."isVisible" = $2 and "assets"."isVisible" = true
) )
) as "photos", ) as "photos",
count(*) filter ( count(*) filter (
where where
( (
"assets"."type" = $3 "assets"."type" = 'VIDEO'
and "assets"."isVisible" = $4 and "assets"."isVisible" = true
) )
) as "videos", ) as "videos",
coalesce( coalesce(
@@ -255,7 +300,7 @@ select
where where
( (
"assets"."libraryId" is null "assets"."libraryId" is null
and "assets"."type" = $5 and "assets"."type" = 'IMAGE'
) )
), ),
0 0
@@ -265,7 +310,7 @@ select
where where
( (
"assets"."libraryId" is null "assets"."libraryId" is null
and "assets"."type" = $6 and "assets"."type" = 'VIDEO'
) )
), ),
0 0
+26 -26
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();
} }
@@ -58,7 +69,7 @@ export class ActivityRepository {
async getStatistics({ albumId, assetId }: { albumId: string; assetId?: string }): Promise<number> { async getStatistics({ albumId, assetId }: { albumId: string; assetId?: string }): Promise<number> {
const { count } = await this.db const { count } = await this.db
.selectFrom('activity') .selectFrom('activity')
.select((eb) => eb.fn.countAll().as('count')) .select((eb) => eb.fn.countAll<number>().as('count'))
.innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null)) .innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null))
.leftJoin('assets', 'assets.id', 'activity.assetId') .leftJoin('assets', 'assets.id', 'activity.assetId')
.$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!)) .$if(!!assetId, (qb) => qb.where('activity.assetId', '=', assetId!))
@@ -70,17 +81,6 @@ export class ActivityRepository {
.where('assets.localDateTime', 'is not', null) .where('assets.localDateTime', 'is not', null)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return count as number; return count;
}
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();
} }
} }
+52 -10
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';
@@ -469,10 +470,51 @@ export class AssetRepository {
async getLivePhotoCount(motionId: string): Promise<number> { async getLivePhotoCount(motionId: string): Promise<number> {
const [{ count }] = await this.db const [{ count }] = await this.db
.selectFrom('assets') .selectFrom('assets')
.select((eb) => eb.fn.countAll().as('count')) .select((eb) => eb.fn.countAll<number>().as('count'))
.where('livePhotoVideoId', '=', asUuid(motionId)) .where('livePhotoVideoId', '=', asUuid(motionId))
.execute(); .execute();
return count as number; return count;
}
@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] })
@@ -731,10 +773,10 @@ export class AssetRepository {
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> { getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO)) .select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO))
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE)) .select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE))
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) .select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO))
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) .select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER))
.where('ownerId', '=', asUuid(ownerId)) .where('ownerId', '=', asUuid(ownerId))
.where('assets.fileCreatedAt', 'is not', null) .where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null) .where('assets.fileModifiedAt', 'is not', null)
@@ -744,7 +786,7 @@ export class AssetRepository {
.$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!)) .$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!))
.$if(!!isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .$if(!!isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.where('deletedAt', isTrashed ? 'is not' : 'is', null) .where('deletedAt', isTrashed ? 'is not' : 'is', null)
.executeTakeFirst() as Promise<AssetStats>; .executeTakeFirstOrThrow();
} }
getRandom(userIds: string[], take: number): Promise<AssetEntity[]> { getRandom(userIds: string[], take: number): Promise<AssetEntity[]> {
@@ -805,7 +847,7 @@ export class AssetRepository {
The line below outputs in YYYY-MM-DD format, but needs a change in the web app to work. The line below outputs in YYYY-MM-DD format, but needs a change in the web app to work.
.select(sql<string>`"timeBucket"::date::text`.as('timeBucket')) .select(sql<string>`"timeBucket"::date::text`.as('timeBucket'))
*/ */
.select((eb) => eb.fn.countAll().as('count')) .select((eb) => eb.fn.countAll<number>().as('count'))
.groupBy('timeBucket') .groupBy('timeBucket')
.orderBy('timeBucket', options.order ?? 'desc') .orderBy('timeBucket', options.order ?? 'desc')
.execute() as any as Promise<TimeBucketItem[]> .execute() as any as Promise<TimeBucketItem[]>
@@ -1103,10 +1145,10 @@ export class AssetRepository {
async getLibraryAssetCount(libraryId: string): Promise<number> { async getLibraryAssetCount(libraryId: string): Promise<number> {
const { count } = await this.db const { count } = await this.db
.selectFrom('assets') .selectFrom('assets')
.select((eb) => eb.fn.countAll().as('count')) .select((eb) => eb.fn.countAll<number>().as('count'))
.where('libraryId', '=', asUuid(libraryId)) .where('libraryId', '=', asUuid(libraryId))
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return Number(count); return count;
} }
} }
+1 -1
View File
@@ -250,7 +250,7 @@ const getEnv = (): EnvData => {
}, },
bigint: { bigint: {
to: 20, to: 20,
from: [20], from: [20, 1700],
parse: (value: string) => Number.parseInt(value), parse: (value: string) => Number.parseInt(value),
serialize: (value: number) => value.toString(), serialize: (value: number) => value.toString(),
}, },
+43 -39
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> {
@@ -76,13 +76,13 @@ export class LibraryRepository {
.leftJoin('exif', 'exif.assetId', 'assets.id') .leftJoin('exif', 'exif.assetId', 'assets.id')
.select((eb) => .select((eb) =>
eb.fn eb.fn
.countAll() .countAll<number>()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)]))
.as('photos'), .as('photos'),
) )
.select((eb) => .select((eb) =>
eb.fn eb.fn
.countAll() .countAll<number>()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)])) .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)]))
.as('videos'), .as('videos'),
) )
@@ -105,10 +105,10 @@ export class LibraryRepository {
} }
return { return {
photos: Number(stats.photos), photos: stats.photos,
videos: Number(stats.videos), videos: stats.videos,
usage: Number(stats.usage), usage: stats.usage,
total: Number(stats.photos) + Number(stats.videos), total: stats.photos + stats.videos,
}; };
} }
@@ -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 });
} }
+5 -5
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, NotNull, 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';
@@ -88,6 +87,7 @@ export class MapRepository {
.on('exif.longitude', 'is not', null), .on('exif.longitude', 'is not', null),
) )
.select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country']) .select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country'])
.$narrowType<{ lat: NotNull; lon: NotNull }>()
.where('isVisible', '=', true) .where('isVisible', '=', true)
.$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!)) .$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!))
.$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!)) .$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!))
@@ -115,7 +115,7 @@ export class MapRepository {
return eb.or(expression); return eb.or(expression);
}) })
.orderBy('fileCreatedAt', 'desc') .orderBy('fileCreatedAt', 'desc')
.execute() as Promise<MapMarker[]>; .execute();
} }
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> { async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
@@ -182,11 +182,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,
+2 -2
View File
@@ -6,7 +6,7 @@ import fs from 'node:fs/promises';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import sharp from 'sharp'; import sharp from 'sharp';
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
import { ExifEntity } from 'src/entities/exif.entity'; import { Exif } from 'src/database';
import { Colorspace, LogLevel } from 'src/enum'; import { Colorspace, LogLevel } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { import {
@@ -66,7 +66,7 @@ export class MediaRepository {
return true; return true;
} }
async writeExif(tags: Partial<ExifEntity>, output: string): Promise<boolean> { async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
try { try {
const tagsToWrite: WriteTags = { const tagsToWrite: WriteTags = {
ExifImageWidth: tags.exifImageWidth, ExifImageWidth: tags.exifImageWidth,
+8 -19
View File
@@ -3,49 +3,38 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DB, MoveHistory } from 'src/db'; import { DB, MoveHistory } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { MoveEntity } from 'src/entities/move.entity';
import { AssetPathType, PathType } from 'src/enum'; import { AssetPathType, PathType } from 'src/enum';
export type MoveCreate = Pick<MoveEntity, 'oldPath' | 'newPath' | 'entityId' | 'pathType'> & Partial<MoveEntity>;
@Injectable() @Injectable()
export class MoveRepository { export class MoveRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
create(entity: Insertable<MoveHistory>): Promise<MoveEntity> { create(entity: Insertable<MoveHistory>) {
return this.db return this.db.insertInto('move_history').values(entity).returningAll().executeTakeFirstOrThrow();
.insertInto('move_history')
.values(entity)
.returningAll()
.executeTakeFirstOrThrow() as Promise<MoveEntity>;
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getByEntity(entityId: string, pathType: PathType): Promise<MoveEntity | undefined> { getByEntity(entityId: string, pathType: PathType) {
return this.db return this.db
.selectFrom('move_history') .selectFrom('move_history')
.selectAll() .selectAll()
.where('entityId', '=', entityId) .where('entityId', '=', entityId)
.where('pathType', '=', pathType) .where('pathType', '=', pathType)
.executeTakeFirst() as Promise<MoveEntity | undefined>; .executeTakeFirst();
} }
update(id: string, entity: Updateable<MoveHistory>): Promise<MoveEntity> { update(id: string, entity: Updateable<MoveHistory>) {
return this.db return this.db
.updateTable('move_history') .updateTable('move_history')
.set(entity) .set(entity)
.where('id', '=', id) .where('id', '=', id)
.returningAll() .returningAll()
.executeTakeFirstOrThrow() as unknown as Promise<MoveEntity>; .executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
delete(id: string): Promise<MoveEntity> { delete(id: string) {
return this.db return this.db.deleteFrom('move_history').where('id', '=', id).returningAll().executeTakeFirstOrThrow();
.deleteFrom('move_history')
.where('id', '=', id)
.returningAll()
.executeTakeFirstOrThrow() as unknown as Promise<MoveEntity>;
} }
@GenerateSql() @GenerateSql()
@@ -63,6 +63,18 @@ export class OAuthRepository {
} }
} }
async getProfilePicture(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch picture: ${response.statusText}`);
}
return {
data: await response.arrayBuffer(),
contentType: response.headers.get('content-type'),
};
}
private async getClient({ private async getClient({
issuerUrl, issuerUrl,
clientId, clientId,
@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely'; import { ExpressionBuilder, Insertable, Kysely, NotNull, Updateable } 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, Partner } from 'src/database'; import { columns } from 'src/database';
import { DB, Partners } from 'src/db'; import { DB, Partners } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
@@ -44,7 +44,7 @@ export class PartnerRepository {
return this.builder() return this.builder()
.where('sharedWithId', '=', sharedWithId) .where('sharedWithId', '=', sharedWithId)
.where('sharedById', '=', sharedById) .where('sharedById', '=', sharedById)
.executeTakeFirst() as Promise<Partner | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] }) @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })
@@ -55,7 +55,8 @@ export class PartnerRepository {
.returningAll() .returningAll()
.returning(withSharedBy) .returning(withSharedBy)
.returning(withSharedWith) .returning(withSharedWith)
.executeTakeFirstOrThrow() as Promise<Partner>; .$narrowType<{ sharedWith: NotNull; sharedBy: NotNull }>()
.executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }, { inTimeline: true }] }) @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }, { inTimeline: true }] })
@@ -68,7 +69,8 @@ export class PartnerRepository {
.returningAll() .returningAll()
.returning(withSharedBy) .returning(withSharedBy)
.returning(withSharedWith) .returning(withSharedWith)
.executeTakeFirstOrThrow() as Promise<Partner>; .$narrowType<{ sharedWith: NotNull; sharedBy: NotNull }>()
.executeTakeFirstOrThrow();
} }
@GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] }) @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] })
+2 -3
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]] })
@@ -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')
+53 -50
View File
@@ -1,14 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns, UserAdmin } from 'src/database'; import { columns } from 'src/database';
import { DB, UserMetadata as DbUserMetadata } from 'src/db'; import { DB, UserMetadata as DbUserMetadata } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity';
import { UserEntity, withMetadata } from 'src/entities/user.entity';
import { AssetType, UserStatus } from 'src/enum'; import { AssetType, UserStatus } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { UserMetadata, UserMetadataItem } from 'src/types';
import { asUuid } from 'src/utils/database'; import { asUuid } from 'src/utils/database';
type Upsert = Insertable<DbUserMetadata>; type Upsert = Insertable<DbUserMetadata>;
@@ -32,12 +32,21 @@ export interface UserFindOptions {
withDeleted?: boolean; withDeleted?: boolean;
} }
const withMetadata = (eb: ExpressionBuilder<DB, 'users'>) => {
return jsonArrayFrom(
eb
.selectFrom('user_metadata')
.select(['user_metadata.key', 'user_metadata.value'])
.whereRef('users.id', '=', 'user_metadata.userId'),
).as('metadata');
};
@Injectable() @Injectable()
export class UserRepository { export class UserRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] })
get(userId: string, options: UserFindOptions): Promise<UserEntity | undefined> { get(userId: string, options: UserFindOptions) {
options = options || {}; options = options || {};
return this.db return this.db
@@ -46,7 +55,7 @@ export class UserRepository {
.select(withMetadata) .select(withMetadata)
.where('users.id', '=', userId) .where('users.id', '=', userId)
.$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) .$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst();
} }
getMetadata(userId: string) { getMetadata(userId: string) {
@@ -58,13 +67,14 @@ export class UserRepository {
} }
@GenerateSql() @GenerateSql()
getAdmin(): Promise<UserEntity | undefined> { getAdmin() {
return this.db return this.db
.selectFrom('users') .selectFrom('users')
.select(columns.userAdmin) .select(columns.userAdmin)
.select(withMetadata)
.where('users.isAdmin', '=', true) .where('users.isAdmin', '=', true)
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql() @GenerateSql()
@@ -80,34 +90,36 @@ export class UserRepository {
} }
@GenerateSql({ params: [DummyValue.EMAIL] }) @GenerateSql({ params: [DummyValue.EMAIL] })
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | undefined> { getByEmail(email: string, withPassword?: boolean) {
return this.db return this.db
.selectFrom('users') .selectFrom('users')
.select(columns.userAdmin) .select(columns.userAdmin)
.select(withMetadata)
.$if(!!withPassword, (eb) => eb.select('password')) .$if(!!withPassword, (eb) => eb.select('password'))
.where('email', '=', email) .where('email', '=', email)
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
getByStorageLabel(storageLabel: string): Promise<UserEntity | undefined> { getByStorageLabel(storageLabel: string) {
return this.db return this.db
.selectFrom('users') .selectFrom('users')
.select(columns.userAdmin) .select(columns.userAdmin)
.where('users.storageLabel', '=', storageLabel) .where('users.storageLabel', '=', storageLabel)
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
getByOAuthId(oauthId: string): Promise<UserEntity | undefined> { getByOAuthId(oauthId: string) {
return this.db return this.db
.selectFrom('users') .selectFrom('users')
.select(columns.userAdmin) .select(columns.userAdmin)
.select(withMetadata)
.where('users.oauthId', '=', oauthId) .where('users.oauthId', '=', oauthId)
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>; .executeTakeFirst();
} }
@GenerateSql({ params: [DateTime.now().minus({ years: 1 })] }) @GenerateSql({ params: [DateTime.now().minus({ years: 1 })] })
@@ -126,18 +138,19 @@ export class UserRepository {
.select(withMetadata) .select(withMetadata)
.$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null)) .$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
.execute() as Promise<UserAdmin[]>; .execute();
} }
async create(dto: Insertable<UserTable>): Promise<UserEntity> { async create(dto: Insertable<UserTable>) {
return this.db return this.db
.insertInto('users') .insertInto('users')
.values(dto) .values(dto)
.returning(columns.userAdmin) .returning(columns.userAdmin)
.executeTakeFirst() as unknown as Promise<UserEntity>; .returning(withMetadata)
.executeTakeFirstOrThrow();
} }
update(id: string, dto: Updateable<UserTable>): Promise<UserEntity> { update(id: string, dto: Updateable<UserTable>) {
return this.db return this.db
.updateTable('users') .updateTable('users')
.set(dto) .set(dto)
@@ -145,17 +158,17 @@ export class UserRepository {
.where('users.deletedAt', 'is', null) .where('users.deletedAt', 'is', null)
.returning(columns.userAdmin) .returning(columns.userAdmin)
.returning(withMetadata) .returning(withMetadata)
.executeTakeFirst() as unknown as Promise<UserEntity>; .executeTakeFirstOrThrow();
} }
restore(id: string): Promise<UserEntity> { restore(id: string) {
return this.db return this.db
.updateTable('users') .updateTable('users')
.set({ status: UserStatus.ACTIVE, deletedAt: null }) .set({ status: UserStatus.ACTIVE, deletedAt: null })
.where('users.id', '=', asUuid(id)) .where('users.id', '=', asUuid(id))
.returning(columns.userAdmin) .returning(columns.userAdmin)
.returning(withMetadata) .returning(withMetadata)
.executeTakeFirst() as unknown as Promise<UserEntity>; .executeTakeFirstOrThrow();
} }
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) { async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
@@ -175,41 +188,41 @@ export class UserRepository {
await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute(); await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute();
} }
delete(user: { id: string }, hard?: boolean): Promise<UserEntity> { delete(user: { id: string }, hard?: boolean) {
return hard return hard
? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise<UserEntity>) ? this.db.deleteFrom('users').where('id', '=', user.id).execute()
: (this.db : this.db.updateTable('users').set({ deletedAt: new Date() }).where('id', '=', user.id).execute();
.updateTable('users')
.set({ deletedAt: new Date() })
.where('id', '=', user.id)
.execute() as unknown as Promise<UserEntity>);
} }
@GenerateSql() @GenerateSql()
async getUserStats(): Promise<UserStatsQueryResponse[]> { getUserStats() {
const stats = (await this.db return this.db
.selectFrom('users') .selectFrom('users')
.leftJoin('assets', 'assets.ownerId', 'users.id') .leftJoin('assets', 'assets.ownerId', 'users.id')
.leftJoin('exif', 'exif.assetId', 'assets.id') .leftJoin('exif', 'exif.assetId', 'assets.id')
.select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes']) .select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes'])
.select((eb) => [ .select((eb) => [
eb.fn eb.fn
.countAll() .countAll<number>()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) .filterWhere((eb) =>
eb.and([eb('assets.type', '=', sql.lit(AssetType.IMAGE)), eb('assets.isVisible', '=', sql.lit(true))]),
)
.as('photos'), .as('photos'),
eb.fn eb.fn
.countAll() .countAll<number>()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)])) .filterWhere((eb) =>
eb.and([eb('assets.type', '=', sql.lit(AssetType.VIDEO)), eb('assets.isVisible', '=', sql.lit(true))]),
)
.as('videos'), .as('videos'),
eb.fn eb.fn
.coalesce(eb.fn.sum('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0)) .coalesce(eb.fn.sum<number>('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0))
.as('usage'), .as('usage'),
eb.fn eb.fn
.coalesce( .coalesce(
eb.fn eb.fn
.sum('exif.fileSizeInByte') .sum<number>('exif.fileSizeInByte')
.filterWhere((eb) => .filterWhere((eb) =>
eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.IMAGE)]), eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', sql.lit(AssetType.IMAGE))]),
), ),
eb.lit(0), eb.lit(0),
) )
@@ -217,9 +230,9 @@ export class UserRepository {
eb.fn eb.fn
.coalesce( .coalesce(
eb.fn eb.fn
.sum('exif.fileSizeInByte') .sum<number>('exif.fileSizeInByte')
.filterWhere((eb) => .filterWhere((eb) =>
eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.VIDEO)]), eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', sql.lit(AssetType.VIDEO))]),
), ),
eb.lit(0), eb.lit(0),
) )
@@ -228,17 +241,7 @@ export class UserRepository {
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.groupBy('users.id') .groupBy('users.id')
.orderBy('users.createdAt', 'asc') .orderBy('users.createdAt', 'asc')
.execute()) as UserStatsQueryResponse[]; .execute();
for (const stat of stats) {
stat.photos = Number(stat.photos);
stat.videos = Number(stat.videos);
stat.usage = Number(stat.usage);
stat.usagePhotos = Number(stat.usagePhotos);
stat.usageVideos = Number(stat.usageVideos);
}
return stats;
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
+12
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),
});
+116
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
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];
}
+13 -12
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;
} }

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