diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 1ec17b381dbfd..5292075cce902 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -22,7 +22,7 @@ permissions: jobs: publish: - name: Publish + name: CLI Publish runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7784b32f362f1..6be26c9bbe62f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -234,3 +234,16 @@ jobs: BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }} BUILD_SOURCE_REF=${{ github.ref_name }} BUILD_SOURCE_COMMIT=${{ github.sha }} + + success-check: + name: Docker Build & Push Success + needs: [build_and_push_ml, build_and_push_server] + runs-on: ubuntu-latest + if: always() + steps: + - name: Any jobs failed? + if: ${{ contains(needs.*.result, 'failure') }} + run: exit 1 + - name: All jobs passed or skipped + if: ${{ !(contains(needs.*.result, 'failure')) }} + run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}" diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 682e3c45f008a..387d8e042496b 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -30,6 +30,7 @@ jobs: run: echo "should_force=${{ github.event_name == 'release' }}" >> "$GITHUB_OUTPUT" build: + name: Docs Build needs: pre-job if: ${{ needs.pre-job.outputs.should_run == 'true' }} runs-on: ubuntu-latest diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index a863cf8ed2f9c..ab197fa459d94 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -7,6 +7,7 @@ on: jobs: checks: + name: Docs Deploy Checks runs-on: ubuntu-latest outputs: parameters: ${{ steps.parameters.outputs.result }} @@ -91,6 +92,7 @@ jobs: return parameters; deploy: + name: Docs Deploy runs-on: ubuntu-latest needs: checks if: ${{ fromJson(needs.checks.outputs.artifact).found && fromJson(needs.checks.outputs.parameters).shouldDeploy }} diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index 861a6319fe95d..80700569245d1 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -5,6 +5,7 @@ on: jobs: deploy: + name: Docs Destroy runs-on: ubuntu-latest steps: - name: Checkout code diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac6236d2eb6c2..24e3e086235f0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,7 @@ jobs: run: echo "should_force=${{ github.event_name == 'workflow_dispatch' }}" >> "$GITHUB_OUTPUT" server-unit-tests: - name: Server + name: Test & Lint Server needs: pre-job if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} runs-on: ubuntu-latest @@ -85,7 +85,7 @@ jobs: if: ${{ !cancelled() }} cli-unit-tests: - name: CLI + name: Unit Test CLI needs: pre-job if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }} runs-on: ubuntu-latest @@ -126,7 +126,7 @@ jobs: if: ${{ !cancelled() }} cli-unit-tests-win: - name: CLI (Windows) + name: Unit Test CLI (Windows) needs: pre-job if: ${{ needs.pre-job.outputs.should_run_cli == 'true' }} runs-on: windows-latest @@ -160,7 +160,7 @@ jobs: if: ${{ !cancelled() }} web-unit-tests: - name: Web + name: Test & Lint Web needs: pre-job if: ${{ needs.pre-job.outputs.should_run_web == 'true' }} runs-on: ubuntu-latest @@ -327,7 +327,7 @@ jobs: if: ${{ !cancelled() }} mobile-unit-tests: - name: Mobile + name: Unit Test Mobile needs: pre-job if: ${{ needs.pre-job.outputs.should_run_mobile == 'true' }} runs-on: ubuntu-latest @@ -343,7 +343,7 @@ jobs: run: flutter test -j 1 ml-unit-tests: - name: Machine Learning + name: Unit Test ML needs: pre-job if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }} runs-on: ubuntu-latest diff --git a/docs/docs/guides/template-backup-script.md b/docs/docs/guides/template-backup-script.md index 9777d002627a7..03c1a7a02b333 100644 --- a/docs/docs/guides/template-backup-script.md +++ b/docs/docs/guides/template-backup-script.md @@ -78,4 +78,4 @@ borg mount "$REMOTE_HOST:$REMOTE_BACKUP_PATH"/immich-borg /tmp/immich-mountpoint cd /tmp/immich-mountpoint ``` -You can find available snapshots in seperate sub-directories at `/tmp/immich-mountpoint`. Restore the files you need, and unmount the Borg repository using `borg umount /tmp/immich-mountpoint` +You can find available snapshots in separate sub-directories at `/tmp/immich-mountpoint`. Restore the files you need, and unmount the Borg repository using `borg umount /tmp/immich-mountpoint` diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 9a4b0b9360b78..a0cf71e044724 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -125,7 +125,7 @@ When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSW All `REDIS_` variables must be provided to all Immich workers, including `api` and `microservices`. `REDIS_URL` must start with `ioredis://` and then include a `base64` encoded JSON string for the configuration. -More info can be found in the upstream [ioredis][redis-api] documentation. +More info can be found in the upstream [ioredis] documentation. When `REDIS_URL` or `REDIS_SOCKET` are defined, the `REDIS_HOSTNAME`, `REDIS_PORT`, `REDIS_USERNAME`, `REDIS_PASSWORD`, and `REDIS_DBINDEX` variables are ignored. ::: @@ -226,4 +226,4 @@ to use use a Docker secret for the password in the Redis container. [docker-secrets-example]: https://github.com/docker-library/redis/issues/46#issuecomment-335326234 [docker-secrets-docs]: https://github.com/docker-library/docs/tree/master/postgres#docker-secrets [docker-secrets]: https://docs.docker.com/engine/swarm/secrets/ -[redis-api]: https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository +[ioredis]: https://ioredis.readthedocs.io/en/latest/README/#connect-to-redis diff --git a/docs/package-lock.json b/docs/package-lock.json index c67c2b64fcb4e..05417ce1275a7 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -13698,9 +13698,10 @@ } }, "node_modules/prism-react-renderer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.3.1.tgz", - "integrity": "sha512-Rdf+HzBLR7KYjzpJ1rSoxT9ioO85nZngQEoFIhL07XhtJHlCU3SOz0GJ6+qvMyQe0Se+BV3qpe6Yd/NmQF5Juw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.0.tgz", + "integrity": "sha512-327BsVCD/unU4CNLZTWVHyUHKnsqcvj2qbPlQ8MiBE2eq2rgctjigPA1Gp9HLF83kZ20zNN6jgizHJeEsyFYOw==", + "license": "MIT", "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 82ce17865a565..7d3c3c6e59ad2 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -6,7 +6,9 @@ import { LoginResponseDto, SharedLinkType, getAssetInfo, + getConfig, getMyUser, + updateConfig, } from '@immich/sdk'; import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; @@ -43,6 +45,9 @@ const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`; +const facesAssetFilepath = `${testAssetDir}/metadata/faces/portrait.jpg`; + +const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAuth(accessToken) }); const readTags = async (bytes: Buffer, filename: string) => { const filepath = join(tempDir, filename); @@ -71,6 +76,7 @@ describe('/asset', () => { let user2Assets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; let ratingAsset: AssetMediaResponseDto; + let facesAsset: AssetMediaResponseDto; const setupTests = async () => { await utils.resetDatabase(); @@ -224,6 +230,64 @@ describe('/asset', () => { }); }); + it('should get the asset faces', async () => { + const config = await getSystemConfig(admin.accessToken); + config.metadata.faces.import = true; + await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); + + // asset faces + facesAsset = await utils.createAsset(admin.accessToken, { + assetData: { + filename: 'portrait.jpg', + bytes: await readFile(facesAssetFilepath), + }, + }); + + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: facesAsset.id }); + + const { status, body } = await request(app) + .get(`/assets/${facesAsset.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body.id).toEqual(facesAsset.id); + expect(body.people).toMatchObject([ + { + name: 'Marie Curie', + birthDate: null, + thumbnailPath: '', + isHidden: false, + faces: [ + { + imageHeight: 700, + imageWidth: 840, + boundingBoxX1: 261, + boundingBoxX2: 356, + boundingBoxY1: 146, + boundingBoxY2: 284, + sourceType: 'exif', + }, + ], + }, + { + name: 'Pierre Curie', + birthDate: null, + thumbnailPath: '', + isHidden: false, + faces: [ + { + imageHeight: 700, + imageWidth: 840, + boundingBoxX1: 536, + boundingBoxX2: 618, + boundingBoxY1: 83, + boundingBoxY2: 252, + sourceType: 'exif', + }, + ], + }, + ]); + }); + it('should work with a shared link', async () => { const sharedLink = await utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 092eab3ec5b67..571d98cda744e 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -102,6 +102,7 @@ describe('/server-info', () => { configFile: false, duplicateDetection: false, facialRecognition: false, + importFaces: false, map: true, reverseGeocoding: true, oauth: false, diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index d19744674fdda..b19e6d85c4ad0 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -110,6 +110,7 @@ describe('/server', () => { facialRecognition: false, map: true, reverseGeocoding: true, + importFaces: false, oauth: false, oauthAutoLaunch: false, passwordLogin: true, diff --git a/e2e/test-assets b/e2e/test-assets index 4e9731d3fc270..3e057d2f58750 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 4e9731d3fc270fe25901f72a6b6f57277cdb8a30 +Subproject commit 3e057d2f58750acdf7ff281a3938e34a86cfef4d diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 8fc72b308f08a..f680aac826af3 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:f7543d9969bdc112dd9819ca642e14433fdacfe857f170f6b803392fc7e451ad AS builder-cpu +FROM python:3.11-bookworm@sha256:20c1819af5af3acba0b2b66074a2615e398ceee6842adf03cd7ad5f8d0ee3daf AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:ad5dadd957a398226996bc4846e522c39f2a77340b531b28aaab85b2d361210b AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:ed4e985674f478c90ce879e9aa224fbb772c84e39b4aed5155b9e2280f131039 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index d458d92d15014..eaa35d14be0dd 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:475730daef12ff9c0733e70092aeeefdf4c373a584c952dac3f7bdb739601990 AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:29174348bd09352e5f1b1f6756cf1d00021487b8340fae040e91e4f98e954ce5 AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 7385d1269d3f8..bd09bd8469e67 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,13 +680,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.112.1" +version = "0.112.2" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.112.1-py3-none-any.whl", hash = "sha256:cc227cf9402d0ba54a24f80eb205c33bcb25d3ea18d53fdac3fd76ea5af8e76d"}, - {file = "fastapi_slim-0.112.1.tar.gz", hash = "sha256:876ebd24e72273986709db2d469b75dc18f04c3ab9140ffd78b29d7785d26687"}, + {file = "fastapi_slim-0.112.2-py3-none-any.whl", hash = "sha256:c023f74768f187af142c2fe5ff9e4ca3c4c1940bbde7df008cb283532422a23f"}, + {file = "fastapi_slim-0.112.2.tar.gz", hash = "sha256:75b8eb0c6ee05a20270da7a527ac7ad53b83414602f42b68f7027484dab3aedb"}, ] [package.dependencies] @@ -695,8 +695,8 @@ starlette = ">=0.37.2,<0.39.0" typing-extensions = ">=4.8.0" [package.extras] -all = ["email_validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email_validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "filelock" @@ -1212,13 +1212,13 @@ test = ["Cython (>=0.29.24,<0.30.0)"] [[package]] name = "httpx" -version = "0.27.0" +version = "0.27.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, - {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, ] [package.dependencies] @@ -1233,6 +1233,7 @@ brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] [[package]] name = "huggingface-hub" @@ -1530,13 +1531,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.31.3" +version = "2.31.5" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.31.3-py3-none-any.whl", hash = "sha256:03122e007519b371a5a553d578af502826755de83551d79ea8a412ea1c660115"}, - {file = "locust-2.31.3.tar.gz", hash = "sha256:25f4603f24afa11ef1ee1f26b1c86a232eb9a1140be30b2a4642c12d7a7af8ae"}, + {file = "locust-2.31.5-py3-none-any.whl", hash = "sha256:2904ff6307d54d3202c9ebd776f9170214f6dfbe4059504dad9e3ffaca03f600"}, + {file = "locust-2.31.5.tar.gz", hash = "sha256:14b2fa6f95bf248668e6dc92d100a44f06c5dcb1c26f88a5442bcaaee18faceb"}, ] [package.dependencies] @@ -1794,38 +1795,38 @@ files = [ [[package]] name = "mypy" -version = "1.11.1" +version = "1.11.2" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"}, - {file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"}, - {file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"}, - {file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"}, - {file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"}, - {file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"}, - {file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"}, - {file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"}, - {file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"}, - {file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"}, - {file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"}, - {file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"}, - {file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"}, - {file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"}, - {file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"}, - {file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"}, - {file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"}, - {file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"}, - {file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"}, - {file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"}, - {file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"}, - {file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"}, - {file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"}, - {file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"}, - {file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"}, - {file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"}, - {file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"}, + {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"}, + {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"}, + {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"}, + {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"}, + {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"}, + {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"}, + {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"}, + {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"}, + {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"}, + {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"}, + {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"}, + {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"}, + {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"}, + {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"}, + {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"}, + {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"}, + {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"}, + {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"}, + {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"}, + {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"}, + {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"}, + {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"}, + {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"}, ] [package.dependencies] @@ -2815,13 +2816,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.7.1" +version = "13.8.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.7.0" files = [ - {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, - {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, + {file = "rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc"}, + {file = "rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4"}, ] [package.dependencies] @@ -2833,29 +2834,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.6.2" +version = "0.6.3" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c"}, - {file = "ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570"}, - {file = "ruff-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3dbeac76ed13456f8158b8f4fe087bf87882e645c8e8b606dd17b0b66c2c1158"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094600ee88cda325988d3f54e3588c46de5c18dae09d683ace278b11f9d4d534"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:316d418fe258c036ba05fbf7dfc1f7d3d4096db63431546163b472285668132b"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d72b8b3abf8a2d51b7b9944a41307d2f442558ccb3859bbd87e6ae9be1694a5d"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2aed7e243be68487aa8982e91c6e260982d00da3f38955873aecd5a9204b1d66"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d371f7fc9cec83497fe7cf5eaf5b76e22a8efce463de5f775a1826197feb9df8"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8f310d63af08f583363dfb844ba8f9417b558199c58a5999215082036d795a1"}, - {file = "ruff-0.6.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7db6880c53c56addb8638fe444818183385ec85eeada1d48fc5abe045301b2f1"}, - {file = "ruff-0.6.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1175d39faadd9a50718f478d23bfc1d4da5743f1ab56af81a2b6caf0a2394f23"}, - {file = "ruff-0.6.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939f9c86d51635fe486585389f54582f0d65b8238e08c327c1534844b3bb9a"}, - {file = "ruff-0.6.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0d62ca91219f906caf9b187dea50d17353f15ec9bb15aae4a606cd697b49b4c"}, - {file = "ruff-0.6.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7438a7288f9d67ed3c8ce4d059e67f7ed65e9fe3aa2ab6f5b4b3610e57e3cb56"}, - {file = "ruff-0.6.2-py3-none-win32.whl", hash = "sha256:279d5f7d86696df5f9549b56b9b6a7f6c72961b619022b5b7999b15db392a4da"}, - {file = "ruff-0.6.2-py3-none-win_amd64.whl", hash = "sha256:d9f3469c7dd43cd22eb1c3fc16926fb8258d50cb1b216658a07be95dd117b0f2"}, - {file = "ruff-0.6.2-py3-none-win_arm64.whl", hash = "sha256:f28fcd2cd0e02bdf739297516d5643a945cc7caf09bd9bcb4d932540a5ea4fa9"}, - {file = "ruff-0.6.2.tar.gz", hash = "sha256:239ee6beb9e91feb8e0ec384204a763f36cb53fb895a1a364618c6abb076b3be"}, + {file = "ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3"}, + {file = "ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc"}, + {file = "ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500"}, + {file = "ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351"}, + {file = "ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8"}, + {file = "ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521"}, + {file = "ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb"}, + {file = "ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82"}, + {file = "ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983"}, ] [[package]] diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt index 052a4e4c1fdd7..c4c87ff5195ab 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt @@ -118,7 +118,9 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct // called when the system has to stop this worker because constraints are // no longer met or the system needs resources for more important tasks Handler(Looper.getMainLooper()).postAtFrontOfQueue { - backgroundChannel.invokeMethod("systemStop", null) + if (::backgroundChannel.isInitialized) { + backgroundChannel.invokeMethod("systemStop", null) + } } waitOnSetForegroundAsync() // cannot await/get(block) on resolvableFuture as its already cancelled (would throw CancellationException) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 3d1fb4e4d6f3e..9dbe49589f75c 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -252,9 +252,10 @@ "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "image_saved_successfully": "Image saved", - "image_viewer_page_state_provider_download_error": "Download Error", - "image_viewer_page_state_provider_download_started": "Download Started", - "image_viewer_page_state_provider_download_success": "Download Success", + "download_error": "Download Error", + "download_started": "Download started", + "download_sucess": "Download success", + "download_sucess_android": "The media has been downloaded to DCIM/Immich", "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", @@ -585,4 +586,4 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} \ No newline at end of file +} diff --git a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart b/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart index ee45e6bc5e56b..631011f200bbd 100644 --- a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart +++ b/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -31,19 +33,21 @@ class ImageViewerStateNotifier extends StateNotifier { ImmichToast.show( context: context, - msg: 'image_viewer_page_state_provider_download_started'.tr(), + msg: 'download_started'.tr(), toastType: ToastType.info, gravity: ToastGravity.BOTTOM, ); - bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset); + bool isSuccess = await _imageViewerService.downloadAsset(asset); if (isSuccess) { state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success); ImmichToast.show( context: context, - msg: 'image_viewer_page_state_provider_download_success'.tr(), + msg: Platform.isAndroid + ? 'download_sucess_android'.tr() + : 'download_sucess'.tr(), toastType: ToastType.success, gravity: ToastGravity.BOTTOM, ); @@ -52,7 +56,7 @@ class ImageViewerStateNotifier extends StateNotifier { state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error); ImmichToast.show( context: context, - msg: 'image_viewer_page_state_provider_download_error'.tr(), + msg: 'download_error'.tr(), toastType: ToastType.error, gravity: ToastGravity.BOTTOM, ); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 17508cba5153e..c4f258e259129 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -8,7 +8,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -309,18 +308,6 @@ class AssetService { useTimeFilter: false, ); - final duplicates = await _apiService.assetsApi.checkExistingAssets( - CheckExistingAssetsDto( - deviceAssetIds: candidates.map((c) => c.asset.id).toList(), - deviceId: Store.get(StoreKey.deviceId), - ), - ); - - if (duplicates != null) { - candidates - .removeWhere((c) => !duplicates.existingIds.contains(c.asset.id)); - } - await refreshRemoteAssets(); final remoteAssets = await _db.assets .where() diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 12edd14d609ca..858499443ee1d 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -484,7 +484,7 @@ class BackupService { ), ); - if (shouldSyncAlbums && !isDuplicate) { + if (shouldSyncAlbums) { await _albumService.syncUploadAlbums( candidate.albumNames, [responseBody['id'] as String], diff --git a/mobile/lib/services/image_viewer.service.dart b/mobile/lib/services/image_viewer.service.dart index e61573af379ae..9bcaba1d26b95 100644 --- a/mobile/lib/services/image_viewer.service.dart +++ b/mobile/lib/services/image_viewer.service.dart @@ -19,7 +19,7 @@ class ImageViewerService { ImageViewerService(this._apiService); - Future downloadAssetToDevice(Asset asset) async { + Future downloadAsset(Asset asset) async { File? imageFile; File? videoFile; try { @@ -82,18 +82,23 @@ class ImageViewerService { } final AssetEntity? entity; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; if (asset.isImage) { entity = await PhotoManager.editor.saveImage( res.bodyBytes, title: asset.fileName, + relativePath: relativePath, ); } else { final tempDir = await getTemporaryDirectory(); videoFile = await File('${tempDir.path}/${asset.fileName}').create(); videoFile.writeAsBytesSync(res.bodyBytes); - entity = await PhotoManager.editor - .saveVideo(videoFile, title: asset.fileName); + entity = await PhotoManager.editor.saveVideo( + videoFile, + title: asset.fileName, + relativePath: relativePath, + ); } return entity != null; } diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index fde0d2e82d617..6de8f5da33944 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -93,6 +93,10 @@ class GalleryAppBar extends ConsumerWidget { ); } + handleDownloadAsset() { + ref.read(imageViewerStateProvider.notifier).downloadAsset(asset, context); + } + return IgnorePointer( ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( @@ -109,13 +113,7 @@ class GalleryAppBar extends ConsumerWidget { onFavorite: toggleFavorite, onRestorePressed: () => handleRestore(asset), onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null, - onDownloadPressed: asset.isLocal - ? null - : () => - ref.read(imageViewerStateProvider.notifier).downloadAsset( - asset, - context, - ), + onDownloadPressed: asset.isLocal ? null : handleDownloadAsset, onToggleMotionVideo: onToggleMotionVideo, onAddToAlbumPressed: () => addToAlbum(asset), onActivitiesPressed: handleActivities, diff --git a/mobile/openapi/.gitignore b/mobile/openapi/.gitignore index 1be28ced0940a..0f74d293b9895 100644 --- a/mobile/openapi/.gitignore +++ b/mobile/openapi/.gitignore @@ -3,7 +3,9 @@ .dart_tool/ .packages build/ -pubspec.lock # Except for application packages + +# Except for application packages +pubspec.lock doc/api/ diff --git a/mobile/openapi/.openapi-generator/VERSION b/mobile/openapi/.openapi-generator/VERSION index 18bb4182dd014..09a6d30847de3 100644 --- a/mobile/openapi/.openapi-generator/VERSION +++ b/mobile/openapi/.openapi-generator/VERSION @@ -1 +1 @@ -7.5.0 +7.8.0 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c7201d1d24354..b67a3e33839e1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -4,7 +4,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: - API version: 1.113.1 -- Generator version: 7.5.0 +- Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements @@ -407,11 +407,13 @@ Class | Method | HTTP request | Description - [SignUpDto](doc//SignUpDto.md) - [SmartInfoResponseDto](doc//SmartInfoResponseDto.md) - [SmartSearchDto](doc//SmartSearchDto.md) + - [SourceType](doc//SourceType.md) - [StackCreateDto](doc//StackCreateDto.md) - [StackResponseDto](doc//StackResponseDto.md) - [StackUpdateDto](doc//StackUpdateDto.md) - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) + - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md) - [SystemConfigImageDto](doc//SystemConfigImageDto.md) - [SystemConfigJobDto](doc//SystemConfigJobDto.md) - [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md) @@ -420,6 +422,7 @@ Class | Method | HTTP request | Description - [SystemConfigLoggingDto](doc//SystemConfigLoggingDto.md) - [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md) - [SystemConfigMapDto](doc//SystemConfigMapDto.md) + - [SystemConfigMetadataDto](doc//SystemConfigMetadataDto.md) - [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md) - [SystemConfigNotificationsDto](doc//SystemConfigNotificationsDto.md) - [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d6ce89624cee6..091e900145ab3 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -221,11 +221,13 @@ part 'model/shared_link_type.dart'; part 'model/sign_up_dto.dart'; part 'model/smart_info_response_dto.dart'; part 'model/smart_search_dto.dart'; +part 'model/source_type.dart'; part 'model/stack_create_dto.dart'; part 'model/stack_response_dto.dart'; part 'model/stack_update_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; +part 'model/system_config_faces_dto.dart'; part 'model/system_config_image_dto.dart'; part 'model/system_config_job_dto.dart'; part 'model/system_config_library_dto.dart'; @@ -234,6 +236,7 @@ part 'model/system_config_library_watch_dto.dart'; part 'model/system_config_logging_dto.dart'; part 'model/system_config_machine_learning_dto.dart'; part 'model/system_config_map_dto.dart'; +part 'model/system_config_metadata_dto.dart'; part 'model/system_config_new_version_check_dto.dart'; part 'model/system_config_notifications_dto.dart'; part 'model/system_config_o_auth_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 47375f0b504f1..9ec00aecc87aa 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -497,6 +497,8 @@ class ApiClient { return SmartInfoResponseDto.fromJson(value); case 'SmartSearchDto': return SmartSearchDto.fromJson(value); + case 'SourceType': + return SourceTypeTypeTransformer().decode(value); case 'StackCreateDto': return StackCreateDto.fromJson(value); case 'StackResponseDto': @@ -507,6 +509,8 @@ class ApiClient { return SystemConfigDto.fromJson(value); case 'SystemConfigFFmpegDto': return SystemConfigFFmpegDto.fromJson(value); + case 'SystemConfigFacesDto': + return SystemConfigFacesDto.fromJson(value); case 'SystemConfigImageDto': return SystemConfigImageDto.fromJson(value); case 'SystemConfigJobDto': @@ -523,6 +527,8 @@ class ApiClient { return SystemConfigMachineLearningDto.fromJson(value); case 'SystemConfigMapDto': return SystemConfigMapDto.fromJson(value); + case 'SystemConfigMetadataDto': + return SystemConfigMetadataDto.fromJson(value); case 'SystemConfigNewVersionCheckDto': return SystemConfigNewVersionCheckDto.fromJson(value); case 'SystemConfigNotificationsDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index a486551cc5987..8dcef880f59a4 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -127,6 +127,9 @@ String parameterToString(dynamic value) { if (value is SharedLinkType) { return SharedLinkTypeTypeTransformer().encode(value).toString(); } + if (value is SourceType) { + return SourceTypeTypeTransformer().encode(value).toString(); + } if (value is TimeBucketSize) { return TimeBucketSizeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_face_response_dto.dart b/mobile/openapi/lib/model/asset_face_response_dto.dart index 812b165caa0eb..7a8588ce5c4af 100644 --- a/mobile/openapi/lib/model/asset_face_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_response_dto.dart @@ -21,6 +21,7 @@ class AssetFaceResponseDto { required this.imageHeight, required this.imageWidth, required this.person, + this.sourceType, }); int boundingBoxX1; @@ -39,6 +40,14 @@ class AssetFaceResponseDto { PersonResponseDto? person; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SourceType? sourceType; + @override bool operator ==(Object other) => identical(this, other) || other is AssetFaceResponseDto && other.boundingBoxX1 == boundingBoxX1 && @@ -48,7 +57,8 @@ class AssetFaceResponseDto { other.id == id && other.imageHeight == imageHeight && other.imageWidth == imageWidth && - other.person == person; + other.person == person && + other.sourceType == sourceType; @override int get hashCode => @@ -60,10 +70,11 @@ class AssetFaceResponseDto { (id.hashCode) + (imageHeight.hashCode) + (imageWidth.hashCode) + - (person == null ? 0 : person!.hashCode); + (person == null ? 0 : person!.hashCode) + + (sourceType == null ? 0 : sourceType!.hashCode); @override - String toString() => 'AssetFaceResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, person=$person]'; + String toString() => 'AssetFaceResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, person=$person, sourceType=$sourceType]'; Map toJson() { final json = {}; @@ -79,6 +90,11 @@ class AssetFaceResponseDto { } else { // json[r'person'] = null; } + if (this.sourceType != null) { + json[r'sourceType'] = this.sourceType; + } else { + // json[r'sourceType'] = null; + } return json; } @@ -98,6 +114,7 @@ class AssetFaceResponseDto { imageHeight: mapValueOfType(json, r'imageHeight')!, imageWidth: mapValueOfType(json, r'imageWidth')!, person: PersonResponseDto.fromJson(json[r'person']), + sourceType: SourceType.fromJson(json[r'sourceType']), ); } return null; diff --git a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart index 893f8ff3530e1..ecfe06bd7d6ce 100644 --- a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart @@ -20,6 +20,7 @@ class AssetFaceWithoutPersonResponseDto { required this.id, required this.imageHeight, required this.imageWidth, + this.sourceType, }); int boundingBoxX1; @@ -36,6 +37,14 @@ class AssetFaceWithoutPersonResponseDto { int imageWidth; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SourceType? sourceType; + @override bool operator ==(Object other) => identical(this, other) || other is AssetFaceWithoutPersonResponseDto && other.boundingBoxX1 == boundingBoxX1 && @@ -44,7 +53,8 @@ class AssetFaceWithoutPersonResponseDto { other.boundingBoxY2 == boundingBoxY2 && other.id == id && other.imageHeight == imageHeight && - other.imageWidth == imageWidth; + other.imageWidth == imageWidth && + other.sourceType == sourceType; @override int get hashCode => @@ -55,10 +65,11 @@ class AssetFaceWithoutPersonResponseDto { (boundingBoxY2.hashCode) + (id.hashCode) + (imageHeight.hashCode) + - (imageWidth.hashCode); + (imageWidth.hashCode) + + (sourceType == null ? 0 : sourceType!.hashCode); @override - String toString() => 'AssetFaceWithoutPersonResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth]'; + String toString() => 'AssetFaceWithoutPersonResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, sourceType=$sourceType]'; Map toJson() { final json = {}; @@ -69,6 +80,11 @@ class AssetFaceWithoutPersonResponseDto { json[r'id'] = this.id; json[r'imageHeight'] = this.imageHeight; json[r'imageWidth'] = this.imageWidth; + if (this.sourceType != null) { + json[r'sourceType'] = this.sourceType; + } else { + // json[r'sourceType'] = null; + } return json; } @@ -87,6 +103,7 @@ class AssetFaceWithoutPersonResponseDto { id: mapValueOfType(json, r'id')!, imageHeight: mapValueOfType(json, r'imageHeight')!, imageWidth: mapValueOfType(json, r'imageWidth')!, + sourceType: SourceType.fromJson(json[r'sourceType']), ); } return null; diff --git a/mobile/openapi/lib/model/server_features_dto.dart b/mobile/openapi/lib/model/server_features_dto.dart index 3e5466237a28f..0a7d8a4b4774a 100644 --- a/mobile/openapi/lib/model/server_features_dto.dart +++ b/mobile/openapi/lib/model/server_features_dto.dart @@ -17,6 +17,7 @@ class ServerFeaturesDto { required this.duplicateDetection, required this.email, required this.facialRecognition, + required this.importFaces, required this.map, required this.oauth, required this.oauthAutoLaunch, @@ -36,6 +37,8 @@ class ServerFeaturesDto { bool facialRecognition; + bool importFaces; + bool map; bool oauth; @@ -60,6 +63,7 @@ class ServerFeaturesDto { other.duplicateDetection == duplicateDetection && other.email == email && other.facialRecognition == facialRecognition && + other.importFaces == importFaces && other.map == map && other.oauth == oauth && other.oauthAutoLaunch == oauthAutoLaunch && @@ -77,6 +81,7 @@ class ServerFeaturesDto { (duplicateDetection.hashCode) + (email.hashCode) + (facialRecognition.hashCode) + + (importFaces.hashCode) + (map.hashCode) + (oauth.hashCode) + (oauthAutoLaunch.hashCode) + @@ -88,7 +93,7 @@ class ServerFeaturesDto { (trash.hashCode); @override - String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]'; + String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]'; Map toJson() { final json = {}; @@ -96,6 +101,7 @@ class ServerFeaturesDto { json[r'duplicateDetection'] = this.duplicateDetection; json[r'email'] = this.email; json[r'facialRecognition'] = this.facialRecognition; + json[r'importFaces'] = this.importFaces; json[r'map'] = this.map; json[r'oauth'] = this.oauth; json[r'oauthAutoLaunch'] = this.oauthAutoLaunch; @@ -120,6 +126,7 @@ class ServerFeaturesDto { duplicateDetection: mapValueOfType(json, r'duplicateDetection')!, email: mapValueOfType(json, r'email')!, facialRecognition: mapValueOfType(json, r'facialRecognition')!, + importFaces: mapValueOfType(json, r'importFaces')!, map: mapValueOfType(json, r'map')!, oauth: mapValueOfType(json, r'oauth')!, oauthAutoLaunch: mapValueOfType(json, r'oauthAutoLaunch')!, @@ -180,6 +187,7 @@ class ServerFeaturesDto { 'duplicateDetection', 'email', 'facialRecognition', + 'importFaces', 'map', 'oauth', 'oauthAutoLaunch', diff --git a/mobile/openapi/lib/model/source_type.dart b/mobile/openapi/lib/model/source_type.dart new file mode 100644 index 0000000000000..13c450b010d5c --- /dev/null +++ b/mobile/openapi/lib/model/source_type.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class SourceType { + /// Instantiate a new enum with the provided [value]. + const SourceType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const machineLearning = SourceType._(r'machine-learning'); + static const exif = SourceType._(r'exif'); + + /// List of all possible values in this [enum][SourceType]. + static const values = [ + machineLearning, + exif, + ]; + + static SourceType? fromJson(dynamic value) => SourceTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SourceType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SourceType] to String, +/// and [decode] dynamic data back to [SourceType]. +class SourceTypeTypeTransformer { + factory SourceTypeTypeTransformer() => _instance ??= const SourceTypeTypeTransformer._(); + + const SourceTypeTypeTransformer._(); + + String encode(SourceType data) => data.value; + + /// Decodes a [dynamic value][data] to a SourceType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + SourceType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'machine-learning': return SourceType.machineLearning; + case r'exif': return SourceType.exif; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SourceTypeTypeTransformer] instance. + static SourceTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index e56169742a7e0..aff8062c8a139 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -20,6 +20,7 @@ class SystemConfigDto { required this.logging, required this.machineLearning, required this.map, + required this.metadata, required this.newVersionCheck, required this.notifications, required this.oauth, @@ -46,6 +47,8 @@ class SystemConfigDto { SystemConfigMapDto map; + SystemConfigMetadataDto metadata; + SystemConfigNewVersionCheckDto newVersionCheck; SystemConfigNotificationsDto notifications; @@ -75,6 +78,7 @@ class SystemConfigDto { other.logging == logging && other.machineLearning == machineLearning && other.map == map && + other.metadata == metadata && other.newVersionCheck == newVersionCheck && other.notifications == notifications && other.oauth == oauth && @@ -96,6 +100,7 @@ class SystemConfigDto { (logging.hashCode) + (machineLearning.hashCode) + (map.hashCode) + + (metadata.hashCode) + (newVersionCheck.hashCode) + (notifications.hashCode) + (oauth.hashCode) + @@ -108,7 +113,7 @@ class SystemConfigDto { (user.hashCode); @override - String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, trash=$trash, user=$user]'; + String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metadata=$metadata, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, trash=$trash, user=$user]'; Map toJson() { final json = {}; @@ -119,6 +124,7 @@ class SystemConfigDto { json[r'logging'] = this.logging; json[r'machineLearning'] = this.machineLearning; json[r'map'] = this.map; + json[r'metadata'] = this.metadata; json[r'newVersionCheck'] = this.newVersionCheck; json[r'notifications'] = this.notifications; json[r'oauth'] = this.oauth; @@ -147,6 +153,7 @@ class SystemConfigDto { logging: SystemConfigLoggingDto.fromJson(json[r'logging'])!, machineLearning: SystemConfigMachineLearningDto.fromJson(json[r'machineLearning'])!, map: SystemConfigMapDto.fromJson(json[r'map'])!, + metadata: SystemConfigMetadataDto.fromJson(json[r'metadata'])!, newVersionCheck: SystemConfigNewVersionCheckDto.fromJson(json[r'newVersionCheck'])!, notifications: SystemConfigNotificationsDto.fromJson(json[r'notifications'])!, oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!, @@ -211,6 +218,7 @@ class SystemConfigDto { 'logging', 'machineLearning', 'map', + 'metadata', 'newVersionCheck', 'notifications', 'oauth', diff --git a/mobile/openapi/lib/model/system_config_faces_dto.dart b/mobile/openapi/lib/model/system_config_faces_dto.dart new file mode 100644 index 0000000000000..980e494fb70b0 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_faces_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigFacesDto { + /// Returns a new [SystemConfigFacesDto] instance. + SystemConfigFacesDto({ + required this.import_, + }); + + bool import_; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigFacesDto && + other.import_ == import_; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (import_.hashCode); + + @override + String toString() => 'SystemConfigFacesDto[import_=$import_]'; + + Map toJson() { + final json = {}; + json[r'import'] = this.import_; + return json; + } + + /// Returns a new [SystemConfigFacesDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigFacesDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return SystemConfigFacesDto( + import_: mapValueOfType(json, r'import')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigFacesDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigFacesDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigFacesDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigFacesDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'import', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_metadata_dto.dart b/mobile/openapi/lib/model/system_config_metadata_dto.dart new file mode 100644 index 0000000000000..60ca35c835bdb --- /dev/null +++ b/mobile/openapi/lib/model/system_config_metadata_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigMetadataDto { + /// Returns a new [SystemConfigMetadataDto] instance. + SystemConfigMetadataDto({ + required this.faces, + }); + + SystemConfigFacesDto faces; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigMetadataDto && + other.faces == faces; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (faces.hashCode); + + @override + String toString() => 'SystemConfigMetadataDto[faces=$faces]'; + + Map toJson() { + final json = {}; + json[r'faces'] = this.faces; + return json; + } + + /// Returns a new [SystemConfigMetadataDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigMetadataDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return SystemConfigMetadataDto( + faces: SystemConfigFacesDto.fromJson(json[r'faces'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigMetadataDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigMetadataDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigMetadataDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigMetadataDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'faces', + }; +} + diff --git a/mobile/openapi/pubspec.yaml b/mobile/openapi/pubspec.yaml index 4a979bf5db2cb..1c26f8707cd09 100644 --- a/mobile/openapi/pubspec.yaml +++ b/mobile/openapi/pubspec.yaml @@ -7,11 +7,11 @@ version: '1.0.0' description: 'OpenAPI API client' homepage: 'homepage' environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.12.0 <4.0.0' dependencies: - collection: '^1.17.0' - http: '>=0.13.0 <0.14.0' + collection: '>=1.17.0 <2.0.0' + http: '>=0.13.0 <2.0.0' intl: any - meta: '^1.1.8' + meta: '>=1.1.8 <2.0.0' immich_mobile: path: ../ diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 14b487ce4dd48..c9493f6490b72 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1737,10 +1737,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "14.2.5" wakelock_plus: dependency: "direct main" description: diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index bf79b0bd82de3..2ca04630468f9 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -OPENAPI_GENERATOR_VERSION=v7.5.0 +OPENAPI_GENERATOR_VERSION=v7.8.0 # usage: ./bin/generate-open-api.sh diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fbec7be7e1763..bbfabfe1d7bf6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8018,6 +8018,9 @@ } ], "nullable": true + }, + "sourceType": { + "$ref": "#/components/schemas/SourceType" } }, "required": [ @@ -8086,6 +8089,9 @@ }, "imageWidth": { "type": "integer" + }, + "sourceType": { + "$ref": "#/components/schemas/SourceType" } }, "required": [ @@ -10688,6 +10694,9 @@ "facialRecognition": { "type": "boolean" }, + "importFaces": { + "type": "boolean" + }, "map": { "type": "boolean" }, @@ -10721,6 +10730,7 @@ "duplicateDetection", "email", "facialRecognition", + "importFaces", "map", "oauth", "oauthAutoLaunch", @@ -11229,6 +11239,13 @@ ], "type": "object" }, + "SourceType": { + "enum": [ + "machine-learning", + "exif" + ], + "type": "string" + }, "StackCreateDto": { "properties": { "assetIds": { @@ -11299,6 +11316,9 @@ "map": { "$ref": "#/components/schemas/SystemConfigMapDto" }, + "metadata": { + "$ref": "#/components/schemas/SystemConfigMetadataDto" + }, "newVersionCheck": { "$ref": "#/components/schemas/SystemConfigNewVersionCheckDto" }, @@ -11338,6 +11358,7 @@ "logging", "machineLearning", "map", + "metadata", "newVersionCheck", "notifications", "oauth", @@ -11464,6 +11485,17 @@ ], "type": "object" }, + "SystemConfigFacesDto": { + "properties": { + "import": { + "type": "boolean" + } + }, + "required": [ + "import" + ], + "type": "object" + }, "SystemConfigImageDto": { "properties": { "colorspace": { @@ -11656,6 +11688,17 @@ ], "type": "object" }, + "SystemConfigMetadataDto": { + "properties": { + "faces": { + "$ref": "#/components/schemas/SystemConfigFacesDto" + } + }, + "required": [ + "faces" + ], + "type": "object" + }, "SystemConfigNewVersionCheckDto": { "properties": { "enabled": { diff --git a/open-api/openapitools.json b/open-api/openapitools.json index cfe74d51f8509..2f4612ceda379 100644 --- a/open-api/openapitools.json +++ b/open-api/openapitools.json @@ -2,6 +2,6 @@ "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", "spaces": 2, "generator-cli": { - "version": "7.5.0" + "version": "7.8.0" } } diff --git a/open-api/patch/pubspec_immich_mobile.yaml.patch b/open-api/patch/pubspec_immich_mobile.yaml.patch index 5f15c7254ab1c..ad3bbc2edd363 100644 --- a/open-api/patch/pubspec_immich_mobile.yaml.patch +++ b/open-api/patch/pubspec_immich_mobile.yaml.patch @@ -1,8 +1,8 @@ # Include code from immich_mobile @@ -13,5 +13,5 @@ - http: '>=0.13.0 <0.14.0' + http: '>=0.13.0 <2.0.0' intl: any - meta: '^1.1.8' + meta: '>=1.1.8 <2.0.0' -dev_dependencies: - test: '>=1.21.6 <1.22.0' + immich_mobile: diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 277aa571413ce..9e74ae88a00d7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -207,6 +207,7 @@ export type AssetFaceWithoutPersonResponseDto = { id: string; imageHeight: number; imageWidth: number; + sourceType?: SourceType; }; export type PersonWithFacesResponseDto = { birthDate: string | null; @@ -508,6 +509,7 @@ export type AssetFaceResponseDto = { imageHeight: number; imageWidth: number; person: (PersonResponseDto) | null; + sourceType?: SourceType; }; export type FaceDto = { id: string; @@ -893,6 +895,7 @@ export type ServerFeaturesDto = { duplicateDetection: boolean; email: boolean; facialRecognition: boolean; + importFaces: boolean; map: boolean; oauth: boolean; oauthAutoLaunch: boolean; @@ -1122,6 +1125,12 @@ export type SystemConfigMapDto = { enabled: boolean; lightStyle: string; }; +export type SystemConfigFacesDto = { + "import": boolean; +}; +export type SystemConfigMetadataDto = { + faces: SystemConfigFacesDto; +}; export type SystemConfigNewVersionCheckDto = { enabled: boolean; }; @@ -1178,6 +1187,7 @@ export type SystemConfigDto = { logging: SystemConfigLoggingDto; machineLearning: SystemConfigMachineLearningDto; map: SystemConfigMapDto; + metadata: SystemConfigMetadataDto; newVersionCheck: SystemConfigNewVersionCheckDto; notifications: SystemConfigNotificationsDto; oauth: SystemConfigOAuthDto; @@ -3226,6 +3236,10 @@ export enum AlbumUserRole { Editor = "editor", Viewer = "viewer" } +export enum SourceType { + MachineLearning = "machine-learning", + Exif = "exif" +} export enum AssetTypeEnum { Image = "IMAGE", Video = "VIDEO", diff --git a/server/package-lock.json b/server/package-lock.json index 45375b6964442..ca6a54c82c20e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -24,7 +24,7 @@ "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.53.0", "@opentelemetry/sdk-node": "^0.53.0", - "@react-email/components": "^0.0.23", + "@react-email/components": "^0.0.24", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -5070,9 +5070,9 @@ } }, "node_modules/@react-email/code-block": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.7.tgz", - "integrity": "sha512-3lYLwn9rK16I4JmTR/sTzAJMVHzUmmcT1PT27+TXnQyBCfpfDV+VockSg1qhsgCusA/u6j0C97BMsa96AWEbbw==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.8.tgz", + "integrity": "sha512-WbuAEpTnB262i9C3SGPmmErgZ4iU5KIpqLUjr7uBJijqldLqZc5x39e8wPWaRdF7NLcShmrc/+G7GJgI1bdC5w==", "dependencies": { "prismjs": "1.29.0" }, @@ -5106,13 +5106,13 @@ } }, "node_modules/@react-email/components": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.23.tgz", - "integrity": "sha512-RcBoffx2IZG6quLBXo5sj3fF47rKmmkiMhG1ZBua4nFjHYlmW8j1uUMyO5HNglxIF9E52NYq4sF7XeZRp9jYjg==", + "version": "0.0.24", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.24.tgz", + "integrity": "sha512-/DNmfTREaT59UFdkHoIK3BewJ214LfRxmduiil3m7POj+gougkItANu1+BMmgbUATxjf7jH1WoBxo9x/rhFEFw==", "dependencies": { "@react-email/body": "0.0.10", "@react-email/button": "0.0.17", - "@react-email/code-block": "0.0.7", + "@react-email/code-block": "0.0.8", "@react-email/code-inline": "0.0.4", "@react-email/column": "0.0.12", "@react-email/container": "0.0.14", @@ -5125,7 +5125,7 @@ "@react-email/link": "0.0.10", "@react-email/markdown": "0.0.12", "@react-email/preview": "0.0.11", - "@react-email/render": "1.0.0", + "@react-email/render": "1.0.1", "@react-email/row": "0.0.10", "@react-email/section": "0.0.14", "@react-email/tailwind": "0.1.0", @@ -5249,9 +5249,9 @@ } }, "node_modules/@react-email/render": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.0.tgz", - "integrity": "sha512-seN2p3JRUSZhwIUiymh9N6ZfhRZ14ywOraQqAokY63DkDeHZW2pA2a6nWpNc/igfOcNyt09Wsoi1Aj0esxhdzw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.1.tgz", + "integrity": "sha512-W3gTrcmLOVYnG80QuUp22ReIT/xfLsVJ+n7ghSlG2BITB8evNABn1AO2rGQoXuK84zKtDAlxCdm3hRyIpZdGSA==", "dependencies": { "html-to-text": "9.0.5", "js-beautify": "^1.14.11", @@ -19280,9 +19280,9 @@ "requires": {} }, "@react-email/code-block": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.7.tgz", - "integrity": "sha512-3lYLwn9rK16I4JmTR/sTzAJMVHzUmmcT1PT27+TXnQyBCfpfDV+VockSg1qhsgCusA/u6j0C97BMsa96AWEbbw==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.8.tgz", + "integrity": "sha512-WbuAEpTnB262i9C3SGPmmErgZ4iU5KIpqLUjr7uBJijqldLqZc5x39e8wPWaRdF7NLcShmrc/+G7GJgI1bdC5w==", "requires": { "prismjs": "1.29.0" } @@ -19300,13 +19300,13 @@ "requires": {} }, "@react-email/components": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.23.tgz", - "integrity": "sha512-RcBoffx2IZG6quLBXo5sj3fF47rKmmkiMhG1ZBua4nFjHYlmW8j1uUMyO5HNglxIF9E52NYq4sF7XeZRp9jYjg==", + "version": "0.0.24", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.24.tgz", + "integrity": "sha512-/DNmfTREaT59UFdkHoIK3BewJ214LfRxmduiil3m7POj+gougkItANu1+BMmgbUATxjf7jH1WoBxo9x/rhFEFw==", "requires": { "@react-email/body": "0.0.10", "@react-email/button": "0.0.17", - "@react-email/code-block": "0.0.7", + "@react-email/code-block": "0.0.8", "@react-email/code-inline": "0.0.4", "@react-email/column": "0.0.12", "@react-email/container": "0.0.14", @@ -19319,7 +19319,7 @@ "@react-email/link": "0.0.10", "@react-email/markdown": "0.0.12", "@react-email/preview": "0.0.11", - "@react-email/render": "1.0.0", + "@react-email/render": "1.0.1", "@react-email/row": "0.0.10", "@react-email/section": "0.0.14", "@react-email/tailwind": "0.1.0", @@ -19389,9 +19389,9 @@ "requires": {} }, "@react-email/render": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.0.tgz", - "integrity": "sha512-seN2p3JRUSZhwIUiymh9N6ZfhRZ14ywOraQqAokY63DkDeHZW2pA2a6nWpNc/igfOcNyt09Wsoi1Aj0esxhdzw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.1.tgz", + "integrity": "sha512-W3gTrcmLOVYnG80QuUp22ReIT/xfLsVJ+n7ghSlG2BITB8evNABn1AO2rGQoXuK84zKtDAlxCdm3hRyIpZdGSA==", "requires": { "html-to-text": "9.0.5", "js-beautify": "^1.14.11", diff --git a/server/package.json b/server/package.json index bfa6dd9e11dcb..58d7208adf91f 100644 --- a/server/package.json +++ b/server/package.json @@ -50,7 +50,7 @@ "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.53.0", "@opentelemetry/sdk-node": "^0.53.0", - "@react-email/components": "^0.0.23", + "@react-email/components": "^0.0.24", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index c6cd68a96ff77..9446010127450 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -18,10 +18,11 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; -import { HttpExceptionFilter } from 'src/middleware/http-exception.filter'; +import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { LoggingInterceptor } from 'src/middleware/logging.interceptor'; import { repositories } from 'src/repositories'; import { services } from 'src/services'; +import { DatabaseService } from 'src/services/database.service'; import { setupEventHandlers } from 'src/utils/events'; import { otelConfig } from 'src/utils/instrumentation'; @@ -29,7 +30,7 @@ const common = [...services, ...repositories]; const middleware = [ FileUploadInterceptor, - { provide: APP_FILTER, useClass: HttpExceptionFilter }, + { provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) }, { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, { provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, @@ -43,7 +44,17 @@ const imports = [ ConfigModule.forRoot(immichAppConfig), EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), - TypeOrmModule.forRoot(databaseConfig), + TypeOrmModule.forRootAsync({ + inject: [ModuleRef], + useFactory: (moduleRef: ModuleRef) => { + return { + ...databaseConfig, + poolErrorHandler: (error) => { + moduleRef.get(DatabaseService, { strict: false }).handleConnectionError(error); + }, + }; + }, + }), TypeOrmModule.forFeature(entities), ]; diff --git a/server/src/config.ts b/server/src/config.ts index 96ce63cf451dd..057c9a69e213c 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -141,6 +141,11 @@ export interface SystemConfig { reverseGeocoding: { enabled: boolean; }; + metadata: { + faces: { + import: boolean; + }; + }; oauth: { autoLaunch: boolean; autoRegister: boolean; @@ -286,6 +291,11 @@ export const defaults = Object.freeze({ reverseGeocoding: { enabled: true, }, + metadata: { + faces: { + import: false, + }, + }, oauth: { autoLaunch: false, autoRegister: true, diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index e20a0c658db7f..a4d0d06152f50 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -301,7 +301,7 @@ export class StorageCore { return this.assetRepository.update({ id, sidecarPath: newPath }); } case PersonPathType.FACE: { - return this.personRepository.update({ id, thumbnailPath: newPath }); + return this.personRepository.update([{ id, thumbnailPath: newPath }]); } } } diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 3833e4f3e7485..94ee52d916f65 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -6,6 +6,7 @@ import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SourceType } from 'src/enum'; import { IsDateStringFormat, MaxDateString, Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class PersonCreateDto { @@ -113,6 +114,8 @@ export class AssetFaceWithoutPersonResponseDto { boundingBoxY1!: number; @ApiProperty({ type: 'integer' }) boundingBoxY2!: number; + @ApiProperty({ enum: SourceType, enumName: 'SourceType' }) + sourceType?: SourceType; } export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto { @@ -176,6 +179,7 @@ export function mapFacesWithoutPerson(face: AssetFaceEntity): AssetFaceWithoutPe boundingBoxX2: face.boundingBoxX2, boundingBoxY1: face.boundingBoxY1, boundingBoxY2: face.boundingBoxY2, + sourceType: face.sourceType, }; } diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 9c18b0b4fe3bf..78e59e4d1a695 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -131,6 +131,7 @@ export class ServerFeaturesDto { map!: boolean; trash!: boolean; reverseGeocoding!: boolean; + importFaces!: boolean; oauth!: boolean; oauthAutoLaunch!: boolean; passwordLogin!: boolean; diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index e2255223d08ba..14027aa16ad32 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -375,6 +375,18 @@ class SystemConfigReverseGeocodingDto { enabled!: boolean; } +class SystemConfigFacesDto { + @IsBoolean() + import!: boolean; +} + +class SystemConfigMetadataDto { + @Type(() => SystemConfigFacesDto) + @ValidateNested() + @IsObject() + faces!: SystemConfigFacesDto; +} + class SystemConfigServerDto { @ValidateIf((_, value: string) => value !== '') @IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] }) @@ -555,6 +567,11 @@ export class SystemConfigDto implements SystemConfig { @IsObject() reverseGeocoding!: SystemConfigReverseGeocodingDto; + @Type(() => SystemConfigMetadataDto) + @ValidateNested() + @IsObject() + metadata!: SystemConfigMetadataDto; + @Type(() => SystemConfigStorageTemplateDto) @ValidateNested() @IsObject() diff --git a/server/src/entities/asset-face.entity.ts b/server/src/entities/asset-face.entity.ts index c21aacfcd1a4d..3a4e916cba6b4 100644 --- a/server/src/entities/asset-face.entity.ts +++ b/server/src/entities/asset-face.entity.ts @@ -1,6 +1,7 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SourceType } from 'src/enum'; import { Column, Entity, Index, ManyToOne, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; @Entity('asset_faces', { synchronize: false }) @@ -37,6 +38,9 @@ export class AssetFaceEntity { @Column({ default: 0, type: 'int' }) boundingBoxY2!: number; + @Column({ default: SourceType.MACHINE_LEARNING, type: 'enum', enum: SourceType }) + sourceType!: SourceType; + @ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) asset!: AssetEntity; diff --git a/server/src/enum.ts b/server/src/enum.ts index 9cd5c189e8431..28973e0205831 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -180,3 +180,8 @@ export enum UserStatus { REMOVING = 'removing', DELETED = 'deleted', } + +export enum SourceType { + MACHINE_LEARNING = 'machine-learning', + EXIF = 'exif', +} diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index 98bb0c02889c2..373f1091429d7 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -40,6 +40,7 @@ export interface VectorUpdateResult { export const IDatabaseRepository = 'IDatabaseRepository'; export interface IDatabaseRepository { + reconnect(): Promise; getExtensionVersion(extension: DatabaseExtension): Promise; getExtensionVersionRange(extension: VectorExtension): string; getPostgresVersion(): Promise; diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 386f69a9e740c..04e7b89d1e138 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -7,7 +7,8 @@ export interface ExifDuration { Scale?: number; } -export interface ImmichTags extends Omit { +type TagsWithWrongTypes = 'FocalLength' | 'Duration' | 'Description' | 'ImageDescription' | 'RegionInfo'; +export interface ImmichTags extends Omit { ContentIdentifier?: string; MotionPhoto?: number; MotionPhotoVersion?: number; @@ -23,6 +24,28 @@ export interface ImmichTags extends Omit; writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; - getCountries(userId: string): Promise>; - getStates(userId: string, country?: string): Promise>; - getCities(userId: string, country?: string, state?: string): Promise>; - getCameraMakes(userId: string, model?: string): Promise>; - getCameraModels(userId: string, make?: string): Promise>; + getCountries(userIds: string[]): Promise>; + getStates(userIds: string[], country?: string): Promise>; + getCities(userIds: string[], country?: string, state?: string): Promise>; + getCameraMakes(userIds: string[], model?: string): Promise>; + getCameraModels(userIds: string[], make?: string): Promise>; } diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index 358310a5cbe21..fc6a389f3cc06 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -15,6 +15,11 @@ export interface PersonNameSearchOptions { withHidden?: boolean; } +export interface PersonNameResponse { + id: string; + name: string; +} + export interface AssetFaceId { assetId: string; personId: string; @@ -35,20 +40,26 @@ export interface PeopleStatistics { hidden: number; } +export interface DeleteAllFacesOptions { + sourceType?: string; +} + export interface IPersonRepository { getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; getAllWithoutFaces(): Promise; getById(personId: string): Promise; getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; + getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise; getAssets(personId: string): Promise; - create(entity: Partial): Promise; + create(entities: Partial[]): Promise; createFaces(entities: Partial[]): Promise; delete(entities: PersonEntity[]): Promise; deleteAll(): Promise; - deleteAllFaces(): Promise; + deleteAllFaces(options: DeleteAllFacesOptions): Promise; + replaceFaces(assetId: string, entities: Partial[], sourceType?: string): Promise; getAllFaces(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getFaceById(id: string): Promise; getFaceByIdWithAssets( @@ -63,6 +74,6 @@ export interface IPersonRepository { reassignFace(assetFaceId: string, newPersonId: string): Promise; getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; - update(entity: Partial): Promise; + update(entities: Partial[]): Promise; getLatestFaceDate(): Promise; } diff --git a/server/src/middleware/error.interceptor.ts b/server/src/middleware/error.interceptor.ts index a0c333e4b24cb..5d93b40dc22d0 100644 --- a/server/src/middleware/error.interceptor.ts +++ b/server/src/middleware/error.interceptor.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/common'; import { Observable, catchError, throwError } from 'rxjs'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { logGlobalError } from 'src/utils/logger'; import { routeToErrorMessage } from 'src/utils/misc'; @Injectable() @@ -25,9 +26,10 @@ export class ErrorInterceptor implements NestInterceptor { return error; } - const errorMessage = routeToErrorMessage(context.getHandler().name); - this.logger.error(errorMessage, error, error?.errors, error?.stack); - return new InternalServerErrorException(errorMessage); + logGlobalError(this.logger, error); + + const message = routeToErrorMessage(context.getHandler().name); + return new InternalServerErrorException(message); }), ), ); diff --git a/server/src/middleware/global-exception.filter.ts b/server/src/middleware/global-exception.filter.ts new file mode 100644 index 0000000000000..6200363e86e9e --- /dev/null +++ b/server/src/middleware/global-exception.filter.ts @@ -0,0 +1,47 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject } from '@nestjs/common'; +import { Response } from 'express'; +import { ClsService } from 'nestjs-cls'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { logGlobalError } from 'src/utils/logger'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + constructor( + @Inject(ILoggerRepository) private logger: ILoggerRepository, + private cls: ClsService, + ) { + this.logger.setContext(GlobalExceptionFilter.name); + } + + catch(error: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const { status, body } = this.fromError(error); + if (!response.headersSent) { + response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() }); + } + } + + private fromError(error: Error) { + logGlobalError(this.logger, error); + + if (error instanceof HttpException) { + const status = error.getStatus(); + let body = error.getResponse(); + + // unclear what circumstances would return a string + if (typeof body === 'string') { + body = { message: body }; + } + + return { status, body }; + } + + return { + status: 500, + body: { + message: 'Internal server error', + }, + }; + } +} diff --git a/server/src/middleware/http-exception.filter.ts b/server/src/middleware/http-exception.filter.ts deleted file mode 100644 index 3306b50ca67e0..0000000000000 --- a/server/src/middleware/http-exception.filter.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject } from '@nestjs/common'; -import { Response } from 'express'; -import { ClsService } from 'nestjs-cls'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; - -@Catch(HttpException) -export class HttpExceptionFilter implements ExceptionFilter { - constructor( - @Inject(ILoggerRepository) private logger: ILoggerRepository, - private cls: ClsService, - ) { - this.logger.setContext(HttpExceptionFilter.name); - } - - catch(exception: HttpException, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const status = exception.getStatus(); - - this.logger.debug(`HttpException(${status}) ${JSON.stringify(exception.getResponse())}`); - - let responseBody = exception.getResponse(); - // unclear what circumstances would return a string - if (typeof responseBody === 'string') { - responseBody = { - error: 'Unknown', - message: responseBody, - statusCode: status, - }; - } - - if (!response.headersSent) { - response.status(status).json({ - ...responseBody, - correlationId: this.cls.getId(), - }); - } - } -} diff --git a/server/src/migrations/1661881837496-AddAssetChecksum.ts b/server/src/migrations/1661881837496-AddAssetChecksum.ts index 231aeecca79cf..2901b4f554038 100644 --- a/server/src/migrations/1661881837496-AddAssetChecksum.ts +++ b/server/src/migrations/1661881837496-AddAssetChecksum.ts @@ -11,7 +11,7 @@ export class AddAssetChecksum1661881837496 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_64c507300988dd1764f9a6530c"`); + await queryRunner.query(`DROP INDEX "IDX_64c507300988dd1764f9a6530c"`); await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "checksum"`); } } diff --git a/server/src/migrations/1670257571385-CreateTagsTable.ts b/server/src/migrations/1670257571385-CreateTagsTable.ts index 0585aecc8ca63..75fba9249c258 100644 --- a/server/src/migrations/1670257571385-CreateTagsTable.ts +++ b/server/src/migrations/1670257571385-CreateTagsTable.ts @@ -17,8 +17,8 @@ export class CreateTagsTable1670257571385 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`); await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`); await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`); - await queryRunner.query(`DROP INDEX "public"."IDX_e99f31ea4cdf3a2c35c7287eb4"`); - await queryRunner.query(`DROP INDEX "public"."IDX_f8e8a9e893cb5c54907f1b798e"`); + await queryRunner.query(`DROP INDEX "IDX_e99f31ea4cdf3a2c35c7287eb4"`); + await queryRunner.query(`DROP INDEX "IDX_f8e8a9e893cb5c54907f1b798e"`); await queryRunner.query(`DROP TABLE "tag_asset"`); await queryRunner.query(`DROP TABLE "tags"`); } diff --git a/server/src/migrations/1673150490490-AddSharedLinkTable.ts b/server/src/migrations/1673150490490-AddSharedLinkTable.ts index a7508722d2c33..8d5bd2f5a5716 100644 --- a/server/src/migrations/1673150490490-AddSharedLinkTable.ts +++ b/server/src/migrations/1673150490490-AddSharedLinkTable.ts @@ -18,10 +18,10 @@ export class AddSharedLinkTable1673150490490 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab"`); await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66"`); await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66"`); - await queryRunner.query(`DROP INDEX "public"."IDX_c9fab4aa97ffd1b034f3d6581a"`); - await queryRunner.query(`DROP INDEX "public"."IDX_5b7decce6c8d3db9593d6111a6"`); + await queryRunner.query(`DROP INDEX "IDX_c9fab4aa97ffd1b034f3d6581a"`); + await queryRunner.query(`DROP INDEX "IDX_5b7decce6c8d3db9593d6111a6"`); await queryRunner.query(`DROP TABLE "shared_link__asset"`); - await queryRunner.query(`DROP INDEX "public"."IDX_sharedlink_key"`); + await queryRunner.query(`DROP INDEX "IDX_sharedlink_key"`); await queryRunner.query(`DROP TABLE "shared_links"`); } diff --git a/server/src/migrations/1675812532822-FixAlbumEntityTypeORM.ts b/server/src/migrations/1675812532822-FixAlbumEntityTypeORM.ts index 3be6a2aa1d147..6f48ac736d804 100644 --- a/server/src/migrations/1675812532822-FixAlbumEntityTypeORM.ts +++ b/server/src/migrations/1675812532822-FixAlbumEntityTypeORM.ts @@ -44,10 +44,10 @@ export class FixAlbumEntityTypeORM1675812532822 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "asset_album" DROP CONSTRAINT "FK_4bd1303d199f4e72ccdf998c621"`); await queryRunner.query(`ALTER TABLE "user_shared_album" DROP CONSTRAINT "FK_427c350ad49bd3935a50baab737"`); await queryRunner.query(`ALTER TABLE "user_shared_album" DROP CONSTRAINT "FK_f48513bf9bccefd6ff3ad30bd06"`); - await queryRunner.query(`DROP INDEX "public"."IDX_427c350ad49bd3935a50baab73"`); - await queryRunner.query(`DROP INDEX "public"."IDX_f48513bf9bccefd6ff3ad30bd0"`); - await queryRunner.query(`DROP INDEX "public"."IDX_e590fa396c6898fcd4a50e4092"`); - await queryRunner.query(`DROP INDEX "public"."IDX_4bd1303d199f4e72ccdf998c62"`); + await queryRunner.query(`DROP INDEX "IDX_427c350ad49bd3935a50baab73"`); + await queryRunner.query(`DROP INDEX "IDX_f48513bf9bccefd6ff3ad30bd0"`); + await queryRunner.query(`DROP INDEX "IDX_e590fa396c6898fcd4a50e4092"`); + await queryRunner.query(`DROP INDEX "IDX_4bd1303d199f4e72ccdf998c62"`); await queryRunner.query(`ALTER TABLE "albums" DROP CONSTRAINT "FK_b22c53f35ef20c28c21637c85f4"`); await queryRunner.query( diff --git a/server/src/migrations/1676437878377-AppleContentIdentifier.ts b/server/src/migrations/1676437878377-AppleContentIdentifier.ts index 40a4dce579c5b..8d11139878e19 100644 --- a/server/src/migrations/1676437878377-AppleContentIdentifier.ts +++ b/server/src/migrations/1676437878377-AppleContentIdentifier.ts @@ -9,7 +9,7 @@ export class AppleContentIdentifier1676437878377 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_live_photo_cid"`); + await queryRunner.query(`DROP INDEX "IDX_live_photo_cid"`); await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "livePhotoCID"`); } } diff --git a/server/src/migrations/1676848629119-ExifEntityDefinitionFixes.ts b/server/src/migrations/1676848629119-ExifEntityDefinitionFixes.ts index 35d4c77eba363..947559ed2d5dd 100644 --- a/server/src/migrations/1676848629119-ExifEntityDefinitionFixes.ts +++ b/server/src/migrations/1676848629119-ExifEntityDefinitionFixes.ts @@ -6,7 +6,7 @@ export class ExifEntityDefinitionFixes1676848629119 implements MigrationInterfac public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "description" SET NOT NULL`); - await queryRunner.query(`DROP INDEX "public"."IDX_c0117fdbc50b917ef9067740c4"`); + await queryRunner.query(`DROP INDEX "IDX_c0117fdbc50b917ef9067740c4"`); await queryRunner.query(`ALTER TABLE "exif" DROP CONSTRAINT "PK_28663352d85078ad0046dafafaa"`); await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "id"`); await queryRunner.query(`ALTER TABLE "exif" DROP CONSTRAINT "FK_c0117fdbc50b917ef9067740c44"`); diff --git a/server/src/migrations/1676852143506-SmartInfoEntityDefinitionFixes.ts b/server/src/migrations/1676852143506-SmartInfoEntityDefinitionFixes.ts index f89c7acdd2c78..e089619c6d3db 100644 --- a/server/src/migrations/1676852143506-SmartInfoEntityDefinitionFixes.ts +++ b/server/src/migrations/1676852143506-SmartInfoEntityDefinitionFixes.ts @@ -4,7 +4,7 @@ export class SmartInfoEntityDefinitionFixes1676852143506 implements MigrationInt name = 'SmartInfoEntityDefinitionFixes1676852143506' public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_5e3753aadd956110bf3ec0244a"`); + await queryRunner.query(`DROP INDEX "IDX_5e3753aadd956110bf3ec0244a"`); await queryRunner.query(`ALTER TABLE "smart_info" DROP CONSTRAINT "PK_0beace66440e9713f5c40470e46"`); await queryRunner.query(`ALTER TABLE "smart_info" DROP COLUMN "id"`); await queryRunner.query(`ALTER TABLE "smart_info" DROP CONSTRAINT "FK_5e3753aadd956110bf3ec0244ac"`); diff --git a/server/src/migrations/1677535643119-AddIndexForAlbumInSharedLinkTable.ts b/server/src/migrations/1677535643119-AddIndexForAlbumInSharedLinkTable.ts index f3fb4a6c63232..986b5ebd20b58 100644 --- a/server/src/migrations/1677535643119-AddIndexForAlbumInSharedLinkTable.ts +++ b/server/src/migrations/1677535643119-AddIndexForAlbumInSharedLinkTable.ts @@ -8,7 +8,7 @@ export class AddIndexForAlbumInSharedLinkTable1677535643119 implements Migration } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_sharedlink_albumId"`); + await queryRunner.query(`DROP INDEX "IDX_sharedlink_albumId"`); } } diff --git a/server/src/migrations/1684328185099-RequireChecksumNotNull.ts b/server/src/migrations/1684328185099-RequireChecksumNotNull.ts index 6da8f326220f0..e691fff2b1f7f 100644 --- a/server/src/migrations/1684328185099-RequireChecksumNotNull.ts +++ b/server/src/migrations/1684328185099-RequireChecksumNotNull.ts @@ -4,13 +4,13 @@ export class RequireChecksumNotNull1684328185099 implements MigrationInterface { name = 'removeNotNullFromChecksumIndex1684328185099'; public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_64c507300988dd1764f9a6530c"`); + await queryRunner.query(`DROP INDEX "IDX_64c507300988dd1764f9a6530c"`); await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "checksum" SET NOT NULL`); await queryRunner.query(`CREATE INDEX "IDX_8d3efe36c0755849395e6ea866" ON "assets" ("checksum") `); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_8d3efe36c0755849395e6ea866"`); + await queryRunner.query(`DROP INDEX "IDX_8d3efe36c0755849395e6ea866"`); await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "checksum" DROP NOT NULL`); await queryRunner.query( `CREATE INDEX "IDX_64c507300988dd1764f9a6530c" ON "assets" ("checksum") WHERE ('checksum' IS NOT NULL)`, diff --git a/server/src/migrations/1692804658140-AddAuditTable.ts b/server/src/migrations/1692804658140-AddAuditTable.ts index 71b8c7b2c63f0..d398051a79e27 100644 --- a/server/src/migrations/1692804658140-AddAuditTable.ts +++ b/server/src/migrations/1692804658140-AddAuditTable.ts @@ -9,7 +9,7 @@ export class AddAuditTable1692804658140 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_ownerId_createdAt"`); + await queryRunner.query(`DROP INDEX "IDX_ownerId_createdAt"`); await queryRunner.query(`DROP TABLE "audit"`); } diff --git a/server/src/migrations/1696888644031-AddOriginalPathIndex.ts b/server/src/migrations/1696888644031-AddOriginalPathIndex.ts index 826700ffe87d4..78e1c92ecb3ea 100644 --- a/server/src/migrations/1696888644031-AddOriginalPathIndex.ts +++ b/server/src/migrations/1696888644031-AddOriginalPathIndex.ts @@ -8,6 +8,6 @@ export class AddOriginalPathIndex1696888644031 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_originalPath_libraryId"`); + await queryRunner.query(`DROP INDEX "IDX_originalPath_libraryId"`); } } diff --git a/server/src/migrations/1698693294632-AddActivity.ts b/server/src/migrations/1698693294632-AddActivity.ts index 46041570ead83..5556ef2b20850 100644 --- a/server/src/migrations/1698693294632-AddActivity.ts +++ b/server/src/migrations/1698693294632-AddActivity.ts @@ -15,7 +15,7 @@ export class AddActivity1698693294632 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_1af8519996fbfb3684b58df280b"`); await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_3571467bcbe021f66e2bdce96ea"`); await queryRunner.query(`ALTER TABLE "activity" DROP CONSTRAINT "FK_8091ea76b12338cb4428d33d782"`); - await queryRunner.query(`DROP INDEX "public"."IDX_activity_like"`); + await queryRunner.query(`DROP INDEX "IDX_activity_like"`); await queryRunner.query(`DROP TABLE "activity"`); } diff --git a/server/src/migrations/1700752078178-AddAssetFaceIndicies.ts b/server/src/migrations/1700752078178-AddAssetFaceIndicies.ts index 723b22b3d14d0..38dd915139991 100644 --- a/server/src/migrations/1700752078178-AddAssetFaceIndicies.ts +++ b/server/src/migrations/1700752078178-AddAssetFaceIndicies.ts @@ -9,8 +9,8 @@ export class AddAssetFaceIndicies1700752078178 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_b463c8edb01364bf2beba08ef1"`); - await queryRunner.query(`DROP INDEX "public"."IDX_bf339a24070dac7e71304ec530"`); + await queryRunner.query(`DROP INDEX "IDX_b463c8edb01364bf2beba08ef1"`); + await queryRunner.query(`DROP INDEX "IDX_bf339a24070dac7e71304ec530"`); } } diff --git a/server/src/migrations/1701665867595-AddExifCityIndex.ts b/server/src/migrations/1701665867595-AddExifCityIndex.ts index 9979762dc4f34..0899ea1e6b9db 100644 --- a/server/src/migrations/1701665867595-AddExifCityIndex.ts +++ b/server/src/migrations/1701665867595-AddExifCityIndex.ts @@ -8,7 +8,7 @@ export class AddExifCityIndex1701665867595 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."exif_city"`); + await queryRunner.query(`DROP INDEX "exif_city"`); } } diff --git a/server/src/migrations/1703035138085-AddAutoStackId.ts b/server/src/migrations/1703035138085-AddAutoStackId.ts index 666914261123b..d8c83ac56573b 100644 --- a/server/src/migrations/1703035138085-AddAutoStackId.ts +++ b/server/src/migrations/1703035138085-AddAutoStackId.ts @@ -9,7 +9,7 @@ export class AddAutoStackId1703035138085 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_auto_stack_id"`); + await queryRunner.query(`DROP INDEX "IDX_auto_stack_id"`); await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "autoStackId"`); } diff --git a/server/src/migrations/1705306747072-AddOriginalFileNameIndex.ts b/server/src/migrations/1705306747072-AddOriginalFileNameIndex.ts index b465d429438c8..c62c01f50c1c7 100644 --- a/server/src/migrations/1705306747072-AddOriginalFileNameIndex.ts +++ b/server/src/migrations/1705306747072-AddOriginalFileNameIndex.ts @@ -8,6 +8,6 @@ export class AddOriginalFileNameIndex1705306747072 implements MigrationInterface } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_4d66e76dada1ca180f67a205dc"`); + await queryRunner.query(`DROP INDEX "IDX_4d66e76dada1ca180f67a205dc"`); } } diff --git a/server/src/migrations/1705363967169-CreateAssetStackTable.ts b/server/src/migrations/1705363967169-CreateAssetStackTable.ts index 74c75d555ceee..d1591797ffa49 100644 --- a/server/src/migrations/1705363967169-CreateAssetStackTable.ts +++ b/server/src/migrations/1705363967169-CreateAssetStackTable.ts @@ -41,7 +41,7 @@ export class CreateAssetStackTable1705197515600 implements MigrationInterface { ); // update constraints - await queryRunner.query(`DROP INDEX "public"."IDX_b463c8edb01364bf2beba08ef1"`); + await queryRunner.query(`DROP INDEX "IDX_b463c8edb01364bf2beba08ef1"`); await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`); await queryRunner.query( `ALTER TABLE "assets" ADD CONSTRAINT "FK_f15d48fa3ea5e4bda05ca8ab207" FOREIGN KEY ("stackId") REFERENCES "asset_stack"("id") ON DELETE SET NULL ON UPDATE CASCADE`, diff --git a/server/src/migrations/1711637874206-AddMemoryTable.ts b/server/src/migrations/1711637874206-AddMemoryTable.ts index 6309cb5082a64..b1c5b437d736e 100644 --- a/server/src/migrations/1711637874206-AddMemoryTable.ts +++ b/server/src/migrations/1711637874206-AddMemoryTable.ts @@ -17,8 +17,8 @@ export class AddMemoryTable1711637874206 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "memories_assets_assets" DROP CONSTRAINT "FK_6942ecf52d75d4273de19d2c16f"`); await queryRunner.query(`ALTER TABLE "memories_assets_assets" DROP CONSTRAINT "FK_984e5c9ab1f04d34538cd32334e"`); await queryRunner.query(`ALTER TABLE "memories" DROP CONSTRAINT "FK_575842846f0c28fa5da46c99b19"`); - await queryRunner.query(`DROP INDEX "public"."IDX_6942ecf52d75d4273de19d2c16"`); - await queryRunner.query(`DROP INDEX "public"."IDX_984e5c9ab1f04d34538cd32334"`); + await queryRunner.query(`DROP INDEX "IDX_6942ecf52d75d4273de19d2c16"`); + await queryRunner.query(`DROP INDEX "IDX_984e5c9ab1f04d34538cd32334"`); await queryRunner.query(`DROP TABLE "memories_assets_assets"`); await queryRunner.query(`DROP TABLE "memories"`); } diff --git a/server/src/migrations/1715804005643-RemoveLibraryType.ts b/server/src/migrations/1715804005643-RemoveLibraryType.ts index d42ba4ec7351d..cd4dc574f2197 100644 --- a/server/src/migrations/1715804005643-RemoveLibraryType.ts +++ b/server/src/migrations/1715804005643-RemoveLibraryType.ts @@ -5,8 +5,8 @@ export class RemoveLibraryType1715804005643 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_9977c3c1de01c3d848039a6b90c"`); - await queryRunner.query(`DROP INDEX "public"."UQ_assets_owner_library_checksum"`); - await queryRunner.query(`DROP INDEX "public"."IDX_originalPath_libraryId"`); + await queryRunner.query(`DROP INDEX "UQ_assets_owner_library_checksum"`); + await queryRunner.query(`DROP INDEX "IDX_originalPath_libraryId"`); await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "libraryId" DROP NOT NULL`); await queryRunner.query(` UPDATE "assets" diff --git a/server/src/migrations/1721249222549-AddSourceColumnToAssetFace.ts b/server/src/migrations/1721249222549-AddSourceColumnToAssetFace.ts new file mode 100644 index 0000000000000..7f185077ff782 --- /dev/null +++ b/server/src/migrations/1721249222549-AddSourceColumnToAssetFace.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSourceColumnToAssetFace1721249222549 implements MigrationInterface { + name = 'AddSourceColumnToAssetFace1721249222549' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE sourceType AS ENUM ('machine-learning', 'exif');`); + await queryRunner.query(`ALTER TABLE "asset_faces" ADD "sourceType" sourceType NOT NULL DEFAULT 'machine-learning'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "sourceType"`); + await queryRunner.query(`DROP TYPE sourceType`); + } + +} diff --git a/server/src/migrations/1724101822106-AddAssetFilesTable.ts b/server/src/migrations/1724101822106-AddAssetFilesTable.ts index 1ed4945749dd8..bb086b084e9f5 100644 --- a/server/src/migrations/1724101822106-AddAssetFilesTable.ts +++ b/server/src/migrations/1724101822106-AddAssetFilesTable.ts @@ -27,7 +27,7 @@ export class AddAssetFilesTable1724101822106 implements MigrationInterface { await queryRunner.query(`UPDATE "assets" SET "thumbnailPath" = "asset_files".path FROM "asset_files" WHERE "assets".id = "asset_files".assetId AND "asset_files".type = 'thumbnail'`); await queryRunner.query(`ALTER TABLE "asset_files" DROP CONSTRAINT "FK_e3e103a5f1d8bc8402999286040"`); - await queryRunner.query(`DROP INDEX "public"."IDX_asset_files_assetId"`); + await queryRunner.query(`DROP INDEX "IDX_asset_files_assetId"`); await queryRunner.query(`DROP TABLE "asset_files"`); } diff --git a/server/src/migrations/1724790460210-NestedTagTable.ts b/server/src/migrations/1724790460210-NestedTagTable.ts index dfda9a6d7a38e..d468ff6ba4e97 100644 --- a/server/src/migrations/1724790460210-NestedTagTable.ts +++ b/server/src/migrations/1724790460210-NestedTagTable.ts @@ -47,8 +47,8 @@ export class NestedTagTable1724790460210 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "tags" ADD "name" character varying NOT NULL`); await queryRunner.query(`ALTER TABLE "tags" ADD "type" character varying NOT NULL`); await queryRunner.query(`ALTER TABLE "tags" ADD "renameTagId" uuid`); - await queryRunner.query(`DROP INDEX "public"."IDX_b1a2a7ed45c29179b5ad51548a"`); - await queryRunner.query(`DROP INDEX "public"."IDX_15fbcbc67663c6bfc07b354c22"`); + await queryRunner.query(`DROP INDEX "IDX_b1a2a7ed45c29179b5ad51548a"`); + await queryRunner.query(`DROP INDEX "IDX_15fbcbc67663c6bfc07b354c22"`); await queryRunner.query(`DROP TABLE "tags_closure"`); await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_tag_name_userId" UNIQUE ("name", "userId")`); await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 3852439936d83..9b4b17425c409 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -199,6 +199,7 @@ SELECT "AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1", "AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2", "AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2", + "AssetEntity__AssetEntity_faces"."sourceType" AS "AssetEntity__AssetEntity_faces_sourceType", "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id", "8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt", "8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt", diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 9c94232d20857..57969e4989c91 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -74,6 +74,7 @@ SELECT "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", @@ -106,6 +107,7 @@ FROM "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", @@ -141,6 +143,7 @@ FROM "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", @@ -226,6 +229,16 @@ ORDER BY LIMIT 20 +-- PersonRepository.getDistinctNames +SELECT DISTINCT + ON (lower("person"."name")) "person"."id" AS "person_id", + "person"."name" AS "person_name" +FROM + "person" "person" +WHERE + "person"."ownerId" = $1 + AND "person"."name" != '' + -- PersonRepository.getStatistics SELECT COUNT(DISTINCT ("asset"."id")) AS "count" @@ -282,6 +295,7 @@ FROM "AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1", "AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2", "AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2", + "AssetEntity__AssetEntity_faces"."sourceType" AS "AssetEntity__AssetEntity_faces_sourceType", "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id", "8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt", "8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt", @@ -375,6 +389,7 @@ SELECT "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType", "AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id", "AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId", "AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId", @@ -425,7 +440,8 @@ SELECT "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", - "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2" + "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."sourceType" AS "AssetFaceEntity_sourceType" FROM "asset_faces" "AssetFaceEntity" WHERE diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index e9e94400ad454..dd2e3ae75c819 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -235,6 +235,7 @@ WITH "faces"."boundingBoxY1" AS "boundingBoxY1", "faces"."boundingBoxX2" AS "boundingBoxX2", "faces"."boundingBoxY2" AS "boundingBoxY2", + "faces"."sourceType" AS "sourceType", "search"."embedding" <= > $1 AS "distance" FROM "asset_faces" "faces" diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 9ee7f8e6fccea..0453421a39d1b 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -31,6 +31,19 @@ export class DatabaseRepository implements IDatabaseRepository { this.logger.setContext(DatabaseRepository.name); } + async reconnect() { + try { + if (this.dataSource.isInitialized) { + await this.dataSource.destroy(); + } + const { isInitialized } = await this.dataSource.initialize(); + return isInitialized; + } catch (error) { + this.logger.error(`Database connection failed: ${error}`); + return false; + } + } + async getExtensionVersion(extension: DatabaseExtension): Promise { const [res]: ExtensionVersion[] = await this.dataSource.query( `SELECT default_version as "availableVersion", installed_version as "installedVersion" diff --git a/server/src/repositories/logger.repository.ts b/server/src/repositories/logger.repository.ts index 1527965b496cd..1e0c7b74d973e 100644 --- a/server/src/repositories/logger.repository.ts +++ b/server/src/repositories/logger.repository.ts @@ -3,7 +3,7 @@ import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-en import { ClsService } from 'nestjs-cls'; import { LogLevel } from 'src/config'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { LogColor } from 'src/utils/logger-colors'; +import { LogColor } from 'src/utils/logger'; const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 832cffbee6ae1..abffc1b78527a 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -56,11 +56,11 @@ export class MetadataRepository implements IMetadataRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - async getCountries(userId: string): Promise { + async getCountries(userIds: string[]): Promise { const results = await this.exifRepository .createQueryBuilder('exif') .leftJoin('exif.asset', 'asset') - .where('asset.ownerId = :userId', { userId }) + .where('asset.ownerId IN (:...userIds )', { userIds }) .select('exif.country', 'country') .distinctOn(['exif.country']) .getRawMany<{ country: string }>(); @@ -69,11 +69,11 @@ export class MetadataRepository implements IMetadataRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getStates(userId: string, country: string | undefined): Promise { + async getStates(userIds: string[], country: string | undefined): Promise { const query = this.exifRepository .createQueryBuilder('exif') .leftJoin('exif.asset', 'asset') - .where('asset.ownerId = :userId', { userId }) + .where('asset.ownerId IN (:...userIds )', { userIds }) .select('exif.state', 'state') .distinctOn(['exif.state']); @@ -87,11 +87,11 @@ export class MetadataRepository implements IMetadataRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] }) - async getCities(userId: string, country: string | undefined, state: string | undefined): Promise { + async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise { const query = this.exifRepository .createQueryBuilder('exif') .leftJoin('exif.asset', 'asset') - .where('asset.ownerId = :userId', { userId }) + .where('asset.ownerId IN (:...userIds )', { userIds }) .select('exif.city', 'city') .distinctOn(['exif.city']); @@ -109,11 +109,11 @@ export class MetadataRepository implements IMetadataRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getCameraMakes(userId: string, model: string | undefined): Promise { + async getCameraMakes(userIds: string[], model: string | undefined): Promise { const query = this.exifRepository .createQueryBuilder('exif') .leftJoin('exif.asset', 'asset') - .where('asset.ownerId = :userId', { userId }) + .where('asset.ownerId IN (:...userIds )', { userIds }) .select('exif.make', 'make') .distinctOn(['exif.make']); @@ -126,11 +126,11 @@ export class MetadataRepository implements IMetadataRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getCameraModels(userId: string, make: string | undefined): Promise { + async getCameraModels(userIds: string[], make: string | undefined): Promise { const query = this.exifRepository .createQueryBuilder('exif') .leftJoin('exif.asset', 'asset') - .where('asset.ownerId = :userId', { userId }) + .where('asset.ownerId IN (:...userIds )', { userIds }) .select('exif.model', 'model') .distinctOn(['exif.model']); diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 876ed369f6f04..7459ca318348e 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import _ from 'lodash'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; @@ -8,8 +8,10 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetFaceId, + DeleteAllFacesOptions, IPersonRepository, PeopleStatistics, + PersonNameResponse, PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, @@ -17,12 +19,13 @@ import { } from 'src/interfaces/person.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; -import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; +import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; @Instrumentation() @Injectable() export class PersonRepository implements IPersonRepository { constructor( + @InjectDataSource() private dataSource: DataSource, @InjectRepository(AssetEntity) private assetRepository: Repository, @InjectRepository(PersonEntity) private personRepository: Repository, @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, @@ -49,7 +52,16 @@ export class PersonRepository implements IPersonRepository { await this.personRepository.clear(); } - async deleteAllFaces(): Promise { + async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise { + if (sourceType) { + await this.assetFaceRepository + .createQueryBuilder('asset_faces') + .delete() + .andWhere('sourceType = :sourceType', { sourceType }) + .execute(); + return; + } + await this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE'); } @@ -182,6 +194,21 @@ export class PersonRepository implements IPersonRepository { return queryBuilder.getMany(); } + @GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] }) + getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise { + const queryBuilder = this.personRepository + .createQueryBuilder('person') + .select(['person.id', 'person.name']) + .distinctOn(['lower(person.name)']) + .where(`person.ownerId = :userId AND person.name != ''`, { userId }); + + if (!withHidden) { + queryBuilder.andWhere('person.isHidden = false'); + } + + return queryBuilder.getMany(); + } + @GenerateSql({ params: [DummyValue.UUID] }) async getStatistics(personId: string): Promise { const items = await this.assetFaceRepository @@ -248,8 +275,8 @@ export class PersonRepository implements IPersonRepository { return result; } - create(entity: Partial): Promise { - return this.personRepository.save(entity); + create(entities: Partial[]): Promise { + return this.personRepository.save(entities); } async createFaces(entities: AssetFaceEntity[]): Promise { @@ -257,9 +284,16 @@ export class PersonRepository implements IPersonRepository { return res.map((row) => row.id); } - async update(entity: Partial): Promise { - const { id } = await this.personRepository.save(entity); - return this.personRepository.findOneByOrFail({ id }); + async replaceFaces(assetId: string, entities: AssetFaceEntity[], sourceType: string): Promise { + return this.dataSource.transaction(async (manager) => { + await manager.delete(AssetFaceEntity, { assetId, sourceType }); + const assetFaces = await manager.save(AssetFaceEntity, entities); + return assetFaces.map(({ id }) => id); + }); + } + + async update(entities: Partial[]): Promise { + return await this.personRepository.save(entities); } @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 72db2b6eb56ce..4f292f7cc1300 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -115,7 +115,7 @@ export class AuditService { } case PersonPathType.FACE: { - await this.personRepository.update({ id, thumbnailPath: pathValue }); + await this.personRepository.update([{ id, thumbnailPath: pathValue }]); break; } diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index d2a2813a0550c..a5280ff28be23 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { Duration } from 'luxon'; import semver from 'semver'; import { getVectorExtension } from 'src/database.config'; import { OnEmit } from 'src/decorators'; @@ -59,8 +60,12 @@ const messages = { If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`, }; +const RETRY_DURATION = Duration.fromObject({ seconds: 5 }); + @Injectable() export class DatabaseService { + private reconnection?: NodeJS.Timeout; + constructor( @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -117,6 +122,26 @@ export class DatabaseService { }); } + handleConnectionError(error: Error) { + if (this.reconnection) { + return; + } + + this.logger.error(`Database disconnected: ${error}`); + this.reconnection = setInterval(() => void this.reconnect(), RETRY_DURATION.toMillis()); + } + + private async reconnect() { + const isConnected = await this.databaseRepository.reconnect(); + if (isConnected) { + this.logger.log('Database reconnected'); + clearInterval(this.reconnection); + delete this.reconnection; + } else { + this.logger.warn(`Database connection failed, retrying in ${RETRY_DURATION.toHuman()}`); + } + } + private async createExtension(extension: DatabaseExtension) { try { await this.databaseRepository.createExtension(extension); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 3f2513474154d..919348b53ef99 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -117,7 +117,7 @@ export class MediaService { continue; } - await this.personRepository.update({ id: person.id, faceAssetId: face.id }); + await this.personRepository.update([{ id: person.id, faceAssetId: face.id }]); } jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); @@ -176,7 +176,7 @@ export class MediaService { async handleGeneratePreview({ id }: IEntityJob): Promise { const [{ image }, [asset]] = await Promise.all([ this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true }), + this.assetRepository.getByIds([id], { exifInfo: true, files: true }), ]); if (!asset) { return JobStatus.FAILED; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 84b67be5cdfed..52f6609772b9d 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1,9 +1,9 @@ -import { BinaryField } from 'exiftool-vendored'; +import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetType } from 'src/enum'; +import { AssetType, SourceType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -24,6 +24,8 @@ import { MetadataService, Orientation } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; +import { metadataStub } from 'test/fixtures/metadata.stub'; +import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; @@ -449,6 +451,60 @@ describe(MetadataService.name, () => { }); }); + it('should extract hierarchy from HierarchicalSubject', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Parent|Child'] }); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + }); + + it('should extract ignore / characters in a HierarchicalSubject tag', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Mom/Dad'] }); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ + userId: 'user-id', + value: 'Mom|Dad', + parent: undefined, + }); + }); + + it('should ignore HierarchicalSubject when TagsList is present', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + userId: 'user-id', + value: 'Parent/Child', + parent: tagStub.parent, + }); + }); + + it('should remove existing tags', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({}); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertAssetTags).toHaveBeenCalledWith({ assetId: 'asset-id', tagIds: [] }); + }); + it('should not apply motion photos if asset is video', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); @@ -701,6 +757,8 @@ describe(MetadataService.name, () => { }); it('should save all metadata', async () => { + const dateForTest = new Date('1970-01-01T00:00:00.000-11:30'); + const tags: ImmichTags = { BitsPerSample: 1, ComponentBitDepth: 1, @@ -708,7 +766,7 @@ describe(MetadataService.name, () => { BitDepth: 1, ColorBitDepth: 1, ColorSpace: '1', - DateTimeOriginal: new Date('1970-01-01').toISOString(), + DateTimeOriginal: ExifDateTime.fromISO(dateForTest.toISOString()), ExposureTime: '100ms', FocalLength: 20, ImageDescription: 'test description', @@ -717,11 +775,11 @@ describe(MetadataService.name, () => { MediaGroupUUID: 'livePhoto', Make: 'test-factory', Model: "'mockel'", - ModifyDate: new Date('1970-01-01').toISOString(), + ModifyDate: ExifDateTime.fromISO(dateForTest.toISOString()), Orientation: 0, ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', - tz: '+02:00', + tz: 'UTC-11:30', Rating: 3, }; assetMock.getByIds.mockResolvedValue([assetStub.image]); @@ -734,7 +792,7 @@ describe(MetadataService.name, () => { bitsPerSample: expect.any(Number), autoStackId: null, colorspace: tags.ColorSpace, - dateTimeOriginal: new Date('1970-01-01'), + dateTimeOriginal: dateForTest, description: tags.ImageDescription, exifImageHeight: null, exifImageWidth: null, @@ -760,11 +818,37 @@ describe(MetadataService.name, () => { expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, duration: null, - fileCreatedAt: new Date('1970-01-01'), - localDateTime: new Date('1970-01-01'), + fileCreatedAt: dateForTest, + localDateTime: dateForTest, }); }); + it('should extract +00:00 timezone from raw value', async () => { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + + // this only tests our assumptions of exiftool-vendored, demonstrating the issue + const someDate = '2024-09-01T00:00:00.000'; + expect(ExifDateTime.fromISO(someDate + 'Z')?.zone).toBe('UTC'); + expect(ExifDateTime.fromISO(someDate + '+00:00')?.zone).toBe('UTC'); // this is the issue, should be UTC+0 + expect(ExifDateTime.fromISO(someDate + '+04:00')?.zone).toBe('UTC+4'); + + const tags: ImmichTags = { + DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), + tz: undefined, + }; + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue(tags); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + timeZone: 'UTC+0', + }), + ); + }); + it('should extract duration', async () => { assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); mediaMock.probe.mockResolvedValue({ @@ -883,6 +967,123 @@ describe(MetadataService.name, () => { }), ); }); + + it('should skip importing metadata when the feature is disabled', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); + systemMock.get.mockResolvedValue({ metadata: { faces: { import: false } } }); + metadataMock.readTags.mockResolvedValue(metadataStub.withFace); + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(personMock.getDistinctNames).not.toHaveBeenCalled(); + }); + + it('should skip importing metadata face for assets without tags.RegionInfo', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); + systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + metadataMock.readTags.mockResolvedValue(metadataStub.empty); + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(personMock.getDistinctNames).not.toHaveBeenCalled(); + }); + + it('should skip importing faces without name', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); + systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + metadataMock.readTags.mockResolvedValue(metadataStub.withFaceNoName); + personMock.getDistinctNames.mockResolvedValue([]); + personMock.create.mockResolvedValue([]); + personMock.replaceFaces.mockResolvedValue([]); + personMock.update.mockResolvedValue([]); + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(personMock.create).toHaveBeenCalledWith([]); + expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF); + expect(personMock.update).toHaveBeenCalledWith([]); + }); + + it('should skip importing faces with empty name', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); + systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + metadataMock.readTags.mockResolvedValue(metadataStub.withFaceEmptyName); + personMock.getDistinctNames.mockResolvedValue([]); + personMock.create.mockResolvedValue([]); + personMock.replaceFaces.mockResolvedValue([]); + personMock.update.mockResolvedValue([]); + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(personMock.create).toHaveBeenCalledWith([]); + expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF); + expect(personMock.update).toHaveBeenCalledWith([]); + }); + + it('should apply metadata face tags creating new persons', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); + systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + metadataMock.readTags.mockResolvedValue(metadataStub.withFace); + personMock.getDistinctNames.mockResolvedValue([]); + personMock.create.mockResolvedValue([personStub.withName]); + personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']); + personMock.update.mockResolvedValue([personStub.withName]); + await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]); + expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + expect(personMock.create).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]); + expect(personMock.replaceFaces).toHaveBeenCalledWith( + assetStub.primaryImage.id, + [ + { + id: 'random-uuid', + assetId: assetStub.primaryImage.id, + personId: 'random-uuid', + imageHeight: 100, + imageWidth: 100, + boundingBoxX1: 0, + boundingBoxX2: 10, + boundingBoxY1: 0, + boundingBoxY2: 10, + sourceType: SourceType.EXIF, + }, + ], + SourceType.EXIF, + ); + expect(personMock.update).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { id: personStub.withName.id }, + }, + ]); + }); + + it('should assign metadata face tags to existing persons', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); + systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); + metadataMock.readTags.mockResolvedValue(metadataStub.withFace); + personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); + personMock.create.mockResolvedValue([]); + personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']); + personMock.update.mockResolvedValue([personStub.withName]); + await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]); + expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); + expect(personMock.create).toHaveBeenCalledWith([]); + expect(personMock.replaceFaces).toHaveBeenCalledWith( + assetStub.primaryImage.id, + [ + { + id: 'random-uuid', + assetId: assetStub.primaryImage.id, + personId: personStub.withName.id, + imageHeight: 100, + imageWidth: 100, + boundingBoxX1: 0, + boundingBoxX2: 10, + boundingBoxY1: 0, + boundingBoxY2: 10, + sourceType: SourceType.EXIF, + }, + ], + SourceType.EXIF, + ); + expect(personMock.update).toHaveBeenCalledWith([]); + expect(jobMock.queueAll).toHaveBeenCalledWith([]); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index de3babb138580..58e7b994480ac 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -9,9 +9,11 @@ import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } from 'src/decorators'; +import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetType } from 'src/enum'; +import { PersonEntity } from 'src/entities/person.entity'; +import { AssetType, SourceType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -37,6 +39,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { isFaceImportEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { upsertTags } from 'src/utils/tag'; @@ -104,7 +107,7 @@ export class MetadataService { @Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IMetadataRepository) private repository: IMetadataRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IPersonRepository) personRepository: IPersonRepository, + @Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ITagRepository) private tagRepository: ITagRepository, @@ -215,6 +218,7 @@ export class MetadataService { } async handleMetadataExtraction({ id }: IEntityJob): Promise { + const { metadata } = await this.configCore.getConfig({ withCache: true }); const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { return JobStatus.FAILED; @@ -253,6 +257,10 @@ export class MetadataService { metadataExtractedAt: new Date(), }); + if (isFaceImportEnabled(metadata)) { + await this.applyTaggedFaces(asset, exifTags); + } + return JobStatus.SUCCESS; } @@ -355,6 +363,16 @@ export class MetadataService { const tags: unknown[] = []; if (exifTags.TagsList) { tags.push(...exifTags.TagsList); + } else if (exifTags.HierarchicalSubject) { + tags.push( + exifTags.HierarchicalSubject.map((tag) => + tag + // convert | to / + .replaceAll('/', '') + .replaceAll('|', '/') + .replaceAll('', '|'), + ), + ); } else if (exifTags.Keywords) { let keywords = exifTags.Keywords; if (!Array.isArray(keywords)) { @@ -363,11 +381,8 @@ export class MetadataService { tags.push(...keywords); } - if (tags.length > 0) { - const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags: tags.map(String) }); - const tagIds = results.map((tag) => tag.id); - await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds }); - } + const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags: tags.map(String) }); + await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) }); } private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) { @@ -502,6 +517,65 @@ export class MetadataService { } } + private async applyTaggedFaces(asset: AssetEntity, tags: ImmichTags) { + if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { + return; + } + + const discoveredFaces: Partial[] = []; + const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); + const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); + const missing: Partial[] = []; + const missingWithFaceAsset: Partial[] = []; + for (const region of tags.RegionInfo.RegionList) { + if (!region.Name) { + continue; + } + + const imageWidth = tags.RegionInfo.AppliedToDimensions.W; + const imageHeight = tags.RegionInfo.AppliedToDimensions.H; + const loweredName = region.Name.toLowerCase(); + const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID(); + + const face = { + id: this.cryptoRepository.randomUUID(), + personId, + assetId: asset.id, + imageWidth, + imageHeight, + boundingBoxX1: Math.floor((region.Area.X - region.Area.W / 2) * imageWidth), + boundingBoxY1: Math.floor((region.Area.Y - region.Area.H / 2) * imageHeight), + boundingBoxX2: Math.floor((region.Area.X + region.Area.W / 2) * imageWidth), + boundingBoxY2: Math.floor((region.Area.Y + region.Area.H / 2) * imageHeight), + sourceType: SourceType.EXIF, + }; + + discoveredFaces.push(face); + if (!existingNameMap.has(loweredName)) { + missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name }); + missingWithFaceAsset.push({ id: personId, faceAssetId: face.id }); + } + } + + if (missing.length > 0) { + this.logger.debug(`Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); + } + + const newPersons = await this.personRepository.create(missing); + + const faceIds = await this.personRepository.replaceFaces(asset.id, discoveredFaces, SourceType.EXIF); + this.logger.debug(`Created ${faceIds.length} faces for asset ${asset.id}`); + + await this.personRepository.update(missingWithFaceAsset); + + await this.jobRepository.queueAll( + newPersons.map((person) => ({ + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { id: person.id }, + })), + ); + } + private async exifData( asset: AssetEntity, ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> { @@ -521,12 +595,16 @@ export class MetadataService { this.logger.verbose('Exif Tags', exifTags); + const dateTimeOriginalWithRawValue = this.getDateTimeOriginalWithRawValue(exifTags); + const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt; + const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue); + const exifData = { // altitude: tags.GPSAltitude ?? null, assetId: asset.id, bitsPerSample: this.getBitsPerSample(exifTags), colorspace: exifTags.ColorSpace ?? null, - dateTimeOriginal: this.getDateTimeOriginal(exifTags) ?? asset.fileCreatedAt, + dateTimeOriginal, description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), exifImageHeight: validate(exifTags.ImageHeight), exifImageWidth: validate(exifTags.ImageWidth), @@ -547,7 +625,7 @@ export class MetadataService { orientation: validate(exifTags.Orientation)?.toString() ?? null, profileDescription: exifTags.ProfileDescription || null, projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, - timeZone: exifTags.tz ?? null, + timeZone, rating: exifTags.Rating ?? null, }; @@ -568,10 +646,25 @@ export class MetadataService { } private getDateTimeOriginal(tags: ImmichTags | Tags | null) { + return this.getDateTimeOriginalWithRawValue(tags).exifDate; + } + + private getDateTimeOriginalWithRawValue(tags: ImmichTags | Tags | null): { exifDate: Date | null; rawValue: string } { if (!tags) { - return null; + return { exifDate: null, rawValue: '' }; } - return exifDate(firstDateTime(tags as Tags, EXIF_DATE_TAGS)); + const first = firstDateTime(tags as Tags, EXIF_DATE_TAGS); + return { exifDate: exifDate(first), rawValue: first?.rawValue ?? '' }; + } + + private getTimeZone(exifTags: ImmichTags, rawValue: string) { + const timeZone = exifTags.tz ?? null; + if (timeZone == null && rawValue.endsWith('+00:00')) { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + return 'UTC+0'; + } + return timeZone; } private getBitsPerSample(tags: ImmichTags): number | null { diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index f8608243ae92c..51598b93d063b 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -3,7 +3,7 @@ import { Colorspace } from 'src/config'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { SystemMetadataKey } from 'src/enum'; +import { SourceType, SystemMetadataKey } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -241,18 +241,18 @@ describe(PersonService.name, () => { }); it("should update a person's name", async () => { - personMock.update.mockResolvedValue(personStub.withName); + personMock.update.mockResolvedValue([personStub.withName]); personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); + expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', name: 'Person 1' }]); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's date of birth", async () => { - personMock.update.mockResolvedValue(personStub.withBirthDate); + personMock.update.mockResolvedValue([personStub.withBirthDate]); personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); @@ -264,25 +264,25 @@ describe(PersonService.name, () => { isHidden: false, updatedAt: expect.any(Date), }); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); + expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', birthDate: '1976-06-30' }]); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should update a person visibility', async () => { - personMock.update.mockResolvedValue(personStub.withName); + personMock.update.mockResolvedValue([personStub.withName]); personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); + expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', isHidden: false }]); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's thumbnailPath", async () => { - personMock.update.mockResolvedValue(personStub.withName); + personMock.update.mockResolvedValue([personStub.withName]); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); @@ -291,7 +291,7 @@ describe(PersonService.name, () => { sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), ).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); + expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', faceAssetId: faceStub.face1.id }]); expect(personMock.getFacesByIds).toHaveBeenCalledWith([ { assetId: faceStub.face1.assetId, @@ -441,11 +441,11 @@ describe(PersonService.name, () => { describe('createPerson', () => { it('should create a new person', async () => { - personMock.create.mockResolvedValue(personStub.primaryPerson); + personMock.create.mockResolvedValue([personStub.primaryPerson]); await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson); - expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); + expect(personMock.create).toHaveBeenCalledWith([{ ownerId: authStub.admin.user.id }]); }); }); @@ -496,6 +496,7 @@ describe(PersonService.name, () => { items: [personStub.withName], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueDetectFaces({ force: true }); @@ -510,7 +511,7 @@ describe(PersonService.name, () => { it('should delete existing people and faces if forced', async () => { personMock.getAll.mockResolvedValue({ - items: [faceStub.face1.person], + items: [faceStub.face1.person, personStub.randomPerson], hasNextPage: false, }); personMock.getAllFaces.mockResolvedValue({ @@ -521,6 +522,7 @@ describe(PersonService.name, () => { items: [assetStub.image], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); await sut.handleQueueDetectFaces({ force: true }); @@ -531,8 +533,8 @@ describe(PersonService.name, () => { data: { id: assetStub.image.id }, }, ]); - expect(personMock.delete).toHaveBeenCalledWith([faceStub.face1.person]); - expect(storageMock.unlink).toHaveBeenCalledWith(faceStub.face1.person.thumbnailPath); + expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); @@ -561,10 +563,14 @@ describe(PersonService.name, () => { items: [faceStub.face1], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { personId: IsNull() } }); + expect(personMock.getAllFaces).toHaveBeenCalledWith( + { skip: 0, take: 1000 }, + { where: { personId: IsNull(), sourceType: IsNull() } }, + ); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -586,6 +592,7 @@ describe(PersonService.name, () => { items: [faceStub.face1], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); @@ -616,6 +623,8 @@ describe(PersonService.name, () => { items: [faceStub.face1], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([]); + await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); @@ -641,6 +650,7 @@ describe(PersonService.name, () => { items: [faceStub.face1], hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); @@ -654,7 +664,7 @@ describe(PersonService.name, () => { it('should delete existing people and faces if forced', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); personMock.getAll.mockResolvedValue({ - items: [faceStub.face1.person], + items: [faceStub.face1.person, personStub.randomPerson], hasNextPage: false, }); personMock.getAllFaces.mockResolvedValue({ @@ -662,17 +672,19 @@ describe(PersonService.name, () => { hasNextPage: false, }); + personMock.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); + await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {}); + expect(personMock.deleteAllFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(personMock.delete).toHaveBeenCalledWith([faceStub.face1.person]); - expect(storageMock.unlink).toHaveBeenCalledWith(faceStub.face1.person.thumbnailPath); + expect(personMock.delete).toHaveBeenCalledWith([personStub.randomPerson]); + expect(storageMock.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); }); }); @@ -807,7 +819,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(faceStub.primaryFace1.person); + personMock.create.mockResolvedValue([faceStub.primaryFace1.person]); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -832,14 +844,16 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + personMock.create.mockResolvedValue([personStub.withName]); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(personMock.create).toHaveBeenCalledWith({ - ownerId: faceStub.noPerson1.asset.ownerId, - faceAssetId: faceStub.noPerson1.id, - }); + expect(personMock.create).toHaveBeenCalledWith([ + { + ownerId: faceStub.noPerson1.asset.ownerId, + faceAssetId: faceStub.noPerson1.id, + }, + ]); expect(personMock.reassignFaces).toHaveBeenCalledWith({ faceIds: [faceStub.noPerson1.id], newPersonId: personStub.withName.id, @@ -851,7 +865,7 @@ describe(PersonService.name, () => { searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + personMock.create.mockResolvedValue([personStub.withName]); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -870,7 +884,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + personMock.create.mockResolvedValue([personStub.withName]); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -892,7 +906,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue(personStub.withName); + personMock.create.mockResolvedValue([personStub.withName]); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); @@ -965,10 +979,12 @@ describe(PersonService.name, () => { processInvalidImages: false, }, ); - expect(personMock.update).toHaveBeenCalledWith({ - id: 'person-1', - thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - }); + expect(personMock.update).toHaveBeenCalledWith([ + { + id: 'person-1', + thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + }, + ]); }); it('should generate a thumbnail without going negative', async () => { @@ -1087,7 +1103,7 @@ describe(PersonService.name, () => { it('should merge two people with smart merge', async () => { personMock.getById.mockResolvedValueOnce(personStub.randomPerson); personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); + personMock.update.mockResolvedValue([{ ...personStub.randomPerson, name: personStub.primaryPerson.name }]); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); @@ -1100,10 +1116,12 @@ describe(PersonService.name, () => { oldPersonId: personStub.primaryPerson.id, }); - expect(personMock.update).toHaveBeenCalledWith({ - id: personStub.randomPerson.id, - name: personStub.primaryPerson.name, - }); + expect(personMock.update).toHaveBeenCalledWith([ + { + id: personStub.randomPerson.id, + name: personStub.primaryPerson.name, + }, + ]); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); @@ -1177,6 +1195,7 @@ describe(PersonService.name, () => { id: faceStub.face1.id, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, person: mapPerson(personStub.withName), }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 6f2283b72c6e8..c4b5df5719352 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -25,7 +25,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonPathType } from 'src/entities/move.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { AssetType, Permission, SystemMetadataKey } from 'src/enum'; +import { AssetType, Permission, SourceType, SystemMetadataKey } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -53,7 +53,7 @@ import { checkAccess, requireAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; -import { isFacialRecognitionEnabled } from 'src/utils/misc'; +import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { IsNull } from 'typeorm'; @@ -173,10 +173,7 @@ export class PersonService { const assetFace = await this.repository.getRandomFace(personId); if (assetFace !== null) { - await this.repository.update({ - id: personId, - faceAssetId: assetFace.id, - }); + await this.repository.update([{ id: personId, faceAssetId: assetFace.id }]); jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } }); } } @@ -214,13 +211,16 @@ export class PersonService { return assets.map((asset) => mapAsset(asset)); } - create(auth: AuthDto, dto: PersonCreateDto): Promise { - return this.repository.create({ - ownerId: auth.user.id, - name: dto.name, - birthDate: dto.birthDate, - isHidden: dto.isHidden, - }); + async create(auth: AuthDto, dto: PersonCreateDto): Promise { + const [created] = await this.repository.create([ + { + ownerId: auth.user.id, + name: dto.name, + birthDate: dto.birthDate, + isHidden: dto.isHidden, + }, + ]); + return created; } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { @@ -239,7 +239,7 @@ export class PersonService { faceId = face.id; } - const person = await this.repository.update({ id, faceAssetId: faceId, name, birthDate, isHidden }); + const [person] = await this.repository.update([{ id, faceAssetId: faceId, name, birthDate, isHidden }]); if (assetId) { await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); @@ -296,8 +296,8 @@ export class PersonService { } if (force) { - await this.deleteAllPeople(); - await this.repository.deleteAllFaces(); + await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.handlePersonCleanup(); } const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { @@ -339,11 +339,7 @@ export class PersonService { return JobStatus.FAILED; } - if (!asset.isVisible) { - return JobStatus.SKIPPED; - } - - if (!asset.isVisible) { + if (!asset.isVisible || asset.faces.length > 0) { return JobStatus.SKIPPED; } @@ -408,7 +404,8 @@ export class PersonService { const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION); if (force) { - await this.deleteAllPeople(); + await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.handlePersonCleanup(); } else if (waiting) { this.logger.debug( `Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`, @@ -418,7 +415,9 @@ export class PersonService { const lastRun = new Date().toISOString(); const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull() } }), + this.repository.getAllFaces(pagination, { + where: force ? undefined : { personId: IsNull(), sourceType: IsNull() }, + }), ); for await (const page of facePagination) { @@ -441,13 +440,18 @@ export class PersonService { const face = await this.repository.getFaceByIdWithAssets( id, { person: true, asset: true, faceSearch: true }, - { id: true, personId: true, faceSearch: { embedding: true } }, + { id: true, personId: true, sourceType: true, faceSearch: { embedding: true } }, ); if (!face || !face.asset) { this.logger.warn(`Face ${id} not found`); return JobStatus.FAILED; } + if (face.sourceType !== SourceType.MACHINE_LEARNING) { + this.logger.warn(`Skipping face ${id} due to source ${face.sourceType}`); + return JobStatus.SKIPPED; + } + if (!face.faceSearch?.embedding) { this.logger.warn(`Face ${id} does not have an embedding`); return JobStatus.FAILED; @@ -497,7 +501,7 @@ export class PersonService { if (isCore && !personId) { this.logger.log(`Creating new person for face ${id}`); - const newPerson = await this.repository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); + const [newPerson] = await this.repository.create([{ ownerId: face.asset.ownerId, faceAssetId: face.id }]); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } }); personId = newPerson.id; } @@ -522,8 +526,8 @@ export class PersonService { } async handleGeneratePersonThumbnail(data: IEntityJob): Promise { - const { machineLearning, image } = await this.configCore.getConfig({ withCache: true }); - if (!isFacialRecognitionEnabled(machineLearning)) { + const { machineLearning, metadata, image } = await this.configCore.getConfig({ withCache: true }); + if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { return JobStatus.SKIPPED; } @@ -573,7 +577,7 @@ export class PersonService { } as const; await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions); - await this.repository.update({ id: person.id, thumbnailPath }); + await this.repository.update([{ id: person.id, thumbnailPath }]); return JobStatus.SUCCESS; } @@ -620,7 +624,7 @@ export class PersonService { } if (Object.keys(update).length > 0) { - primaryPerson = await this.repository.update({ id: primaryPerson.id, ...update }); + [primaryPerson] = await this.repository.update([{ id: primaryPerson.id, ...update }]); } const mergeName = mergePerson.name || mergePerson.id; diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 89609d5d89294..ded087b8b5dc4 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -103,7 +103,7 @@ describe(SearchService.name, () => { await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA', null]); - expect(metadataMock.getCountries).toHaveBeenCalledWith(authStub.user1.user.id); + expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); it('should return search suggestions (without null)', async () => { @@ -111,7 +111,7 @@ describe(SearchService.name, () => { await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA']); - expect(metadataMock.getCountries).toHaveBeenCalledWith(authStub.user1.user.id); + expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); }); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 35fd29a2de468..4c86d4ad75ebe 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -121,26 +121,27 @@ export class SearchService { } async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto) { - const results = await this.getSuggestions(auth.user.id, dto); + const userIds = await this.getUserIdsToSearch(auth); + const results = await this.getSuggestions(userIds, dto); return results.filter((result) => (dto.includeNull ? true : result !== null)); } - private getSuggestions(userId: string, dto: SearchSuggestionRequestDto) { + private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) { switch (dto.type) { case SearchSuggestionType.COUNTRY: { - return this.metadataRepository.getCountries(userId); + return this.metadataRepository.getCountries(userIds); } case SearchSuggestionType.STATE: { - return this.metadataRepository.getStates(userId, dto.country); + return this.metadataRepository.getStates(userIds, dto.country); } case SearchSuggestionType.CITY: { - return this.metadataRepository.getCities(userId, dto.country, dto.state); + return this.metadataRepository.getCities(userIds, dto.country, dto.state); } case SearchSuggestionType.CAMERA_MAKE: { - return this.metadataRepository.getCameraMakes(userId, dto.model); + return this.metadataRepository.getCameraMakes(userIds, dto.model); } case SearchSuggestionType.CAMERA_MODEL: { - return this.metadataRepository.getCameraModels(userId, dto.make); + return this.metadataRepository.getCameraModels(userIds, dto.make); } default: { return []; diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 799ec2c5a38d9..ac899f7b13ba8 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -160,6 +160,7 @@ describe(ServerService.name, () => { smartSearch: true, duplicateDetection: true, facialRecognition: true, + importFaces: false, map: true, reverseGeocoding: true, oauth: false, diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 5ea8a3e45921f..e57a206765f96 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -90,7 +90,7 @@ export class ServerService { } async getFeatures(): Promise { - const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } = + const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } = await this.configCore.getConfig({ withCache: false }); return { @@ -99,6 +99,7 @@ export class ServerService { duplicateDetection: isDuplicateDetectionEnabled(machineLearning), map: map.enabled, reverseGeocoding: reverseGeocoding.enabled, + importFaces: metadata.faces.import, sidecar: true, search: true, trash: trash.enabled, diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index bb0e706d61022..af2b564ab2fe8 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -74,6 +74,11 @@ const updatedConfig = Object.freeze({ enabled: true, level: LogLevel.LOG, }, + metadata: { + faces: { + import: false, + }, + }, machineLearning: { enabled: true, url: 'http://immich-machine-learning:3003', diff --git a/server/src/utils/logger-colors.ts b/server/src/utils/logger.ts similarity index 55% rename from server/src/utils/logger-colors.ts rename to server/src/utils/logger.ts index 36104ee520c82..d4eb02ead21ab 100644 --- a/server/src/utils/logger-colors.ts +++ b/server/src/utils/logger.ts @@ -1,3 +1,7 @@ +import { HttpException } from '@nestjs/common'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { TypeORMError } from 'typeorm'; + type ColorTextFn = (text: string) => string; const isColorAllowed = () => !process.env.NO_COLOR; @@ -15,3 +19,22 @@ export const LogColor = { export const LogStyle = { bold: colorIfAllowed((text: string) => `\u001B[1m${text}\u001B[0m`), }; + +export const logGlobalError = (logger: ILoggerRepository, error: Error) => { + if (error instanceof HttpException) { + const status = error.getStatus(); + const response = error.getResponse(); + logger.debug(`HttpException(${status}): ${JSON.stringify(response)}`); + return; + } + + if (error instanceof TypeORMError) { + logger.error(`Database error: ${error}`); + return; + } + + if (error instanceof Error) { + logger.error(`Unknown error: ${error}`); + return; + } +}; diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 6063b4925ce8d..47f3f552c47e7 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -64,6 +64,7 @@ export const isFacialRecognitionEnabled = (machineLearning: SystemConfig['machin isMachineLearningEnabled(machineLearning) && machineLearning.facialRecognition.enabled; export const isDuplicateDetectionEnabled = (machineLearning: SystemConfig['machineLearning']) => isSmartSearchEnabled(machineLearning) && machineLearning.duplicateDetection.enabled; +export const isFaceImportEnabled = (metadata: SystemConfig['metadata']) => metadata.faces.import; export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 82935dd345658..27ca2a4356e22 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -1,4 +1,5 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; +import { SourceType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { personStub } from 'test/fixtures/person.stub'; @@ -17,6 +18,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId1', embedding: [1, 2, 3, 4] }, }), primaryFace1: Object.freeze>({ @@ -31,6 +33,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId2', embedding: [1, 2, 3, 4] }, }), mergeFace1: Object.freeze>({ @@ -45,6 +48,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] }, }), mergeFace2: Object.freeze>({ @@ -59,6 +63,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId4', embedding: [1, 2, 3, 4] }, }), start: Object.freeze>({ @@ -73,6 +78,7 @@ export const faceStub = { boundingBoxY2: 505, imageHeight: 2880, imageWidth: 2160, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId5', embedding: [1, 2, 3, 4] }, }), middle: Object.freeze>({ @@ -87,6 +93,7 @@ export const faceStub = { boundingBoxY2: 200, imageHeight: 500, imageWidth: 400, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId6', embedding: [1, 2, 3, 4] }, }), end: Object.freeze>({ @@ -101,6 +108,7 @@ export const faceStub = { boundingBoxY2: 495, imageHeight: 500, imageWidth: 500, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId7', embedding: [1, 2, 3, 4] }, }), noPerson1: Object.freeze({ @@ -115,6 +123,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId8', embedding: [1, 2, 3, 4] }, }), noPerson2: Object.freeze({ @@ -129,6 +138,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId9', embedding: [1, 2, 3, 4] }, }), }; diff --git a/server/test/fixtures/metadata.stub.ts b/server/test/fixtures/metadata.stub.ts new file mode 100644 index 0000000000000..05535303e45a6 --- /dev/null +++ b/server/test/fixtures/metadata.stub.ts @@ -0,0 +1,71 @@ +import { ImmichTags } from 'src/interfaces/metadata.interface'; +import { personStub } from 'test/fixtures/person.stub'; + +export const metadataStub = { + empty: Object.freeze({}), + withFace: Object.freeze({ + RegionInfo: { + AppliedToDimensions: { + W: 100, + H: 100, + Unit: 'normalized', + }, + RegionList: [ + { + Type: 'face', + Name: personStub.withName.name, + Area: { + X: 0.05, + Y: 0.05, + W: 0.1, + H: 0.1, + Unit: 'normalized', + }, + }, + ], + }, + }), + withFaceEmptyName: Object.freeze({ + RegionInfo: { + AppliedToDimensions: { + W: 100, + H: 100, + Unit: 'normalized', + }, + RegionList: [ + { + Type: 'face', + Name: '', + Area: { + X: 0.05, + Y: 0.05, + W: 0.1, + H: 0.1, + Unit: 'normalized', + }, + }, + ], + }, + }), + withFaceNoName: Object.freeze({ + RegionInfo: { + AppliedToDimensions: { + W: 100, + H: 100, + Unit: 'normalized', + }, + RegionList: [ + { + Type: 'face', + Area: { + X: 0.05, + Y: 0.05, + W: 0.1, + H: 0.1, + Unit: 'normalized', + }, + }, + ], + }, + }), +}; diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index e8b0817dfe0b7..0e1d4ab3e71dd 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -3,6 +3,7 @@ import { Mocked, vitest } from 'vitest'; export const newDatabaseRepositoryMock = (): Mocked => { return { + reconnect: vitest.fn(), getExtensionVersion: vitest.fn(), getExtensionVersionRange: vitest.fn(), getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'), diff --git a/server/test/repositories/partner.repository.mock.ts b/server/test/repositories/partner.repository.mock.ts index e16bb6ffdc348..ec1f141075b8b 100644 --- a/server/test/repositories/partner.repository.mock.ts +++ b/server/test/repositories/partner.repository.mock.ts @@ -5,7 +5,7 @@ export const newPartnerRepositoryMock = (): Mocked => { return { create: vitest.fn(), remove: vitest.fn(), - getAll: vitest.fn(), + getAll: vitest.fn().mockResolvedValue([]), get: vitest.fn(), update: vitest.fn(), }; diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 94a4486c817ef..6547a543390a0 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -10,6 +10,7 @@ export const newPersonRepositoryMock = (): Mocked => { getAllWithoutFaces: vitest.fn(), getByName: vitest.fn(), + getDistinctNames: vitest.fn(), create: vitest.fn(), update: vitest.fn(), @@ -24,6 +25,7 @@ export const newPersonRepositoryMock = (): Mocked => { reassignFaces: vitest.fn(), createFaces: vitest.fn(), + replaceFaces: vitest.fn(), getFaces: vitest.fn(), reassignFace: vitest.fn(), getFaceById: vitest.fn(), diff --git a/web/package-lock.json b/web/package-lock.json index 97b1a303a573e..4ddc6d9baa966 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", "@immich/sdk": "file:../open-api/typescript-sdk", + "@mapbox/mapbox-gl-rtl-text": "^0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", @@ -27,7 +28,7 @@ "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.0", "svelte-local-storage-store": "^0.6.4", - "svelte-maplibre": "^0.9.0", + "svelte-maplibre": "^0.9.13", "thumbhash": "^0.1.1" }, "devDependencies": { @@ -1446,6 +1447,13 @@ "geojson-rewind": "geojson-rewind" } }, + "node_modules/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC", + "peer": true + }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", @@ -1454,6 +1462,25 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/mapbox-gl-rtl-text": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.2.3.tgz", + "integrity": "sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw==", + "license": "BSD-2-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peer": true, + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", @@ -3307,6 +3334,13 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT", + "peer": true + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4563,6 +4597,13 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC", + "peer": true + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -5388,6 +5429,78 @@ "node": ">=10" } }, + "node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/mapbox-gl/node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "peer": true, + "dependencies": { + "kdbush": "^3.0.0" + } + }, "node_modules/maplibre-gl": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.0.1.tgz", diff --git a/web/package.json b/web/package.json index 1996f4eaefe1c..1ba350022d98c 100644 --- a/web/package.json +++ b/web/package.json @@ -67,6 +67,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", "@immich/sdk": "file:../open-api/typescript-sdk", + "@mapbox/mapbox-gl-rtl-text": "^0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", @@ -83,7 +84,7 @@ "svelte-gestures": "^5.0.4", "svelte-i18n": "^4.0.0", "svelte-local-storage-store": "^0.6.4", - "svelte-maplibre": "^0.9.0", + "svelte-maplibre": "^0.9.13", "thumbhash": "^0.1.1" }, "volta": { diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts index 222f76be6322d..700ae0c3733b4 100644 --- a/web/src/lib/actions/intersection-observer.ts +++ b/web/src/lib/actions/intersection-observer.ts @@ -10,10 +10,10 @@ type TrackedProperties = { left?: string; }; type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown; -type OnSeperateCallback = (element: HTMLElement) => unknown; +type OnSeparateCallback = (element: HTMLElement) => unknown; type IntersectionObserverActionProperties = { key?: string; - onSeparate?: OnSeperateCallback; + onSeparate?: OnSeparateCallback; onIntersect?: OnIntersectCallback; root?: Element | Document | null; @@ -22,8 +22,6 @@ type IntersectionObserverActionProperties = { right?: string; bottom?: string; left?: string; - - disabled?: boolean; }; type TaskKey = HTMLElement | string; @@ -92,11 +90,7 @@ function _intersectionObserver( element: HTMLElement, properties: IntersectionObserverActionProperties, ) { - if (properties.disabled) { - properties.onIntersect?.(element); - } else { - configure(key, element, properties); - } + configure(key, element, properties); return { update(properties: IntersectionObserverActionProperties) { const config = elementToConfig.get(key); @@ -106,20 +100,14 @@ function _intersectionObserver( if (isEquivalent(config, properties)) { return; } + configure(key, element, properties); }, destroy: () => { - if (properties.disabled) { - properties.onSeparate?.(element); - } else { - const config = elementToConfig.get(key); - const { observer, onSeparate } = config || {}; - observer?.unobserve(element); - elementToConfig.delete(key); - if (onSeparate) { - onSeparate?.(element); - } - } + const config = elementToConfig.get(key); + const { observer } = config || {}; + observer?.unobserve(element); + elementToConfig.delete(key); }, }; } @@ -148,5 +136,5 @@ export function intersectionObserver( }, }; } - return _intersectionObserver(element, element, properties); + return _intersectionObserver(properties.key || element, element, properties); } diff --git a/web/src/lib/actions/shortcut.ts b/web/src/lib/actions/shortcut.ts index d28c294a8996b..df155ea821ad0 100644 --- a/web/src/lib/actions/shortcut.ts +++ b/web/src/lib/actions/shortcut.ts @@ -15,7 +15,7 @@ export type ShortcutOptions = { preventDefault?: boolean; }; -export const shouldIgnoreShortcut = (event: KeyboardEvent): boolean => { +export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => { if (event.target === event.currentTarget) { return false; } @@ -52,7 +52,7 @@ export const shortcuts = ( options: ShortcutOptions[], ): ActionReturn[]> => { function onKeydown(event: KeyboardEvent) { - const ignoreShortcut = shouldIgnoreShortcut(event); + const ignoreShortcut = shouldIgnoreEvent(event); for (const { shortcut, onShortcut, ignoreInputFields = true, preventDefault = true } of options) { if (ignoreInputFields && ignoreShortcut) { continue; diff --git a/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte b/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte new file mode 100644 index 0000000000000..c28050e0229cb --- /dev/null +++ b/web/src/lib/components/admin-page/settings/metadata-settings/metadata-settings.svelte @@ -0,0 +1,38 @@ + + +
+
+
+
+ +
+ + onReset({ ...options, configKeys: ['metadata'] })} + onSave={() => onSave({ metadata: config.metadata })} + showResetToDefault={!isEqual(savedConfig.metadata.faces.import, defaultConfig.metadata.faces.import)} + {disabled} + /> + +
+
diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index 4355aca94d58b..5e3499bd10b3f 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -1,6 +1,6 @@ + export const headerId = 'user-page-header'; + +
- - {#if title} -
{title}
- {/if} -
+ {#if title} +
{title}
+ {/if} {#if description}

{description}

{/if} diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 5cbc2e7dcaaca..240b6c2ba2162 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -35,7 +35,7 @@ $: dateGroups = bucket.dateGroups; const { - DATEGROUP: { INTERSECTION_DISABLED, INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM }, + DATEGROUP: { INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM }, } = TUNABLES; /* TODO figure out a way to calculate this*/ const TITLE_HEIGHT = 51; @@ -116,7 +116,6 @@ top: INTERSECTION_ROOT_TOP, bottom: INTERSECTION_ROOT_BOTTOM, root: assetGridElement, - disabled: INTERSECTION_DISABLED, }} data-display={display} data-date-group={dateGroup.date} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 94e7803b97ff8..f59911dbaf6c4 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -804,12 +804,13 @@ class:invisible={showSkeleton} style:height={$assetStore.timelineHeight + 'px'} > - {#each $assetStore.buckets as bucket (bucket.bucketDate)} + {#each $assetStore.buckets as bucket (bucket.viewId)} {@const isPremeasure = preMeasure.includes(bucket)} {@const display = bucket.intersecting || bucket === $assetStore.pendingScrollBucket || isPremeasure}
handleIntersect(bucket), onSeparate: () => handleSeparate(bucket), top: BUCKET_INTERSECTION_ROOT_TOP, diff --git a/web/src/lib/components/shared-components/album-selection-modal.svelte b/web/src/lib/components/shared-components/album-selection-modal.svelte index e7a4ef985c577..0690374c01702 100644 --- a/web/src/lib/components/shared-components/album-selection-modal.svelte +++ b/web/src/lib/components/shared-components/album-selection-modal.svelte @@ -8,6 +8,8 @@ import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import { initInput } from '$lib/actions/focus'; import { t } from 'svelte-i18n'; + import { sortAlbums } from '$lib/utils/album-utils'; + import { albumViewSettings } from '$lib/stores/preferences.store'; let albums: AlbumResponseDto[] = []; let recentAlbums: AlbumResponseDto[] = []; @@ -29,14 +31,14 @@ loading = false; }); - $: { - filteredAlbums = - search.length > 0 && albums.length > 0 - ? albums.filter((album) => { - return normalizeSearchString(album.albumName).includes(normalizeSearchString(search)); - }) - : albums; - } + $: filteredAlbums = sortAlbums( + search.length > 0 && albums.length > 0 + ? albums.filter((album) => { + return normalizeSearchString(album.albumName).includes(normalizeSearchString(search)); + }) + : albums, + { sortBy: $albumViewSettings.sortBy, orderBy: $albumViewSettings.sortOrder }, + ); const handleSelect = (album: AlbumResponseDto) => { dispatch('album', album); diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index 962a97ecf7e0c..306ba46b4afd1 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -10,48 +10,111 @@ type ZoneOption = { /** - * Timezone name + * Timezone name with offset * * e.g. Asia/Jerusalem (+03:00) */ label: string; /** - * Timezone offset + * Timezone name * - * e.g. UTC+01:00 + * e.g. Asia/Jerusalem */ value: string; + + /** + * Timezone offset in minutes + * + * e.g. 300 + */ + offsetMinutes: number; + + /** + * True iff the date is valid + * + * Dates may be invalid for various reasons, for example setting a day that does not exist (30 Feb 2024). + * Due to daylight saving time, 2:30am is invalid for Europe/Berlin on Mar 31 2024.The two following local times + * are one second apart: + * + * - Mar 31 2024 01:59:59 (GMT+0100, unix timestamp 1725058799) + * - Mar 31 2024 03:00:00 (GMT+0200, unix timestamp 1711846800) + * + * Mar 31 2024 02:30:00 does not exist in Europe/Berlin, this is an invalid date/time/time zone combination. + */ + valid: boolean; }; - const timezones: ZoneOption[] = Intl.supportedValuesOf('timeZone') - .map((zone) => DateTime.local({ zone })) - .sort((zoneA, zoneB) => { - let numericallyCorrect = zoneA.offset - zoneB.offset; - if (numericallyCorrect != 0) { - return numericallyCorrect; - } - return zoneA.zoneName.localeCompare(zoneB.zoneName, undefined, { sensitivity: 'base' }); - }) - .map((zone) => { - const offset = zone.toFormat('ZZ'); - return { - label: `${zone.zoneName} (${offset})`, - value: 'UTC' + offset, - }; - }); + const knownTimezones = Intl.supportedValuesOf('timeZone'); - const initialOption = timezones.find((item) => item.value === 'UTC' + initialDate.toFormat('ZZ')); + let timezones: ZoneOption[]; + $: timezones = knownTimezones + .map((zone) => zoneOptionForDate(zone, selectedDate)) + .filter((zone) => zone.valid) + .sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB)); - let selectedOption = initialOption && { - label: initialOption?.label || '', - value: initialOption?.value || '', - }; + const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + // the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list + let selectedOption: ZoneOption | undefined; + $: selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, selectedOption); let selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm"); - // Keep local time if not it's really confusing - $: date = DateTime.fromISO(selectedDate).setZone(selectedOption?.value, { keepLocalTime: true }); + // when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it) + $: date = DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }); + + function zoneOptionForDate(zone: string, date: string) { + const dateAtZone: DateTime = DateTime.fromISO(date, { zone }); + const zoneOffsetAtDate = dateAtZone.toFormat('ZZ'); + const valid = dateAtZone.isValid && date.toString() === dateAtZone.toFormat("yyyy-MM-dd'T'HH:mm"); + return { + value: zone, + offsetMinutes: dateAtZone.offset, + label: zone + ' (' + zoneOffsetAtDate + ')' + (valid ? '' : ' [invalid date!]'), + valid, + }; + } + + /* + * Find the time zone to select for a given time, date, and offset (e.g. +02:00). + * + * This is done so that the list shown to the user includes more helpful names like "Europe/Berlin (+02:00)" + * instead of just the raw offset or something like "UTC+02:00". + * + * The provided information (initialDate, from some asset) includes the offset (e.g. +02:00), but no information about + * the actual time zone. As several countries/regions may share the same offset, for example Berlin (Germany) and + * Blantyre (Malawi) sharing +02:00 in summer, we have to guess and somehow pick a suitable time zone. + * + * If the time zone configured by the user (in the browser) provides the same offset for the given date (accounting + * for daylight saving time and other weirdness), we prefer to show it. This way, for German users, we might be able + * to show "Europe/Berlin" instead of the lexicographically first entry "Africa/Blantyre". + */ + function getPreferredTimeZone( + date: DateTime, + userTimeZone: string, + timezones: ZoneOption[], + selectedOption?: ZoneOption, + ) { + const offset = date.offset; + const previousSelection = timezones.find((item) => item.value === selectedOption?.value); + const sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone); + const firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset); + const utcFallback = { + label: 'UTC (+00:00)', + offsetMinutes: 0, + value: 'UTC', + valid: true, + }; + return previousSelection ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback; + } + + function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) { + let offsetDifference = zoneA.offsetMinutes - zoneB.offsetMinutes; + if (offsetDifference != 0) { + return offsetDifference; + } + return zoneA.value.localeCompare(zoneB.value, undefined, { sensitivity: 'base' }); + } const dispatch = createEventDispatcher<{ cancel: void; diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte index e84d2d66f08f2..6f92d81886485 100644 --- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte +++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte @@ -1,11 +1,12 @@ + @@ -14,7 +14,6 @@ - diff --git a/web/src/lib/components/shared-components/tree/tree.svelte b/web/src/lib/components/shared-components/tree/tree.svelte index 5bc7a715ac793..5c4b367a5482f 100644 --- a/web/src/lib/components/shared-components/tree/tree.svelte +++ b/web/src/lib/components/shared-components/tree/tree.svelte @@ -13,7 +13,7 @@ export let getColor: (path: string) => string | undefined; $: path = normalizeTreePath(`${parent}/${value}`); - $: isActive = active.startsWith(path); + $: isActive = active === path || active.startsWith(`${path}/`); $: isOpen = isActive; $: isTarget = active === path; $: color = getColor(path); diff --git a/web/src/lib/components/shared-components/upload-asset-preview.svelte b/web/src/lib/components/shared-components/upload-asset-preview.svelte index d3f12555c76ce..a7ba3430a02ed 100644 --- a/web/src/lib/components/shared-components/upload-asset-preview.svelte +++ b/web/src/lib/components/shared-components/upload-asset-preview.svelte @@ -1,21 +1,32 @@ @@ -23,86 +34,69 @@
-
-
-
- -
-
-

- .{getFilenameExtension(uploadAsset.file.name)} -

-
+
+
+ {#if uploadAsset.state === UploadState.PENDING} + + {:else if uploadAsset.state === UploadState.STARTED} + + {:else if uploadAsset.state === UploadState.ERROR} + + {:else if uploadAsset.state === UploadState.DUPLICATED} + + {:else if uploadAsset.state === UploadState.DONE} + + {/if}
-
- + + {uploadAsset.file.name} -
- {#if uploadAsset.state === UploadState.STARTED} -
-

- {#if uploadAsset.message} - {uploadAsset.message} - {:else} - {uploadAsset.progress}% - {getByteUnitString(uploadAsset.speed || 0, $locale)}/s - {uploadAsset.eta}s - {/if} -

- {:else if uploadAsset.state === UploadState.PENDING} -
-

{$t('pending')}

- {:else if uploadAsset.state === UploadState.ERROR} -
-

{$t('error')}

- {:else if uploadAsset.state === UploadState.DUPLICATED} -
-

- {$t('asset_skipped')} - {#if uploadAsset.message} - ({uploadAsset.message}) - {/if} -

- {:else if uploadAsset.state === UploadState.DONE} -
-

- {$t('asset_uploaded')} - {#if uploadAsset.message} - ({uploadAsset.message}) - {/if} -

- {/if} -
-
- {#if uploadAsset.state === UploadState.ERROR} -
- - +
+ {:else if uploadAsset.state === UploadState.ERROR} +
+ +
{/if}
+ {#if uploadAsset.state === UploadState.STARTED} +
+
+

+ {#if uploadAsset.message} + {uploadAsset.message} + {:else} + {uploadAsset.progress}% - {getByteUnitString(uploadAsset.speed || 0, $locale)}/s - {uploadAsset.eta}s + {/if} +

+
+ {/if} + {#if uploadAsset.state === UploadState.ERROR}
-

+

{uploadAsset.error}

diff --git a/web/src/lib/components/shared-components/upload-panel.svelte b/web/src/lib/components/shared-components/upload-panel.svelte index ee213d796925f..d5360532862b1 100644 --- a/web/src/lib/components/shared-components/upload-panel.svelte +++ b/web/src/lib/components/shared-components/upload-panel.svelte @@ -15,8 +15,7 @@ let showOptions = false; let concurrency = uploadExecutionQueue.concurrency; - let { isUploading, hasError, remainingUploads, errorCounter, duplicateCounter, successCounter, totalUploadCounter } = - uploadAssetsStore; + let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore; const autoHide = () => { if (!$isUploading && showDetail) { @@ -33,29 +32,29 @@ } -{#if $hasError || $isUploading} +{#if $isUploading}
{ - if ($errorCounter > 0) { + if ($stats.errors > 0) { notificationController.show({ - message: $t('upload_errors', { values: { count: $errorCounter } }), + message: $t('upload_errors', { values: { count: $stats.errors } }), type: NotificationType.Warning, }); - } else if ($successCounter > 0) { + } else if ($stats.success > 0) { notificationController.show({ message: $t('upload_success'), type: NotificationType.Info, }); } - if ($duplicateCounter > 0) { + if ($stats.duplicates > 0) { notificationController.show({ - message: $t('upload_skipped_duplicates', { values: { count: $duplicateCounter } }), + message: $t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }), type: NotificationType.Warning, }); } - uploadAssetsStore.resetStore(); + uploadAssetsStore.reset(); }} class="fixed bottom-6 right-6 z-[10000]" > @@ -70,20 +69,20 @@ {$t('upload_progress', { values: { remaining: $remainingUploads, - processed: $successCounter + $errorCounter, - total: $totalUploadCounter, + processed: $stats.total - $remainingUploads, + total: $stats.total, }, })}

{$t('upload_status_uploaded')} - {$successCounter.toLocaleString($locale)} + {$stats.success.toLocaleString($locale)} - {$t('upload_status_errors')} - {$errorCounter.toLocaleString($locale)} + {$stats.errors.toLocaleString($locale)} - {$t('upload_status_duplicates')} - {$duplicateCounter.toLocaleString($locale)} + {$stats.duplicates.toLocaleString($locale)}

@@ -103,7 +102,7 @@ on:click={() => (showDetail = false)} />
- {#if $hasError} + {#if $isDismissible}
{#if showOptions} -
+
@@ -133,7 +132,7 @@ />
{/if} -
+
{#each $uploadAssetsStore as uploadAsset (uploadAsset.id)} {/each} @@ -149,14 +148,14 @@ > {$remainingUploads.toLocaleString($locale)} - {#if $hasError} + {#if $stats.errors > 0} {/if}