From ee3b2a0cf56e77de1379e83d1062af487bcf86f8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 12:16:41 -0500 Subject: [PATCH 01/47] chore(deps): update server (#7652) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 220 ++++++++++++++++++++++++--------------- 1 file changed, 134 insertions(+), 86 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 20325a7375594..122c553cea6aa 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -2800,12 +2800,12 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "node_modules/@testcontainers/postgresql": { - "version": "10.7.1", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.7.1.tgz", - "integrity": "sha512-2tlrD7vRNdi+nynFCNaGbjTTE7aUNk9Pipcu7PIkPGc8v1AxJdc1BnmI07I1yfW18kOqRi7fo7x4gOlqzAOXJQ==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.7.2.tgz", + "integrity": "sha512-BB1C4SUDhWYY4X9afHtVYXDcYpvtRz7M4CtNEI3gA5A8Zm09msg5NUmhDSJiNAO8/xILv5LHcMJ0NZPBjgM9Jg==", "dev": true, "dependencies": { - "testcontainers": "^10.7.1" + "testcontainers": "^10.7.2" } }, "node_modules/@tsconfig/node10": { @@ -2986,9 +2986,9 @@ } }, "node_modules/@types/dockerode": { - "version": "3.3.23", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.23.tgz", - "integrity": "sha512-Lz5J+NFgZS4cEVhquwjIGH4oQwlVn2h7LXD3boitujBnzOE5o7s9H8hchEjoDK2SlRsJTogdKnQeiJgPPKLIEw==", + "version": "3.3.24", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.24.tgz", + "integrity": "sha512-679y69OYusf7Fr2HtdjXPUF6hnHxSA9K4EsuagsMuPno/XpJHjXxCOy2I5YL8POnWbzjsQAi0pyKIYM9HSpQog==", "dev": true, "dependencies": { "@types/docker-modem": "*", @@ -3179,9 +3179,9 @@ } }, "node_modules/@types/node": { - "version": "20.11.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.22.tgz", - "integrity": "sha512-/G+IxWxma6V3E+pqK1tSl2Fo1kl41pK1yeCyDsgkF9WlVAme4j5ISYM2zR11bgLFJGLN5sVK40T4RJNuiZbEjA==", + "version": "20.11.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", + "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", "dependencies": { "undici-types": "~5.26.4" } @@ -4252,6 +4252,43 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/bare-events": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.2.1.tgz", + "integrity": "sha512-9GYPpsPFvrWBkelIhOhTWtkeZxVxZOdb3VnFTCzlOo3OjvmTvzLoZFUT8kNFACx0vJej6QPney1Cf9BvzCNE/A==", + "dev": true, + "optional": true + }, + "node_modules/bare-fs": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.2.1.tgz", + "integrity": "sha512-+CjmZANQDFZWy4PGbVdmALIwmt33aJg8qTkVjClU6X4WmZkTPBDxRHiBn7fpqEWEfF3AC2io++erpViAIQbSjg==", + "dev": true, + "optional": true, + "dependencies": { + "bare-events": "^2.0.0", + "bare-os": "^2.0.0", + "bare-path": "^2.0.0", + "streamx": "^2.13.0" + } + }, + "node_modules/bare-os": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.2.0.tgz", + "integrity": "sha512-hD0rOPfYWOMpVirTACt4/nK8mC55La12K5fY1ij8HAdfQakD62M+H4o4tpfKzVGLgRDTuk3vjA4GqGXXCeFbag==", + "dev": true, + "optional": true + }, + "node_modules/bare-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.0.tgz", + "integrity": "sha512-DIIg7ts8bdRKwJRJrUMy/PICEaQZaPGZ26lsSx9MJSwIhSrcdHn7/C8W+XmnG/rKi6BaRcz+JO00CjZteybDtw==", + "dev": true, + "optional": true, + "dependencies": { + "bare-os": "^2.1.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5417,9 +5454,9 @@ "dev": true }, "node_modules/docker-compose": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.2.tgz", - "integrity": "sha512-2/WLvA7UZ6A2LDLQrYW0idKipmNBWhtfvrn2yzjC5PnHDzuFVj1zAZN6MJxVMKP0zZH8uzAK6OwVZYHGuyCmTw==", + "version": "0.24.6", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.6.tgz", + "integrity": "sha512-VidlUyNzXMaVsuM79sjSvwC4nfojkP2VneL+Zfs538M2XFnffZDhx6veqnz/evCNIYGyz5O+1fgL6+g0NLWTBA==", "dev": true, "dependencies": { "yaml": "^2.2.2" @@ -11145,14 +11182,17 @@ } }, "node_modules/tar-fs": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", - "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.5.tgz", + "integrity": "sha512-JOgGAmZyMgbqpLwct7ZV8VzkEB6pxXFBVErLtb+XCOqzc6w1xiWKI9GVd6bwk68EX7eJ4DWmfXVmq8K2ziZTGg==", "dev": true, "dependencies": { - "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^2.1.1", + "bare-path": "^2.1.0" } }, "node_modules/tar-stream": { @@ -11311,25 +11351,25 @@ } }, "node_modules/testcontainers": { - "version": "10.7.1", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.7.1.tgz", - "integrity": "sha512-JarbT6o7fv1siUts4tGv3wBoYrWKxjla69+5QWG9+bcd4l+ECJ3ikfGD/hpXRmRBsnjzeWyV+tL9oWOBRzk+lA==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.7.2.tgz", + "integrity": "sha512-7d+LVd/4YKp/cutiVMLL5cnj/8p8oYELAVRRyNUM4FyUDz1OLQuwW868nDl7Vd1ZAQxzGeCR+F86FlR9Yw9fMA==", "dev": true, "dependencies": { "@balena/dockerignore": "^1.0.2", - "@types/dockerode": "^3.3.21", + "@types/dockerode": "^3.3.24", "archiver": "^5.3.2", - "async-lock": "^1.4.0", + "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.3.4", - "docker-compose": "^0.24.2", + "docker-compose": "^0.24.6", "dockerode": "^3.3.5", "get-port": "^5.1.1", "node-fetch": "^2.7.0", "proper-lockfile": "^4.1.2", "properties-reader": "^2.3.0", "ssh-remote-port-forward": "^1.0.4", - "tar-fs": "^3.0.4", + "tar-fs": "^3.0.5", "tmp": "^0.2.1" } }, @@ -11435,21 +11475,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/testcontainers/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/testcontainers/node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -11482,15 +11507,12 @@ } }, "node_modules/testcontainers/node_modules/tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", "dev": true, - "dependencies": { - "rimraf": "^3.0.0" - }, "engines": { - "node": ">=8.17.0" + "node": ">=14.14" } }, "node_modules/testcontainers/node_modules/zip-stream": { @@ -14360,12 +14382,12 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "@testcontainers/postgresql": { - "version": "10.7.1", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.7.1.tgz", - "integrity": "sha512-2tlrD7vRNdi+nynFCNaGbjTTE7aUNk9Pipcu7PIkPGc8v1AxJdc1BnmI07I1yfW18kOqRi7fo7x4gOlqzAOXJQ==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.7.2.tgz", + "integrity": "sha512-BB1C4SUDhWYY4X9afHtVYXDcYpvtRz7M4CtNEI3gA5A8Zm09msg5NUmhDSJiNAO8/xILv5LHcMJ0NZPBjgM9Jg==", "dev": true, "requires": { - "testcontainers": "^10.7.1" + "testcontainers": "^10.7.2" } }, "@tsconfig/node10": { @@ -14537,9 +14559,9 @@ } }, "@types/dockerode": { - "version": "3.3.23", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.23.tgz", - "integrity": "sha512-Lz5J+NFgZS4cEVhquwjIGH4oQwlVn2h7LXD3boitujBnzOE5o7s9H8hchEjoDK2SlRsJTogdKnQeiJgPPKLIEw==", + "version": "3.3.24", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.24.tgz", + "integrity": "sha512-679y69OYusf7Fr2HtdjXPUF6hnHxSA9K4EsuagsMuPno/XpJHjXxCOy2I5YL8POnWbzjsQAi0pyKIYM9HSpQog==", "dev": true, "requires": { "@types/docker-modem": "*", @@ -14730,9 +14752,9 @@ } }, "@types/node": { - "version": "20.11.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.22.tgz", - "integrity": "sha512-/G+IxWxma6V3E+pqK1tSl2Fo1kl41pK1yeCyDsgkF9WlVAme4j5ISYM2zR11bgLFJGLN5sVK40T4RJNuiZbEjA==", + "version": "20.11.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz", + "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==", "requires": { "undici-types": "~5.26.4" } @@ -15590,6 +15612,43 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "bare-events": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.2.1.tgz", + "integrity": "sha512-9GYPpsPFvrWBkelIhOhTWtkeZxVxZOdb3VnFTCzlOo3OjvmTvzLoZFUT8kNFACx0vJej6QPney1Cf9BvzCNE/A==", + "dev": true, + "optional": true + }, + "bare-fs": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.2.1.tgz", + "integrity": "sha512-+CjmZANQDFZWy4PGbVdmALIwmt33aJg8qTkVjClU6X4WmZkTPBDxRHiBn7fpqEWEfF3AC2io++erpViAIQbSjg==", + "dev": true, + "optional": true, + "requires": { + "bare-events": "^2.0.0", + "bare-os": "^2.0.0", + "bare-path": "^2.0.0", + "streamx": "^2.13.0" + } + }, + "bare-os": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.2.0.tgz", + "integrity": "sha512-hD0rOPfYWOMpVirTACt4/nK8mC55La12K5fY1ij8HAdfQakD62M+H4o4tpfKzVGLgRDTuk3vjA4GqGXXCeFbag==", + "dev": true, + "optional": true + }, + "bare-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.0.tgz", + "integrity": "sha512-DIIg7ts8bdRKwJRJrUMy/PICEaQZaPGZ26lsSx9MJSwIhSrcdHn7/C8W+XmnG/rKi6BaRcz+JO00CjZteybDtw==", + "dev": true, + "optional": true, + "requires": { + "bare-os": "^2.1.0" + } + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -16420,9 +16479,9 @@ "dev": true }, "docker-compose": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.2.tgz", - "integrity": "sha512-2/WLvA7UZ6A2LDLQrYW0idKipmNBWhtfvrn2yzjC5PnHDzuFVj1zAZN6MJxVMKP0zZH8uzAK6OwVZYHGuyCmTw==", + "version": "0.24.6", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.6.tgz", + "integrity": "sha512-VidlUyNzXMaVsuM79sjSvwC4nfojkP2VneL+Zfs538M2XFnffZDhx6veqnz/evCNIYGyz5O+1fgL6+g0NLWTBA==", "dev": true, "requires": { "yaml": "^2.2.2" @@ -20766,12 +20825,13 @@ } }, "tar-fs": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", - "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.5.tgz", + "integrity": "sha512-JOgGAmZyMgbqpLwct7ZV8VzkEB6pxXFBVErLtb+XCOqzc6w1xiWKI9GVd6bwk68EX7eJ4DWmfXVmq8K2ziZTGg==", "dev": true, "requires": { - "mkdirp-classic": "^0.5.2", + "bare-fs": "^2.1.1", + "bare-path": "^2.1.0", "pump": "^3.0.0", "tar-stream": "^3.1.5" } @@ -20869,25 +20929,25 @@ } }, "testcontainers": { - "version": "10.7.1", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.7.1.tgz", - "integrity": "sha512-JarbT6o7fv1siUts4tGv3wBoYrWKxjla69+5QWG9+bcd4l+ECJ3ikfGD/hpXRmRBsnjzeWyV+tL9oWOBRzk+lA==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.7.2.tgz", + "integrity": "sha512-7d+LVd/4YKp/cutiVMLL5cnj/8p8oYELAVRRyNUM4FyUDz1OLQuwW868nDl7Vd1ZAQxzGeCR+F86FlR9Yw9fMA==", "dev": true, "requires": { "@balena/dockerignore": "^1.0.2", - "@types/dockerode": "^3.3.21", + "@types/dockerode": "^3.3.24", "archiver": "^5.3.2", - "async-lock": "^1.4.0", + "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.3.4", - "docker-compose": "^0.24.2", + "docker-compose": "^0.24.6", "dockerode": "^3.3.5", "get-port": "^5.1.1", "node-fetch": "^2.7.0", "proper-lockfile": "^4.1.2", "properties-reader": "^2.3.0", "ssh-remote-port-forward": "^1.0.4", - "tar-fs": "^3.0.4", + "tar-fs": "^3.0.5", "tmp": "^0.2.1" }, "dependencies": { @@ -20977,15 +21037,6 @@ "path-is-absolute": "^1.0.0" } }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -21015,13 +21066,10 @@ } }, "tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", - "dev": true, - "requires": { - "rimraf": "^3.0.0" - } + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true }, "zip-stream": { "version": "4.1.1", From 8df63b7c949168f2a23f03629e15db6976d0b3b0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 12:26:54 -0500 Subject: [PATCH 02/47] fix(deps): update dependency archiver to v7 (#7622) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 477 +++++++++++++++++++++++++++++---------- server/package.json | 2 +- 2 files changed, 357 insertions(+), 122 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 122c553cea6aa..9989c2747f131 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -23,7 +23,7 @@ "@nestjs/websockets": "^10.2.2", "@socket.io/postgres-adapter": "^0.3.1", "@types/picomatch": "^2.3.3", - "archiver": "^6.0.0", + "archiver": "^7.0.0", "async-lock": "^1.4.0", "bcrypt": "^5.1.1", "bullmq": "^4.8.0", @@ -3780,6 +3780,17 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3978,28 +3989,28 @@ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" }, "node_modules/archiver": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-6.0.2.tgz", - "integrity": "sha512-UQ/2nW7NMl1G+1UnrLypQw1VdT9XZg/ECcKPq7l+STzStrSivFIXIp34D8M5zeNGW5NoOupdYCHv6VySCPNNlw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.0.tgz", + "integrity": "sha512-R9HM9egs8FfktSqUqyjlKmvF4U+CWNqm/2tlROV+lOFg79MLdT67ae1l3hU47pGy8twSXxHoiefMCh43w0BriQ==", "dependencies": { - "archiver-utils": "^4.0.1", + "archiver-utils": "^5.0.0", "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", - "zip-stream": "^5.0.1" + "zip-stream": "^6.0.0" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 14" } }, "node_modules/archiver-utils": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-4.0.1.tgz", - "integrity": "sha512-Q4Q99idbvzmgCTEAAhi32BkOyq8iVI5EwdO0PmBDSGIzzjYNdcFn7Q7k3OzbLy4kLUPXfJtG6fO2RjftXbobBg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.1.tgz", + "integrity": "sha512-MMAoLdMvT/nckofX1tCLrf7uJce4jTNkiT6smA2u57AOImc1nce7mR3EDujxL5yv6/MnILuQH4sAsPtDS8kTvg==", "dependencies": { - "glob": "^8.0.0", + "glob": "^10.0.0", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash": "^4.17.15", @@ -4007,44 +4018,53 @@ "readable-stream": "^3.6.0" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 14" } }, - "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "dependencies": { - "balanced-match": "^1.0.0" + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, - "node_modules/archiver-utils/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/are-we-there-yet": { @@ -4505,6 +4525,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "engines": { "node": "*" } @@ -5017,17 +5038,55 @@ "dev": true }, "node_modules/compress-commons": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.1.tgz", - "integrity": "sha512-MPh//1cERdLtqwO3pOFLeXtpuai0Y2WCd5AhtKxznqM7WtaMYaOEMSgn45d9D10sIHSfIKE603HlOp8OPGrvag==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.1.tgz", + "integrity": "sha512-l7occIJn8YwlCEbWUCrG6gPms9qnJTCZSaznCa5HaV+yJMH4kM8BDc7q9NyoQuoiB2O6jKgTcTeY462qw6MyHw==", "dependencies": { "crc-32": "^1.2.0", - "crc32-stream": "^5.0.0", + "crc32-stream": "^6.0.0", "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" + "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/concat-map": { @@ -5197,15 +5256,53 @@ } }, "node_modules/crc32-stream": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-5.0.0.tgz", - "integrity": "sha512-B0EPa1UK+qnpBZpG+7FgPCu0J2ETLpXq09o9BkLkEAhdB6Z61Qo4pJ3JYu0c+Qi+/SAL7QThqnzS06pmSSyZaw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", "dependencies": { "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" + "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/create-jest": { @@ -5996,11 +6093,18 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "engines": { "node": ">=0.8.x" } @@ -9706,6 +9810,14 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -12606,16 +12718,54 @@ } }, "node_modules/zip-stream": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.1.tgz", - "integrity": "sha512-UfZ0oa0C8LI58wJ+moL46BDIMgCQbnsb+2PoiJYtonhBsMh2bq1eRBVkvjfVsqbEHd9/EgKPUuL9saSSsec8OA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.0.tgz", + "integrity": "sha512-X0WFquRRDtL9HR9hc1OrabOP/VKJEX7gAr2geayt3b7dLgXgSXI6ucC4CphLQP/aQt2GyHIYgmXxtC+dVdghAQ==", "dependencies": { - "archiver-utils": "^4.0.1", - "compress-commons": "^5.0.1", - "readable-stream": "^3.6.0" + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.0", + "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 12.0.0" + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } } }, @@ -15247,6 +15397,14 @@ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -15382,60 +15540,58 @@ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" }, "archiver": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-6.0.2.tgz", - "integrity": "sha512-UQ/2nW7NMl1G+1UnrLypQw1VdT9XZg/ECcKPq7l+STzStrSivFIXIp34D8M5zeNGW5NoOupdYCHv6VySCPNNlw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.0.tgz", + "integrity": "sha512-R9HM9egs8FfktSqUqyjlKmvF4U+CWNqm/2tlROV+lOFg79MLdT67ae1l3hU47pGy8twSXxHoiefMCh43w0BriQ==", "requires": { - "archiver-utils": "^4.0.1", + "archiver-utils": "^5.0.0", "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", - "zip-stream": "^5.0.1" + "zip-stream": "^6.0.0" + }, + "dependencies": { + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==" + }, + "readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "requires": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + } + } } }, "archiver-utils": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-4.0.1.tgz", - "integrity": "sha512-Q4Q99idbvzmgCTEAAhi32BkOyq8iVI5EwdO0PmBDSGIzzjYNdcFn7Q7k3OzbLy4kLUPXfJtG6fO2RjftXbobBg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.1.tgz", + "integrity": "sha512-MMAoLdMvT/nckofX1tCLrf7uJce4jTNkiT6smA2u57AOImc1nce7mR3EDujxL5yv6/MnILuQH4sAsPtDS8kTvg==", "requires": { - "glob": "^8.0.0", + "glob": "^10.0.0", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash": "^4.17.15", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "requires": { - "balanced-match": "^1.0.0" - } - }, - "glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - } - }, - "minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "requires": { - "brace-expansion": "^2.0.1" - } - } } }, "are-we-there-yet": { @@ -15797,7 +15953,8 @@ "buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true }, "buffer-from": { "version": "1.1.2", @@ -16161,14 +16318,37 @@ "dev": true }, "compress-commons": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.1.tgz", - "integrity": "sha512-MPh//1cERdLtqwO3pOFLeXtpuai0Y2WCd5AhtKxznqM7WtaMYaOEMSgn45d9D10sIHSfIKE603HlOp8OPGrvag==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.1.tgz", + "integrity": "sha512-l7occIJn8YwlCEbWUCrG6gPms9qnJTCZSaznCa5HaV+yJMH4kM8BDc7q9NyoQuoiB2O6jKgTcTeY462qw6MyHw==", "requires": { "crc-32": "^1.2.0", - "crc32-stream": "^5.0.0", + "crc32-stream": "^6.0.0", "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" + "readable-stream": "^4.0.0" + }, + "dependencies": { + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "requires": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + } + } } }, "concat-map": { @@ -16292,12 +16472,35 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==" }, "crc32-stream": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-5.0.0.tgz", - "integrity": "sha512-B0EPa1UK+qnpBZpG+7FgPCu0J2ETLpXq09o9BkLkEAhdB6Z61Qo4pJ3JYu0c+Qi+/SAL7QThqnzS06pmSSyZaw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", "requires": { "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" + "readable-stream": "^4.0.0" + }, + "dependencies": { + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "requires": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + } + } } }, "create-jest": { @@ -16868,11 +17071,15 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" }, "execa": { "version": "5.1.1", @@ -19680,6 +19887,11 @@ } } }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -21780,13 +21992,36 @@ "dev": true }, "zip-stream": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.1.tgz", - "integrity": "sha512-UfZ0oa0C8LI58wJ+moL46BDIMgCQbnsb+2PoiJYtonhBsMh2bq1eRBVkvjfVsqbEHd9/EgKPUuL9saSSsec8OA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.0.tgz", + "integrity": "sha512-X0WFquRRDtL9HR9hc1OrabOP/VKJEX7gAr2geayt3b7dLgXgSXI6ucC4CphLQP/aQt2GyHIYgmXxtC+dVdghAQ==", "requires": { - "archiver-utils": "^4.0.1", - "compress-commons": "^5.0.1", - "readable-stream": "^3.6.0" + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.0", + "readable-stream": "^4.0.0" + }, + "dependencies": { + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "requires": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + } + } } } } diff --git a/server/package.json b/server/package.json index d09854dede7d9..3ae073ac9b839 100644 --- a/server/package.json +++ b/server/package.json @@ -48,7 +48,7 @@ "@nestjs/websockets": "^10.2.2", "@socket.io/postgres-adapter": "^0.3.1", "@types/picomatch": "^2.3.3", - "archiver": "^6.0.0", + "archiver": "^7.0.0", "async-lock": "^1.4.0", "bcrypt": "^5.1.1", "bullmq": "^4.8.0", From 972d5a34111488448caf3a73bfda7437fc447b0c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 5 Mar 2024 16:04:43 -0500 Subject: [PATCH 03/47] feat(server): deterministic download order (#7658) --- .../domain/download/download.service.spec.ts | 33 +++++++++++++++++-- .../src/domain/download/download.service.ts | 9 ++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/server/src/domain/download/download.service.spec.ts b/server/src/domain/download/download.service.spec.ts index f59374d706f72..bc0e32b6ba0d1 100644 --- a/server/src/domain/download/download.service.spec.ts +++ b/server/src/domain/download/download.service.spec.ts @@ -90,7 +90,10 @@ describe(DownloadService.name, () => { }; accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.noWebpPath]); + assetMock.getByIds.mockResolvedValue([ + { ...assetStub.noResizePath, id: 'asset-1' }, + { ...assetStub.noWebpPath, id: 'asset-2' }, + ]); storageMock.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ @@ -110,7 +113,33 @@ describe(DownloadService.name, () => { }; accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.noResizePath]); + assetMock.getByIds.mockResolvedValue([ + { ...assetStub.noResizePath, id: 'asset-1' }, + { ...assetStub.noResizePath, id: 'asset-2' }, + ]); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledTimes(2); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg'); + }); + + it('should be deterministic', async () => { + const archiveMock = { + addFile: jest.fn(), + finalize: jest.fn(), + stream: new Readable(), + }; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + assetMock.getByIds.mockResolvedValue([ + { ...assetStub.noResizePath, id: 'asset-2' }, + { ...assetStub.noResizePath, id: 'asset-1' }, + ]); storageMock.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ diff --git a/server/src/domain/download/download.service.ts b/server/src/domain/download/download.service.ts index 03bd6fee60f21..0ead298490086 100644 --- a/server/src/domain/download/download.service.ts +++ b/server/src/domain/download/download.service.ts @@ -81,9 +81,16 @@ export class DownloadService { const zip = this.storageRepository.createZipStream(); const assets = await this.assetRepository.getByIds(dto.assetIds); + const assetMap = new Map(assets.map((asset) => [asset.id, asset])); const paths: Record = {}; - for (const { originalPath, originalFileName } of assets) { + for (const assetId of dto.assetIds) { + const asset = assetMap.get(assetId); + if (!asset) { + continue; + } + + const { originalPath, originalFileName } = asset; const extension = extname(originalPath); let filename = `${originalFileName}${extension}`; const count = paths[filename] || 0; From 5d377e5b0f763d68b0be108b890c2db1c2eed69b Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Tue, 5 Mar 2024 23:23:06 +0100 Subject: [PATCH 04/47] chore(server): eslint await-thenable (#7545) * await-thenable * fix library watchers * moar eslint * fix test * fix typo * try to remove check void return * fix checksVoidReturn * move to domain utils * remove eslint ignores * chore: cleanup types * chore: use logger * fix: e2e --------- Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> Co-authored-by: Jason Rasmussen --- server/.eslintrc.js | 6 ++ .../jobs/specs/library-watcher.e2e-spec.ts | 2 +- server/src/domain/activity/activity.spec.ts | 2 +- server/src/domain/album/album.service.spec.ts | 2 +- .../domain/api-key/api-key.service.spec.ts | 2 +- server/src/domain/asset/asset.service.spec.ts | 2 +- server/src/domain/audit/audit.service.spec.ts | 2 +- server/src/domain/auth/auth.service.spec.ts | 2 +- .../domain/database/database.service.spec.ts | 4 +- .../src/domain/database/database.service.ts | 7 ++- server/src/domain/domain.util.ts | 7 ++- .../domain/download/download.service.spec.ts | 2 +- .../src/domain/download/download.service.ts | 4 +- server/src/domain/job/job.service.spec.ts | 2 +- .../domain/library/library.service.spec.ts | 48 ++++++-------- server/src/domain/library/library.service.ts | 63 +++++++++++-------- server/src/domain/media/media.service.spec.ts | 2 +- .../domain/metadata/metadata.service.spec.ts | 2 +- .../src/domain/metadata/metadata.service.ts | 4 +- .../domain/partner/partner.service.spec.ts | 2 +- .../src/domain/person/person.service.spec.ts | 2 +- .../repositories/communication.repository.ts | 2 +- .../domain/repositories/storage.repository.ts | 2 +- server/src/domain/search/search.service.ts | 2 +- .../domain/server-info/server-info.service.ts | 2 +- .../shared-link/shared-link.service.spec.ts | 2 +- .../smart-info/smart-info.service.spec.ts | 2 +- .../storage-template.service.spec.ts | 2 +- .../domain/storage/storage.service.spec.ts | 2 +- .../system-config.service.spec.ts | 2 +- .../system-config/system-config.service.ts | 4 +- server/src/domain/trash/trash.service.spec.ts | 2 +- server/src/domain/user/user.service.spec.ts | 2 +- .../immich/interceptors/error.interceptor.ts | 2 +- .../interceptors/file-upload.interceptor.ts | 4 +- .../infra/repositories/asset.repository.ts | 2 +- .../infra/repositories/library.repository.ts | 2 +- .../infra/repositories/media.repository.ts | 13 +++- server/src/test-utils/utils.ts | 16 ++--- .../repositories/storage.repository.mock.ts | 9 ++- 40 files changed, 133 insertions(+), 110 deletions(-) diff --git a/server/.eslintrc.js b/server/.eslintrc.js index f1e6564d8745f..3673add3c18d2 100644 --- a/server/.eslintrc.js +++ b/server/.eslintrc.js @@ -25,6 +25,12 @@ module.exports = { 'unicorn/prefer-top-level-await': 'off', 'unicorn/prefer-event-target': 'off', 'unicorn/no-thenable': 'off', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-misused-promises': 'error', + // Note: you must disable the base rule as it can report incorrect errors + 'require-await': 'off', + '@typescript-eslint/require-await': 'error', curly: 2, 'prettier/prettier': 0, }, diff --git a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts index 93f7163531487..d22748e1c696d 100644 --- a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts +++ b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts @@ -208,7 +208,7 @@ describe(`Library watcher (e2e)`, () => { await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true }); }); - it('should use an updated import paths', async () => { + it('should use an updated import path', async () => { await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir4`, { recursive: true }); await api.libraryApi.setImportPaths(server, admin.accessToken, library.id, [ diff --git a/server/src/domain/activity/activity.spec.ts b/server/src/domain/activity/activity.spec.ts index 79c466b92266a..10a4c072578e3 100644 --- a/server/src/domain/activity/activity.spec.ts +++ b/server/src/domain/activity/activity.spec.ts @@ -11,7 +11,7 @@ describe(ActivityService.name, () => { let accessMock: IAccessRepositoryMock; let activityMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { accessMock = newAccessRepositoryMock(); activityMock = newActivityRepositoryMock(); diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index 10b6dde5e5ee0..fa0852d8cd0ad 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -23,7 +23,7 @@ describe(AlbumService.name, () => { let jobMock: jest.Mocked; let userMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { accessMock = newAccessRepositoryMock(); albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); diff --git a/server/src/domain/api-key/api-key.service.spec.ts b/server/src/domain/api-key/api-key.service.spec.ts index f6d650c4127fd..f3b2910845c7e 100644 --- a/server/src/domain/api-key/api-key.service.spec.ts +++ b/server/src/domain/api-key/api-key.service.spec.ts @@ -8,7 +8,7 @@ describe(APIKeyService.name, () => { let keyMock: jest.Mocked; let cryptoMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { cryptoMock = newCryptoRepositoryMock(); keyMock = newKeyRepositoryMock(); sut = new APIKeyService(cryptoMock, keyMock); diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 67721dc85f663..0b8dea717360b 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -169,7 +169,7 @@ describe(AssetService.name, () => { expect(sut).toBeDefined(); }); - beforeEach(async () => { + beforeEach(() => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); communicationMock = newCommunicationRepositoryMock(); diff --git a/server/src/domain/audit/audit.service.spec.ts b/server/src/domain/audit/audit.service.spec.ts index d2f8bb6bcfdb4..861e0edc10ca9 100644 --- a/server/src/domain/audit/audit.service.spec.ts +++ b/server/src/domain/audit/audit.service.spec.ts @@ -31,7 +31,7 @@ describe(AuditService.name, () => { let storageMock: jest.Mocked; let userMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); diff --git a/server/src/domain/auth/auth.service.spec.ts b/server/src/domain/auth/auth.service.spec.ts index 359b28a00cad3..214b6748e35f5 100644 --- a/server/src/domain/auth/auth.service.spec.ts +++ b/server/src/domain/auth/auth.service.spec.ts @@ -74,7 +74,7 @@ describe('AuthService', () => { let callbackMock: jest.Mock; let userinfoMock: jest.Mock; - beforeEach(async () => { + beforeEach(() => { callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' }); userinfoMock = jest.fn().mockResolvedValue({ sub, email }); diff --git a/server/src/domain/database/database.service.spec.ts b/server/src/domain/database/database.service.spec.ts index 703805b06520d..14464c0cd1d0f 100644 --- a/server/src/domain/database/database.service.spec.ts +++ b/server/src/domain/database/database.service.spec.ts @@ -13,7 +13,7 @@ describe(DatabaseService.name, () => { let sut: DatabaseService; let databaseMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { databaseMock = newDatabaseRepositoryMock(); sut = new DatabaseService(databaseMock); @@ -31,7 +31,7 @@ describe(DatabaseService.name, () => { let errorLog: jest.SpyInstance; let warnLog: jest.SpyInstance; - beforeEach(async () => { + beforeEach(() => { fatalLog = jest.spyOn(ImmichLogger.prototype, 'fatal'); errorLog = jest.spyOn(ImmichLogger.prototype, 'error'); warnLog = jest.spyOn(ImmichLogger.prototype, 'warn'); diff --git a/server/src/domain/database/database.service.ts b/server/src/domain/database/database.service.ts index d697d032b319b..946c6dac8209c 100644 --- a/server/src/domain/database/database.service.ts +++ b/server/src/domain/database/database.service.ts @@ -1,6 +1,5 @@ import { ImmichLogger } from '@app/infra/logger'; import { Inject, Injectable } from '@nestjs/common'; -import { QueryFailedError } from 'typeorm'; import { Version, VersionType } from '../domain.constant'; import { DatabaseExtension, @@ -61,7 +60,9 @@ export class DatabaseService { } private async createVectorExtension() { - await this.databaseRepository.createExtension(this.vectorExt).catch(async (error: QueryFailedError) => { + try { + await this.databaseRepository.createExtension(this.vectorExt); + } catch (error) { const otherExt = this.vectorExt === DatabaseExtension.VECTORS ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS; this.logger.fatal(` @@ -78,7 +79,7 @@ export class DatabaseService { In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup. `); throw error; - }); + } } private async updateVectorExtension() { diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts index 5fdfd7ec5ba9e..1dadf03aed956 100644 --- a/server/src/domain/domain.util.ts +++ b/server/src/domain/domain.util.ts @@ -1,3 +1,4 @@ +import { ImmichLogger } from '@app/infra/logger'; import { applyDecorators } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; @@ -157,7 +158,7 @@ export type Paginated = Promise>; export async function* usePagination( pageSize: number, - getNextPage: (pagination: PaginationOptions) => Paginated, + getNextPage: (pagination: PaginationOptions) => PaginationResult | Paginated, ) { let hasNextPage = true; @@ -252,3 +253,7 @@ export const setIsSuperset = (set: Set, subset: Set): boolean => { export const setIsEqual = (setA: Set, setB: Set): boolean => { return setA.size === setB.size && setIsSuperset(setA, setB); }; + +export const handlePromiseError = (promise: Promise, logger: ImmichLogger): void => { + promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack)); +}; diff --git a/server/src/domain/download/download.service.spec.ts b/server/src/domain/download/download.service.spec.ts index bc0e32b6ba0d1..fb9ae95678d4c 100644 --- a/server/src/domain/download/download.service.spec.ts +++ b/server/src/domain/download/download.service.spec.ts @@ -34,7 +34,7 @@ describe(DownloadService.name, () => { expect(sut).toBeDefined(); }); - beforeEach(async () => { + beforeEach(() => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); storageMock = newStorageRepositoryMock(); diff --git a/server/src/domain/download/download.service.ts b/server/src/domain/download/download.service.ts index 0ead298490086..afd57b7d17cb0 100644 --- a/server/src/domain/download/download.service.ts +++ b/server/src/domain/download/download.service.ts @@ -114,9 +114,7 @@ export class DownloadService { const assetIds = dto.assetIds; await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, assetIds); const assets = await this.assetRepository.getByIds(assetIds); - return (async function* () { - yield assets; - })(); + return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets })); } if (dto.albumId) { diff --git a/server/src/domain/job/job.service.spec.ts b/server/src/domain/job/job.service.spec.ts index 5a4d26b3c4816..9fe38a2ff1081 100644 --- a/server/src/domain/job/job.service.spec.ts +++ b/server/src/domain/job/job.service.spec.ts @@ -37,7 +37,7 @@ describe(JobService.name, () => { let jobMock: jest.Mocked; let personMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { assetMock = newAssetRepositoryMock(); configMock = newSystemConfigRepositoryMock(); communicationMock = newCommunicationRepositoryMock(); diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index ba1dd8374b517..ec5d25a3b1bfa 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -17,6 +17,7 @@ import { systemConfigStub, userStub, } from '@test'; +import { when } from 'jest-when'; import { Stats } from 'node:fs'; import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job'; import { @@ -55,7 +56,7 @@ describe(LibraryService.name, () => { storageMock = newStorageRepositoryMock(); // Always validate owner access for library. - accessMock.library.checkOwnerAccess.mockImplementation(async (_, libraryIds) => libraryIds); + accessMock.library.checkOwnerAccess.mockImplementation((_, libraryIds) => Promise.resolve(libraryIds)); sut = new LibraryService( accessMock, @@ -106,19 +107,13 @@ describe(LibraryService.name, () => { configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.get.mockImplementation(async (id) => { - switch (id) { - case libraryStub.externalLibraryWithImportPaths1.id: { - return libraryStub.externalLibraryWithImportPaths1; - } - case libraryStub.externalLibraryWithImportPaths2.id: { - return libraryStub.externalLibraryWithImportPaths2; - } - default: { - return null; - } - } - }); + when(libraryMock.get) + .calledWith(libraryStub.externalLibraryWithImportPaths1.id) + .mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + + when(libraryMock.get) + .calledWith(libraryStub.externalLibraryWithImportPaths2.id) + .mockResolvedValue(libraryStub.externalLibraryWithImportPaths2); await sut.init(); @@ -1278,19 +1273,13 @@ describe(LibraryService.name, () => { configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - libraryMock.get.mockImplementation(async (id) => { - switch (id) { - case libraryStub.externalLibraryWithImportPaths1.id: { - return libraryStub.externalLibraryWithImportPaths1; - } - case libraryStub.externalLibraryWithImportPaths2.id: { - return libraryStub.externalLibraryWithImportPaths2; - } - default: { - return null; - } - } - }); + when(libraryMock.get) + .calledWith(libraryStub.externalLibraryWithImportPaths1.id) + .mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + + when(libraryMock.get) + .calledWith(libraryStub.externalLibraryWithImportPaths2.id) + .mockResolvedValue(libraryStub.externalLibraryWithImportPaths2); const mockClose = jest.fn(); storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); @@ -1304,9 +1293,8 @@ describe(LibraryService.name, () => { describe('handleDeleteLibrary', () => { it('should not delete a nonexistent library', async () => { - libraryMock.get.mockImplementation(async () => { - return null; - }); + libraryMock.get.mockResolvedValue(null); + libraryMock.getAssetIds.mockResolvedValue([]); libraryMock.delete.mockImplementation(async () => {}); diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 4d8912685999c..33edf74bf35ba 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -9,7 +9,7 @@ import picomatch from 'picomatch'; import { AccessCore, Permission } from '../access'; import { AuthDto } from '../auth'; import { mimeTypes } from '../domain.constant'; -import { usePagination, validateCronExpression } from '../domain.util'; +import { handlePromiseError, usePagination, validateCronExpression } from '../domain.util'; import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { @@ -43,7 +43,7 @@ export class LibraryService extends EventEmitter { private access: AccessCore; private configCore: SystemConfigCore; private watchLibraries = false; - private watchers: Record void> = {}; + private watchers: Record Promise> = {}; constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @@ -73,7 +73,11 @@ export class LibraryService extends EventEmitter { this.jobRepository.addCronJob( 'libraryScan', scan.cronExpression, - () => this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }), + () => + handlePromiseError( + this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }), + this.logger, + ), scan.enabled, ); @@ -81,12 +85,12 @@ export class LibraryService extends EventEmitter { await this.watchAll(); } - this.configCore.config$.subscribe(async ({ library }) => { + this.configCore.config$.subscribe(({ library }) => { this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); if (library.watch.enabled !== this.watchLibraries) { this.watchLibraries = library.watch.enabled; - await (this.watchLibraries ? this.watchAll() : this.unwatchAll()); + handlePromiseError(this.watchLibraries ? this.watchAll() : this.unwatchAll(), this.logger); } }); } @@ -124,28 +128,37 @@ export class LibraryService extends EventEmitter { }, { onReady: () => _resolve(), - onAdd: async (path) => { - this.logger.debug(`File add event received for ${path} in library ${library.id}}`); - if (matcher(path)) { - await this.scanAssets(library.id, [path], library.ownerId, false); - } - this.emit('add', path); + onAdd: (path) => { + const handler = async () => { + this.logger.debug(`File add event received for ${path} in library ${library.id}}`); + if (matcher(path)) { + await this.scanAssets(library.id, [path], library.ownerId, false); + } + this.emit('add', path); + }; + return handlePromiseError(handler(), this.logger); }, - onChange: async (path) => { - this.logger.debug(`Detected file change for ${path} in library ${library.id}`); - if (matcher(path)) { - // Note: if the changed file was not previously imported, it will be imported now. - await this.scanAssets(library.id, [path], library.ownerId, false); - } - this.emit('change', path); + onChange: (path) => { + const handler = async () => { + this.logger.debug(`Detected file change for ${path} in library ${library.id}`); + if (matcher(path)) { + // Note: if the changed file was not previously imported, it will be imported now. + await this.scanAssets(library.id, [path], library.ownerId, false); + } + this.emit('change', path); + }; + return handlePromiseError(handler(), this.logger); }, - onUnlink: async (path) => { - this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`); - const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); - if (asset && matcher(path)) { - await this.assetRepository.save({ id: asset.id, isOffline: true }); - } - this.emit('unlink', path); + onUnlink: (path) => { + const handler = async () => { + this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`); + const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); + if (asset && matcher(path)) { + await this.assetRepository.save({ id: asset.id, isOffline: true }); + } + this.emit('unlink', path); + }; + return handlePromiseError(handler(), this.logger); }, onError: (error) => { // TODO: should we log, or throw an exception? diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 0944016379498..244978d099d25 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -48,7 +48,7 @@ describe(MediaService.name, () => { let storageMock: jest.Mocked; let cryptoMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { assetMock = newAssetRepositoryMock(); configMock = newSystemConfigRepositoryMock(); jobMock = newJobRepositoryMock(); diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index 6eafc176bb99b..3da9ba371887c 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -56,7 +56,7 @@ describe(MetadataService.name, () => { let databaseMock: jest.Mocked; let sut: MetadataService; - beforeEach(async () => { + beforeEach(() => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); configMock = newSystemConfigRepositoryMock(); diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 562568adf6b68..7d22485118b35 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import { Duration } from 'luxon'; import { constants } from 'node:fs/promises'; import { Subscription } from 'rxjs'; -import { usePagination } from '../domain.util'; +import { handlePromiseError, usePagination } from '../domain.util'; import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; import { ClientEvent, @@ -124,7 +124,7 @@ export class MetadataService { async init() { if (!this.subscription) { - this.subscription = this.configCore.config$.subscribe(() => this.init()); + this.subscription = this.configCore.config$.subscribe(() => handlePromiseError(this.init(), this.logger)); } const { reverseGeocoding } = await this.configCore.getConfig(); diff --git a/server/src/domain/partner/partner.service.spec.ts b/server/src/domain/partner/partner.service.spec.ts index 2bc5f3ca90409..6e9c10c5c793e 100644 --- a/server/src/domain/partner/partner.service.spec.ts +++ b/server/src/domain/partner/partner.service.spec.ts @@ -49,7 +49,7 @@ describe(PartnerService.name, () => { let partnerMock: jest.Mocked; let accessMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { partnerMock = newPartnerRepositoryMock(); sut = new PartnerService(partnerMock, accessMock); }); diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index ffda9034bdde8..0419fc0561f16 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -80,7 +80,7 @@ describe(PersonService.name, () => { let cryptoMock: jest.Mocked; let sut: PersonService; - beforeEach(async () => { + beforeEach(() => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); configMock = newSystemConfigRepositoryMock(); diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index daf0aef0a4bd5..4a3bc552c9cf3 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -34,7 +34,7 @@ export interface ClientEventMap { [ClientEvent.NEW_RELEASE]: ReleaseNotification; } -export type OnConnectCallback = (userId: string) => Promise; +export type OnConnectCallback = (userId: string) => void | Promise; export type OnServerEventCallback = () => Promise; export interface ICommunicationRepository { diff --git a/server/src/domain/repositories/storage.repository.ts b/server/src/domain/repositories/storage.repository.ts index c88095b17bfe8..d263713afa942 100644 --- a/server/src/domain/repositories/storage.repository.ts +++ b/server/src/domain/repositories/storage.repository.ts @@ -47,6 +47,6 @@ export interface IStorageRepository { crawl(crawlOptions: CrawlOptionsDto): Promise; copyFile(source: string, target: string): Promise; rename(source: string, target: string): Promise; - watch(paths: string[], options: WatchOptions, events: Partial): () => void; + watch(paths: string[], options: WatchOptions, events: Partial): () => Promise; utimes(filepath: string, atime: Date, mtime: Date): Promise; } diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 5b563999818f9..8dce8434c7e13 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -181,7 +181,7 @@ export class SearchService { return userIds; } - private async mapResponse(assets: AssetEntity[], nextPage: string | null): Promise { + private mapResponse(assets: AssetEntity[], nextPage: string | null): SearchResponseDto { return { albums: { total: 0, count: 0, items: [], facets: [] }, assets: { diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index 82223573099c0..ba295aefab127 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -170,7 +170,7 @@ export class ServerInfoService { return true; } - private async handleConnect(userId: string) { + private handleConnect(userId: string) { this.communicationRepository.send(ClientEvent.SERVER_VERSION, userId, serverVersion); this.newReleaseNotification(userId); } diff --git a/server/src/domain/shared-link/shared-link.service.spec.ts b/server/src/domain/shared-link/shared-link.service.spec.ts index 6d95d2831fba9..f0d0715a3574c 100644 --- a/server/src/domain/shared-link/shared-link.service.spec.ts +++ b/server/src/domain/shared-link/shared-link.service.spec.ts @@ -22,7 +22,7 @@ describe(SharedLinkService.name, () => { let cryptoMock: jest.Mocked; let shareMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { accessMock = newAccessRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); shareMock = newSharedLinkRepositoryMock(); diff --git a/server/src/domain/smart-info/smart-info.service.spec.ts b/server/src/domain/smart-info/smart-info.service.spec.ts index 9835ea1a53521..712c2b6a777fb 100644 --- a/server/src/domain/smart-info/smart-info.service.spec.ts +++ b/server/src/domain/smart-info/smart-info.service.spec.ts @@ -35,7 +35,7 @@ describe(SmartInfoService.name, () => { let machineMock: jest.Mocked; let databaseMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { assetMock = newAssetRepositoryMock(); configMock = newSystemConfigRepositoryMock(); searchMock = newSearchRepositoryMock(); diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index 67d2bd2226037..1db312d78866b 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/server/src/domain/storage-template/storage-template.service.spec.ts @@ -45,7 +45,7 @@ describe(StorageTemplateService.name, () => { expect(sut).toBeDefined(); }); - beforeEach(async () => { + beforeEach(() => { configMock = newSystemConfigRepositoryMock(); assetMock = newAssetRepositoryMock(); albumMock = newAlbumRepositoryMock(); diff --git a/server/src/domain/storage/storage.service.spec.ts b/server/src/domain/storage/storage.service.spec.ts index 0c5531e5f6966..7858910860ad4 100644 --- a/server/src/domain/storage/storage.service.spec.ts +++ b/server/src/domain/storage/storage.service.spec.ts @@ -6,7 +6,7 @@ describe(StorageService.name, () => { let sut: StorageService; let storageMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { storageMock = newStorageRepositoryMock(); sut = new StorageService(storageMock); }); diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index a3d29b1eeeec3..35e306a705906 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -148,7 +148,7 @@ describe(SystemConfigService.name, () => { let communicationMock: jest.Mocked; let smartInfoMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { delete process.env.IMMICH_CONFIG_FILE; configMock = newSystemConfigRepositoryMock(); communicationMock = newCommunicationRepositoryMock(); diff --git a/server/src/domain/system-config/system-config.service.ts b/server/src/domain/system-config/system-config.service.ts index 39a3ea1dfb1f0..54d113cf60546 100644 --- a/server/src/domain/system-config/system-config.service.ts +++ b/server/src/domain/system-config/system-config.service.ts @@ -118,7 +118,7 @@ export class SystemConfigService { await this.core.refreshConfig(); } - private async setLogLevel({ logging }: SystemConfig) { + private setLogLevel({ logging }: SystemConfig) { const envLevel = this.getEnvLogLevel(); const configLevel = logging.enabled ? logging.level : false; const level = envLevel ?? configLevel; @@ -130,7 +130,7 @@ export class SystemConfigService { return process.env.LOG_LEVEL as LogLevel; } - private async validateConfig(newConfig: SystemConfig, oldConfig: SystemConfig) { + private validateConfig(newConfig: SystemConfig, oldConfig: SystemConfig) { if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { throw new Error('Logging cannot be changed while the environment variable LOG_LEVEL is set.'); } diff --git a/server/src/domain/trash/trash.service.spec.ts b/server/src/domain/trash/trash.service.spec.ts index 1b200a1bd8d84..81f4186e8d198 100644 --- a/server/src/domain/trash/trash.service.spec.ts +++ b/server/src/domain/trash/trash.service.spec.ts @@ -23,7 +23,7 @@ describe(TrashService.name, () => { expect(sut).toBeDefined(); }); - beforeEach(async () => { + beforeEach(() => { accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); communicationMock = newCommunicationRepositoryMock(); diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index 13ae149b4e269..a1e8b28c1a19b 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -49,7 +49,7 @@ describe(UserService.name, () => { let libraryMock: jest.Mocked; let storageMock: jest.Mocked; - beforeEach(async () => { + beforeEach(() => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); cryptoRepositoryMock = newCryptoRepositoryMock(); diff --git a/server/src/immich/interceptors/error.interceptor.ts b/server/src/immich/interceptors/error.interceptor.ts index 1dc52258eb527..5fabdbe55b2a8 100644 --- a/server/src/immich/interceptors/error.interceptor.ts +++ b/server/src/immich/interceptors/error.interceptor.ts @@ -15,7 +15,7 @@ import { routeToErrorMessage } from '../app.utils'; export class ErrorInterceptor implements NestInterceptor { private logger = new ImmichLogger(ErrorInterceptor.name); - async intercept(context: ExecutionContext, next: CallHandler): Promise> { + intercept(context: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( catchError((error) => throwError(() => { diff --git a/server/src/immich/interceptors/file-upload.interceptor.ts b/server/src/immich/interceptors/file-upload.interceptor.ts index 52cc447e8e74a..a698dc8a63f91 100644 --- a/server/src/immich/interceptors/file-upload.interceptor.ts +++ b/server/src/immich/interceptors/file-upload.interceptor.ts @@ -40,9 +40,9 @@ interface Callback { (error: null, result: T): void; } -const callbackify = async (target: (...arguments_: any[]) => T, callback: Callback) => { +const callbackify = (target: (...arguments_: any[]) => T, callback: Callback) => { try { - return callback(null, await target()); + return callback(null, target()); } catch (error: Error | any) { return callback(error); } diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 4ed885e58183e..4bcfc963fab85 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -544,7 +544,7 @@ export class AssetRepository implements IAssetRepository { } async getStatistics(ownerId: string, options: AssetStatsOptions): Promise { - let builder = await this.repository + let builder = this.repository .createQueryBuilder('asset') .select(`COUNT(asset.id)`, 'count') .addSelect(`asset.type`, 'type') diff --git a/server/src/infra/repositories/library.repository.ts b/server/src/infra/repositories/library.repository.ts index 804fdc481d990..89db3d175e638 100644 --- a/server/src/infra/repositories/library.repository.ts +++ b/server/src/infra/repositories/library.repository.ts @@ -166,7 +166,7 @@ export class LibraryRepository implements ILibraryRepository { @GenerateSql({ params: [DummyValue.UUID] }) async getAssetIds(libraryId: string, withDeleted = false): Promise { - let query = await this.repository + let query = this.repository .createQueryBuilder('library') .innerJoinAndSelect('library.assets', 'assets') .where('library.id = :id', { id: libraryId }) diff --git a/server/src/infra/repositories/media.repository.ts b/server/src/infra/repositories/media.repository.ts index 1f9395ff212f4..d5e4cd36f4c18 100644 --- a/server/src/infra/repositories/media.repository.ts +++ b/server/src/infra/repositories/media.repository.ts @@ -1,4 +1,11 @@ -import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain'; +import { + CropOptions, + IMediaRepository, + ResizeOptions, + TranscodeOptions, + VideoInfo, + handlePromiseError, +} from '@app/domain'; import { Colorspace } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; @@ -99,8 +106,8 @@ export class MediaRepository implements IMediaRepository { .addOptions('-pass', '2') .addOptions('-passlogfile', output) .on('error', reject) - .on('end', () => fs.unlink(`${output}-0.log`)) - .on('end', () => fs.rm(`${output}-0.log.mbtree`, { force: true })) + .on('end', () => handlePromiseError(fs.unlink(`${output}-0.log`), this.logger)) + .on('end', () => handlePromiseError(fs.rm(`${output}-0.log.mbtree`, { force: true }), this.logger)) .on('end', resolve) .run(); }) diff --git a/server/src/test-utils/utils.ts b/server/src/test-utils/utils.ts index e5566f95deb67..7b4faf99b9d86 100644 --- a/server/src/test-utils/utils.ts +++ b/server/src/test-utils/utils.ts @@ -75,22 +75,22 @@ class JobMock implements IJobRepository { async resume() {} async empty() {} async setConcurrency() {} - async getQueueStatus() { - return null as any; + getQueueStatus() { + return Promise.resolve(null) as any; } - async getJobCounts() { - return null as any; + getJobCounts() { + return Promise.resolve(null) as any; } async pause() {} - async clear() { - return []; + clear() { + return Promise.resolve([]); } async waitForQueueCompletion() {} } class MediaMockRepository extends MediaRepository { - async generateThumbhash() { - return Buffer.from('mock-thumbhash'); + generateThumbhash() { + return Promise.resolve(Buffer.from('mock-thumbhash')); } } diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 1ee57b78dda18..1ef51fabceecf 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -3,7 +3,7 @@ import { WatchOptions } from 'chokidar'; interface MockWatcherOptions { items?: Array<{ event: 'change' | 'add' | 'unlink' | 'error'; value: string }>; - close?: () => void; + close?: () => Promise; } export const makeMockWatcher = @@ -29,7 +29,12 @@ export const makeMockWatcher = } } } - return () => close?.(); + + if (close) { + return () => close(); + } + + return () => Promise.resolve(); }; export const newStorageRepositoryMock = (reset = true): jest.Mocked => { From 51f6b8f23b38cb9f18836cb726494761172bbb2a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 17:31:26 -0500 Subject: [PATCH 05/47] chore(deps): update dependency @types/cookie-parser to v1.4.7 (#7661) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 9989c2747f131..21d1331e53361 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -2953,9 +2953,9 @@ "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" }, "node_modules/@types/cookie-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.6.tgz", - "integrity": "sha512-KoooCrD56qlLskXPLGUiJxOMnv5l/8m7cQD2OxJ73NPMhuSz9PmvwRD6EpjDyKBVrdJDdQ4bQK7JFNHnNmax0w==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", "dev": true, "dependencies": { "@types/express": "*" @@ -14676,9 +14676,9 @@ "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" }, "@types/cookie-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.6.tgz", - "integrity": "sha512-KoooCrD56qlLskXPLGUiJxOMnv5l/8m7cQD2OxJ73NPMhuSz9PmvwRD6EpjDyKBVrdJDdQ4bQK7JFNHnNmax0w==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", "dev": true, "requires": { "@types/express": "*" From ae46188753b72f459306bc3ea621a60c5c228108 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Tue, 5 Mar 2024 23:35:52 +0100 Subject: [PATCH 06/47] chore(deps): bump sanitize-html, fixing CVE-2024-21501 (#7662) * bump sanitize-html * bump better --- docs/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index c78ca4b2e75f2..9a55af7f5e30e 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -12231,7 +12231,7 @@ "mime-format": "2.0.0", "mime-types": "2.1.27", "postman-url-encoder": "2.1.3", - "sanitize-html": "^2.11.0", + "sanitize-html": "^2.12.1", "semver": "^7.5.4", "uuid": "3.4.0" } @@ -14762,9 +14762,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sanitize-html": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz", - "integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.12.1.tgz", + "integrity": "sha512-Plh+JAn0UVDpBRP/xEjsk+xDCoOvMBwQUf/K+/cBAVuTbtX8bj2VB7S1sL1dssVpykqp0/KPSesHrqXtokVBpA==", "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", From 2f53f6a62cedeb05a1da983b292db18b1ec95618 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 5 Mar 2024 17:08:35 -0600 Subject: [PATCH 07/47] feat(web): search by filename (#7624) * Toggle to search by filename * wild card search and pr feedback * Pr feedback * naming * placeholder * Create index * pr feedback * pr feedback * Update web/src/lib/components/shared-components/search-bar/search-text-section.svelte Co-authored-by: Jason Rasmussen * pr feedback * pr feedback * pr feedback * pr feedback --------- Co-authored-by: Jason Rasmussen --- server/src/infra/entities/asset.entity.ts | 1 + server/src/infra/infra.utils.ts | 8 ++- ...140355-AddAssetOriginalPathTrigramIndex.ts | 14 +++++ .../search-bar/search-filter-box.svelte | 20 ++----- .../search-bar/search-text-section.svelte | 57 +++++++++++++++++++ web/src/routes/(user)/search/+page.svelte | 1 + 6 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 server/src/infra/migrations/1709608140355-AddAssetOriginalPathTrigramIndex.ts create mode 100644 web/src/lib/components/shared-components/search-bar/search-text-section.svelte diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 373271158177c..7335c3ddf74be 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -35,6 +35,7 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum'; @Index('IDX_day_of_month', { synchronize: false }) @Index('IDX_month', { synchronize: false }) @Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId']) +@Index('idx_originalpath_trigram', { synchronize: false }) // For all assets, each originalpath must be unique per user and library export class AssetEntity { @PrimaryGeneratedColumn('uuid') diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 745f5a38ffda2..2c6e4b7470061 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -160,9 +160,15 @@ export function searchAssetBuilder( builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds }); } - const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'originalPath', 'resizePath', 'webpPath']); + const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'resizePath', 'webpPath']); builder.andWhere(_.omitBy(path, _.isUndefined)); + if (options.originalPath) { + builder.andWhere(`f_unaccent(${builder.alias}.originalPath) ILIKE f_unaccent(:originalPath)`, { + originalPath: `%${options.originalPath}%`, + }); + } + const status = _.pick(options, ['isExternal', 'isFavorite', 'isOffline', 'isReadOnly', 'isVisible', 'type']); const { isArchived, diff --git a/server/src/infra/migrations/1709608140355-AddAssetOriginalPathTrigramIndex.ts b/server/src/infra/migrations/1709608140355-AddAssetOriginalPathTrigramIndex.ts new file mode 100644 index 0000000000000..fdca15cbfffbc --- /dev/null +++ b/server/src/infra/migrations/1709608140355-AddAssetOriginalPathTrigramIndex.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAssetOriginalPathTrigramIndex1709608140355 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX idx_originalpath_trigram + ON assets + USING gin (f_unaccent("originalPath") gin_trgm_ops)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "idx_originalpath_trigram"`); + } +} diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte index b05e2d5a3bf0c..d81ed566ca8ec 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte @@ -11,6 +11,7 @@ export type SearchFilter = { context?: string; + filename?: string; personIds: Set; location: SearchLocationFilter; camera: SearchCameraFilter; @@ -32,6 +33,7 @@ import SearchMediaSection from './search-media-section.svelte'; import { parseUtcDate } from '$lib/utils/date-time'; import SearchDisplaySection from './search-display-section.svelte'; + import SearchTextSection from './search-text-section.svelte'; export let searchQuery: MetadataSearchDto | SmartSearchDto; @@ -41,6 +43,7 @@ let filter: SearchFilter = { context: 'query' in searchQuery ? searchQuery.query : '', + filename: 'originalPath' in searchQuery ? searchQuery.originalPath : undefined, personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []), location: { country: searchQuery.country, @@ -91,6 +94,7 @@ let payload: SmartSearchDto | MetadataSearchDto = { query: filter.context || undefined, + originalPath: filter.filename, country: filter.location.country, state: filter.location.state, city: filter.location.city, @@ -124,20 +128,8 @@ - -
- -
+ + diff --git a/web/src/lib/components/shared-components/search-bar/search-text-section.svelte b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte new file mode 100644 index 0000000000000..fd3751eecc133 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-text-section.svelte @@ -0,0 +1,57 @@ + + +
+ + + +
+ +{#if selectedOption === TextSearchOptions.Context} + +{:else} + +{/if} diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index b6c28f4b35097..3b7d6b8e6e5b1 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -173,6 +173,7 @@ make: 'Camera brand', model: 'Camera model', personIds: 'People', + originalPath: 'File name', }; return keyMap[key] || key; } From 4ef4cc8016b9751c3aee32e9672d3d4acfec25a3 Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Tue, 5 Mar 2024 22:42:22 -0500 Subject: [PATCH 08/47] refactor(mobile): Refactor video player page and gallery bottom app bar (#7625) * Fixes double video auto initialize issue and placeholder for video controller * WIP unravel stack index * Refactors video player controller format fixing video format Working format * Fixes hide on pause * Got hiding when tapped working * Hides controls when video starts and fixes placeholder for memory card Remove prints * Fixes show controls with microtask * fix LivePhotos not playing * removes unused function callbacks and moves wakelock * Update motion video * Fixing motion photo playing * Renames to isPlayingVideo * Fixes playing video on change * pause on dispose * fixing issues with sync between controls * Adds gallery app bar * Switches to memoized * Fixes pause * Revert "Switches to memoized" This reverts commit 234e6741dea05aa0b967dde746f1d625f15bed94. * uses stateful widget * Fixes double video play by using provider and new chewie video player wip format Fixes motion photos format --------- Co-authored-by: Alex Tran --- .../hooks/chewiew_controller_hook.dart | 97 +-- .../providers/asset_stack.provider.dart | 8 + .../providers/asset_stack.provider.g.dart | 158 ++++ .../video_player_controller_provider.dart | 44 ++ .../video_player_controller_provider.g.dart | 164 +++++ .../video_player_controls_provider.dart | 58 +- .../video_player_value_provider.dart | 71 +- .../asset_viewer/ui/bottom_gallery_bar.dart | 345 +++++++++ .../ui/custom_video_player_controls.dart | 107 +++ .../asset_viewer/ui/gallery_app_bar.dart | 110 +++ .../asset_viewer/ui/video_controls.dart | 125 ++++ .../modules/asset_viewer/ui/video_player.dart | 45 ++ .../ui/video_player_controls.dart | 209 ------ .../asset_viewer/views/gallery_viewer.dart | 675 +++--------------- .../asset_viewer/views/video_viewer_page.dart | 158 +++- .../map/providers/map_state.provider.g.dart | 2 +- .../lib/modules/memories/ui/memory_card.dart | 10 +- mobile/lib/routing/router.gr.dart | 24 +- mobile/lib/shared/ui/hooks/timer_hook.dart | 48 ++ mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + 21 files changed, 1528 insertions(+), 933 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart create mode 100644 mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart create mode 100644 mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/video_controls.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/video_player.dart delete mode 100644 mobile/lib/modules/asset_viewer/ui/video_player_controls.dart create mode 100644 mobile/lib/shared/ui/hooks/timer_hook.dart diff --git a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart index 224eb838e7407..5daeb389ec44d 100644 --- a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart +++ b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart @@ -1,26 +1,19 @@ -import 'dart:async'; - import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/store.dart'; import 'package:video_player/video_player.dart'; -import 'package:immich_mobile/shared/models/store.dart' as store; -import 'package:wakelock_plus/wakelock_plus.dart'; /// Provides the initialized video player controller /// If the asset is local, use the local file /// Otherwise, use a video player with a URL -ChewieController? useChewieController( - Asset asset, { +ChewieController useChewieController({ + required VideoPlayerController controller, EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only( bottom: 100, ), bool showOptions = true, bool showControlsOnInitialize = false, bool autoPlay = true, - bool autoInitialize = true, bool allowFullScreen = false, bool allowedScreenSleep = false, bool showControls = true, @@ -33,7 +26,7 @@ ChewieController? useChewieController( }) { return use( _ChewieControllerHook( - asset: asset, + controller: controller, placeholder: placeholder, showOptions: showOptions, controlsSafeAreaMinimum: controlsSafeAreaMinimum, @@ -43,7 +36,6 @@ ChewieController? useChewieController( hideControlsTimer: hideControlsTimer, showControlsOnInitialize: showControlsOnInitialize, showControls: showControls, - autoInitialize: autoInitialize, allowedScreenSleep: allowedScreenSleep, onPlaying: onPlaying, onPaused: onPaused, @@ -52,13 +44,12 @@ ChewieController? useChewieController( ); } -class _ChewieControllerHook extends Hook { - final Asset asset; +class _ChewieControllerHook extends Hook { + final VideoPlayerController controller; final EdgeInsets controlsSafeAreaMinimum; final bool showOptions; final bool showControlsOnInitialize; final bool autoPlay; - final bool autoInitialize; final bool allowFullScreen; final bool allowedScreenSleep; final bool showControls; @@ -70,14 +61,13 @@ class _ChewieControllerHook extends Hook { final VoidCallback? onVideoEnded; const _ChewieControllerHook({ - required this.asset, + required this.controller, this.controlsSafeAreaMinimum = const EdgeInsets.only( bottom: 100, ), this.showOptions = true, this.showControlsOnInitialize = false, this.autoPlay = true, - this.autoInitialize = true, this.allowFullScreen = false, this.allowedScreenSleep = false, this.showControls = true, @@ -94,28 +84,33 @@ class _ChewieControllerHook extends Hook { } class _ChewieControllerHookState - extends HookState { - ChewieController? chewieController; - VideoPlayerController? videoPlayerController; - - @override - void initHook() async { - super.initHook(); - unawaited(_initialize()); - } + extends HookState { + late ChewieController chewieController = ChewieController( + videoPlayerController: hook.controller, + controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, + showOptions: hook.showOptions, + showControlsOnInitialize: hook.showControlsOnInitialize, + autoPlay: hook.autoPlay, + allowFullScreen: hook.allowFullScreen, + allowedScreenSleep: hook.allowedScreenSleep, + showControls: hook.showControls, + customControls: hook.customControls, + placeholder: hook.placeholder, + hideControlsTimer: hook.hideControlsTimer, + ); @override void dispose() { - chewieController?.dispose(); - videoPlayerController?.dispose(); + chewieController.dispose(); super.dispose(); } @override - ChewieController? build(BuildContext context) { + ChewieController build(BuildContext context) { return chewieController; } + /* /// Initializes the chewie controller and video player controller Future _initialize() async { if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) { @@ -141,39 +136,21 @@ class _ChewieControllerHookState ); } - videoPlayerController!.addListener(() { - final value = videoPlayerController!.value; - if (value.isPlaying) { - WakelockPlus.enable(); - hook.onPlaying?.call(); - } else if (!value.isPlaying) { - WakelockPlus.disable(); - hook.onPaused?.call(); - } - - if (value.position == value.duration) { - WakelockPlus.disable(); - hook.onVideoEnded?.call(); - } - }); - await videoPlayerController!.initialize(); - setState(() { - chewieController = ChewieController( - videoPlayerController: videoPlayerController!, - controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, - showOptions: hook.showOptions, - showControlsOnInitialize: hook.showControlsOnInitialize, - autoPlay: hook.autoPlay, - autoInitialize: hook.autoInitialize, - allowFullScreen: hook.allowFullScreen, - allowedScreenSleep: hook.allowedScreenSleep, - showControls: hook.showControls, - customControls: hook.customControls, - placeholder: hook.placeholder, - hideControlsTimer: hook.hideControlsTimer, - ); - }); + chewieController = ChewieController( + videoPlayerController: videoPlayerController!, + controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, + showOptions: hook.showOptions, + showControlsOnInitialize: hook.showControlsOnInitialize, + autoPlay: hook.autoPlay, + allowFullScreen: hook.allowFullScreen, + allowedScreenSleep: hook.allowedScreenSleep, + showControls: hook.showControls, + customControls: hook.customControls, + placeholder: hook.placeholder, + hideControlsTimer: hook.hideControlsTimer, + ); } + */ } diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart index 5c20e1479fc64..b6928c6ba8e7e 100644 --- a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart @@ -2,6 +2,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:isar/isar.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'asset_stack.provider.g.dart'; class AssetStackNotifier extends StateNotifier> { final Asset _asset; @@ -49,3 +52,8 @@ final assetStackProvider = .sortByFileCreatedAtDesc() .findAll(); }); + +@riverpod +int assetStackIndex(AssetStackIndexRef ref, Asset asset) { + return -1; +} diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart new file mode 100644 index 0000000000000..142e46d32292f --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.g.dart @@ -0,0 +1,158 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'asset_stack.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$assetStackIndexHash() => r'0f2df55e929767c8c698bd432b5e6e351d000a16'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [assetStackIndex]. +@ProviderFor(assetStackIndex) +const assetStackIndexProvider = AssetStackIndexFamily(); + +/// See also [assetStackIndex]. +class AssetStackIndexFamily extends Family { + /// See also [assetStackIndex]. + const AssetStackIndexFamily(); + + /// See also [assetStackIndex]. + AssetStackIndexProvider call( + Asset asset, + ) { + return AssetStackIndexProvider( + asset, + ); + } + + @override + AssetStackIndexProvider getProviderOverride( + covariant AssetStackIndexProvider provider, + ) { + return call( + provider.asset, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'assetStackIndexProvider'; +} + +/// See also [assetStackIndex]. +class AssetStackIndexProvider extends AutoDisposeProvider { + /// See also [assetStackIndex]. + AssetStackIndexProvider( + Asset asset, + ) : this._internal( + (ref) => assetStackIndex( + ref as AssetStackIndexRef, + asset, + ), + from: assetStackIndexProvider, + name: r'assetStackIndexProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$assetStackIndexHash, + dependencies: AssetStackIndexFamily._dependencies, + allTransitiveDependencies: + AssetStackIndexFamily._allTransitiveDependencies, + asset: asset, + ); + + AssetStackIndexProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.asset, + }) : super.internal(); + + final Asset asset; + + @override + Override overrideWith( + int Function(AssetStackIndexRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: AssetStackIndexProvider._internal( + (ref) => create(ref as AssetStackIndexRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + asset: asset, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _AssetStackIndexProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is AssetStackIndexProvider && other.asset == asset; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, asset.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin AssetStackIndexRef on AutoDisposeProviderRef { + /// The parameter `asset` of this provider. + Asset get asset; +} + +class _AssetStackIndexProviderElement extends AutoDisposeProviderElement + with AssetStackIndexRef { + _AssetStackIndexProviderElement(super.provider); + + @override + Asset get asset => (origin as AssetStackIndexProvider).asset; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart b/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart new file mode 100644 index 0000000000000..714c38e2abf8f --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.dart @@ -0,0 +1,44 @@ +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/store.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:video_player/video_player.dart'; + +part 'video_player_controller_provider.g.dart'; + +@riverpod +Future videoPlayerController( + VideoPlayerControllerRef ref, { + required Asset asset, +}) async { + late VideoPlayerController controller; + if (asset.isLocal && asset.livePhotoVideoId == null) { + // Use a local file for the video player controller + final file = await asset.local!.file; + if (file == null) { + throw Exception('No file found for the video'); + } + controller = VideoPlayerController.file(file); + } else { + // Use a network URL for the video player controller + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final String videoUrl = asset.livePhotoVideoId != null + ? '$serverEndpoint/asset/file/${asset.livePhotoVideoId}' + : '$serverEndpoint/asset/file/${asset.remoteId}'; + + final url = Uri.parse(videoUrl); + final accessToken = Store.get(StoreKey.accessToken); + + controller = VideoPlayerController.networkUrl( + url, + httpHeaders: {"x-immich-user-token": accessToken}, + ); + } + + await controller.initialize(); + + ref.onDispose(() { + controller.dispose(); + }); + + return controller; +} diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart b/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart new file mode 100644 index 0000000000000..a9b287e953e17 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/video_player_controller_provider.g.dart @@ -0,0 +1,164 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'video_player_controller_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$videoPlayerControllerHash() => + r'72b45de66542021717807655e25ec92d78d80eec'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [videoPlayerController]. +@ProviderFor(videoPlayerController) +const videoPlayerControllerProvider = VideoPlayerControllerFamily(); + +/// See also [videoPlayerController]. +class VideoPlayerControllerFamily + extends Family> { + /// See also [videoPlayerController]. + const VideoPlayerControllerFamily(); + + /// See also [videoPlayerController]. + VideoPlayerControllerProvider call({ + required Asset asset, + }) { + return VideoPlayerControllerProvider( + asset: asset, + ); + } + + @override + VideoPlayerControllerProvider getProviderOverride( + covariant VideoPlayerControllerProvider provider, + ) { + return call( + asset: provider.asset, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'videoPlayerControllerProvider'; +} + +/// See also [videoPlayerController]. +class VideoPlayerControllerProvider + extends AutoDisposeFutureProvider { + /// See also [videoPlayerController]. + VideoPlayerControllerProvider({ + required Asset asset, + }) : this._internal( + (ref) => videoPlayerController( + ref as VideoPlayerControllerRef, + asset: asset, + ), + from: videoPlayerControllerProvider, + name: r'videoPlayerControllerProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$videoPlayerControllerHash, + dependencies: VideoPlayerControllerFamily._dependencies, + allTransitiveDependencies: + VideoPlayerControllerFamily._allTransitiveDependencies, + asset: asset, + ); + + VideoPlayerControllerProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.asset, + }) : super.internal(); + + final Asset asset; + + @override + Override overrideWith( + FutureOr Function(VideoPlayerControllerRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: VideoPlayerControllerProvider._internal( + (ref) => create(ref as VideoPlayerControllerRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + asset: asset, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _VideoPlayerControllerProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is VideoPlayerControllerProvider && other.asset == asset; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, asset.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin VideoPlayerControllerRef + on AutoDisposeFutureProviderRef { + /// The parameter `asset` of this provider. + Asset get asset; +} + +class _VideoPlayerControllerProviderElement + extends AutoDisposeFutureProviderElement + with VideoPlayerControllerRef { + _VideoPlayerControllerProviderElement(super.provider); + + @override + Asset get asset => (origin as VideoPlayerControllerProvider).asset; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart b/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart index b73824f864b5f..d935358936032 100644 --- a/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/video_player_controls_provider.dart @@ -1,10 +1,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; class VideoPlaybackControls { - VideoPlaybackControls({required this.position, required this.mute}); + VideoPlaybackControls({ + required this.position, + required this.mute, + required this.pause, + }); final double position; final bool mute; + final bool pause; } final videoPlayerControlsProvider = @@ -17,6 +22,7 @@ class VideoPlayerControls extends StateNotifier { : super( VideoPlaybackControls( position: 0, + pause: false, mute: false, ), ); @@ -29,18 +35,62 @@ class VideoPlayerControls extends StateNotifier { state = value; } + void reset() { + state = VideoPlaybackControls( + position: 0, + pause: false, + mute: false, + ); + } + double get position => state.position; bool get mute => state.mute; set position(double value) { - state = VideoPlaybackControls(position: value, mute: state.mute); + state = VideoPlaybackControls( + position: value, + mute: state.mute, + pause: state.pause, + ); } set mute(bool value) { - state = VideoPlaybackControls(position: state.position, mute: value); + state = VideoPlaybackControls( + position: state.position, + mute: value, + pause: state.pause, + ); } void toggleMute() { - state = VideoPlaybackControls(position: state.position, mute: !state.mute); + state = VideoPlaybackControls( + position: state.position, + mute: !state.mute, + pause: state.pause, + ); + } + + void pause() { + state = VideoPlaybackControls( + position: state.position, + mute: state.mute, + pause: true, + ); + } + + void play() { + state = VideoPlaybackControls( + position: state.position, + mute: state.mute, + pause: false, + ); + } + + void togglePlay() { + state = VideoPlaybackControls( + position: state.position, + mute: state.mute, + pause: !state.pause, + ); } } diff --git a/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart b/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart index 66f9389a090fc..ebdf739ef03de 100644 --- a/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/video_player_value_provider.dart @@ -1,10 +1,65 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:video_player/video_player.dart'; + +enum VideoPlaybackState { + initializing, + paused, + playing, + buffering, + completed, +} class VideoPlaybackValue { - VideoPlaybackValue({required this.position, required this.duration}); - + /// The current position of the video final Duration position; + + /// The total duration of the video final Duration duration; + + /// The current state of the video playback + final VideoPlaybackState state; + + /// The volume of the video + final double volume; + + VideoPlaybackValue({ + required this.position, + required this.duration, + required this.state, + required this.volume, + }); + + factory VideoPlaybackValue.fromController(VideoPlayerController? controller) { + final video = controller?.value; + late VideoPlaybackState s; + if (video == null) { + s = VideoPlaybackState.initializing; + } else if (video.isCompleted) { + s = VideoPlaybackState.completed; + } else if (video.isPlaying) { + s = VideoPlaybackState.playing; + } else if (video.isBuffering) { + s = VideoPlaybackState.buffering; + } else { + s = VideoPlaybackState.paused; + } + + return VideoPlaybackValue( + position: video?.position ?? Duration.zero, + duration: video?.duration ?? Duration.zero, + state: s, + volume: video?.volume ?? 0.0, + ); + } + + factory VideoPlaybackValue.uninitialized() { + return VideoPlaybackValue( + position: Duration.zero, + duration: Duration.zero, + state: VideoPlaybackState.initializing, + volume: 0.0, + ); + } } final videoPlaybackValueProvider = @@ -15,10 +70,7 @@ final videoPlaybackValueProvider = class VideoPlaybackValueState extends StateNotifier { VideoPlaybackValueState(this.ref) : super( - VideoPlaybackValue( - position: Duration.zero, - duration: Duration.zero, - ), + VideoPlaybackValue.uninitialized(), ); final Ref ref; @@ -30,6 +82,11 @@ class VideoPlaybackValueState extends StateNotifier { } set position(Duration value) { - state = VideoPlaybackValue(position: value, duration: state.duration); + state = VideoPlaybackValue( + position: value, + duration: state.duration, + state: state.state, + volume: state.volume, + ); } } diff --git a/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart b/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart new file mode 100644 index 0000000000000..a7d5e4e71c2a6 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/bottom_gallery_bar.dart @@ -0,0 +1,345 @@ +import 'dart:io'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/video_controls.dart'; +import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; + +class BottomGalleryBar extends ConsumerWidget { + final Asset asset; + final bool showStack; + final int stackIndex; + final int totalAssets; + final bool showVideoPlayerControls; + final PageController controller; + + const BottomGalleryBar({ + super.key, + required this.showStack, + required this.stackIndex, + required this.asset, + required this.controller, + required this.totalAssets, + required this.showVideoPlayerControls, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; + + final stack = showStack && asset.stackChildrenCount > 0 + ? ref.watch(assetStackStateProvider(asset)) + : []; + final stackElements = showStack ? [asset, ...stack] : []; + bool isParent = stackIndex == -1 || stackIndex == 0; + final navStack = AutoRouter.of(context).stackData; + final isTrashEnabled = + ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); + final isFromTrash = isTrashEnabled && + navStack.length > 2 && + navStack.elementAt(navStack.length - 2).name == TrashRoute.name; + // !!!! itemsList and actionlist should always be in sync + final itemsList = [ + BottomNavigationBarItem( + icon: Icon( + Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, + ), + label: 'control_bottom_app_bar_share'.tr(), + tooltip: 'control_bottom_app_bar_share'.tr(), + ), + if (isOwner) + asset.isArchived + ? BottomNavigationBarItem( + icon: const Icon(Icons.unarchive_rounded), + label: 'control_bottom_app_bar_unarchive'.tr(), + tooltip: 'control_bottom_app_bar_unarchive'.tr(), + ) + : BottomNavigationBarItem( + icon: const Icon(Icons.archive_outlined), + label: 'control_bottom_app_bar_archive'.tr(), + tooltip: 'control_bottom_app_bar_archive'.tr(), + ), + if (isOwner && stack.isNotEmpty) + BottomNavigationBarItem( + icon: const Icon(Icons.burst_mode_outlined), + label: 'control_bottom_app_bar_stack'.tr(), + tooltip: 'control_bottom_app_bar_stack'.tr(), + ), + if (isOwner) + BottomNavigationBarItem( + icon: const Icon(Icons.delete_outline), + label: 'control_bottom_app_bar_delete'.tr(), + tooltip: 'control_bottom_app_bar_delete'.tr(), + ), + if (!isOwner) + BottomNavigationBarItem( + icon: const Icon(Icons.download_outlined), + label: 'download'.tr(), + tooltip: 'download'.tr(), + ), + ]; + + void removeAssetFromStack() { + if (stackIndex > 0 && showStack) { + ref + .read(assetStackStateProvider(asset).notifier) + .removeChild(stackIndex - 1); + } + } + + void handleDelete() async { + // Cannot delete readOnly / external assets. They are handled through library offline jobs + if (asset.isReadOnly) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_delete_err_read_only'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + Future onDelete(bool force) async { + final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( + {asset}, + force: force, + ); + if (isDeleted && isParent) { + if (totalAssets == 1) { + // Handle only one asset + context.popRoute(); + } else { + // Go to next page otherwise + controller.nextPage( + duration: const Duration(milliseconds: 100), + curve: Curves.fastLinearToSlowEaseIn, + ); + } + } + return isDeleted; + } + + // Asset is trashed + if (isTrashEnabled && !isFromTrash) { + final isDeleted = await onDelete(false); + if (isDeleted) { + // Can only trash assets stored in server. Local assets are always permanently removed for now + if (context.mounted && asset.isRemote && isParent) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'Asset trashed', + gravity: ToastGravity.BOTTOM, + ); + } + removeAssetFromStack(); + } + return; + } + + // Asset is permanently removed + showDialog( + context: context, + builder: (BuildContext _) { + return DeleteDialog( + onDelete: () async { + final isDeleted = await onDelete(true); + if (isDeleted) { + removeAssetFromStack(); + } + }, + ); + }, + ); + } + + void showStackActionItems() { + showModalBottomSheet( + context: context, + enableDrag: false, + builder: (BuildContext ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isParent) + ListTile( + leading: const Icon( + Icons.bookmark_border_outlined, + size: 24, + ), + onTap: () async { + await ref + .read(assetStackServiceProvider) + .updateStackParent( + asset, + stackElements.elementAt(stackIndex), + ); + ctx.pop(); + context.popRoute(); + }, + title: const Text( + "viewer_stack_use_as_main_asset", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ListTile( + leading: const Icon( + Icons.copy_all_outlined, + size: 24, + ), + onTap: () async { + if (isParent) { + await ref + .read(assetStackServiceProvider) + .updateStackParent( + asset, + stackElements + .elementAt(1), // Next asset as parent + ); + // Remove itself from stack + await ref.read(assetStackServiceProvider).updateStack( + stackElements.elementAt(1), + childrenToRemove: [asset], + ); + ctx.pop(); + context.popRoute(); + } else { + await ref.read(assetStackServiceProvider).updateStack( + asset, + childrenToRemove: [ + stackElements.elementAt(stackIndex), + ], + ); + removeAssetFromStack(); + ctx.pop(); + } + }, + title: const Text( + "viewer_remove_from_stack", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ListTile( + leading: const Icon( + Icons.filter_none_outlined, + size: 18, + ), + onTap: () async { + await ref.read(assetStackServiceProvider).updateStack( + asset, + childrenToRemove: stack, + ); + ctx.pop(); + context.popRoute(); + }, + title: const Text( + "viewer_unstack", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ], + ), + ), + ); + }, + ); + } + + shareAsset() { + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); + } + + handleArchive() { + ref.read(assetProvider.notifier).toggleArchive([asset]); + if (isParent) { + context.popRoute(); + return; + } + removeAssetFromStack(); + } + + handleDownload() { + if (asset.isLocal) { + return; + } + if (asset.isOffline) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'asset_action_share_err_offline'.tr(), + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(imageViewerStateProvider.notifier).downloadAsset( + asset, + context, + ); + } + + List actionslist = [ + (_) => shareAsset(), + if (isOwner) (_) => handleArchive(), + if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(), + if (isOwner) (_) => handleDelete(), + if (!isOwner) (_) => handleDownload(), + ]; + + return IgnorePointer( + ignoring: !ref.watch(showControlsProvider), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + child: Column( + children: [ + Visibility( + visible: showVideoPlayerControls, + child: const VideoControls(), + ), + BottomNavigationBar( + backgroundColor: Colors.black.withOpacity(0.4), + unselectedIconTheme: const IconThemeData(color: Colors.white), + selectedIconTheme: const IconThemeData(color: Colors.white), + unselectedLabelStyle: const TextStyle(color: Colors.black), + selectedLabelStyle: const TextStyle(color: Colors.black), + showSelectedLabels: false, + showUnselectedLabels: false, + items: itemsList, + onTap: (index) { + if (index < actionslist.length) { + actionslist[index].call(index); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart b/mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart new file mode 100644 index 0000000000000..0e8f14301ac78 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/custom_video_player_controls.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart'; +import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; +import 'package:immich_mobile/shared/ui/hooks/timer_hook.dart'; + +class CustomVideoPlayerControls extends HookConsumerWidget { + final Duration hideTimerDuration; + + const CustomVideoPlayerControls({ + super.key, + this.hideTimerDuration = const Duration(seconds: 3), + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // A timer to hide the controls + final hideTimer = useTimer( + hideTimerDuration, + () { + final state = ref.read(videoPlaybackValueProvider).state; + // Do not hide on paused + if (state != VideoPlaybackState.paused) { + ref.read(showControlsProvider.notifier).show = false; + } + }, + ); + + final showBuffering = useState(false); + final VideoPlaybackState state = + ref.watch(videoPlaybackValueProvider).state; + + /// Shows the controls and starts the timer to hide them + void showControlsAndStartHideTimer() { + hideTimer.reset(); + ref.read(showControlsProvider.notifier).show = true; + } + + // When we mute, show the controls + ref.listen(videoPlayerControlsProvider.select((v) => v.mute), + (previous, next) { + showControlsAndStartHideTimer(); + }); + + // When we change position, show or hide timer + ref.listen(videoPlayerControlsProvider.select((v) => v.position), + (previous, next) { + showControlsAndStartHideTimer(); + }); + + ref.listen(videoPlaybackValueProvider.select((value) => value.state), + (_, state) { + // Show buffering + showBuffering.value = state == VideoPlaybackState.buffering; + }); + + /// Toggles between playing and pausing depending on the state of the video + void togglePlay() { + showControlsAndStartHideTimer(); + final state = ref.read(videoPlaybackValueProvider).state; + if (state == VideoPlaybackState.playing) { + ref.read(videoPlayerControlsProvider.notifier).pause(); + } else { + ref.read(videoPlayerControlsProvider.notifier).play(); + } + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: showControlsAndStartHideTimer, + child: AbsorbPointer( + absorbing: !ref.watch(showControlsProvider), + child: Stack( + children: [ + if (showBuffering.value) + const Center( + child: DelayedLoadingIndicator( + fadeInDuration: Duration(milliseconds: 400), + ), + ) + else + GestureDetector( + onTap: () { + if (state != VideoPlaybackState.playing) { + togglePlay(); + } + ref.read(showControlsProvider.notifier).show = false; + }, + child: CenterPlayButton( + backgroundColor: Colors.black54, + iconColor: Colors.white, + isFinished: state == VideoPlaybackState.completed, + isPlaying: state == VideoPlaybackState.playing, + show: ref.watch(showControlsProvider), + onPressed: togglePlay, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart new file mode 100644 index 0000000000000..a16f1f04d6b7f --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/gallery_app_bar.dart @@ -0,0 +1,110 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; +import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; +import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; +import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; +import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; + +class GalleryAppBar extends ConsumerWidget { + final Asset asset; + final void Function() showInfo; + final void Function() onToggleMotionVideo; + final bool isPlayingVideo; + + const GalleryAppBar({ + super.key, + required this.asset, + required this.showInfo, + required this.onToggleMotionVideo, + required this.isPlayingVideo, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final album = ref.watch(currentAlbumProvider); + final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; + + final isPartner = ref + .watch(partnerSharedWithProvider) + .map((e) => e.isarId) + .contains(asset.ownerId); + + toggleFavorite(Asset asset) => + ref.read(assetProvider.notifier).toggleFavorite([asset]); + + handleActivities() { + if (album != null && album.shared && album.remoteId != null) { + context.pushRoute(const ActivitiesRoute()); + } + } + + handleUpload(Asset asset) { + showDialog( + context: context, + builder: (BuildContext _) { + return UploadDialog( + onUpload: () { + ref + .read(manualUploadProvider.notifier) + .uploadAssets(context, [asset]); + }, + ); + }, + ); + } + + addToAlbum(Asset addToAlbumAsset) { + showModalBottomSheet( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + context: context, + builder: (BuildContext _) { + return AddToAlbumBottomSheet( + assets: [addToAlbumAsset], + ); + }, + ); + } + + return IgnorePointer( + ignoring: !ref.watch(showControlsProvider), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + child: Container( + color: Colors.black.withOpacity(0.4), + child: TopControlAppBar( + isOwner: isOwner, + isPartner: isPartner, + isPlayingMotionVideo: isPlayingVideo, + asset: asset, + onMoreInfoPressed: showInfo, + onFavorite: toggleFavorite, + onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null, + onDownloadPressed: asset.isLocal + ? null + : () => + ref.read(imageViewerStateProvider.notifier).downloadAsset( + asset, + context, + ), + onToggleMotionVideo: onToggleMotionVideo, + onAddToAlbumPressed: () => addToAlbum(asset), + onActivitiesPressed: handleActivities, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/video_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_controls.dart new file mode 100644 index 0000000000000..45a9372099f10 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/video_controls.dart @@ -0,0 +1,125 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; + +/// The video controls for the [videPlayerControlsProvider] +class VideoControls extends ConsumerWidget { + const VideoControls({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final duration = + ref.watch(videoPlaybackValueProvider.select((v) => v.duration)); + final position = + ref.watch(videoPlaybackValueProvider.select((v) => v.position)); + + return AnimatedOpacity( + opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + duration: const Duration(milliseconds: 100), + child: OrientationBuilder( + builder: (context, orientation) => Container( + padding: EdgeInsets.symmetric( + horizontal: orientation == Orientation.portrait ? 12.0 : 64.0, + ), + color: Colors.black.withOpacity(0.4), + child: Padding( + padding: MediaQuery.of(context).orientation == Orientation.portrait + ? const EdgeInsets.symmetric(horizontal: 12.0) + : const EdgeInsets.symmetric(horizontal: 64.0), + child: Row( + children: [ + Text( + _formatDuration(position), + style: TextStyle( + fontSize: 14.0, + color: Colors.white.withOpacity(.75), + fontWeight: FontWeight.normal, + ), + ), + Expanded( + child: Slider( + value: duration == Duration.zero + ? 0.0 + : min( + position.inMicroseconds / + duration.inMicroseconds * + 100, + 100, + ), + min: 0, + max: 100, + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: Colors.white.withOpacity(0.75), + onChanged: (position) { + ref.read(videoPlayerControlsProvider.notifier).position = + position; + }, + ), + ), + Text( + _formatDuration(duration), + style: TextStyle( + fontSize: 14.0, + color: Colors.white.withOpacity(.75), + fontWeight: FontWeight.normal, + ), + ), + IconButton( + icon: Icon( + ref.watch( + videoPlayerControlsProvider.select((value) => value.mute), + ) + ? Icons.volume_off + : Icons.volume_up, + ), + onPressed: () => ref + .read(videoPlayerControlsProvider.notifier) + .toggleMute(), + color: Colors.white, + ), + ], + ), + ), + ), + ), + ); + } + + String _formatDuration(Duration position) { + final ms = position.inMilliseconds; + + int seconds = ms ~/ 1000; + final int hours = seconds ~/ 3600; + seconds = seconds % 3600; + final minutes = seconds ~/ 60; + seconds = seconds % 60; + + final hoursString = hours >= 10 + ? '$hours' + : hours == 0 + ? '00' + : '0$hours'; + + final minutesString = minutes >= 10 + ? '$minutes' + : minutes == 0 + ? '00' + : '0$minutes'; + + final secondsString = seconds >= 10 + ? '$seconds' + : seconds == 0 + ? '00' + : '0$seconds'; + + final formattedTime = + '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; + + return formattedTime; + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/video_player.dart b/mobile/lib/modules/asset_viewer/ui/video_player.dart new file mode 100644 index 0000000000000..1f856e7d0f5c0 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/video_player.dart @@ -0,0 +1,45 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/custom_video_player_controls.dart'; +import 'package:video_player/video_player.dart'; + +class VideoPlayerViewer extends HookConsumerWidget { + final VideoPlayerController controller; + final bool isMotionVideo; + final Widget? placeholder; + final Duration hideControlsTimer; + final bool showControls; + final bool showDownloadingIndicator; + + const VideoPlayerViewer({ + super.key, + required this.controller, + required this.isMotionVideo, + this.placeholder, + required this.hideControlsTimer, + required this.showControls, + required this.showDownloadingIndicator, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final chewie = useChewieController( + controller: controller, + controlsSafeAreaMinimum: const EdgeInsets.only( + bottom: 100, + ), + placeholder: SizedBox.expand(child: placeholder), + customControls: CustomVideoPlayerControls( + hideTimerDuration: hideControlsTimer, + ), + showControls: showControls && !isMotionVideo, + hideControlsTimer: hideControlsTimer, + ); + + return Chewie( + controller: chewie, + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart deleted file mode 100644 index bfc45b8a3570f..0000000000000 --- a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart +++ /dev/null @@ -1,209 +0,0 @@ -import 'dart:async'; - -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart'; -import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; -import 'package:video_player/video_player.dart'; - -class VideoPlayerControls extends ConsumerStatefulWidget { - const VideoPlayerControls({ - super.key, - }); - - @override - VideoPlayerControlsState createState() => VideoPlayerControlsState(); -} - -class VideoPlayerControlsState extends ConsumerState - with SingleTickerProviderStateMixin { - late VideoPlayerController controller; - late VideoPlayerValue _latestValue; - bool _displayBufferingIndicator = false; - double? _latestVolume; - Timer? _hideTimer; - - ChewieController? _chewieController; - ChewieController get chewieController => _chewieController!; - - @override - Widget build(BuildContext context) { - ref.listen(videoPlayerControlsProvider.select((value) => value.mute), - (_, value) { - _mute(value); - _cancelAndRestartTimer(); - }); - - ref.listen(videoPlayerControlsProvider.select((value) => value.position), - (_, position) { - _seekTo(position); - _cancelAndRestartTimer(); - }); - - if (_latestValue.hasError) { - return chewieController.errorBuilder?.call( - context, - chewieController.videoPlayerController.value.errorDescription!, - ) ?? - const Center( - child: Icon( - Icons.error, - color: Colors.white, - size: 42, - ), - ); - } - - return GestureDetector( - onTap: () => _cancelAndRestartTimer(), - child: AbsorbPointer( - absorbing: !ref.watch(showControlsProvider), - child: Stack( - children: [ - if (_displayBufferingIndicator) - const Center( - child: DelayedLoadingIndicator( - fadeInDuration: Duration(milliseconds: 400), - ), - ) - else - _buildHitArea(), - ], - ), - ), - ); - } - - @override - void dispose() { - _dispose(); - - super.dispose(); - } - - void _dispose() { - controller.removeListener(_updateState); - _hideTimer?.cancel(); - } - - @override - void didChangeDependencies() { - final oldController = _chewieController; - _chewieController = ChewieController.of(context); - controller = chewieController.videoPlayerController; - _latestValue = controller.value; - - if (oldController != chewieController) { - _dispose(); - _initialize(); - } - - super.didChangeDependencies(); - } - - Widget _buildHitArea() { - final bool isFinished = _latestValue.position >= _latestValue.duration; - - return GestureDetector( - onTap: () { - if (!_latestValue.isPlaying) { - _playPause(); - } - ref.read(showControlsProvider.notifier).show = false; - }, - child: CenterPlayButton( - backgroundColor: Colors.black54, - iconColor: Colors.white, - isFinished: isFinished, - isPlaying: controller.value.isPlaying, - show: ref.watch(showControlsProvider), - onPressed: _playPause, - ), - ); - } - - void _cancelAndRestartTimer() { - _hideTimer?.cancel(); - _startHideTimer(); - ref.read(showControlsProvider.notifier).show = true; - } - - Future _initialize() async { - ref.read(showControlsProvider.notifier).show = false; - _mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute))); - - _latestValue = controller.value; - controller.addListener(_updateState); - - if (controller.value.isPlaying || chewieController.autoPlay) { - _startHideTimer(); - } - } - - void _playPause() { - final isFinished = _latestValue.position >= _latestValue.duration; - - setState(() { - if (controller.value.isPlaying) { - ref.read(showControlsProvider.notifier).show = true; - _hideTimer?.cancel(); - controller.pause(); - } else { - _cancelAndRestartTimer(); - - if (!controller.value.isInitialized) { - controller.initialize().then((_) { - controller.play(); - }); - } else { - if (isFinished) { - controller.seekTo(Duration.zero); - } - controller.play(); - } - } - }); - } - - void _startHideTimer() { - final hideControlsTimer = chewieController.hideControlsTimer; - _hideTimer?.cancel(); - _hideTimer = Timer(hideControlsTimer, () { - ref.read(showControlsProvider.notifier).show = false; - }); - } - - void _updateState() { - if (!mounted) return; - - _displayBufferingIndicator = controller.value.isBuffering; - - setState(() { - _latestValue = controller.value; - ref.read(videoPlaybackValueProvider.notifier).value = VideoPlaybackValue( - position: _latestValue.position, - duration: _latestValue.duration, - ); - }); - } - - void _mute(bool mute) { - if (mute) { - _latestVolume = controller.value.volume; - controller.setVolume(0); - } else { - controller.setVolume(_latestVolume ?? 0.5); - } - } - - void _seekTo(double position) { - final Duration pos = controller.value.duration * (position / 100.0); - if (pos != controller.value.position) { - controller.seekTo(pos); - } - } -} diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index dfdfb328443d3..2af7679a91e88 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -2,46 +2,31 @@ import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'dart:ui' as ui; -import 'package:easy_localization/easy_localization.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; -import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/bottom_gallery_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/gallery_app_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; -import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; -import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; -import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; -import 'package:immich_mobile/shared/providers/server_info.provider.dart'; -import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; -import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:isar/isar.dart'; import 'package:openapi/api.dart' show ThumbnailFormat; @@ -73,18 +58,16 @@ class GalleryViewerPage extends HookConsumerWidget { final settings = ref.watch(appSettingsServiceProvider); final isLoadPreview = useState(AppSettingsEnum.loadPreview.defaultValue); final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); - final isZoomed = useState(false); - final isPlayingMotionVideo = useState(false); + final isZoomed = useState(false); final isPlayingVideo = useState(false); - Offset? localPosition; + final localPosition = useState(null); final currentIndex = useState(initialIndex); final currentAsset = loadAsset(currentIndex.value); - final isTrashEnabled = - ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); - final navStack = AutoRouter.of(context).stackData; - final isFromTrash = isTrashEnabled && - navStack.length > 2 && - navStack.elementAt(navStack.length - 2).name == TrashRoute.name; + // Update is playing motion video + ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) { + isPlayingVideo.value = state == VideoPlaybackState.playing; + }); + final stackIndex = useState(-1); final stack = showStack && currentAsset.stackChildrenCount > 0 ? ref.watch(assetStackStateProvider(currentAsset)) @@ -92,30 +75,23 @@ class GalleryViewerPage extends HookConsumerWidget { final stackElements = showStack ? [currentAsset, ...stack] : []; // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id final isFromDto = currentAsset.id == Isar.autoIncrement; - final album = ref.watch(currentAlbumProvider); - Asset asset() => stackIndex.value == -1 + Asset asset = stackIndex.value == -1 ? currentAsset : stackElements.elementAt(stackIndex.value); - final isOwner = asset().ownerId == ref.watch(currentUserProvider)?.isarId; - final isPartner = ref - .watch(partnerSharedWithProvider) - .map((e) => e.isarId) - .contains(asset().ownerId); - - bool isParent = stackIndex.value == -1 || stackIndex.value == 0; + final isMotionPhoto = asset.livePhotoVideoId != null; // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page ref.listen(currentAssetProvider, (_, __) {}); useEffect( () { // Delay state update to after the execution of build method Future.microtask( - () => ref.read(currentAssetProvider.notifier).set(asset()), + () => ref.read(currentAssetProvider.notifier).set(asset), ); return null; }, - [asset()], + [asset], ); useEffect( @@ -124,15 +100,11 @@ class GalleryViewerPage extends HookConsumerWidget { settings.getSetting(AppSettingsEnum.loadPreview); isLoadOriginal.value = settings.getSetting(AppSettingsEnum.loadOriginal); - isPlayingMotionVideo.value = false; return null; }, [], ); - void toggleFavorite(Asset asset) => - ref.read(assetProvider.notifier).toggleFavorite([asset]); - Future precacheNextImage(int index) async { void onError(Object exception, StackTrace? stackTrace) { // swallow error silently @@ -168,97 +140,8 @@ class GalleryViewerPage extends HookConsumerWidget { child: ref .watch(appSettingsServiceProvider) .getSetting(AppSettingsEnum.advancedTroubleshooting) - ? AdvancedBottomSheet(assetDetail: asset()) - : ExifBottomSheet(asset: asset()), - ); - }, - ); - } - - void removeAssetFromStack() { - if (stackIndex.value > 0 && showStack) { - ref - .read(assetStackStateProvider(currentAsset).notifier) - .removeChild(stackIndex.value - 1); - stackIndex.value = stackIndex.value - 1; - } - } - - void handleDelete(Asset deleteAsset) async { - // Cannot delete readOnly / external assets. They are handled through library offline jobs - if (asset().isReadOnly) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_delete_err_read_only'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } - Future onDelete(bool force) async { - final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( - {deleteAsset}, - force: force, - ); - if (isDeleted && isParent) { - if (totalAssets == 1) { - // Handle only one asset - context.popRoute(); - } else { - // Go to next page otherwise - controller.nextPage( - duration: const Duration(milliseconds: 100), - curve: Curves.fastLinearToSlowEaseIn, - ); - } - } - return isDeleted; - } - - // Asset is trashed - if (isTrashEnabled && !isFromTrash) { - final isDeleted = await onDelete(false); - if (isDeleted) { - // Can only trash assets stored in server. Local assets are always permanently removed for now - if (context.mounted && deleteAsset.isRemote && isParent) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'Asset trashed', - gravity: ToastGravity.BOTTOM, - ); - } - removeAssetFromStack(); - } - return; - } - - // Asset is permanently removed - showDialog( - context: context, - builder: (BuildContext _) { - return DeleteDialog( - onDelete: () async { - final isDeleted = await onDelete(true); - if (isDeleted) { - removeAssetFromStack(); - } - }, - ); - }, - ); - } - - void addToAlbum(Asset addToAlbumAsset) { - showModalBottomSheet( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15.0), - ), - context: context, - builder: (BuildContext _) { - return AddToAlbumBottomSheet( - assets: [addToAlbumAsset], + ? AdvancedBottomSheet(assetDetail: asset) + : ExifBottomSheet(asset: asset), ); }, ); @@ -274,12 +157,12 @@ class GalleryViewerPage extends HookConsumerWidget { } // Guard [localPosition] null - if (localPosition == null) { + if (localPosition.value == null) { return; } // Check for delta from initial down point - final d = details.localPosition - localPosition!; + final d = details.localPosition - localPosition.value!; // If the magnitude of the dx swipe is large, we probably didn't mean to go down if (d.dx.abs() > dxThreshold) { return; @@ -293,175 +176,52 @@ class GalleryViewerPage extends HookConsumerWidget { } } - shareAsset() { - if (asset().isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_share_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } - ref.read(imageViewerStateProvider.notifier).shareAsset(asset(), context); - } + useEffect( + () { + if (ref.read(showControlsProvider)) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + isPlayingVideo.value = false; + return null; + }, + [], + ); - handleArchive(Asset asset) { - ref.read(assetProvider.notifier).toggleArchive([asset]); - if (isParent) { - context.popRoute(); - return; - } - removeAssetFromStack(); - } - - handleUpload(Asset asset) { - showDialog( - context: context, - builder: (BuildContext _) { - return UploadDialog( - onUpload: () { - ref - .read(manualUploadProvider.notifier) - .uploadAssets(context, [asset]); - }, - ); - }, - ); - } - - handleDownload() { - if (asset().isLocal) { - return; - } - if (asset().isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_share_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } - - ref.read(imageViewerStateProvider.notifier).downloadAsset( - asset(), - context, - ); - } - - handleActivities() { - if (album != null && album.shared && album.remoteId != null) { - context.pushRoute(const ActivitiesRoute()); - } - } - - buildAppBar() { - return IgnorePointer( - ignoring: !ref.watch(showControlsProvider), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 100), - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, - child: Container( - color: Colors.black.withOpacity(0.4), - child: TopControlAppBar( - isOwner: isOwner, - isPartner: isPartner, - isPlayingMotionVideo: isPlayingMotionVideo.value, - asset: asset(), - onMoreInfoPressed: showInfo, - onFavorite: toggleFavorite, - onUploadPressed: - asset().isLocal ? () => handleUpload(asset()) : null, - onDownloadPressed: asset().isLocal - ? null - : () => - ref.read(imageViewerStateProvider.notifier).downloadAsset( - asset(), - context, - ), - onToggleMotionVideo: (() { - isPlayingMotionVideo.value = !isPlayingMotionVideo.value; - }), - onAddToAlbumPressed: () => addToAlbum(asset()), - onActivitiesPressed: handleActivities, - ), + useEffect( + () { + // No need to await this + unawaited( + // Delay this a bit so we can finish loading the page + Future.delayed(const Duration(milliseconds: 400)).then( + // Precache the next image + (_) => precacheNextImage(currentIndex.value + 1), ), - ), - ); - } + ); + return null; + }, + [], + ); - Widget buildProgressBar() { - final playerValue = ref.watch(videoPlaybackValueProvider); - - return Expanded( - child: Slider( - value: playerValue.duration == Duration.zero - ? 0.0 - : min( - playerValue.position.inMicroseconds / - playerValue.duration.inMicroseconds * - 100, - 100, - ), - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: Colors.white.withOpacity(0.75), - onChanged: (position) { - ref.read(videoPlayerControlsProvider.notifier).position = position; - }, - ), - ); - } - - Text buildPosition() { - final position = ref - .watch(videoPlaybackValueProvider.select((value) => value.position)); - - return Text( - _formatDuration(position), - style: TextStyle( - fontSize: 14.0, - color: Colors.white.withOpacity(.75), - fontWeight: FontWeight.normal, - ), - ); - } - - Text buildDuration() { - final duration = ref - .watch(videoPlaybackValueProvider.select((value) => value.duration)); - - return Text( - _formatDuration(duration), - style: TextStyle( - fontSize: 14.0, - color: Colors.white.withOpacity(.75), - fontWeight: FontWeight.normal, - ), - ); - } - - Widget buildMuteButton() { - return IconButton( - icon: Icon( - ref.watch(videoPlayerControlsProvider.select((value) => value.mute)) - ? Icons.volume_off - : Icons.volume_up, - ), - onPressed: () => - ref.read(videoPlayerControlsProvider.notifier).toggleMute(), - color: Colors.white, - ); - } + ref.listen(showControlsProvider, (_, show) { + if (show) { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } else { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + }); Widget buildStackedChildren() { return ListView.builder( shrinkWrap: true, scrollDirection: Axis.horizontal, itemCount: stackElements.length, + padding: const EdgeInsets.only( + left: 10, + right: 10, + bottom: 30, + ), itemBuilder: (context, index) { final assetId = stackElements.elementAt(index).remoteId; return Padding( @@ -495,246 +255,6 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - void showStackActionItems() { - showModalBottomSheet( - context: context, - enableDrag: false, - builder: (BuildContext ctx) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.only(top: 24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isParent) - ListTile( - leading: const Icon( - Icons.bookmark_border_outlined, - size: 24, - ), - onTap: () async { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - currentAsset, - stackElements.elementAt(stackIndex.value), - ); - ctx.pop(); - context.popRoute(); - }, - title: const Text( - "viewer_stack_use_as_main_asset", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ListTile( - leading: const Icon( - Icons.copy_all_outlined, - size: 24, - ), - onTap: () async { - if (isParent) { - await ref - .read(assetStackServiceProvider) - .updateStackParent( - currentAsset, - stackElements - .elementAt(1), // Next asset as parent - ); - // Remove itself from stack - await ref.read(assetStackServiceProvider).updateStack( - stackElements.elementAt(1), - childrenToRemove: [currentAsset], - ); - ctx.pop(); - context.popRoute(); - } else { - await ref.read(assetStackServiceProvider).updateStack( - currentAsset, - childrenToRemove: [ - stackElements.elementAt(stackIndex.value), - ], - ); - removeAssetFromStack(); - ctx.pop(); - } - }, - title: const Text( - "viewer_remove_from_stack", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ListTile( - leading: const Icon( - Icons.filter_none_outlined, - size: 18, - ), - onTap: () async { - await ref.read(assetStackServiceProvider).updateStack( - currentAsset, - childrenToRemove: stack, - ); - ctx.pop(); - context.popRoute(); - }, - title: const Text( - "viewer_unstack", - style: TextStyle(fontWeight: FontWeight.bold), - ).tr(), - ), - ], - ), - ), - ); - }, - ); - } - - // TODO: Migrate to a custom bottom bar and handle long press to delete - Widget buildBottomBar() { - // !!!! itemsList and actionlist should always be in sync - final itemsList = [ - BottomNavigationBarItem( - icon: Icon( - Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, - ), - label: 'control_bottom_app_bar_share'.tr(), - tooltip: 'control_bottom_app_bar_share'.tr(), - ), - if (isOwner) - asset().isArchived - ? BottomNavigationBarItem( - icon: const Icon(Icons.unarchive_rounded), - label: 'control_bottom_app_bar_unarchive'.tr(), - tooltip: 'control_bottom_app_bar_unarchive'.tr(), - ) - : BottomNavigationBarItem( - icon: const Icon(Icons.archive_outlined), - label: 'control_bottom_app_bar_archive'.tr(), - tooltip: 'control_bottom_app_bar_archive'.tr(), - ), - if (isOwner && stack.isNotEmpty) - BottomNavigationBarItem( - icon: const Icon(Icons.burst_mode_outlined), - label: 'control_bottom_app_bar_stack'.tr(), - tooltip: 'control_bottom_app_bar_stack'.tr(), - ), - if (isOwner) - BottomNavigationBarItem( - icon: const Icon(Icons.delete_outline), - label: 'control_bottom_app_bar_delete'.tr(), - tooltip: 'control_bottom_app_bar_delete'.tr(), - ), - if (!isOwner) - BottomNavigationBarItem( - icon: const Icon(Icons.download_outlined), - label: 'download'.tr(), - tooltip: 'download'.tr(), - ), - ]; - - List actionslist = [ - (_) => shareAsset(), - if (isOwner) (_) => handleArchive(asset()), - if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(), - if (isOwner) (_) => handleDelete(asset()), - if (!isOwner) (_) => handleDownload(), - ]; - - return IgnorePointer( - ignoring: !ref.watch(showControlsProvider), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 100), - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, - child: Column( - children: [ - if (stack.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - left: 10, - bottom: 30, - ), - child: SizedBox( - height: 40, - child: buildStackedChildren(), - ), - ), - Visibility( - visible: !asset().isImage && !isPlayingMotionVideo.value, - child: Container( - color: Colors.black.withOpacity(0.4), - child: Padding( - padding: MediaQuery.of(context).orientation == - Orientation.portrait - ? const EdgeInsets.symmetric(horizontal: 12.0) - : const EdgeInsets.symmetric(horizontal: 64.0), - child: Row( - children: [ - buildPosition(), - buildProgressBar(), - buildDuration(), - buildMuteButton(), - ], - ), - ), - ), - ), - BottomNavigationBar( - backgroundColor: Colors.black.withOpacity(0.4), - unselectedIconTheme: const IconThemeData(color: Colors.white), - selectedIconTheme: const IconThemeData(color: Colors.white), - unselectedLabelStyle: const TextStyle(color: Colors.black), - selectedLabelStyle: const TextStyle(color: Colors.black), - showSelectedLabels: false, - showUnselectedLabels: false, - items: itemsList, - onTap: (index) { - if (index < actionslist.length) { - actionslist[index].call(index); - } - }, - ), - ], - ), - ), - ); - } - - useEffect( - () { - if (ref.read(showControlsProvider)) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - } - return null; - }, - [], - ); - - useEffect( - () { - // No need to await this - unawaited( - // Delay this a bit so we can finish loading the page - Future.delayed(const Duration(milliseconds: 400)).then( - // Precache the next image - (_) => precacheNextImage(currentIndex.value + 1), - ), - ); - return null; - }, - [], - ); - - ref.listen(showControlsProvider, (_, show) { - if (show) { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - } - }); - return PopScope( canPop: false, onPopInvoked: (_) { @@ -762,7 +282,7 @@ class GalleryViewerPage extends HookConsumerWidget { ), ), ImmichThumbnail( - asset: asset(), + asset: asset, fit: BoxFit.contain, ), ], @@ -782,6 +302,7 @@ class GalleryViewerPage extends HookConsumerWidget { HapticFeedback.selectionClick(); currentIndex.value = value; stackIndex.value = -1; + isPlayingVideo.value = false; // Wait for page change animation to finish await Future.delayed(const Duration(milliseconds: 400)); @@ -790,14 +311,14 @@ class GalleryViewerPage extends HookConsumerWidget { }, builder: (context, index) { final a = - index == currentIndex.value ? asset() : loadAsset(index); + index == currentIndex.value ? asset : loadAsset(index); final ImageProvider provider = ImmichImage.imageProvider(asset: a); - if (a.isImage && !isPlayingMotionVideo.value) { + if (a.isImage && !isPlayingVideo.value) { return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) => - localPosition = details.localPosition, + localPosition.value = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), onTapDown: (_, __, ___) { @@ -821,7 +342,7 @@ class GalleryViewerPage extends HookConsumerWidget { } else { return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => - localPosition = details.localPosition, + localPosition.value = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( @@ -834,15 +355,9 @@ class GalleryViewerPage extends HookConsumerWidget { minScale: 1.0, basePosition: Alignment.center, child: VideoViewerPage( - onPlaying: () { - isPlayingVideo.value = true; - }, - onPaused: () => - WidgetsBinding.instance.addPostFrameCallback( - (_) => isPlayingVideo.value = false, - ), + key: ValueKey(a), asset: a, - isMotionVideo: isPlayingMotionVideo.value, + isMotionVideo: a.livePhotoVideoId != null, placeholder: Image( image: provider, fit: BoxFit.contain, @@ -850,11 +365,6 @@ class GalleryViewerPage extends HookConsumerWidget { width: context.width, alignment: Alignment.center, ), - onVideoEnded: () { - if (isPlayingMotionVideo.value) { - isPlayingMotionVideo.value = false; - } - }, ), ); } @@ -864,50 +374,41 @@ class GalleryViewerPage extends HookConsumerWidget { top: 0, left: 0, right: 0, - child: buildAppBar(), + child: GalleryAppBar( + asset: asset, + showInfo: showInfo, + isPlayingVideo: isPlayingVideo.value, + onToggleMotionVideo: () => + isPlayingVideo.value = !isPlayingVideo.value, + ), ), Positioned( bottom: 0, left: 0, right: 0, - child: buildBottomBar(), + child: Column( + children: [ + Visibility( + visible: stack.isNotEmpty, + child: SizedBox( + height: 40, + child: buildStackedChildren(), + ), + ), + BottomGalleryBar( + totalAssets: totalAssets, + controller: controller, + showStack: showStack, + stackIndex: stackIndex.value, + asset: asset, + showVideoPlayerControls: !asset.isImage && !isMotionPhoto, + ), + ], + ), ), ], ), ), ); } - - String _formatDuration(Duration position) { - final ms = position.inMilliseconds; - - int seconds = ms ~/ 1000; - final int hours = seconds ~/ 3600; - seconds = seconds % 3600; - final minutes = seconds ~/ 60; - seconds = seconds % 60; - - final hoursString = hours >= 10 - ? '$hours' - : hours == 0 - ? '00' - : '0$hours'; - - final minutesString = minutes >= 10 - ? '$minutes' - : minutes == 0 - ? '00' - : '0$minutes'; - - final secondsString = seconds >= 10 - ? '$seconds' - : seconds == 0 - ? '00' - : '0$seconds'; - - final formattedTime = - '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; - - return formattedTime; - } } diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index 0da2bc52dbaae..22f00c001d67a 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -1,21 +1,22 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:chewie/chewie.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controller_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/video_player.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; @RoutePage() // ignore: must_be_immutable -class VideoViewerPage extends HookWidget { +class VideoViewerPage extends HookConsumerWidget { final Asset asset; final bool isMotionVideo; final Widget? placeholder; - final VoidCallback? onVideoEnded; - final VoidCallback? onPlaying; - final VoidCallback? onPaused; final Duration hideControlsTimer; final bool showControls; final bool showDownloadingIndicator; @@ -24,9 +25,6 @@ class VideoViewerPage extends HookWidget { super.key, required this.asset, this.isMotionVideo = false, - this.onVideoEnded, - this.onPlaying, - this.onPaused, this.placeholder, this.showControls = true, this.hideControlsTimer = const Duration(seconds: 5), @@ -34,29 +32,107 @@ class VideoViewerPage extends HookWidget { }); @override - Widget build(BuildContext context) { - final controller = useChewieController( - asset, - controlsSafeAreaMinimum: const EdgeInsets.only( - bottom: 100, - ), - placeholder: placeholder, - showControls: showControls && !isMotionVideo, - hideControlsTimer: hideControlsTimer, - customControls: const VideoPlayerControls(), - onPlaying: onPlaying, - onPaused: onPaused, - onVideoEnded: onVideoEnded, + build(BuildContext context, WidgetRef ref) { + final controller = + ref.watch(videoPlayerControllerProvider(asset: asset)).value; + // The last volume of the video used when mute is toggled + final lastVolume = useState(0.5); + + // When the volume changes, set the volume + ref.listen(videoPlayerControlsProvider.select((value) => value.mute), + (_, mute) { + if (mute) { + controller?.setVolume(0.0); + } else { + controller?.setVolume(lastVolume.value); + } + }); + + // When the position changes, seek to the position + ref.listen(videoPlayerControlsProvider.select((value) => value.position), + (_, position) { + if (controller == null) { + // No seeeking if there is no video + return; + } + + // Find the position to seek to + final Duration seek = controller.value.duration * (position / 100.0); + controller.seekTo(seek); + }); + + // When the custom video controls paus or plays + ref.listen(videoPlayerControlsProvider.select((value) => value.pause), + (lastPause, pause) { + if (pause) { + controller?.pause(); + } else { + controller?.play(); + } + }); + + // Updates the [videoPlaybackValueProvider] with the current + // position and duration of the video from the Chewie [controller] + // Also sets the error if there is an error in the playback + void updateVideoPlayback() { + final videoPlayback = VideoPlaybackValue.fromController(controller); + ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; + final state = videoPlayback.state; + + // Enable the WakeLock while the video is playing + if (state == VideoPlaybackState.playing) { + // Sync with the controls playing + WakelockPlus.enable(); + } else { + // Sync with the controls pause + WakelockPlus.disable(); + } + } + + // Adds and removes the listener to the video player + useEffect( + () { + Future.microtask( + () => ref.read(videoPlayerControlsProvider.notifier).reset(), + ); + // Guard no controller + if (controller == null) { + return null; + } + + // Hide the controls + // Done in a microtask to avoid setting the state while the is building + if (!isMotionVideo) { + Future.microtask(() { + ref.read(showControlsProvider.notifier).show = false; + }); + } + + // Subscribes to listener + controller.addListener(updateVideoPlayback); + return () { + // Removes listener when we dispose + controller.removeListener(updateVideoPlayback); + controller.pause(); + }; + }, + [controller], ); - // Loading + final size = MediaQuery.sizeOf(context); + return PopScope( + onPopInvoked: (pop) { + ref.read(videoPlaybackValueProvider.notifier).value = + VideoPlaybackValue.uninitialized(); + }, child: AnimatedSwitcher( duration: const Duration(milliseconds: 400), - child: Builder( - builder: (context) { - if (controller == null) { - return Stack( + child: Stack( + children: [ + Visibility( + visible: controller == null, + child: Stack( children: [ if (placeholder != null) placeholder!, const Positioned.fill( @@ -67,18 +143,22 @@ class VideoViewerPage extends HookWidget { ), ), ], - ); - } - - final size = MediaQuery.of(context).size; - return SizedBox( - height: size.height, - width: size.width, - child: Chewie( - controller: controller, ), - ); - }, + ), + if (controller != null) + SizedBox( + height: size.height, + width: size.width, + child: VideoPlayerViewer( + controller: controller, + isMotionVideo: isMotionVideo, + placeholder: placeholder, + hideControlsTimer: hideControlsTimer, + showControls: showControls, + showDownloadingIndicator: showDownloadingIndicator, + ), + ), + ], ), ), ); diff --git a/mobile/lib/modules/map/providers/map_state.provider.g.dart b/mobile/lib/modules/map/providers/map_state.provider.g.dart index ca75292e78927..d1b3e54b71069 100644 --- a/mobile/lib/modules/map/providers/map_state.provider.g.dart +++ b/mobile/lib/modules/map/providers/map_state.provider.g.dart @@ -6,7 +6,7 @@ part of 'map_state.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$mapStateNotifierHash() => r'3b509b57b7400b09817e9caee9debf899172cd52'; +String _$mapStateNotifierHash() => r'6408d616ec9fc0d1ff26e25692417c43504ff754'; /// See also [MapStateNotifier]. @ProviderFor(MapStateNotifier) diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart index af57c272aed3d..5a316db279fca 100644 --- a/mobile/lib/modules/memories/ui/memory_card.dart +++ b/mobile/lib/modules/memories/ui/memory_card.dart @@ -69,14 +69,16 @@ class MemoryCard extends StatelessWidget { return Hero( tag: 'memory-${asset.id}', child: VideoViewerPage( + key: ValueKey(asset), asset: asset, showDownloadingIndicator: false, - placeholder: ImmichImage( - asset, - fit: fit, + placeholder: SizedBox.expand( + child: ImmichImage( + asset, + fit: fit, + ), ), hideControlsTimer: const Duration(seconds: 2), - onVideoEnded: onVideoEnded, showControls: false, ), ); diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 16ac5efb0e956..64bd492a77169 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -350,9 +350,6 @@ abstract class _$AppRouter extends RootStackRouter { key: args.key, asset: args.asset, isMotionVideo: args.isMotionVideo, - onVideoEnded: args.onVideoEnded, - onPlaying: args.onPlaying, - onPaused: args.onPaused, placeholder: args.placeholder, showControls: args.showControls, hideControlsTimer: args.hideControlsTimer, @@ -1388,12 +1385,9 @@ class VideoViewerRoute extends PageRouteInfo { Key? key, required Asset asset, bool isMotionVideo = false, - void Function()? onVideoEnded, - void Function()? onPlaying, - void Function()? onPaused, Widget? placeholder, bool showControls = true, - Duration hideControlsTimer = const Duration(milliseconds: 1500), + Duration hideControlsTimer = const Duration(seconds: 5), bool showDownloadingIndicator = true, List? children, }) : super( @@ -1402,9 +1396,6 @@ class VideoViewerRoute extends PageRouteInfo { key: key, asset: asset, isMotionVideo: isMotionVideo, - onVideoEnded: onVideoEnded, - onPlaying: onPlaying, - onPaused: onPaused, placeholder: placeholder, showControls: showControls, hideControlsTimer: hideControlsTimer, @@ -1424,12 +1415,9 @@ class VideoViewerRouteArgs { this.key, required this.asset, this.isMotionVideo = false, - this.onVideoEnded, - this.onPlaying, - this.onPaused, this.placeholder, this.showControls = true, - this.hideControlsTimer = const Duration(milliseconds: 1500), + this.hideControlsTimer = const Duration(seconds: 5), this.showDownloadingIndicator = true, }); @@ -1439,12 +1427,6 @@ class VideoViewerRouteArgs { final bool isMotionVideo; - final void Function()? onVideoEnded; - - final void Function()? onPlaying; - - final void Function()? onPaused; - final Widget? placeholder; final bool showControls; @@ -1455,6 +1437,6 @@ class VideoViewerRouteArgs { @override String toString() { - return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, onVideoEnded: $onVideoEnded, onPlaying: $onPlaying, onPaused: $onPaused, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer, showDownloadingIndicator: $showDownloadingIndicator}'; + return 'VideoViewerRouteArgs{key: $key, asset: $asset, isMotionVideo: $isMotionVideo, placeholder: $placeholder, showControls: $showControls, hideControlsTimer: $hideControlsTimer, showDownloadingIndicator: $showDownloadingIndicator}'; } } diff --git a/mobile/lib/shared/ui/hooks/timer_hook.dart b/mobile/lib/shared/ui/hooks/timer_hook.dart new file mode 100644 index 0000000000000..a78fed42c30da --- /dev/null +++ b/mobile/lib/shared/ui/hooks/timer_hook.dart @@ -0,0 +1,48 @@ +import 'package:async/async.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +RestartableTimer useTimer( + Duration duration, + void Function() callback, +) { + return use( + _TimerHook( + duration: duration, + callback: callback, + ), + ); +} + +class _TimerHook extends Hook { + final Duration duration; + final void Function() callback; + + const _TimerHook({ + required this.duration, + required this.callback, + }); + @override + HookState> createState() => + _TimerHookState(); +} + +class _TimerHookState extends HookState { + late RestartableTimer timer; + @override + void initHook() { + super.initHook(); + timer = RestartableTimer(hook.duration, hook.callback); + } + + @override + RestartableTimer build(BuildContext context) { + return timer; + } + + @override + void dispose() { + timer.cancel(); + super.dispose(); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f27351898d12f..f7a57bb2b3714 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "2.4.2" async: - dependency: transitive + dependency: "direct main" description: name: async sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 04056977a43f0..cf29809caa409 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: timezone: ^0.9.2 octo_image: ^2.0.0 thumbhash: 0.1.0+1 + async: ^2.11.0 openapi: path: openapi From 4c0bb2308c99e03f12f565b5a90722a2337ca8a6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 00:24:33 -0500 Subject: [PATCH 09/47] fix(deps): update machine-learning (#7634) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- machine-learning/Dockerfile | 2 +- machine-learning/export/Dockerfile | 2 +- machine-learning/poetry.lock | 117 +++++++++++++++-------------- 3 files changed, 62 insertions(+), 59 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index c0a1a2030fd4d..561d2af41b4ac 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -39,7 +39,7 @@ FROM python:3.11-slim-bookworm@sha256:ce81dc539f0aedc9114cae640f8352fad83d37461c FROM openvino/ubuntu22_runtime:2023.1.0@sha256:002842a9005ba01543b7169ff6f14ecbec82287f09c4d1dd37717f0a8e8754a7 as prod-openvino USER root -FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04@sha256:8b51b1fe922964d73c482a267b5b519e990d90bf744ec7a40419923737caff6d as prod-cuda +FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04@sha256:2d913b09e6be8387e1a10976933642c73c840c0b735f0bf3c28d97fc9bc422e0 as prod-cuda COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3 COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11 diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index c22668380efd8..1016b330c26e0 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:6038b89363c9181215f3d9e8ce2720c880e224537f4028a854482e43a9b4998a as builder +FROM mambaorg/micromamba:bookworm-slim@sha256:96586e238e2fed914b839e50cf91943b5655262348d141466b34ced2e0b5b155 as builder ENV NODE_ENV=production \ TRANSFORMERS_CACHE=/cache \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index d3bb8578da682..ac5dfd2bfb83d 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,13 +680,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi" -version = "0.109.2" +version = "0.110.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi-0.109.2-py3-none-any.whl", hash = "sha256:2c9bab24667293b501cad8dd388c05240c850b58ec5876ee3283c47d6e1e3a4d"}, - {file = "fastapi-0.109.2.tar.gz", hash = "sha256:f3817eac96fe4f65a2ebb4baa000f394e55f5fccdaf7f75250804bc58f354f73"}, + {file = "fastapi-0.110.0-py3-none-any.whl", hash = "sha256:87a1f6fb632a218222c5984be540055346a8f5d8a68e8f6fb647b1dc9934de4b"}, + {file = "fastapi-0.110.0.tar.gz", hash = "sha256:266775f0dcc95af9d3ef39bad55cff525329a931d5fd51930aadd4f428bf7ff3"}, ] [package.dependencies] @@ -1274,13 +1274,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "huggingface-hub" -version = "0.20.3" +version = "0.21.3" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.20.3-py3-none-any.whl", hash = "sha256:d988ae4f00d3e307b0c80c6a05ca6dbb7edba8bba3079f74cda7d9c2e562a7b6"}, - {file = "huggingface_hub-0.20.3.tar.gz", hash = "sha256:94e7f8e074475fbc67d6a71957b678e1b4a74ff1b64a644fd6cbb83da962d05d"}, + {file = "huggingface_hub-0.21.3-py3-none-any.whl", hash = "sha256:b183144336fdf2810a8c109822e0bb6ef1fd61c65da6fb60e8c3f658b7144016"}, + {file = "huggingface_hub-0.21.3.tar.gz", hash = "sha256:26a15b604e4fc7bad37c467b76456543ec849386cbca9cd7e1e135f53e500423"}, ] [package.dependencies] @@ -1297,11 +1297,12 @@ all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", cli = ["InquirerPy (==0.3.4)"] dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "mypy (==1.5.1)", "numpy", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.1.3)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] +hf-transfer = ["hf-transfer (>=0.1.4)"] inference = ["aiohttp", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)"] quality = ["mypy (==1.5.1)", "ruff (>=0.1.3)"] tensorflow = ["graphviz", "pydot", "tensorflow"] testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "numpy", "pydantic (>1.1,<2.0)", "pydantic (>1.1,<3.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] -torch = ["torch"] +torch = ["safetensors", "torch"] typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] [[package]] @@ -1566,13 +1567,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.23.1" +version = "2.24.0" description = "Developer friendly load testing framework" optional = false python-versions = ">=3.8" files = [ - {file = "locust-2.23.1-py3-none-any.whl", hash = "sha256:96013a460a4b4d6d4fd46c70e6ff1fd2b6e03b48ddb1b48d1513d3134ba2cecf"}, - {file = "locust-2.23.1.tar.gz", hash = "sha256:6cc729729e5ebf5852fc9d845302cfcf0ab0132f198e68b3eb0c88b438b6a863"}, + {file = "locust-2.24.0-py3-none-any.whl", hash = "sha256:1b6b878b4fd0108fec956120815e69775d2616c8f4d1e9f365c222a7a5c17d9a"}, + {file = "locust-2.24.0.tar.gz", hash = "sha256:6cffa378d995244a7472af6be1d6139331f19aee44e907deee73e0281252804d"}, ] [package.dependencies] @@ -1588,6 +1589,7 @@ pywin32 = {version = "*", markers = "platform_system == \"Windows\""} pyzmq = ">=25.0.0" requests = ">=2.26.0" roundrobin = ">=0.0.2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} Werkzeug = ">=2.0.0" [[package]] @@ -1988,36 +1990,36 @@ reference = ["Pillow", "google-re2"] [[package]] name = "onnxruntime" -version = "1.17.0" +version = "1.17.1" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime-1.17.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d2b22a25a94109cc983443116da8d9805ced0256eb215c5e6bc6dcbabefeab96"}, - {file = "onnxruntime-1.17.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4c87d83c6f58d1af2675fc99e3dc810f2dbdb844bcefd0c1b7573632661f6fc"}, - {file = "onnxruntime-1.17.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dba55723bf9b835e358f48c98a814b41692c393eb11f51e02ece0625c756b797"}, - {file = "onnxruntime-1.17.0-cp310-cp310-win32.whl", hash = "sha256:ee48422349cc500273beea7607e33c2237909f58468ae1d6cccfc4aecd158565"}, - {file = "onnxruntime-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:f34cc46553359293854e38bdae2ab1be59543aad78a6317e7746d30e311110c3"}, - {file = "onnxruntime-1.17.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:16d26badd092c8c257fa57c458bb600d96dc15282c647ccad0ed7b2732e6c03b"}, - {file = "onnxruntime-1.17.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f1273bebcdb47ed932d076c85eb9488bc4768fcea16d5f2747ca692fad4f9d3"}, - {file = "onnxruntime-1.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb60fd3c2c1acd684752eb9680e89ae223e9801a9b0e0dc7b28adabe45a2e380"}, - {file = "onnxruntime-1.17.0-cp311-cp311-win32.whl", hash = "sha256:4b038324586bc905299e435f7c00007e6242389c856b82fe9357fdc3b1ef2bdc"}, - {file = "onnxruntime-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:93d39b3fa1ee01f034f098e1c7769a811a21365b4883f05f96c14a2b60c6028b"}, - {file = "onnxruntime-1.17.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:90c0890e36f880281c6c698d9bc3de2afbeee2f76512725ec043665c25c67d21"}, - {file = "onnxruntime-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7466724e809a40e986b1637cba156ad9fc0d1952468bc00f79ef340bc0199552"}, - {file = "onnxruntime-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d47bee7557a8b99c8681b6882657a515a4199778d6d5e24e924d2aafcef55b0a"}, - {file = "onnxruntime-1.17.0-cp312-cp312-win32.whl", hash = "sha256:bb1bf1ee575c665b8bbc3813ab906e091a645a24ccc210be7932154b8260eca1"}, - {file = "onnxruntime-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac2f286da3494b29b4186ca193c7d4e6a2c1f770c4184c7192c5da142c3dec28"}, - {file = "onnxruntime-1.17.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1ec485643b93e0a3896c655eb2426decd63e18a278bb7ccebc133b340723624f"}, - {file = "onnxruntime-1.17.0-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83c35809cda898c5a11911c69ceac8a2ac3925911854c526f73bad884582f911"}, - {file = "onnxruntime-1.17.0-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fa464aa4d81df818375239e481887b656e261377d5b6b9a4692466f5f3261edc"}, - {file = "onnxruntime-1.17.0-cp38-cp38-win32.whl", hash = "sha256:b7b337cd0586f7836601623cbd30a443df9528ef23965860d11c753ceeb009f2"}, - {file = "onnxruntime-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:fbb9faaf51d01aa2c147ef52524d9326744c852116d8005b9041809a71838878"}, - {file = "onnxruntime-1.17.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:5a06ab84eaa350bf64b1d747b33ccf10da64221ed1f38f7287f15eccbec81603"}, - {file = "onnxruntime-1.17.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d3d11db2c8242766212a68d0b139745157da7ce53bd96ba349a5c65e5a02357"}, - {file = "onnxruntime-1.17.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5632077c3ab8b0cd4f74b0af9c4e924be012b1a7bcd7daa845763c6c6bf14b7d"}, - {file = "onnxruntime-1.17.0-cp39-cp39-win32.whl", hash = "sha256:61a12732cba869b3ad2d4e29ab6cb62c7a96f61b8c213f7fcb961ba412b70b37"}, - {file = "onnxruntime-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:461fa0fc7d9c392c352b6cccdedf44d818430f3d6eacd924bb804fdea2dcfd02"}, + {file = "onnxruntime-1.17.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d43ac17ac4fa3c9096ad3c0e5255bb41fd134560212dc124e7f52c3159af5d21"}, + {file = "onnxruntime-1.17.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55b5e92a4c76a23981c998078b9bf6145e4fb0b016321a8274b1607bd3c6bd35"}, + {file = "onnxruntime-1.17.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebbcd2bc3a066cf54e6f18c75708eb4d309ef42be54606d22e5bdd78afc5b0d7"}, + {file = "onnxruntime-1.17.1-cp310-cp310-win32.whl", hash = "sha256:5e3716b5eec9092e29a8d17aab55e737480487deabfca7eac3cd3ed952b6ada9"}, + {file = "onnxruntime-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:fbb98cced6782ae1bb799cc74ddcbbeeae8819f3ad1d942a74d88e72b6511337"}, + {file = "onnxruntime-1.17.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:36fd6f87a1ecad87e9c652e42407a50fb305374f9a31d71293eb231caae18784"}, + {file = "onnxruntime-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99a8bddeb538edabc524d468edb60ad4722cff8a49d66f4e280c39eace70500b"}, + {file = "onnxruntime-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd7fddb4311deb5a7d3390cd8e9b3912d4d963efbe4dfe075edbaf18d01c024e"}, + {file = "onnxruntime-1.17.1-cp311-cp311-win32.whl", hash = "sha256:606a7cbfb6680202b0e4f1890881041ffc3ac6e41760a25763bd9fe146f0b335"}, + {file = "onnxruntime-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:53e4e06c0a541696ebdf96085fd9390304b7b04b748a19e02cf3b35c869a1e76"}, + {file = "onnxruntime-1.17.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:40f08e378e0f85929712a2b2c9b9a9cc400a90c8a8ca741d1d92c00abec60843"}, + {file = "onnxruntime-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac79da6d3e1bb4590f1dad4bb3c2979d7228555f92bb39820889af8b8e6bd472"}, + {file = "onnxruntime-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ae9ba47dc099004e3781f2d0814ad710a13c868c739ab086fc697524061695ea"}, + {file = "onnxruntime-1.17.1-cp312-cp312-win32.whl", hash = "sha256:2dff1a24354220ac30e4a4ce2fb1df38cb1ea59f7dac2c116238d63fe7f4c5ff"}, + {file = "onnxruntime-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:6226a5201ab8cafb15e12e72ff2a4fc8f50654e8fa5737c6f0bd57c5ff66827e"}, + {file = "onnxruntime-1.17.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:cd0c07c0d1dfb8629e820b05fda5739e4835b3b82faf43753d2998edf2cf00aa"}, + {file = "onnxruntime-1.17.1-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:617ebdf49184efa1ba6e4467e602fbfa029ed52c92f13ce3c9f417d303006381"}, + {file = "onnxruntime-1.17.1-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dae9071e3facdf2920769dceee03b71c684b6439021defa45b830d05e148924"}, + {file = "onnxruntime-1.17.1-cp38-cp38-win32.whl", hash = "sha256:835d38fa1064841679433b1aa8138b5e1218ddf0cfa7a3ae0d056d8fd9cec713"}, + {file = "onnxruntime-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:96621e0c555c2453bf607606d08af3f70fbf6f315230c28ddea91754e17ad4e6"}, + {file = "onnxruntime-1.17.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:7a9539935fb2d78ebf2cf2693cad02d9930b0fb23cdd5cf37a7df813e977674d"}, + {file = "onnxruntime-1.17.1-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45c6a384e9d9a29c78afff62032a46a993c477b280247a7e335df09372aedbe9"}, + {file = "onnxruntime-1.17.1-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e19f966450f16863a1d6182a685ca33ae04d7772a76132303852d05b95411ea"}, + {file = "onnxruntime-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e2ae712d64a42aac29ed7a40a426cb1e624a08cfe9273dcfe681614aa65b07dc"}, + {file = "onnxruntime-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:f7e9f7fb049825cdddf4a923cfc7c649d84d63c0134315f8e0aa9e0c3004672c"}, ] [package.dependencies] @@ -2633,6 +2635,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -2812,13 +2815,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.7.0" +version = "13.7.1" 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.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"}, - {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"}, + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, ] [package.dependencies] @@ -2840,28 +2843,28 @@ files = [ [[package]] name = "ruff" -version = "0.2.2" +version = "0.3.0" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"}, - {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"}, - {file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"}, - {file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"}, - {file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"}, - {file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"}, - {file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"}, - {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"}, + {file = "ruff-0.3.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7deb528029bacf845bdbb3dbb2927d8ef9b4356a5e731b10eef171e3f0a85944"}, + {file = "ruff-0.3.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e1e0d4381ca88fb2b73ea0766008e703f33f460295de658f5467f6f229658c19"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f7dbba46e2827dfcb0f0cc55fba8e96ba7c8700e0a866eb8cef7d1d66c25dcb"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23dbb808e2f1d68eeadd5f655485e235c102ac6f12ad31505804edced2a5ae77"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ef655c51f41d5fa879f98e40c90072b567c666a7114fa2d9fe004dffba00932"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d0d3d7ef3d4f06433d592e5f7d813314a34601e6c5be8481cccb7fa760aa243e"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b08b356d06a792e49a12074b62222f9d4ea2a11dca9da9f68163b28c71bf1dd4"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9343690f95710f8cf251bee1013bf43030072b9f8d012fbed6ad702ef70d360a"}, + {file = "ruff-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1f3ed501a42f60f4dedb7805fa8d4534e78b4e196f536bac926f805f0743d49"}, + {file = "ruff-0.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cc30a9053ff2f1ffb505a585797c23434d5f6c838bacfe206c0e6cf38c921a1e"}, + {file = "ruff-0.3.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5da894a29ec018a8293d3d17c797e73b374773943e8369cfc50495573d396933"}, + {file = "ruff-0.3.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:755c22536d7f1889be25f2baf6fedd019d0c51d079e8417d4441159f3bcd30c2"}, + {file = "ruff-0.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd73fe7f4c28d317855da6a7bc4aa29a1500320818dd8f27df95f70a01b8171f"}, + {file = "ruff-0.3.0-py3-none-win32.whl", hash = "sha256:19eacceb4c9406f6c41af806418a26fdb23120dfe53583df76d1401c92b7c14b"}, + {file = "ruff-0.3.0-py3-none-win_amd64.whl", hash = "sha256:128265876c1d703e5f5e5a4543bd8be47c73a9ba223fd3989d4aa87dd06f312f"}, + {file = "ruff-0.3.0-py3-none-win_arm64.whl", hash = "sha256:e3a4a6d46aef0a84b74fcd201a4401ea9a6cd85614f6a9435f2d33dd8cefbf83"}, + {file = "ruff-0.3.0.tar.gz", hash = "sha256:0886184ba2618d815067cf43e005388967b67ab9c80df52b32ec1152ab49f53a"}, ] [[package]] From 52dfe5fc9257a6942d65c35c38efaabe3fa92c80 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 5 Mar 2024 23:44:56 -0600 Subject: [PATCH 10/47] fix(server): stack info in asset response for mobile (#7346) * fix(server): stack info in asset response for mobile * fix(server): getAllAssets - do not filter by stack ID * tet(server): GET /assets stack e2e * chore(server): fix checks * stack asset height --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/ios/Podfile.lock | 2 +- .../asset_viewer/views/gallery_viewer.dart | 11 ++++++----- server/e2e/api/specs/asset.e2e-spec.ts | 18 ++++++++++++++++++ .../domain/repositories/search.repository.ts | 2 +- .../src/immich/api-v1/asset/asset.service.ts | 12 ++++++++++-- server/src/infra/infra.utils.ts | 8 +------- server/src/infra/sql/search.repository.sql | 8 -------- 7 files changed, 37 insertions(+), 24 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index a9ac5b33817e9..6081988b7aaf6 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -180,4 +180,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d -COCOAPODS: 1.12.1 +COCOAPODS: 1.11.3 diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 2af7679a91e88..c556adbec2e3c 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -218,18 +218,19 @@ class GalleryViewerPage extends HookConsumerWidget { scrollDirection: Axis.horizontal, itemCount: stackElements.length, padding: const EdgeInsets.only( - left: 10, - right: 10, + left: 5, + right: 5, bottom: 30, ), itemBuilder: (context, index) { final assetId = stackElements.elementAt(index).remoteId; return Padding( - padding: const EdgeInsets.only(right: 10), + padding: const EdgeInsets.only(right: 5), child: GestureDetector( onTap: () => stackIndex.value = index, child: Container( - width: 40, + width: 60, + height: 60, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(6), @@ -391,7 +392,7 @@ class GalleryViewerPage extends HookConsumerWidget { Visibility( visible: stack.isNotEmpty, child: SizedBox( - height: 40, + height: 80, child: buildStackedChildren(), ), ), diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts index 748418718241f..3f49fb407afff 100644 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ b/server/e2e/api/specs/asset.e2e-spec.ts @@ -14,6 +14,7 @@ import { AssetEntity, AssetStackEntity, AssetType, SharedLinkType } from '@app/i import { AssetRepository } from '@app/infra/repositories'; import { INestApplication } from '@nestjs/common'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; +import { assetApi } from 'e2e/client/asset-api'; import { randomBytes } from 'node:crypto'; import request from 'supertest'; import { api } from '../../client'; @@ -532,6 +533,23 @@ describe(`${AssetController.name} (e2e)`, () => { } }); } + + it('should return stack data', async () => { + const parentId = asset1.id; + const childIds = [asset2.id, asset3.id]; + await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: parentId, ids: childIds }); + + const body = await assetApi.getAllAssets(server, user1.accessToken); + // Response includes parent with stack children count + const parentDto = body.find((a) => a.id == parentId); + expect(parentDto?.stackCount).toEqual(3); + + // Response includes children at the root level + expect.arrayContaining([expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })]); + }); }); describe('POST /asset/upload', () => { diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index c9fec3cf71c79..10182a44ec3f4 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -92,12 +92,12 @@ export interface SearchStatusOptions { export interface SearchOneToOneRelationOptions { withExif?: boolean; withSmartInfo?: boolean; + withStacked?: boolean; } export interface SearchRelationOptions extends SearchOneToOneRelationOptions { withFaces?: boolean; withPeople?: boolean; - withStacked?: boolean; } export interface SearchDateOptions { diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 5dcc487be44ec..b8c3222ee196e 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -116,9 +116,17 @@ export class AssetService { await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); const assets = await this.assetRepository.getAllByFileCreationDate( { take: dto.take ?? 1000, skip: dto.skip }, - { ...dto, userIds: [userId], withDeleted: true, orderDirection: 'DESC', withExif: true, isVisible: true }, + { + ...dto, + userIds: [userId], + withDeleted: true, + orderDirection: 'DESC', + withExif: true, + isVisible: true, + withStacked: true, + }, ); - return assets.items.map((asset) => mapAsset(asset)); + return assets.items.map((asset) => mapAsset(asset, { withStack: true })); } async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise { diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 2c6e4b7470061..652bced6b6ae7 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -2,7 +2,6 @@ import { AssetSearchBuilderOptions, Paginated, PaginationOptions } from '@app/do import _ from 'lodash'; import { Between, - Brackets, FindManyOptions, IsNull, LessThanOrEqual, @@ -229,12 +228,7 @@ export function searchAssetBuilder( } if (withStacked) { - builder - .leftJoinAndSelect(`${builder.alias}.stack`, 'stack') - .leftJoinAndSelect('stack.assets', 'stackedAssets') - .andWhere( - new Brackets((qb) => qb.where(`stack.primaryAssetId = ${builder.alias}.id`).orWhere('asset.stackId IS NULL')), - ); + builder.leftJoinAndSelect(`${builder.alias}.stack`, 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets'); } const withDeleted = options.withDeleted ?? (trashedAfter !== undefined || trashedBefore !== undefined); diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql index c45d90a7a36b8..48a7fc8e5bce1 100644 --- a/server/src/infra/sql/search.repository.sql +++ b/server/src/infra/sql/search.repository.sql @@ -83,10 +83,6 @@ FROM "asset"."isFavorite" = $3 AND "asset"."isArchived" = $4 ) - AND ( - "stack"."primaryAssetId" = "asset"."id" - OR "asset"."stackId" IS NULL - ) ) AND ("asset"."deletedAt" IS NULL) ) "distinctAlias" @@ -184,10 +180,6 @@ WHERE "asset"."isFavorite" = $3 AND "asset"."isArchived" = $4 ) - AND ( - "stack"."primaryAssetId" = "asset"."id" - OR "asset"."stackId" IS NULL - ) AND "asset"."ownerId" IN ($5) ) AND ("asset"."deletedAt" IS NULL) From 9125999d1a41dc042b545545429cabbc120cd036 Mon Sep 17 00:00:00 2001 From: Sam Holton Date: Wed, 6 Mar 2024 00:45:40 -0500 Subject: [PATCH 11/47] feat(server,web): make user deletion delay configurable (#7663) * feat(server,web): make user deletion delay configurable * alphabetical order * add min for user.deleteDelay in SettingInputField * make config.user.deleteDelay SettingInputField min consistent format * fix e2e test * update description on user delete delay --- docs/docs/install/config-file.md | 3 + e2e/src/api/specs/server-info.e2e-spec.ts | 1 + mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 1 + mobile/openapi/doc/ServerConfigDto.md | 1 + mobile/openapi/doc/SystemConfigDto.md | 1 + mobile/openapi/doc/SystemConfigUserDto.md | 15 +++ mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/server_config_dto.dart | 14 ++- .../openapi/lib/model/system_config_dto.dart | 14 ++- .../lib/model/system_config_user_dto.dart | 98 +++++++++++++++++++ .../openapi/test/server_config_dto_test.dart | 5 + .../openapi/test/system_config_dto_test.dart | 5 + .../test/system_config_user_dto_test.dart | 27 +++++ open-api/immich-openapi-specs.json | 23 ++++- open-api/typescript-sdk/src/fetch-client.ts | 5 + .../src/domain/server-info/server-info.dto.ts | 2 + .../server-info/server-info.service.spec.ts | 1 + .../domain/server-info/server-info.service.ts | 1 + .../dto/system-config-user.dto.ts | 11 +++ .../system-config/dto/system-config.dto.ts | 6 ++ .../system-config/system-config.core.ts | 3 + .../system-config.service.spec.ts | 12 ++- server/src/domain/user/user.service.spec.ts | 42 +++++++- server/src/domain/user/user.service.ts | 17 +++- .../infra/entities/system-config.entity.ts | 5 + server/test/fixtures/system-config.stub.ts | 1 + .../admin-page/delete-confirm-dialoge.svelte | 3 +- .../admin-page/settings/admin-settings.svelte | 3 + .../user-settings/user-settings.svelte | 45 +++++++++ web/src/lib/stores/server-config.store.ts | 1 + .../routes/admin/system-settings/+page.svelte | 10 +- 33 files changed, 366 insertions(+), 16 deletions(-) create mode 100644 mobile/openapi/doc/SystemConfigUserDto.md create mode 100644 mobile/openapi/lib/model/system_config_user_dto.dart create mode 100644 mobile/openapi/test/system_config_user_dto_test.dart create mode 100644 server/src/domain/system-config/dto/system-config-user.dto.ts create mode 100644 web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index 8a7776a42048c..0d7c8dafc1944 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -128,6 +128,9 @@ The default configuration looks like this: "theme": { "customCss": "" }, + "user": { + "deleteDelay": 7 + }, "library": { "scan": { "enabled": true, diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 7c8c45709ea15..b8262cb68a4e6 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -88,6 +88,7 @@ describe('/server-info', () => { loginPageMessage: '', oauthButtonText: 'Login with OAuth', trashDays: 30, + userDeleteDelay: 7, isInitialized: true, externalDomain: '', isOnboarded: false, diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index ea413b4870c4d..6144510b10f57 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -160,6 +160,7 @@ doc/SystemConfigTemplateStorageOptionDto.md doc/SystemConfigThemeDto.md doc/SystemConfigThumbnailDto.md doc/SystemConfigTrashDto.md +doc/SystemConfigUserDto.md doc/TagApi.md doc/TagResponseDto.md doc/TagTypeEnum.md @@ -357,6 +358,7 @@ lib/model/system_config_template_storage_option_dto.dart lib/model/system_config_theme_dto.dart lib/model/system_config_thumbnail_dto.dart lib/model/system_config_trash_dto.dart +lib/model/system_config_user_dto.dart lib/model/tag_response_dto.dart lib/model/tag_type_enum.dart lib/model/thumbnail_format.dart @@ -539,6 +541,7 @@ test/system_config_template_storage_option_dto_test.dart test/system_config_theme_dto_test.dart test/system_config_thumbnail_dto_test.dart test/system_config_trash_dto_test.dart +test/system_config_user_dto_test.dart test/tag_api_test.dart test/tag_response_dto_test.dart test/tag_type_enum_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b8548c79e60ab..d61ebcb65d153 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -355,6 +355,7 @@ Class | Method | HTTP request | Description - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md) - [SystemConfigThumbnailDto](doc//SystemConfigThumbnailDto.md) - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md) + - [SystemConfigUserDto](doc//SystemConfigUserDto.md) - [TagResponseDto](doc//TagResponseDto.md) - [TagTypeEnum](doc//TagTypeEnum.md) - [ThumbnailFormat](doc//ThumbnailFormat.md) diff --git a/mobile/openapi/doc/ServerConfigDto.md b/mobile/openapi/doc/ServerConfigDto.md index 317431b9bb6fe..7261965bfb0e4 100644 --- a/mobile/openapi/doc/ServerConfigDto.md +++ b/mobile/openapi/doc/ServerConfigDto.md @@ -14,6 +14,7 @@ Name | Type | Description | Notes **loginPageMessage** | **String** | | **oauthButtonText** | **String** | | **trashDays** | **int** | | +**userDeleteDelay** | **int** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SystemConfigDto.md b/mobile/openapi/doc/SystemConfigDto.md index 51bf203ff78e3..ad1afbe9fcb05 100644 --- a/mobile/openapi/doc/SystemConfigDto.md +++ b/mobile/openapi/doc/SystemConfigDto.md @@ -23,6 +23,7 @@ Name | Type | Description | Notes **theme** | [**SystemConfigThemeDto**](SystemConfigThemeDto.md) | | **thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) | | **trash** | [**SystemConfigTrashDto**](SystemConfigTrashDto.md) | | +**user** | [**SystemConfigUserDto**](SystemConfigUserDto.md) | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SystemConfigUserDto.md b/mobile/openapi/doc/SystemConfigUserDto.md new file mode 100644 index 0000000000000..c295954a8d3a6 --- /dev/null +++ b/mobile/openapi/doc/SystemConfigUserDto.md @@ -0,0 +1,15 @@ +# openapi.model.SystemConfigUserDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**deleteDelay** | **int** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 56bd907e0ab8f..2dfe3a3bee225 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -190,6 +190,7 @@ part 'model/system_config_template_storage_option_dto.dart'; part 'model/system_config_theme_dto.dart'; part 'model/system_config_thumbnail_dto.dart'; part 'model/system_config_trash_dto.dart'; +part 'model/system_config_user_dto.dart'; part 'model/tag_response_dto.dart'; part 'model/tag_type_enum.dart'; part 'model/thumbnail_format.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 24cffb7cff229..d73b505937a2f 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -462,6 +462,8 @@ class ApiClient { return SystemConfigThumbnailDto.fromJson(value); case 'SystemConfigTrashDto': return SystemConfigTrashDto.fromJson(value); + case 'SystemConfigUserDto': + return SystemConfigUserDto.fromJson(value); case 'TagResponseDto': return TagResponseDto.fromJson(value); case 'TagTypeEnum': diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 1509c1bbeb7ab..faa167c73a305 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -19,6 +19,7 @@ class ServerConfigDto { required this.loginPageMessage, required this.oauthButtonText, required this.trashDays, + required this.userDeleteDelay, }); String externalDomain; @@ -33,6 +34,8 @@ class ServerConfigDto { int trashDays; + int userDeleteDelay; + @override bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto && other.externalDomain == externalDomain && @@ -40,7 +43,8 @@ class ServerConfigDto { other.isOnboarded == isOnboarded && other.loginPageMessage == loginPageMessage && other.oauthButtonText == oauthButtonText && - other.trashDays == trashDays; + other.trashDays == trashDays && + other.userDeleteDelay == userDeleteDelay; @override int get hashCode => @@ -50,10 +54,11 @@ class ServerConfigDto { (isOnboarded.hashCode) + (loginPageMessage.hashCode) + (oauthButtonText.hashCode) + - (trashDays.hashCode); + (trashDays.hashCode) + + (userDeleteDelay.hashCode); @override - String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays]'; + String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; Map toJson() { final json = {}; @@ -63,6 +68,7 @@ class ServerConfigDto { json[r'loginPageMessage'] = this.loginPageMessage; json[r'oauthButtonText'] = this.oauthButtonText; json[r'trashDays'] = this.trashDays; + json[r'userDeleteDelay'] = this.userDeleteDelay; return json; } @@ -80,6 +86,7 @@ class ServerConfigDto { loginPageMessage: mapValueOfType(json, r'loginPageMessage')!, oauthButtonText: mapValueOfType(json, r'oauthButtonText')!, trashDays: mapValueOfType(json, r'trashDays')!, + userDeleteDelay: mapValueOfType(json, r'userDeleteDelay')!, ); } return null; @@ -133,6 +140,7 @@ class ServerConfigDto { 'loginPageMessage', 'oauthButtonText', 'trashDays', + 'userDeleteDelay', }; } diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 26387a163104f..0b5f64fc2709b 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -28,6 +28,7 @@ class SystemConfigDto { required this.theme, required this.thumbnail, required this.trash, + required this.user, }); SystemConfigFFmpegDto ffmpeg; @@ -60,6 +61,8 @@ class SystemConfigDto { SystemConfigTrashDto trash; + SystemConfigUserDto user; + @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto && other.ffmpeg == ffmpeg && @@ -76,7 +79,8 @@ class SystemConfigDto { other.storageTemplate == storageTemplate && other.theme == theme && other.thumbnail == thumbnail && - other.trash == trash; + other.trash == trash && + other.user == user; @override int get hashCode => @@ -95,10 +99,11 @@ class SystemConfigDto { (storageTemplate.hashCode) + (theme.hashCode) + (thumbnail.hashCode) + - (trash.hashCode); + (trash.hashCode) + + (user.hashCode); @override - String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]'; + String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash, user=$user]'; Map toJson() { final json = {}; @@ -117,6 +122,7 @@ class SystemConfigDto { json[r'theme'] = this.theme; json[r'thumbnail'] = this.thumbnail; json[r'trash'] = this.trash; + json[r'user'] = this.user; return json; } @@ -143,6 +149,7 @@ class SystemConfigDto { theme: SystemConfigThemeDto.fromJson(json[r'theme'])!, thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!, trash: SystemConfigTrashDto.fromJson(json[r'trash'])!, + user: SystemConfigUserDto.fromJson(json[r'user'])!, ); } return null; @@ -205,6 +212,7 @@ class SystemConfigDto { 'theme', 'thumbnail', 'trash', + 'user', }; } diff --git a/mobile/openapi/lib/model/system_config_user_dto.dart b/mobile/openapi/lib/model/system_config_user_dto.dart new file mode 100644 index 0000000000000..08d939c4836d2 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_user_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// 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 SystemConfigUserDto { + /// Returns a new [SystemConfigUserDto] instance. + SystemConfigUserDto({ + required this.deleteDelay, + }); + + int deleteDelay; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigUserDto && + other.deleteDelay == deleteDelay; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (deleteDelay.hashCode); + + @override + String toString() => 'SystemConfigUserDto[deleteDelay=$deleteDelay]'; + + Map toJson() { + final json = {}; + json[r'deleteDelay'] = this.deleteDelay; + return json; + } + + /// Returns a new [SystemConfigUserDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigUserDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return SystemConfigUserDto( + deleteDelay: mapValueOfType(json, r'deleteDelay')!, + ); + } + 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 = SystemConfigUserDto.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 = SystemConfigUserDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigUserDto-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] = SystemConfigUserDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'deleteDelay', + }; +} + diff --git a/mobile/openapi/test/server_config_dto_test.dart b/mobile/openapi/test/server_config_dto_test.dart index 813ac25656db1..f76556c50f6fc 100644 --- a/mobile/openapi/test/server_config_dto_test.dart +++ b/mobile/openapi/test/server_config_dto_test.dart @@ -46,6 +46,11 @@ void main() { // TODO }); + // int userDeleteDelay + test('to test the property `userDeleteDelay`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/system_config_dto_test.dart b/mobile/openapi/test/system_config_dto_test.dart index 5f41549870eeb..b41d07e5f9b28 100644 --- a/mobile/openapi/test/system_config_dto_test.dart +++ b/mobile/openapi/test/system_config_dto_test.dart @@ -91,6 +91,11 @@ void main() { // TODO }); + // SystemConfigUserDto user + test('to test the property `user`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/system_config_user_dto_test.dart b/mobile/openapi/test/system_config_user_dto_test.dart new file mode 100644 index 0000000000000..d3c7be050dd21 --- /dev/null +++ b/mobile/openapi/test/system_config_user_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// 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 + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SystemConfigUserDto +void main() { + // final instance = SystemConfigUserDto(); + + group('test SystemConfigUserDto', () { + // int deleteDelay + test('to test the property `deleteDelay`', () async { + // TODO + }); + + + }); + +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 132df95b918d9..676c91233ca34 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9090,6 +9090,9 @@ }, "trashDays": { "type": "integer" + }, + "userDeleteDelay": { + "type": "integer" } }, "required": [ @@ -9098,7 +9101,8 @@ "isOnboarded", "loginPageMessage", "oauthButtonText", - "trashDays" + "trashDays", + "userDeleteDelay" ], "type": "object" }, @@ -9661,6 +9665,9 @@ }, "trash": { "$ref": "#/components/schemas/SystemConfigTrashDto" + }, + "user": { + "$ref": "#/components/schemas/SystemConfigUserDto" } }, "required": [ @@ -9678,7 +9685,8 @@ "storageTemplate", "theme", "thumbnail", - "trash" + "trash", + "user" ], "type": "object" }, @@ -10162,6 +10170,17 @@ ], "type": "object" }, + "SystemConfigUserDto": { + "properties": { + "deleteDelay": { + "type": "integer" + } + }, + "required": [ + "deleteDelay" + ], + "type": "object" + }, "TagResponseDto": { "properties": { "id": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 77fd06fe74c22..334037f1e6a50 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -705,6 +705,7 @@ export type ServerConfigDto = { loginPageMessage: string; oauthButtonText: string; trashDays: number; + userDeleteDelay: number; }; export type ServerFeaturesDto = { configFile: boolean; @@ -918,6 +919,9 @@ export type SystemConfigTrashDto = { days: number; enabled: boolean; }; +export type SystemConfigUserDto = { + deleteDelay: number; +}; export type SystemConfigDto = { ffmpeg: SystemConfigFFmpegDto; job: SystemConfigJobDto; @@ -934,6 +938,7 @@ export type SystemConfigDto = { theme: SystemConfigThemeDto; thumbnail: SystemConfigThumbnailDto; trash: SystemConfigTrashDto; + user: SystemConfigUserDto; }; export type SystemConfigTemplateStorageOptionDto = { dayOptions: string[]; diff --git a/server/src/domain/server-info/server-info.dto.ts b/server/src/domain/server-info/server-info.dto.ts index b3ef426daead4..99d4f1566b250 100644 --- a/server/src/domain/server-info/server-info.dto.ts +++ b/server/src/domain/server-info/server-info.dto.ts @@ -88,6 +88,8 @@ export class ServerConfigDto { loginPageMessage!: string; @ApiProperty({ type: 'integer' }) trashDays!: number; + @ApiProperty({ type: 'integer' }) + userDeleteDelay!: number; isInitialized!: boolean; isOnboarded!: boolean; externalDomain!: string; diff --git a/server/src/domain/server-info/server-info.service.spec.ts b/server/src/domain/server-info/server-info.service.spec.ts index e097509e6abab..8c90f8107f9f2 100644 --- a/server/src/domain/server-info/server-info.service.spec.ts +++ b/server/src/domain/server-info/server-info.service.spec.ts @@ -196,6 +196,7 @@ describe(ServerInfoService.name, () => { loginPageMessage: '', oauthButtonText: 'Login with OAuth', trashDays: 30, + userDeleteDelay: 7, isInitialized: undefined, isOnboarded: false, externalDomain: '', diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index ba295aefab127..04b3c4b6e6034 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -96,6 +96,7 @@ export class ServerInfoService { return { loginPageMessage: config.server.loginPageMessage, trashDays: config.trash.days, + userDeleteDelay: config.user.deleteDelay, oauthButtonText: config.oauth.buttonText, isInitialized, isOnboarded: onboarding?.isOnboarded || false, diff --git a/server/src/domain/system-config/dto/system-config-user.dto.ts b/server/src/domain/system-config/dto/system-config-user.dto.ts new file mode 100644 index 0000000000000..22d6ef5fc375e --- /dev/null +++ b/server/src/domain/system-config/dto/system-config-user.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, Min } from 'class-validator'; + +export class SystemConfigUserDto { + @IsInt() + @Min(1) + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + deleteDelay!: number; +} diff --git a/server/src/domain/system-config/dto/system-config.dto.ts b/server/src/domain/system-config/dto/system-config.dto.ts index 122d78ca61b35..4906e293e9d72 100644 --- a/server/src/domain/system-config/dto/system-config.dto.ts +++ b/server/src/domain/system-config/dto/system-config.dto.ts @@ -16,6 +16,7 @@ import { SystemConfigStorageTemplateDto } from './system-config-storage-template import { SystemConfigThemeDto } from './system-config-theme.dto'; import { SystemConfigThumbnailDto } from './system-config-thumbnail.dto'; import { SystemConfigTrashDto } from './system-config-trash.dto'; +import { SystemConfigUserDto } from './system-config-user.dto'; export class SystemConfigDto implements SystemConfig { @Type(() => SystemConfigFFmpegDto) @@ -92,6 +93,11 @@ export class SystemConfigDto implements SystemConfig { @ValidateNested() @IsObject() server!: SystemConfigServerDto; + + @Type(() => SystemConfigUserDto) + @ValidateNested() + @IsObject() + user!: SystemConfigUserDto; } export function mapConfig(config: SystemConfig): SystemConfigDto { diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index a9d41d76d7943..1699f7131dd22 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -140,6 +140,9 @@ export const defaults = Object.freeze({ externalDomain: '', loginPageMessage: '', }, + user: { + deleteDelay: 7, + }, }); export enum FeatureFlag { diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 35e306a705906..77211821520ef 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -23,6 +23,7 @@ const updates: SystemConfigEntity[] = [ { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, { key: SystemConfigKey.TRASH_DAYS, value: 10 }, + { key: SystemConfigKey.USER_DELETE_DELAY, value: 15 }, ]; const updatedConfig = Object.freeze({ @@ -140,6 +141,9 @@ const updatedConfig = Object.freeze({ enabled: false, }, }, + user: { + deleteDelay: 15, + }, }); describe(SystemConfigService.name, () => { @@ -199,6 +203,7 @@ describe(SystemConfigService.name, () => { { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, { key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true }, { key: SystemConfigKey.TRASH_DAYS, value: 10 }, + { key: SystemConfigKey.USER_DELETE_DELAY, value: 15 }, ]); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); @@ -206,7 +211,12 @@ describe(SystemConfigService.name, () => { it('should load the config from a file', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 } }; + const partialConfig = { + ffmpeg: { crf: 30 }, + oauth: { autoLaunch: true }, + trash: { days: 10 }, + user: { deleteDelay: 15 }, + }; configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); await expect(sut.getConfig()).resolves.toEqual(updatedConfig); diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index a1e8b28c1a19b..cba4581562afd 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -13,7 +13,9 @@ import { newJobRepositoryMock, newLibraryRepositoryMock, newStorageRepositoryMock, + newSystemConfigRepositoryMock, newUserRepositoryMock, + systemConfigStub, userStub, } from '@test'; import { when } from 'jest-when'; @@ -26,6 +28,7 @@ import { IJobRepository, ILibraryRepository, IStorageRepository, + ISystemConfigRepository, IUserRepository, } from '../repositories'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -48,17 +51,28 @@ describe(UserService.name, () => { let jobMock: jest.Mocked; let libraryMock: jest.Mocked; let storageMock: jest.Mocked; + let configMock: jest.Mocked; beforeEach(() => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); + configMock = newSystemConfigRepositoryMock(); cryptoRepositoryMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); libraryMock = newLibraryRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new UserService(albumMock, assetMock, cryptoRepositoryMock, jobMock, libraryMock, storageMock, userMock); + sut = new UserService( + albumMock, + assetMock, + cryptoRepositoryMock, + jobMock, + libraryMock, + storageMock, + configMock, + userMock, + ); when(userMock.get).calledWith(authStub.admin.user.id, {}).mockResolvedValue(userStub.admin); when(userMock.get).calledWith(authStub.admin.user.id, { withDeleted: true }).mockResolvedValue(userStub.admin); @@ -461,6 +475,22 @@ describe(UserService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([]); }); + it('should skip users not ready for deletion - deleteDelay30', async () => { + configMock.load.mockResolvedValue(systemConfigStub.deleteDelay30); + userMock.getDeletedUsers.mockResolvedValue([ + {}, + { deletedAt: undefined }, + { deletedAt: null }, + { deletedAt: makeDeletedAt(15) }, + ] as UserEntity[]); + + await sut.handleUserDeleteCheck(); + + expect(userMock.getDeletedUsers).toHaveBeenCalled(); + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(jobMock.queueAll).toHaveBeenCalledWith([]); + }); + it('should queue user ready for deletion', async () => { const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) }; userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); @@ -470,6 +500,16 @@ describe(UserService.name, () => { expect(userMock.getDeletedUsers).toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); }); + + it('should queue user ready for deletion - deleteDelay30', async () => { + const user = { id: 'deleted-user', deletedAt: makeDeletedAt(31) }; + userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]); + + await sut.handleUserDeleteCheck(); + + expect(userMock.getDeletedUsers).toHaveBeenCalled(); + expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); + }); }); describe('handleUserDelete', () => { diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index a5b3fb7dc73f8..ace2fb5e17d16 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -13,16 +13,19 @@ import { IJobRepository, ILibraryRepository, IStorageRepository, + ISystemConfigRepository, IUserRepository, UserFindOptions, } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; +import { SystemConfigCore } from '../system-config/system-config.core'; import { CreateUserDto, UpdateUserDto } from './dto'; import { CreateProfileImageResponseDto, UserResponseDto, mapCreateProfileImageResponse, mapUser } from './response-dto'; import { UserCore } from './user.core'; @Injectable() export class UserService { + private configCore: SystemConfigCore; private logger = new ImmichLogger(UserService.name); private userCore: UserCore; @@ -33,9 +36,11 @@ export class UserService { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, + @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IUserRepository) private userRepository: IUserRepository, ) { this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); + this.configCore = SystemConfigCore.create(configRepository); } async getAll(auth: AuthDto, isAll: boolean): Promise { @@ -140,22 +145,26 @@ export class UserService { async handleUserDeleteCheck() { const users = await this.userRepository.getDeletedUsers(); + const config = await this.configCore.getConfig(); await this.jobRepository.queueAll( users.flatMap((user) => - this.isReadyForDeletion(user) ? [{ name: JobName.USER_DELETION, data: { id: user.id } }] : [], + this.isReadyForDeletion(user, config.user.deleteDelay) + ? [{ name: JobName.USER_DELETION, data: { id: user.id } }] + : [], ), ); return true; } async handleUserDelete({ id }: IEntityJob) { + const config = await this.configCore.getConfig(); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { return false; } // just for extra protection here - if (!this.isReadyForDeletion(user)) { + if (!this.isReadyForDeletion(user, config.user.deleteDelay)) { this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`); return false; } @@ -184,12 +193,12 @@ export class UserService { return true; } - private isReadyForDeletion(user: UserEntity): boolean { + private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean { if (!user.deletedAt) { return false; } - return DateTime.now().minus({ days: 7 }) > DateTime.fromJSDate(user.deletedAt); + return DateTime.now().minus({ days: deleteDelay }) > DateTime.fromJSDate(user.deletedAt); } private async findOrFail(id: string, options: UserFindOptions) { diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index e2d0c71f6b3b1..1ba219429e03c 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -108,6 +108,8 @@ export enum SystemConfigKey { TRASH_DAYS = 'trash.days', THEME_CUSTOM_CSS = 'theme.customCss', + + USER_DELETE_DELAY = 'user.deleteDelay', } export enum TranscodePolicy { @@ -276,4 +278,7 @@ export interface SystemConfig { externalDomain: string; loginPageMessage: string; }; + user: { + deleteDelay: number; + }; } diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index 0e99fb07a285c..9f9f02144c37d 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -27,6 +27,7 @@ export const systemConfigStub: Record = { { key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true }, { key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 }, ], + deleteDelay30: [{ key: SystemConfigKey.USER_DELETE_DELAY, value: 30 }], libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }], libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }], }; diff --git a/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte b/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte index 1046b7ef67a91..90246eb82b43f 100644 --- a/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte +++ b/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte @@ -2,6 +2,7 @@ import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; import { handleError } from '$lib/utils/handle-error'; import { deleteUser, type UserResponseDto } from '@immich/sdk'; + import { serverConfig } from '$lib/stores/server-config.store'; import { createEventDispatcher } from 'svelte'; export let user: UserResponseDto; @@ -30,7 +31,7 @@

- {user.name}'s account and assets will be permanently deleted after 7 days. + {user.name}'s account and assets will be permanently deleted after {$serverConfig.userDeleteDelay} days.

Are you sure you want to continue?

diff --git a/web/src/lib/components/admin-page/settings/admin-settings.svelte b/web/src/lib/components/admin-page/settings/admin-settings.svelte index 16b2afc7fe46c..8f819f1eb35a8 100644 --- a/web/src/lib/components/admin-page/settings/admin-settings.svelte +++ b/web/src/lib/components/admin-page/settings/admin-settings.svelte @@ -7,6 +7,7 @@ } from '$lib/components/shared-components/notification/notification'; import { handleError } from '$lib/utils/handle-error'; import { getConfig, getConfigDefaults, updateConfig, type SystemConfigDto } from '@immich/sdk'; + import { loadConfig } from '$lib/stores/server-config.store'; import { cloneDeep } from 'lodash-es'; import { createEventDispatcher, onMount } from 'svelte'; import type { SettingsEventType } from './admin-settings'; @@ -35,6 +36,8 @@ savedConfig = cloneDeep(newConfig); notificationController.show({ message: 'Settings saved', type: NotificationType.Info }); + await loadConfig(); + dispatch('save'); } catch (error) { handleError(error, 'Unable to save settings'); diff --git a/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte new file mode 100644 index 0000000000000..81a93a4091274 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte @@ -0,0 +1,45 @@ + + +
+
+
+
+ +
+ +
+ dispatch('reset', { ...detail, configKeys: ['user'] })} + on:save={() => dispatch('save', { user: config.user })} + showResetToDefault={!isEqual(savedConfig, defaultConfig)} + {disabled} + /> +
+
+
+
diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 5b0a52983406c..3190a8e23f95d 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -25,6 +25,7 @@ export const serverConfig = writable({ oauthButtonText: '', loginPageMessage: '', trashDays: 30, + userDeleteDelay: 7, isInitialized: false, isOnboarded: false, externalDomain: '', diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index e5d67bb99aa44..9cfd23b8cfb77 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -15,6 +15,7 @@ import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte'; import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte'; import TrashSettings from '$lib/components/admin-page/settings/trash-settings/trash-settings.svelte'; + import UserSettings from '$lib/components/admin-page/settings/user-settings/user-settings.svelte'; import LinkButton from '$lib/components/elements/buttons/link-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; @@ -45,7 +46,8 @@ | typeof ThumbnailSettings | typeof TrashSettings | typeof NewVersionCheckSettings - | typeof FFmpegSettings; + | typeof FFmpegSettings + | typeof UserSettings; const downloadConfig = () => { const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); @@ -134,6 +136,12 @@ subtitle: 'Manage trash settings', key: 'trash', }, + { + item: UserSettings, + title: 'User Settings', + subtitle: 'Manage user settings', + key: 'user-settings', + }, { item: NewVersionCheckSettings, title: 'Version Check', From 52a52f9f4006ccf013d050be4e981d4f048708d8 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 6 Mar 2024 12:47:15 +0100 Subject: [PATCH 12/47] fix(web): date input on chrome (#7669) --- web/src/lib/components/elements/date-input.svelte | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/elements/date-input.svelte b/web/src/lib/components/elements/date-input.svelte index e4ec4bcab8303..1f621b246484a 100644 --- a/web/src/lib/components/elements/date-input.svelte +++ b/web/src/lib/components/elements/date-input.svelte @@ -6,19 +6,15 @@ } export let value: $$Props['value'] = undefined; + + // Updating `value` directly causes the date input to reset itself or + // interfere with user changes. $: updatedValue = value; { - updatedValue = e.currentTarget.value; - - // Only update when value is not empty to prevent resetting the input - if (updatedValue !== '') { - value = updatedValue; - } - }} + on:input={(e) => (updatedValue = e.currentTarget.value)} on:blur={() => (value = updatedValue)} /> From ba12d92af36a4cb622032e4ffe5863a5dcc838fd Mon Sep 17 00:00:00 2001 From: Emanuel Bennici Date: Wed, 6 Mar 2024 17:15:54 +0100 Subject: [PATCH 13/47] feat(mobile): Add people list to exit bottom sheet (#6717) * feat(mobile): Define constants as 'const' * feat(mobile): Add people list to asset bottom sheet Add a list of people per asset in the exif bottom sheet, like on the web. Currently the list of people is loaded by making a request each time to the server. This is the MVP approach. In the future, the people information can be synced like we're doing with the assets. * styling --------- Co-authored-by: Alex Tran --- mobile/assets/i18n/de-DE.json | 3 +- mobile/assets/i18n/en-US.json | 3 +- mobile/assets/i18n/it-IT.json | 3 +- .../providers/asset_people.provider.dart | 51 +++++ .../providers/asset_people.provider.g.dart | 189 ++++++++++++++++++ .../asset_viewer/ui/exif_bottom_sheet.dart | 86 ++++++++ .../asset_viewer/views/gallery_viewer.dart | 6 +- .../modules/search/ui/curated_people_row.dart | 4 - .../lib/modules/search/views/search_page.dart | 32 +-- mobile/lib/shared/services/asset.service.dart | 21 ++ 10 files changed, 375 insertions(+), 23 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/providers/asset_people.provider.dart create mode 100644 mobile/lib/modules/asset_viewer/providers/asset_people.provider.g.dart diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index 4beb2f7010013..9b36e360b899c 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "STANDORT", "exif_bottom_sheet_location_add": "Aufnahmeort hinzufügen", + "exif_bottom_sheet_people": "PERSONEN", "experimental_settings_new_asset_list_subtitle": "In Arbeit", "experimental_settings_new_asset_list_title": "Experimentelles Fotogitter aktivieren", "experimental_settings_subtitle": "Benutzung auf eigene Gefahr!", @@ -476,4 +477,4 @@ "viewer_remove_from_stack": "Aus Stapel entfernen", "viewer_stack_use_as_main_asset": "An Stapelanfang", "viewer_unstack": "Stapel aufheben" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index d855502ef03e8..b32ce5f493683 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATION", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", @@ -476,4 +477,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/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index 1d08ccf130c02..4f38ba868e90e 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETTAGLI", "exif_bottom_sheet_location": "POSIZIONE", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PERSONE", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Attiva griglia di foto sperimentale", "experimental_settings_subtitle": "Usalo a tuo rischio!", @@ -476,4 +477,4 @@ "viewer_remove_from_stack": "Rimuovi dalla pila", "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/modules/asset_viewer/providers/asset_people.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_people.provider.dart new file mode 100644 index 0000000000000..a856a00140f43 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_people.provider.dart @@ -0,0 +1,51 @@ +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/services/asset.service.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'asset_people.provider.g.dart'; + +/// Maintains the list of people for an asset. +@riverpod +class AssetPeopleNotifier extends _$AssetPeopleNotifier { + final log = Logger('AssetPeopleNotifier'); + + @override + Future> build(Asset asset) async { + if (!asset.isRemote) { + return []; + } + + final list = await ref + .watch(assetServiceProvider) + .getRemotePeopleOfAsset(asset.remoteId!); + if (list == null) { + return []; + } + + // explicitly a sorted slice to make it deterministic + // named people will be at the beginning, and names are sorted + // ascendingly + list.sort((a, b) { + final aNotEmpty = a.name.isNotEmpty; + final bNotEmpty = b.name.isNotEmpty; + if (aNotEmpty && !bNotEmpty) { + return -1; + } else if (!aNotEmpty && bNotEmpty) { + return 1; + } else if (!aNotEmpty && !bNotEmpty) { + return 0; + } + + return a.name.compareTo(b.name); + }); + return list; + } + + Future refresh() async { + // invalidate the state – this way we don't have to + // duplicate the code from build. + ref.invalidateSelf(); + } +} diff --git a/mobile/lib/modules/asset_viewer/providers/asset_people.provider.g.dart b/mobile/lib/modules/asset_viewer/providers/asset_people.provider.g.dart new file mode 100644 index 0000000000000..449d5b6c8c7e6 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_people.provider.g.dart @@ -0,0 +1,189 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'asset_people.provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$assetPeopleNotifierHash() => + r'192a4ee188f781000fe43f1675c49e1081ccc631'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$AssetPeopleNotifier extends BuildlessAutoDisposeAsyncNotifier< + List> { + late final Asset asset; + + Future> build( + Asset asset, + ); +} + +/// Maintains the list of people for an asset. +/// +/// Copied from [AssetPeopleNotifier]. +@ProviderFor(AssetPeopleNotifier) +const assetPeopleNotifierProvider = AssetPeopleNotifierFamily(); + +/// Maintains the list of people for an asset. +/// +/// Copied from [AssetPeopleNotifier]. +class AssetPeopleNotifierFamily + extends Family>> { + /// Maintains the list of people for an asset. + /// + /// Copied from [AssetPeopleNotifier]. + const AssetPeopleNotifierFamily(); + + /// Maintains the list of people for an asset. + /// + /// Copied from [AssetPeopleNotifier]. + AssetPeopleNotifierProvider call( + Asset asset, + ) { + return AssetPeopleNotifierProvider( + asset, + ); + } + + @override + AssetPeopleNotifierProvider getProviderOverride( + covariant AssetPeopleNotifierProvider provider, + ) { + return call( + provider.asset, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'assetPeopleNotifierProvider'; +} + +/// Maintains the list of people for an asset. +/// +/// Copied from [AssetPeopleNotifier]. +class AssetPeopleNotifierProvider extends AutoDisposeAsyncNotifierProviderImpl< + AssetPeopleNotifier, List> { + /// Maintains the list of people for an asset. + /// + /// Copied from [AssetPeopleNotifier]. + AssetPeopleNotifierProvider( + Asset asset, + ) : this._internal( + () => AssetPeopleNotifier()..asset = asset, + from: assetPeopleNotifierProvider, + name: r'assetPeopleNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$assetPeopleNotifierHash, + dependencies: AssetPeopleNotifierFamily._dependencies, + allTransitiveDependencies: + AssetPeopleNotifierFamily._allTransitiveDependencies, + asset: asset, + ); + + AssetPeopleNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.asset, + }) : super.internal(); + + final Asset asset; + + @override + Future> runNotifierBuild( + covariant AssetPeopleNotifier notifier, + ) { + return notifier.build( + asset, + ); + } + + @override + Override overrideWith(AssetPeopleNotifier Function() create) { + return ProviderOverride( + origin: this, + override: AssetPeopleNotifierProvider._internal( + () => create()..asset = asset, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + asset: asset, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement> createElement() { + return _AssetPeopleNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is AssetPeopleNotifierProvider && other.asset == asset; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, asset.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin AssetPeopleNotifierRef + on AutoDisposeAsyncNotifierProviderRef> { + /// The parameter `asset` of this provider. + Asset get asset; +} + +class _AssetPeopleNotifierProviderElement + extends AutoDisposeAsyncNotifierProviderElement> with AssetPeopleNotifierRef { + _AssetPeopleNotifierProviderElement(super.provider); + + @override + Asset get asset => (origin as AssetPeopleNotifierProvider).asset; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index 3c6d5f2b6c892..c84e857eefd81 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -1,13 +1,21 @@ import 'dart:io'; +import 'dart:math' as math; +import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asset_extensions.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_people.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart'; +import 'package:immich_mobile/modules/search/models/curated_content.dart'; +import 'package:immich_mobile/modules/search/ui/curated_people_row.dart'; +import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/utils/selection_handlers.dart'; @@ -24,6 +32,10 @@ class ExifBottomSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final assetWithExif = ref.watch(assetDetailProvider(asset)); final exifInfo = (assetWithExif.value ?? asset).exifInfo; + final peopleProvider = + ref.watch(assetPeopleNotifierProvider(asset).notifier); + final people = ref.watch(assetPeopleNotifierProvider(asset)); + final double imageSize = math.min(context.width / 3, 150); var textColor = context.isDarkTheme ? Colors.white : Colors.black; bool hasCoordinates() => @@ -212,6 +224,72 @@ class ExifBottomSheet extends HookConsumerWidget { ); } + showPersonNameEditModel( + String personId, + String personName, + ) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PersonNameEditForm(personId: personId, personName: personName); + }, + ).then((_) { + // ensure the people list is up-to-date. + peopleProvider.refresh(); + }); + } + + buildPeople() { + return people.widgetWhen( + onData: (data) { + // either the server is not reachable or this asset has no people + if (data.isEmpty) { + return Container(); + } + + final curatedPeople = + data.map((p) => CuratedContent(id: p.id, label: p.name)).toList(); + + return Column( + children: [ + Align( + alignment: Alignment.topLeft, + child: Text( + "exif_bottom_sheet_people", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + ), + SizedBox( + height: imageSize, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: CuratedPeopleRow( + content: curatedPeople, + onTap: (content, index) { + context + .pushRoute( + PersonResultRoute( + personId: content.id, + personName: content.label, + ), + ) + .then((_) => peopleProvider.refresh()); + }, + onNameTap: (person, index) => { + showPersonNameEditModel(person.id, person.label), + }, + ), + ), + ), + ], + ); + }, + ); + } + buildDate() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -350,6 +428,12 @@ class ExifBottomSheet extends HookConsumerWidget { child: buildLocation(), ), ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: buildPeople(), + ), + ), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 300), child: Padding( @@ -382,6 +466,8 @@ class ExifBottomSheet extends HookConsumerWidget { child: CircularProgressIndicator.adaptive(), ), ), + const SizedBox(height: 16), + buildPeople(), buildLocation(), SizedBox(height: hasCoordinates() ? 16.0 : 6.0), buildDetail(), diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index c556adbec2e3c..09225a35fc665 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -148,9 +148,9 @@ class GalleryViewerPage extends HookConsumerWidget { } void handleSwipeUpDown(DragUpdateDetails details) { - int sensitivity = 15; - int dxThreshold = 50; - double ratioThreshold = 3.0; + const int sensitivity = 15; + const int dxThreshold = 50; + const double ratioThreshold = 3.0; if (isZoomed.value) { return; diff --git a/mobile/lib/modules/search/ui/curated_people_row.dart b/mobile/lib/modules/search/ui/curated_people_row.dart index aa3403f2a1d8b..f85f13e602d9b 100644 --- a/mobile/lib/modules/search/ui/curated_people_row.dart +++ b/mobile/lib/modules/search/ui/curated_people_row.dart @@ -44,10 +44,6 @@ class CuratedPeopleRow extends StatelessWidget { return ListView.builder( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.only( - left: 16, - top: 8, - ), itemBuilder: (context, index) { final person = content[index]; final headers = { diff --git a/mobile/lib/modules/search/views/search_page.dart b/mobile/lib/modules/search/views/search_page.dart index d6c556ef6d850..ab114d691b328 100644 --- a/mobile/lib/modules/search/views/search_page.dart +++ b/mobile/lib/modules/search/views/search_page.dart @@ -78,19 +78,25 @@ class SearchPage extends HookConsumerWidget { height: imageSize, child: curatedPeople.widgetWhen( onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (people) => CuratedPeopleRow( - content: people.take(12).toList(), - onTap: (content, index) { - context.pushRoute( - PersonResultRoute( - personId: content.id, - personName: content.label, - ), - ); - }, - onNameTap: (person, index) => { - showNameEditModel(person.id, person.label), - }, + onData: (people) => Padding( + padding: const EdgeInsets.only( + left: 16, + top: 8, + ), + child: CuratedPeopleRow( + content: people.take(12).toList(), + onTap: (content, index) { + context.pushRoute( + PersonResultRoute( + personId: content.id, + personName: content.label, + ), + ); + }, + onNameTap: (person, index) => { + showNameEditModel(person.id, person.label), + }, + ), ), ), ); diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index 3086ab9246b65..a9a65d26323bb 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -61,6 +61,27 @@ class AssetService { return (assetDto.map(Asset.remote).toList(), deleted.ids); } + /// Returns the list of people of the given asset id. + // If the server is not reachable `null` is returned. + Future?> getRemotePeopleOfAsset( + String remoteId, + ) async { + try { + final AssetResponseDto? dto = + await _apiService.assetApi.getAssetInfo(remoteId); + + return dto?.people; + } catch (error, stack) { + log.severe( + 'Error while getting remote asset info: ${error.toString()}', + error, + stack, + ); + + return null; + } + } + /// Returns `null` if the server state did not change, else list of assets Future?> _getRemoteAssets(User user) async { const int chunkSize = 10000; From f88343019dc397f8068542f5ccf4599004944901 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 6 Mar 2024 12:05:53 -0500 Subject: [PATCH 14/47] perf(web): optimize response sizes for initial page load (#7594) --- .../components/photos-page/asset-grid.svelte | 25 ++++++++++--------- .../components/photos-page/memory-lane.svelte | 2 +- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 10226a5ae47e6..2dd35ca30fea8 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -14,7 +14,6 @@ import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; import { createEventDispatcher, onDestroy, onMount } from 'svelte'; - import AssetViewer from '../asset-viewer/asset-viewer.svelte'; import IntersectionObserver from '../asset-viewer/intersection-observer.svelte'; import Portal from '../shared-components/portal/portal.svelte'; import Scrollbar from '../shared-components/scrollbar/scrollbar.svelte'; @@ -451,17 +450,19 @@ {#if $showAssetViewer} - handleAction(action.type, action.asset)} - /> + {#await import('../asset-viewer/asset-viewer.svelte') then AssetViewer} + handleAction(action.type, action.asset)} + /> + {/await} {/if} diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 51c1e4fc67308..6faa41362fdff 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -73,7 +73,7 @@ > {`Memory From 3da2b05428c120b52b35a697c7cabd42f6ce370b Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 6 Mar 2024 20:34:55 -0600 Subject: [PATCH 15/47] chore(server): save original file name with extension (#7679) * chore(server): save original file name with extension * extract extension * update e2e test * update e2e test * download archive * fix download archive appending name * pr feedback * remove unused code * test * unit test * remove unused code * migration * noops * pr feedback * Update server/src/domain/download/download.service.ts Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> --------- Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> --- e2e/package-lock.json | 1 - e2e/src/api/specs/asset.e2e-spec.ts | 10 +++--- e2e/src/api/specs/shared-link.e2e-spec.ts | 2 +- server/e2e/api/specs/asset.e2e-spec.ts | 36 +++++++++++++++++++ .../src/domain/download/download.service.ts | 9 ++--- .../src/immich/api-v1/asset/asset.service.ts | 3 +- ...63765506-AddExtensionToOriginalFileName.ts | 20 +++++++++++ server/test/fixtures/asset.stub.ts | 4 +-- web/src/lib/utils/asset-utils.ts | 4 +-- 9 files changed, 72 insertions(+), 17 deletions(-) create mode 100644 server/src/infra/migrations/1709763765506-AddExtensionToOriginalFileName.ts diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 561fdef537cf2..bfb64e1a9830d 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -49,7 +49,6 @@ }, "devDependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", - "@testcontainers/postgresql": "^10.7.1", "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 2bb0e7c4d1565..30dfa5d643971 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -494,7 +494,7 @@ describe('/asset', () => { input: 'formats/jpg/el_torcal_rocks.jpg', expected: { type: AssetTypeEnum.Image, - originalFileName: 'el_torcal_rocks', + originalFileName: 'el_torcal_rocks.jpg', resized: true, exifInfo: { dateTimeOriginal: '2012-08-05T11:39:59.000Z', @@ -518,7 +518,7 @@ describe('/asset', () => { input: 'formats/heic/IMG_2682.heic', expected: { type: AssetTypeEnum.Image, - originalFileName: 'IMG_2682', + originalFileName: 'IMG_2682.heic', resized: true, fileCreatedAt: '2019-03-21T16:04:22.348Z', exifInfo: { @@ -543,7 +543,7 @@ describe('/asset', () => { input: 'formats/png/density_plot.png', expected: { type: AssetTypeEnum.Image, - originalFileName: 'density_plot', + originalFileName: 'density_plot.png', resized: true, exifInfo: { exifImageWidth: 800, @@ -558,7 +558,7 @@ describe('/asset', () => { input: 'formats/raw/Nikon/D80/glarus.nef', expected: { type: AssetTypeEnum.Image, - originalFileName: 'glarus', + originalFileName: 'glarus.nef', resized: true, fileCreatedAt: '2010-07-20T17:27:12.000Z', exifInfo: { @@ -580,7 +580,7 @@ describe('/asset', () => { input: 'formats/raw/Nikon/D700/philadelphia.nef', expected: { type: AssetTypeEnum.Image, - originalFileName: 'philadelphia', + originalFileName: 'philadelphia.nef', resized: true, fileCreatedAt: '2016-09-22T22:10:29.060Z', exifInfo: { diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index f2e5b01867b45..7ff4bb6bf7f4b 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -194,7 +194,7 @@ describe('/shared-link', () => { expect(body.assets).toHaveLength(1); expect(body.assets[0]).toEqual( expect.objectContaining({ - originalFileName: 'example', + originalFileName: 'example.png', localDateTime: expect.any(String), fileCreatedAt: expect.any(String), exifInfo: expect.any(Object), diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts index 3f49fb407afff..6badd4c67460e 100644 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ b/server/e2e/api/specs/asset.e2e-spec.ts @@ -609,6 +609,42 @@ describe(`${AssetController.name} (e2e)`, () => { expect(asset).toMatchObject({ id: body.id, isFavorite: true }); }); + it('should have correct original file name and extension (simple)', async () => { + const { body, status } = await request(server) + .post('/asset/upload') + .set('Authorization', `Bearer ${user1.accessToken}`) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'TEST') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .field('isFavorite', 'true') + .field('duration', '0:00:00.000000') + .attach('assetData', randomBytes(32), 'example.jpg'); + expect(status).toBe(201); + expect(body).toEqual({ id: expect.any(String), duplicate: false }); + + const asset = await api.assetApi.get(server, user1.accessToken, body.id); + expect(asset).toMatchObject({ id: body.id, originalFileName: 'example.jpg' }); + }); + + it('should have correct original file name and extension (complex)', async () => { + const { body, status } = await request(server) + .post('/asset/upload') + .set('Authorization', `Bearer ${user1.accessToken}`) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'TEST') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .field('isFavorite', 'true') + .field('duration', '0:00:00.000000') + .attach('assetData', randomBytes(32), 'example.complex.ext.jpg'); + expect(status).toBe(201); + expect(body).toEqual({ id: expect.any(String), duplicate: false }); + + const asset = await api.assetApi.get(server, user1.accessToken, body.id); + expect(asset).toMatchObject({ id: body.id, originalFileName: 'example.complex.ext.jpg' }); + }); + it('should not upload the same asset twice', async () => { const content = randomBytes(32); await api.assetApi.upload(server, user1.accessToken, 'example-image', { content }); diff --git a/server/src/domain/download/download.service.ts b/server/src/domain/download/download.service.ts index afd57b7d17cb0..fcad2b6e7e0b5 100644 --- a/server/src/domain/download/download.service.ts +++ b/server/src/domain/download/download.service.ts @@ -1,6 +1,6 @@ import { AssetEntity } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { extname } from 'node:path'; +import { parse } from 'node:path'; import { AccessCore, Permission } from '../access'; import { AssetIdsDto } from '../asset'; import { AuthDto } from '../auth'; @@ -91,12 +91,13 @@ export class DownloadService { } const { originalPath, originalFileName } = asset; - const extension = extname(originalPath); - let filename = `${originalFileName}${extension}`; + + let filename = originalFileName; const count = paths[filename] || 0; paths[filename] = count + 1; if (count !== 0) { - filename = `${originalFileName}+${count}${extension}`; + const parsedFilename = parse(originalFileName); + filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`; } zip.addFile(originalPath, filename); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index b8c3222ee196e..923cb4ebe871e 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -26,7 +26,6 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; -import { parse } from 'node:path'; import { QueryFailedError } from 'typeorm'; import { IAssetRepositoryV1 } from './asset-repository'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; @@ -356,7 +355,7 @@ export class AssetService { duration: dto.duration || null, isVisible: dto.isVisible ?? true, livePhotoVideo: livePhotoAssetId === null ? null : ({ id: livePhotoAssetId } as AssetEntity), - originalFileName: parse(file.originalName).name, + originalFileName: file.originalName, sidecarPath: sidecarPath || null, isReadOnly: dto.isReadOnly ?? false, isOffline: dto.isOffline ?? false, diff --git a/server/src/infra/migrations/1709763765506-AddExtensionToOriginalFileName.ts b/server/src/infra/migrations/1709763765506-AddExtensionToOriginalFileName.ts new file mode 100644 index 0000000000000..526d09ccf4ba4 --- /dev/null +++ b/server/src/infra/migrations/1709763765506-AddExtensionToOriginalFileName.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddExtensionToOriginalFileName1709763765506 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH extension AS (WITH cte AS (SELECT a.id, STRING_TO_ARRAY(a."originalPath", '.')::TEXT[] AS arr + FROM assets a) + SELECT cte.id, cte.arr[ARRAY_UPPER(cte.arr, 1)] AS "ext" + FROM cte) + UPDATE assets + SET "originalFileName" = assets."originalFileName" || '.' || extension."ext" + FROM extension + INNER JOIN assets a ON a.id = extension.id; + `); + } + + public async down(): Promise { + // noop + } +} diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 3d880143eb975..ea1617d6aeb13 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -16,7 +16,7 @@ export const assetStackStub = (stackId: string, assets: AssetEntity[]): AssetSta export const assetStub = { noResizePath: Object.freeze({ id: 'asset-id', - originalFileName: 'IMG_123', + originalFileName: 'IMG_123.jpg', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -77,7 +77,7 @@ export const assetStub = { livePhotoVideoId: null, tags: [], sharedLinks: [], - originalFileName: 'IMG_456', + originalFileName: 'IMG_456.jpg', faces: [], sidecarPath: null, isReadOnly: false, diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 94f451c3c1b6e..f1a3d44be5188 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -102,14 +102,14 @@ export const downloadFile = async (asset: AssetResponseDto) => { } const assets = [ { - filename: `${asset.originalFileName}.${getFilenameExtension(asset.originalPath)}`, + filename: asset.originalFileName, id: asset.id, size: asset.exifInfo?.fileSizeInByte || 0, }, ]; if (asset.livePhotoVideoId) { assets.push({ - filename: `${asset.originalFileName}.mov`, + filename: asset.originalFileName, id: asset.livePhotoVideoId, size: 0, }); From 5dd11ca17a1be5642848320623a40dcc696ec711 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 7 Mar 2024 04:18:53 +0100 Subject: [PATCH 16/47] fix(web): consistent modal escape behavior (#7677) * fix(web): consistent modal escape behavior * make onClose optional --- .../admin-page/delete-confirm-dialoge.svelte | 8 +++++- .../admin-page/jobs/jobs-panel.svelte | 4 +-- .../admin-page/restore-dialoge.svelte | 5 ++-- .../settings/confirm-disable-login.svelte | 5 +++- .../settings/oauth/oauth-settings.svelte | 2 +- .../password-login-settings.svelte | 2 +- .../album-page/album-options.svelte | 2 +- .../album-page/share-info-modal.svelte | 8 +++--- .../faces-page/merge-face-selector.svelte | 4 +-- .../faces-page/merge-suggestion-modal.svelte | 13 +-------- .../faces-page/set-birth-date-modal.svelte | 2 +- .../lib/components/forms/api-key-form.svelte | 2 +- .../components/forms/edit-user-form.svelte | 4 +-- .../library-exclusion-pattern-form.svelte | 2 +- .../forms/library-import-path-form.svelte | 2 +- .../forms/library-user-picker-form.svelte | 2 +- .../map-page/map-settings-modal.svelte | 2 +- .../actions/remove-from-album.svelte | 4 +-- .../actions/remove-from-shared-link.svelte | 4 +-- .../photos-page/delete-asset-dialog.svelte | 5 ++-- .../shared-components/change-date.svelte | 4 +-- .../shared-components/change-location.svelte | 4 +-- .../shared-components/confirm-dialogue.svelte | 22 ++++----------- .../full-screen-modal.svelte | 13 ++------- .../navigation-bar/avatar-selector.svelte | 2 +- .../shared-components/show-shortcuts.svelte | 2 +- .../version-announcement-box.svelte | 2 +- .../lib/components/slideshow-settings.svelte | 2 +- .../user-settings-page/device-list.svelte | 8 +++--- .../partner-settings.svelte | 4 +-- .../user-api-key-list.svelte | 4 +-- web/src/lib/utils/click-outside.ts | 28 +++++++++++++++++-- web/src/routes/(user)/albums/+page.svelte | 6 ++-- .../(user)/albums/[albumId]/+page.svelte | 4 +-- web/src/routes/(user)/people/+page.svelte | 20 ++++++------- .../(user)/sharing/sharedlinks/+page.svelte | 4 +-- web/src/routes/(user)/trash/+page.svelte | 4 +-- .../admin/library-management/+page.svelte | 4 +-- .../routes/admin/user-management/+page.svelte | 15 ++-------- 39 files changed, 111 insertions(+), 123 deletions(-) diff --git a/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte b/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte index 90246eb82b43f..63065a0d99bb0 100644 --- a/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte +++ b/web/src/lib/components/admin-page/delete-confirm-dialoge.svelte @@ -10,6 +10,7 @@ const dispatch = createEventDispatcher<{ success: void; fail: void; + cancel: void; }>(); const handleDeleteUser = async () => { @@ -27,7 +28,12 @@ }; - + dispatch('cancel')} +>

diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index edf5c483006ff..15b91c98b00c9 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -148,8 +148,8 @@ {#if confirmJob} (confirmJob = null)} + {onConfirm} + onClose={() => (confirmJob = null)} /> {/if} diff --git a/web/src/lib/components/admin-page/restore-dialoge.svelte b/web/src/lib/components/admin-page/restore-dialoge.svelte index c585d60e93186..d9a8ed3bc01b2 100644 --- a/web/src/lib/components/admin-page/restore-dialoge.svelte +++ b/web/src/lib/components/admin-page/restore-dialoge.svelte @@ -8,6 +8,7 @@ const dispatch = createEventDispatcher<{ success: void; fail: void; + cancel: void; }>(); const handleRestoreUser = async () => { @@ -24,8 +25,8 @@ title="Restore User" confirmText="Continue" confirmColor="green" - on:confirm={handleRestoreUser} - on:cancel + onConfirm={handleRestoreUser} + onClose={() => dispatch('cancel')} >

{user.name}'s account will be restored.

diff --git a/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte b/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte index b0c3e650d04ae..e57d25398f94f 100644 --- a/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte +++ b/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte @@ -1,8 +1,11 @@ - +

Are you sure you want to disable all login methods? Login will be completely disabled.

diff --git a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte index 217017447adc3..387d7e470e507 100644 --- a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte @@ -56,7 +56,7 @@ {#if isConfirmOpen} - handleConfirm(false)} on:confirm={() => handleConfirm(true)} /> + handleConfirm(false)} onConfirm={() => handleConfirm(true)} /> {/if}
diff --git a/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte b/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte index 8dc7323cf9b08..2d3344dbeb2f6 100644 --- a/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte +++ b/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte @@ -41,7 +41,7 @@ {#if isConfirmOpen} - handleConfirm(false)} on:confirm={() => handleConfirm(true)} /> + handleConfirm(false)} onConfirm={() => handleConfirm(true)} /> {/if}
diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte index 4d576aa2fa49d..6cbce418ba7fa 100644 --- a/web/src/lib/components/album-page/album-options.svelte +++ b/web/src/lib/components/album-page/album-options.svelte @@ -19,7 +19,7 @@ }>(); - dispatch('close')}> + dispatch('close')}>
(selectedRemoveUser = null)} + onConfirm={handleRemoveUser} + onClose={() => (selectedRemoveUser = null)} /> {/if} @@ -139,7 +139,7 @@ title="Remove User?" prompt="Are you sure you want to remove {selectedRemoveUser.name}" confirmText="Remove" - on:confirm={handleRemoveUser} - on:cancel={() => (selectedRemoveUser = null)} + onConfirm={handleRemoveUser} + onClose={() => (selectedRemoveUser = null)} /> {/if} diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte index 7b075405caaf2..9fa8628ddcd9d 100644 --- a/web/src/lib/components/faces-page/merge-face-selector.svelte +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -158,8 +158,8 @@ (isShowConfirmation = false)} + onConfirm={handleMerge} + onClose={() => (isShowConfirmation = false)} >

Are you sure you want merge these people ?

(); - const handleKeyboardPress = (event: KeyboardEvent) => { - switch (event.key) { - case 'Escape': { - dispatch('close'); - return; - } - } - }; - const changePersonToMerge = (newperson: PersonResponseDto) => { const index = potentialMergePeople.indexOf(newperson); [potentialMergePeople[index], personMerge2] = [personMerge2, potentialMergePeople[index]]; @@ -39,9 +30,7 @@ }; - - - dispatch('close')}> + dispatch('close')}>
- handleCancel()}> +
diff --git a/web/src/lib/components/forms/api-key-form.svelte b/web/src/lib/components/forms/api-key-form.svelte index 62034d651c9f4..15d2aefc660a3 100644 --- a/web/src/lib/components/forms/api-key-form.svelte +++ b/web/src/lib/components/forms/api-key-form.svelte @@ -29,7 +29,7 @@ }; - +
diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index f55d341a7161e..13a797a9c8779 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -147,8 +147,8 @@ (isShowResetPasswordConfirmation = false)} + onConfirm={resetPassword} + onClose={() => (isShowResetPasswordConfirmation = false)} >

diff --git a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte index 439ae2b3686a2..c94a458094453 100644 --- a/web/src/lib/components/forms/library-exclusion-pattern-form.svelte +++ b/web/src/lib/components/forms/library-exclusion-pattern-form.svelte @@ -29,7 +29,7 @@ const handleSubmit = () => dispatch('submit', { excludePattern: exclusionPattern }); - handleCancel()}> +

diff --git a/web/src/lib/components/forms/library-import-path-form.svelte b/web/src/lib/components/forms/library-import-path-form.svelte index 06a5f63ea59a8..1c9f693862919 100644 --- a/web/src/lib/components/forms/library-import-path-form.svelte +++ b/web/src/lib/components/forms/library-import-path-form.svelte @@ -31,7 +31,7 @@ const handleSubmit = () => dispatch('submit', { importPath }); - handleCancel()}> +
diff --git a/web/src/lib/components/forms/library-user-picker-form.svelte b/web/src/lib/components/forms/library-user-picker-form.svelte index b3c70adb6d99d..cc1d31b884741 100644 --- a/web/src/lib/components/forms/library-user-picker-form.svelte +++ b/web/src/lib/components/forms/library-user-picker-form.svelte @@ -28,7 +28,7 @@ const handleSubmit = () => dispatch('submit', { ownerId }); - handleCancel()}> +
diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte index 49d841116aa78..aa95714b46e4b 100644 --- a/web/src/lib/components/map-page/map-settings-modal.svelte +++ b/web/src/lib/components/map-page/map-settings-modal.svelte @@ -21,7 +21,7 @@ const handleClose = () => dispatch('close'); - +
diff --git a/web/src/lib/components/photos-page/actions/remove-from-album.svelte b/web/src/lib/components/photos-page/actions/remove-from-album.svelte index a2a10297d6c5d..8fbc5e6ad0f96 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-album.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-album.svelte @@ -59,8 +59,8 @@ (isShowConfirmation = false)} + onConfirm={removeFromAlbum} + onClose={() => (isShowConfirmation = false)} >

diff --git a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte index f870ac3d35013..001cabf655987 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte @@ -53,7 +53,7 @@ title="Remove Assets?" prompt="Are you sure you want to remove {getAssets().size} asset(s) from this shared link?" confirmText="Remove" - on:confirm={() => handleRemove()} - on:cancel={() => (removing = false)} + onConfirm={() => handleRemove()} + onClose={() => (removing = false)} /> {/if} diff --git a/web/src/lib/components/photos-page/delete-asset-dialog.svelte b/web/src/lib/components/photos-page/delete-asset-dialog.svelte index 0f2e1b611f825..5e5804242901d 100644 --- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte +++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte @@ -27,9 +27,8 @@ dispatch('cancel')} - on:escape={() => dispatch('cancel')} + onConfirm={handleConfirm} + onClose={() => dispatch('cancel')} >

diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index f4c45c1e03cb8..51d6611b5aa35 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -68,8 +68,8 @@ title="Edit date & time" prompt="Please select a new date:" disabled={!date.isValid} - on:confirm={handleConfirm} - on:cancel={handleCancel} + onConfirm={handleConfirm} + onClose={handleCancel} >

diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index a31bd6cc2f92a..786e8721b5a2d 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -144,8 +144,8 @@ cancelColor="secondary" title="Change Location" width={800} - on:confirm={handleConfirm} - on:cancel={handleCancel} + onConfirm={handleConfirm} + onClose={handleCancel} >
(hideSuggestion = true)}> diff --git a/web/src/lib/components/shared-components/confirm-dialogue.svelte b/web/src/lib/components/shared-components/confirm-dialogue.svelte index 6ae10b30a890b..a63efccd0d901 100644 --- a/web/src/lib/components/shared-components/confirm-dialogue.svelte +++ b/web/src/lib/components/shared-components/confirm-dialogue.svelte @@ -1,5 +1,4 @@ - handleEscape()}> +
{#if !hideCancelButton} - {/if} diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index d263d0b317bdf..3ca3717ef4042 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -1,12 +1,8 @@
-
dispatch('clickOutside')} - on:escape={() => dispatch('escape')} - > +
diff --git a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte index 3ba5745ae232b..c6b00adc4d61b 100644 --- a/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/avatar-selector.svelte @@ -15,7 +15,7 @@ const colors: UserAvatarColor[] = Object.values(UserAvatarColor); - dispatch('close')} on:escape={() => dispatch('close')}> + dispatch('close')}>
(); - dispatch('close')} on:escape={() => dispatch('close')}> + dispatch('close')}>
{#if showModal} - (showModal = false)}> + (showModal = false)}>
diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte index 70c893c4edff7..eec2f83e3731c 100644 --- a/web/src/lib/components/slideshow-settings.svelte +++ b/web/src/lib/components/slideshow-settings.svelte @@ -30,7 +30,7 @@ }; - +
diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 080e79145da87..2cf00f9f72061 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -50,16 +50,16 @@ {#if deleteDevice} handleDelete()} - on:cancel={() => (deleteDevice = null)} + onConfirm={() => handleDelete()} + onClose={() => (deleteDevice = null)} /> {/if} {#if deleteAll} handleDeleteAll()} - on:cancel={() => (deleteAll = false)} + onConfirm={() => handleDeleteAll()} + onClose={() => (deleteAll = false)} /> {/if} diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte index fbe5efa58a477..70803f77539a8 100644 --- a/web/src/lib/components/user-settings-page/partner-settings.svelte +++ b/web/src/lib/components/user-settings-page/partner-settings.svelte @@ -190,7 +190,7 @@ (removePartnerDto = null)} - on:confirm={() => handleRemovePartner()} + onClose={() => (removePartnerDto = null)} + onConfirm={() => handleRemovePartner()} /> {/if} diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index 9eca630bf6828..a51d623813a16 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -105,8 +105,8 @@ {#if deleteKey} handleDelete()} - on:cancel={() => (deleteKey = null)} + onConfirm={() => handleDelete()} + onClose={() => (deleteKey = null)} /> {/if} diff --git a/web/src/lib/utils/click-outside.ts b/web/src/lib/utils/click-outside.ts index d028ca5b1f13c..f7a83c0b3a203 100644 --- a/web/src/lib/utils/click-outside.ts +++ b/web/src/lib/utils/click-outside.ts @@ -1,20 +1,42 @@ import type { ActionReturn } from 'svelte/action'; interface Attributes { + /** @deprecated */ 'on:outclick'?: (e: CustomEvent) => void; + /** @deprecated **/ 'on:escape'?: (e: CustomEvent) => void; } -export function clickOutside(node: HTMLElement): ActionReturn { +interface Options { + onOutclick?: () => void; + onEscape?: () => void; +} + +export function clickOutside(node: HTMLElement, options: Options = {}): ActionReturn { + const { onOutclick, onEscape } = options; + const handleClick = (event: MouseEvent) => { const targetNode = event.target as Node | null; - if (!node.contains(targetNode)) { + if (node.contains(targetNode)) { + return; + } + + if (onOutclick) { + onOutclick(); + } else { node.dispatchEvent(new CustomEvent('outclick')); } }; const handleKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') { + if (event.key !== 'Escape') { + return; + } + + if (onEscape) { + event.stopPropagation(); + onEscape(); + } else { node.dispatchEvent(new CustomEvent('escape')); } }; diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index 4f1cccbb0bffe..d14be267e2c54 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -236,7 +236,7 @@ {#if shouldShowEditUserForm} - (shouldShowEditUserForm = false)}> + (shouldShowEditUserForm = false)}> successModifyAlbum()} @@ -399,8 +399,8 @@ (albumToDelete = null)} + onConfirm={deleteSelectedAlbum} + onClose={() => (albumToDelete = null)} >

Are you sure you want to delete the album {albumToDelete.albumName}?

diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index f0daba676ec37..279054d915691 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -692,8 +692,8 @@ (viewMode = ViewMode.VIEW)} + onConfirm={handleRemoveAlbum} + onClose={() => (viewMode = ViewMode.VIEW)} >

Are you sure you want to delete the album {album.albumName}?

diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 6373b81fd5f44..22dc98ad3e238 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -420,16 +420,14 @@ {#if showMergeModal} - (showMergeModal = false)}> - (showMergeModal = false)} - on:reject={() => changeName()} - on:confirm={(event) => handleMergeSamePerson(event.detail)} - /> - + (showMergeModal = false)} + on:reject={() => changeName()} + on:confirm={(event) => handleMergeSamePerson(event.detail)} + /> {/if} (showChangeNameModal = false)}> + (showChangeNameModal = false)}>
diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte index 3a6ff6ac0a0e4..d90a0a6c188ec 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte @@ -91,7 +91,7 @@ title="Delete Shared Link" prompt="Are you sure you want to delete this shared link?" confirmText="Delete" - on:confirm={() => handleDeleteLink()} - on:cancel={() => (deleteLinkId = null)} + onConfirm={() => handleDeleteLink()} + onClose={() => (deleteLinkId = null)} /> {/if} diff --git a/web/src/routes/(user)/trash/+page.svelte b/web/src/routes/(user)/trash/+page.svelte index cba8373b862f4..6ce6c54236348 100644 --- a/web/src/routes/(user)/trash/+page.svelte +++ b/web/src/routes/(user)/trash/+page.svelte @@ -105,8 +105,8 @@ (isShowEmptyConfirmation = false)} + onConfirm={handleEmptyTrash} + onClose={() => (isShowEmptyConfirmation = false)} >

Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.

diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index e9ac41b83eeca..9f809409f4426 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -307,8 +307,8 @@ (confirmDeleteLibrary = null)} + onConfirm={handleDelete} + onClose={() => (confirmDeleteLibrary = null)} /> {/if} diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 84917d591e129..c5d8874a45088 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -103,19 +103,13 @@
{#if shouldShowCreateUserForm} - (shouldShowCreateUserForm = false)} - on:escape={() => (shouldShowCreateUserForm = false)} - > + (shouldShowCreateUserForm = false)}> (shouldShowCreateUserForm = false)} /> {/if} {#if shouldShowEditUserForm} - (shouldShowEditUserForm = false)} - on:escape={() => (shouldShowEditUserForm = false)} - > + (shouldShowEditUserForm = false)}> (shouldShowInfoPanel = false)} - on:escape={() => (shouldShowInfoPanel = false)} - > + (shouldShowInfoPanel = false)}>

Password reset success

From ffaa08e7ea3de2f6bfc95bdfbe4d856a1020bb5c Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 6 Mar 2024 22:20:38 -0500 Subject: [PATCH 17/47] chore(server): lower default max recognition distance for facial recognition (#7689) lower default to 0.5 --- server/src/domain/person/person.service.spec.ts | 2 +- server/src/domain/system-config/system-config.core.ts | 2 +- server/src/domain/system-config/system-config.service.spec.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 0419fc0561f16..deb7474e2a231 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -709,7 +709,7 @@ describe(PersonService.name, () => { }, { enabled: true, - maxDistance: 0.6, + maxDistance: 0.5, minScore: 0.7, minFaces: 3, modelName: 'buffalo_l', diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 1699f7131dd22..644d5c3cbb4bd 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -75,7 +75,7 @@ export const defaults = Object.freeze({ enabled: true, modelName: 'buffalo_l', minScore: 0.7, - maxDistance: 0.6, + maxDistance: 0.5, minFaces: 3, }, }, diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 77211821520ef..91c095cb75ef7 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -76,7 +76,7 @@ const updatedConfig = Object.freeze({ enabled: true, modelName: 'buffalo_l', minScore: 0.7, - maxDistance: 0.6, + maxDistance: 0.5, minFaces: 3, }, }, From fcb990665c3337fe4098c528797015381249c7d9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 22:22:55 -0500 Subject: [PATCH 18/47] chore(deps): update base-image to v20240305 (major) (#7682) chore(deps): update base-image to v20240305 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 0ebd5c44cbdd2..e6af34fd68b63 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240227@sha256:b1e212c106ce2318a587e0b2ef377215c958e877f61993ed9310534e4589cce4 as dev +FROM ghcr.io/immich-app/base-server-dev:20240305@sha256:99ca204d84284dac24dbec59ffeaea07c02f4bd9b06b09e1aa9aacc4f3ece92e as dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -40,7 +40,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240227@sha256:d47f5f7f2b6c53957c6353352b2fa24f2845da50e6491a7c74eb779ace10628c +FROM ghcr.io/immich-app/base-server-prod:20240305@sha256:d0bcac4e77f1371d6c4b8ecc415c390cc348d09e48504d4455f38f2968e41c1c WORKDIR /usr/src/app ENV NODE_ENV=production \ From 1ec5d612fa865252bc2e9a2746a2ec821ef50661 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 6 Mar 2024 22:23:10 -0500 Subject: [PATCH 19/47] perf(server): use queries to refresh library assets (#7685) * use queries instead of js * missing await * add mock methods * fix test * update sql * linting --- .../domain/library/library.service.spec.ts | 1 + server/src/domain/library/library.service.ts | 15 ++------- .../domain/repositories/asset.repository.ts | 2 ++ .../infra/repositories/asset.repository.ts | 23 +++++++++++++ server/src/infra/sql/asset.repository.sql | 33 +++++++++++++++++++ .../repositories/asset.repository.mock.ts | 2 ++ 6 files changed, 63 insertions(+), 13 deletions(-) diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index ec5d25a3b1bfa..720824b672e52 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -144,6 +144,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); + assetMock.getPathsNotInLibrary.mockResolvedValue(['/data/user1/photo.jpg']); assetMock.getByLibraryId.mockResolvedValue([]); userMock.get.mockResolvedValue(userStub.admin); diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 33edf74bf35ba..2c509cdaaace7 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -621,29 +621,18 @@ export class LibraryService extends EventEmitter { pathsToCrawl: validImportPaths, exclusionPatterns: library.exclusionPatterns, }); - const crawledAssetPaths = rawPaths.map((filePath) => path.normalize(filePath)); this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`); - const assetsInLibrary = await this.assetRepository.getByLibraryId([job.id]); - const onlineFiles = new Set(crawledAssetPaths); - const offlineAssetIds = assetsInLibrary - .filter((asset) => !onlineFiles.has(asset.originalPath)) - .filter((asset) => !asset.isOffline) - .map((asset) => asset.id); - this.logger.debug(`Marking ${offlineAssetIds.length} assets as offline`); - await this.assetRepository.updateAll(offlineAssetIds, { isOffline: true }); + await this.assetRepository.updateOfflineLibraryAssets(library.id, crawledAssetPaths); if (crawledAssetPaths.length > 0) { let filteredPaths: string[] = []; if (job.refreshAllFiles || job.refreshModifiedFiles) { filteredPaths = crawledAssetPaths; } else { - const onlinePathsInLibrary = new Set( - assetsInLibrary.filter((asset) => !asset.isOffline).map((asset) => asset.originalPath), - ); - filteredPaths = crawledAssetPaths.filter((assetPath) => !onlinePathsInLibrary.has(assetPath)); + filteredPaths = await this.assetRepository.getPathsNotInLibrary(library.id, crawledAssetPaths); this.logger.debug(`Will import ${filteredPaths.length} new asset(s)`); } diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index d0e22f676fc6a..dd5e76577c88f 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -136,6 +136,8 @@ export interface IAssetRepository { getLastUpdatedAssetForAlbumId(albumId: string): Promise; getByLibraryId(libraryIds: string[]): Promise; getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; + getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise; + updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; getAllByFileCreationDate( diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 4bcfc963fab85..4813056659efa 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -199,6 +199,29 @@ export class AssetRepository implements IAssetRepository { }); } + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) + @ChunkedArray({ paramIndex: 1 }) + async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise { + const result = await this.repository.query( + ` + WITH paths AS (SELECT unnest($2::text[]) AS path) + SELECT path FROM paths + WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); + `, + [libraryId, originalPaths], + ); + return result.map((row: { path: string }) => row.path); + } + + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) + @ChunkedArray({ paramIndex: 1 }) + async updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise { + await this.repository.update( + { library: { id: libraryId }, originalPath: Not(In(originalPaths)), isOffline: false }, + { isOffline: true }, + ); + } + getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { let builder = this.repository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); diff --git a/server/src/infra/sql/asset.repository.sql b/server/src/infra/sql/asset.repository.sql index e5cf6771fdf3b..54992e5f87779 100644 --- a/server/src/infra/sql/asset.repository.sql +++ b/server/src/infra/sql/asset.repository.sql @@ -395,6 +395,39 @@ ORDER BY LIMIT 1 +-- AssetRepository.getPathsNotInLibrary +WITH + paths AS ( + SELECT + unnest($2::text []) AS path + ) +SELECT + path +FROM + paths +WHERE + NOT EXISTS ( + SELECT + 1 + FROM + assets + WHERE + "libraryId" = $1 + AND "originalPath" = path + ); + +-- AssetRepository.updateOfflineLibraryAssets +UPDATE "assets" +SET + "isOffline" = $1, + "updatedAt" = CURRENT_TIMESTAMP +WHERE + ( + "libraryId" = $2 + AND NOT ("originalPath" IN ($3)) + AND "isOffline" = $4 + ) + -- AssetRepository.getAllByFileCreationDate SELECT "asset"."id" AS "asset_id", diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 0be384b3aeff1..63f1229a23fec 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -23,6 +23,8 @@ export const newAssetRepositoryMock = (): jest.Mocked => { updateAll: jest.fn(), getByLibraryId: jest.fn(), getByLibraryIdAndOriginalPath: jest.fn(), + updateOfflineLibraryAssets: jest.fn(), + getPathsNotInLibrary: jest.fn(), deleteAll: jest.fn(), save: jest.fn(), remove: jest.fn(), From 4323d18387787a2baafa373cab3e1a8aebbf8cf2 Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Wed, 6 Mar 2024 22:27:33 -0500 Subject: [PATCH 20/47] fix(mobile): Refactors exif bottom sheet to use widgets and fixes slow sliding up exif bottom sheet (#7671) * Refactors exif bottom sheet to use widgets and fixes slow sliding up experience format * Refactors exif bottom sheet to use widgets and fixes slow sliding up experience format * Fixes people * removes wrong exif bottom sheet format format * Moved more widgets out of exit bottom sheet format * small styling --------- Co-authored-by: Marty Fuhry Co-authored-by: Alex Tran --- .../asset_viewer/ui/exif_bottom_sheet.dart | 482 ------------------ .../ui/exif_sheet/exif_bottom_sheet.dart | 210 ++++++++ .../ui/exif_sheet/exif_detail.dart | 60 +++ .../ui/exif_sheet/exif_image_properties.dart | 58 +++ .../ui/exif_sheet/exif_location.dart | 105 ++++ .../asset_viewer/ui/exif_sheet/exif_map.dart | 94 ++++ .../ui/exif_sheet/exif_people.dart | 94 ++++ .../asset_viewer/views/gallery_viewer.dart | 21 +- .../modules/search/ui/curated_people_row.dart | 3 + mobile/lib/shared/models/exif_info.dart | 4 + .../lib/shared/providers/asset.provider.dart | 4 +- 11 files changed, 643 insertions(+), 492 deletions(-) delete mode 100644 mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_bottom_sheet.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_detail.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_location.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_map.dart create mode 100644 mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart deleted file mode 100644 index c84e857eefd81..0000000000000 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ /dev/null @@ -1,482 +0,0 @@ -import 'dart:io'; -import 'dart:math' as math; - -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/asset_extensions.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/duration_extensions.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/asset_people.provider.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; -import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart'; -import 'package:immich_mobile/modules/search/models/curated_content.dart'; -import 'package:immich_mobile/modules/search/ui/curated_people_row.dart'; -import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/providers/asset.provider.dart'; -import 'package:immich_mobile/utils/selection_handlers.dart'; -import 'package:immich_mobile/utils/bytes_units.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class ExifBottomSheet extends HookConsumerWidget { - final Asset asset; - - const ExifBottomSheet({super.key, required this.asset}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final assetWithExif = ref.watch(assetDetailProvider(asset)); - final exifInfo = (assetWithExif.value ?? asset).exifInfo; - final peopleProvider = - ref.watch(assetPeopleNotifierProvider(asset).notifier); - final people = ref.watch(assetPeopleNotifierProvider(asset)); - final double imageSize = math.min(context.width / 3, 150); - var textColor = context.isDarkTheme ? Colors.white : Colors.black; - - bool hasCoordinates() => - exifInfo != null && - exifInfo.latitude != null && - exifInfo.longitude != null && - exifInfo.latitude != 0 && - exifInfo.longitude != 0; - - String formattedDateTime() { - final (dt, timeZone) = - (assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset(); - final date = DateFormat.yMMMEd().format(dt); - final time = DateFormat.jm().format(dt); - - return '$date • $time GMT${timeZone.formatAsOffset()}'; - } - - Future createCoordinatesUri() async { - if (!hasCoordinates()) { - return null; - } - - final double latitude = exifInfo!.latitude!; - final double longitude = exifInfo.longitude!; - - const zoomLevel = 16; - - if (Platform.isAndroid) { - Uri uri = Uri( - scheme: 'geo', - host: '$latitude,$longitude', - queryParameters: { - 'z': '$zoomLevel', - 'q': '$latitude,$longitude($formattedDateTime)', - }, - ); - if (await canLaunchUrl(uri)) { - return uri; - } - } else if (Platform.isIOS) { - var params = { - 'll': '$latitude,$longitude', - 'q': formattedDateTime, - 'z': '$zoomLevel', - }; - Uri uri = Uri.https('maps.apple.com', '/', params); - if (await canLaunchUrl(uri)) { - return uri; - } - } - - return Uri( - scheme: 'https', - host: 'openstreetmap.org', - queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'}, - fragment: 'map=$zoomLevel/$latitude/$longitude', - ); - } - - buildMap() { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: LayoutBuilder( - builder: (context, constraints) { - return MapThumbnail( - centre: LatLng( - exifInfo?.latitude ?? 0, - exifInfo?.longitude ?? 0, - ), - height: 150, - width: constraints.maxWidth, - zoom: 12.0, - assetMarkerRemoteId: asset.remoteId, - onTap: (tapPosition, latLong) async { - Uri? uri = await createCoordinatesUri(); - - if (uri == null) { - return; - } - - debugPrint('Opening Map Uri: $uri'); - launchUrl(uri); - }, - ); - }, - ), - ); - } - - buildSizeText(Asset a) { - String resolution = a.width != null && a.height != null - ? "${a.height} x ${a.width} " - : ""; - String fileSize = a.exifInfo?.fileSize != null - ? formatBytes(a.exifInfo!.fileSize!) - : ""; - String text = resolution + fileSize; - return text.isNotEmpty ? text : null; - } - - buildLocation() { - // Guard no lat/lng - if (!hasCoordinates()) { - return asset.isRemote && !asset.isReadOnly - ? ListTile( - minLeadingWidth: 0, - contentPadding: const EdgeInsets.all(0), - leading: const Icon(Icons.location_on), - title: Text( - "exif_bottom_sheet_location_add", - style: context.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - color: context.primaryColor, - ), - ).tr(), - onTap: () => handleEditLocation( - ref, - context, - [assetWithExif.value ?? asset], - ), - ) - : const SizedBox.shrink(); - } - - return Column( - children: [ - // Location - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "exif_bottom_sheet_location", - style: context.textTheme.labelMedium?.copyWith( - color: - context.textTheme.labelMedium?.color?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), - if (asset.isRemote && !asset.isReadOnly) - IconButton( - onPressed: () => handleEditLocation( - ref, - context, - [assetWithExif.value ?? asset], - ), - icon: const Icon(Icons.edit_outlined), - iconSize: 20, - ), - ], - ), - buildMap(), - RichText( - text: TextSpan( - style: context.textTheme.labelLarge, - children: [ - if (exifInfo != null && exifInfo.city != null) - TextSpan( - text: exifInfo.city, - ), - if (exifInfo != null && - exifInfo.city != null && - exifInfo.state != null) - const TextSpan( - text: ", ", - ), - if (exifInfo != null && exifInfo.state != null) - TextSpan( - text: "${exifInfo.state}", - ), - ], - ), - ), - Text( - "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(150), - ), - ), - ], - ), - ], - ); - } - - showPersonNameEditModel( - String personId, - String personName, - ) { - return showDialog( - context: context, - builder: (BuildContext context) { - return PersonNameEditForm(personId: personId, personName: personName); - }, - ).then((_) { - // ensure the people list is up-to-date. - peopleProvider.refresh(); - }); - } - - buildPeople() { - return people.widgetWhen( - onData: (data) { - // either the server is not reachable or this asset has no people - if (data.isEmpty) { - return Container(); - } - - final curatedPeople = - data.map((p) => CuratedContent(id: p.id, label: p.name)).toList(); - - return Column( - children: [ - Align( - alignment: Alignment.topLeft, - child: Text( - "exif_bottom_sheet_people", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), - ), - SizedBox( - height: imageSize, - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: CuratedPeopleRow( - content: curatedPeople, - onTap: (content, index) { - context - .pushRoute( - PersonResultRoute( - personId: content.id, - personName: content.label, - ), - ) - .then((_) => peopleProvider.refresh()); - }, - onNameTap: (person, index) => { - showPersonNameEditModel(person.id, person.label), - }, - ), - ), - ), - ], - ); - }, - ); - } - - buildDate() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - formattedDateTime(), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - if (asset.isRemote && !asset.isReadOnly) - IconButton( - onPressed: () => handleEditDateTime( - ref, - context, - [assetWithExif.value ?? asset], - ), - icon: const Icon(Icons.edit_outlined), - iconSize: 20, - ), - ], - ); - } - - buildImageProperties() { - // Helper to create the ListTile and avoid repeating code - createImagePropertiesListStyle(title, subtitle) => ListTile( - contentPadding: const EdgeInsets.all(0), - dense: true, - leading: Icon( - Icons.image, - color: textColor.withAlpha(200), - ), - titleAlignment: ListTileTitleAlignment.center, - title: Text( - title, - style: context.textTheme.labelLarge, - ), - subtitle: subtitle, - ); - - final imgSizeString = buildSizeText(asset); - - if (imgSizeString == null && asset.fileName.isNotEmpty) { - // There is only filename - return createImagePropertiesListStyle( - asset.fileName, - null, - ); - } else if (imgSizeString != null && asset.fileName.isNotEmpty) { - // There is both filename and size information - return createImagePropertiesListStyle( - asset.fileName, - Text(imgSizeString, style: context.textTheme.bodySmall), - ); - } else if (imgSizeString != null && asset.fileName.isEmpty) { - // There is only size information - return createImagePropertiesListStyle( - imgSizeString, - null, - ); - } - } - - buildDetail() { - final imgProperties = buildImageProperties(); - - // There are no details - if (imgProperties == null && - (exifInfo == null || exifInfo.make == null)) { - return Container(); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Text( - "exif_bottom_sheet_details", - style: context.textTheme.labelMedium?.copyWith( - color: context.textTheme.labelMedium?.color?.withAlpha(200), - fontWeight: FontWeight.w600, - ), - ).tr(), - ), - if (imgProperties != null) imgProperties, - if (exifInfo?.make != null) - ListTile( - contentPadding: const EdgeInsets.all(0), - dense: true, - leading: Icon( - Icons.camera, - color: textColor.withAlpha(200), - ), - title: Text( - "${exifInfo!.make} ${exifInfo.model}", - style: context.textTheme.labelLarge, - ), - subtitle: exifInfo.f != null || - exifInfo.exposureSeconds != null || - exifInfo.mm != null || - exifInfo.iso != null - ? Text( - "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ", - style: context.textTheme.bodySmall, - ) - : null, - ), - ], - ); - } - - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16.0), - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth > 600) { - // Two column - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildDate(), - if (asset.isRemote) DescriptionInput(asset: asset), - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: buildLocation(), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: buildPeople(), - ), - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 300), - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: buildDetail(), - ), - ), - ], - ), - const SizedBox(height: 50), - ], - ); - } - - // One column - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildDate(), - assetWithExif.when( - data: (data) => DescriptionInput(asset: data), - error: (error, stackTrace) => Icon( - Icons.image_not_supported_outlined, - color: context.primaryColor, - ), - loading: () => const SizedBox( - width: 75, - height: 75, - child: CircularProgressIndicator.adaptive(), - ), - ), - const SizedBox(height: 16), - buildPeople(), - buildLocation(), - SizedBox(height: hasCoordinates() ? 16.0 : 6.0), - buildDetail(), - const SizedBox(height: 50), - ], - ); - }, - ), - ), - ); - } -} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_bottom_sheet.dart new file mode 100644 index 0000000000000..00d5a1ae6b961 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_bottom_sheet.dart @@ -0,0 +1,210 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/asset_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_detail.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_location.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_people.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/utils/selection_handlers.dart'; + +class ExifBottomSheet extends HookConsumerWidget { + final Asset asset; + + const ExifBottomSheet({super.key, required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final assetWithExif = ref.watch(assetDetailProvider(asset)); + var textColor = context.isDarkTheme ? Colors.white : Colors.black; + final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo; + // Format the date time with the timezone + final (dt, timeZone) = + (assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset(); + final date = DateFormat.yMMMEd().format(dt); + final time = DateFormat.jm().format(dt); + + String formattedDateTime = '$date • $time GMT${timeZone.formatAsOffset()}'; + + final dateWidget = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + formattedDateTime, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + if (asset.isRemote && !asset.isReadOnly) + IconButton( + onPressed: () => handleEditDateTime( + ref, + context, + [assetWithExif.value ?? asset], + ), + icon: const Icon(Icons.edit_outlined), + iconSize: 20, + ), + ], + ); + + return SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: 50, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final horizontalPadding = constraints.maxWidth > 600 ? 24.0 : 16.0; + if (constraints.maxWidth > 600) { + // Two column + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + child: Column( + children: [ + dateWidget, + if (asset.isRemote) DescriptionInput(asset: asset), + ], + ), + ), + ExifPeople( + asset: asset, + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + ), + Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ExifLocation( + asset: asset, + exifInfo: exifInfo, + editLocation: () => handleEditLocation( + ref, + context, + [assetWithExif.value ?? asset], + ), + formattedDateTime: formattedDateTime, + ), + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ExifDetail(asset: asset, exifInfo: exifInfo), + ), + ), + ], + ), + ), + ], + ); + } + + // One column + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + child: Column( + children: [ + dateWidget, + if (asset.isRemote) DescriptionInput(asset: asset), + Padding( + padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16.0), + child: ExifLocation( + asset: asset, + exifInfo: exifInfo, + editLocation: () => handleEditLocation( + ref, + context, + [assetWithExif.value ?? asset], + ), + formattedDateTime: formattedDateTime, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: ExifPeople( + asset: asset, + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + "exif_bottom_sheet_details", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color + ?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + ), + ExifImageProperties(asset: asset), + if (exifInfo?.make != null) + ListTile( + contentPadding: const EdgeInsets.all(0), + dense: true, + leading: Icon( + Icons.camera, + color: textColor.withAlpha(200), + ), + title: Text( + "${exifInfo!.make} ${exifInfo.model}", + style: context.textTheme.labelLarge, + ), + subtitle: exifInfo.f != null || + exifInfo.exposureSeconds != null || + exifInfo.mm != null || + exifInfo.iso != null + ? Text( + "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ", + style: context.textTheme.bodySmall, + ) + : null, + ), + ], + ), + ), + const SizedBox(height: 50), + ], + ); + }, + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_detail.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_detail.dart new file mode 100644 index 0000000000000..4f49066206aa2 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_detail.dart @@ -0,0 +1,60 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; + +class ExifDetail extends StatelessWidget { + final Asset asset; + final ExifInfo? exifInfo; + + const ExifDetail({ + super.key, + required this.asset, + this.exifInfo, + }); + + @override + Widget build(BuildContext context) { + final textColor = context.isDarkTheme ? Colors.white : Colors.black; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + "exif_bottom_sheet_details", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + ), + ExifImageProperties(asset: asset), + if (exifInfo?.make != null) + ListTile( + contentPadding: const EdgeInsets.all(0), + dense: true, + leading: Icon( + Icons.camera, + color: textColor.withAlpha(200), + ), + title: Text( + "${exifInfo?.make} ${exifInfo?.model}", + style: context.textTheme.labelLarge, + ), + subtitle: exifInfo?.f != null || + exifInfo?.exposureSeconds != null || + exifInfo?.mm != null || + exifInfo?.iso != null + ? Text( + "ƒ/${exifInfo?.fNumber} ${exifInfo?.exposureTime} ${exifInfo?.focalLength} mm ISO ${exifInfo?.iso ?? ''} ", + style: context.textTheme.bodySmall, + ) + : null, + ), + ], + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart new file mode 100644 index 0000000000000..4f584d1c9c6bd --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_image_properties.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; + +class ExifImageProperties extends StatelessWidget { + final Asset asset; + + const ExifImageProperties({ + super.key, + required this.asset, + }); + + @override + Widget build(BuildContext context) { + final textColor = context.isDarkTheme ? Colors.white : Colors.black; + + String resolution = asset.width != null && asset.height != null + ? "${asset.height} x ${asset.width} " + : ""; + String fileSize = asset.exifInfo?.fileSize != null + ? formatBytes(asset.exifInfo!.fileSize!) + : ""; + String text = resolution + fileSize; + final imgSizeString = text.isNotEmpty ? text : null; + + String? title; + String? subtitle; + + if (imgSizeString == null && asset.fileName.isNotEmpty) { + // There is only filename + title = asset.fileName; + } else if (imgSizeString != null && asset.fileName.isNotEmpty) { + // There is both filename and size information + title = asset.fileName; + subtitle = imgSizeString; + } else if (imgSizeString != null && asset.fileName.isEmpty) { + title = imgSizeString; + } else { + return const SizedBox.shrink(); + } + + return ListTile( + contentPadding: const EdgeInsets.all(0), + dense: true, + leading: Icon( + Icons.image, + color: textColor.withAlpha(200), + ), + titleAlignment: ListTileTitleAlignment.center, + title: Text( + title, + style: context.textTheme.labelLarge, + ), + subtitle: subtitle == null ? null : Text(subtitle), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_location.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_location.dart new file mode 100644 index 0000000000000..c4a8b9d508071 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_location.dart @@ -0,0 +1,105 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_map.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; + +class ExifLocation extends StatelessWidget { + final Asset asset; + final ExifInfo? exifInfo; + final void Function() editLocation; + final String formattedDateTime; + + const ExifLocation({ + super.key, + required this.asset, + required this.exifInfo, + required this.editLocation, + required this.formattedDateTime, + }); + + @override + Widget build(BuildContext context) { + final hasCoordinates = exifInfo?.hasCoordinates ?? false; + // Guard no lat/lng + if (!hasCoordinates) { + return asset.isRemote && !asset.isReadOnly + ? ListTile( + minLeadingWidth: 0, + contentPadding: const EdgeInsets.all(0), + leading: const Icon(Icons.location_on), + title: Text( + "exif_bottom_sheet_location_add", + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ).tr(), + onTap: editLocation, + ) + : const SizedBox.shrink(); + } + + return Column( + children: [ + // Location + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "exif_bottom_sheet_location", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + if (asset.isRemote && !asset.isReadOnly) + IconButton( + onPressed: editLocation, + icon: const Icon(Icons.edit_outlined), + iconSize: 20, + ), + ], + ), + ExifMap( + exifInfo: exifInfo!, + formattedDateTime: formattedDateTime, + markerId: asset.remoteId, + ), + RichText( + text: TextSpan( + style: context.textTheme.labelLarge, + children: [ + if (exifInfo != null && exifInfo?.city != null) + TextSpan( + text: exifInfo!.city, + ), + if (exifInfo != null && + exifInfo?.city != null && + exifInfo?.state != null) + const TextSpan( + text: ", ", + ), + if (exifInfo != null && exifInfo?.state != null) + TextSpan( + text: exifInfo!.state, + ), + ], + ), + ), + Text( + "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(150), + ), + ), + ], + ), + ], + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_map.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_map.dart new file mode 100644 index 0000000000000..6c0050aeeae8b --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_map.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ExifMap extends StatelessWidget { + final ExifInfo exifInfo; + final String formattedDateTime; + final String? markerId; + + const ExifMap({ + super.key, + required this.exifInfo, + required this.formattedDateTime, + this.markerId = 'marker', + }); + + @override + Widget build(BuildContext context) { + final hasCoordinates = exifInfo.hasCoordinates; + Future createCoordinatesUri() async { + if (!hasCoordinates) { + return null; + } + + final double latitude = exifInfo.latitude!; + final double longitude = exifInfo.longitude!; + + const zoomLevel = 16; + + if (Platform.isAndroid) { + Uri uri = Uri( + scheme: 'geo', + host: '$latitude,$longitude', + queryParameters: { + 'z': '$zoomLevel', + 'q': '$latitude,$longitude($formattedDateTime)', + }, + ); + if (await canLaunchUrl(uri)) { + return uri; + } + } else if (Platform.isIOS) { + var params = { + 'll': '$latitude,$longitude', + 'q': formattedDateTime, + 'z': '$zoomLevel', + }; + Uri uri = Uri.https('maps.apple.com', '/', params); + if (await canLaunchUrl(uri)) { + return uri; + } + } + + return Uri( + scheme: 'https', + host: 'openstreetmap.org', + queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'}, + fragment: 'map=$zoomLevel/$latitude/$longitude', + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: LayoutBuilder( + builder: (context, constraints) { + return MapThumbnail( + centre: LatLng( + exifInfo.latitude ?? 0, + exifInfo.longitude ?? 0, + ), + height: 150, + width: constraints.maxWidth, + zoom: 12.0, + assetMarkerRemoteId: markerId, + onTap: (tapPosition, latLong) async { + Uri? uri = await createCoordinatesUri(); + + if (uri == null) { + return; + } + + debugPrint('Opening Map Uri: $uri'); + launchUrl(uri); + }, + ); + }, + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart new file mode 100644 index 0000000000000..a94a1239f6585 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart @@ -0,0 +1,94 @@ +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_people.provider.dart'; +import 'package:immich_mobile/modules/search/models/curated_content.dart'; +import 'package:immich_mobile/modules/search/ui/curated_people_row.dart'; +import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; + +class ExifPeople extends ConsumerWidget { + final Asset asset; + final EdgeInsets? padding; + + const ExifPeople({super.key, required this.asset, this.padding}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final peopleProvider = + ref.watch(assetPeopleNotifierProvider(asset).notifier); + final people = ref.watch(assetPeopleNotifierProvider(asset)); + final double imageSize = math.min(context.width / 3, 150); + + showPersonNameEditModel( + String personId, + String personName, + ) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PersonNameEditForm(personId: personId, personName: personName); + }, + ).then((_) { + // ensure the people list is up-to-date. + peopleProvider.refresh(); + }); + } + + if (people.value?.isEmpty ?? true) { + // Empty list or loading + return Container(); + } + + final curatedPeople = people.value + ?.map((p) => CuratedContent(id: p.id, label: p.name)) + .toList() ?? + []; + + return Column( + children: [ + Padding( + padding: padding ?? EdgeInsets.zero, + child: Align( + alignment: Alignment.topLeft, + child: Text( + "exif_bottom_sheet_people", + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), + ).tr(), + ), + ), + SizedBox( + height: imageSize, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: CuratedPeopleRow( + padding: padding, + content: curatedPeople, + onTap: (content, index) { + context + .pushRoute( + PersonResultRoute( + personId: content.id, + personName: content.label, + ), + ) + .then((_) => peopleProvider.refresh()); + }, + onNameTap: (person, index) => { + showPersonNameEditModel(person.id, person.label), + }, + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 09225a35fc665..059c0c976dde5 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -15,7 +15,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provi import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/bottom_gallery_bar.dart'; -import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/exif_sheet/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/gallery_app_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; @@ -133,15 +133,18 @@ class GalleryViewerPage extends HookConsumerWidget { context: context, useSafeArea: true, builder: (context) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.viewInsetsOf(context).bottom, + return FractionallySizedBox( + heightFactor: 0.75, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.advancedTroubleshooting) + ? AdvancedBottomSheet(assetDetail: asset) + : ExifBottomSheet(asset: asset), ), - child: ref - .watch(appSettingsServiceProvider) - .getSetting(AppSettingsEnum.advancedTroubleshooting) - ? AdvancedBottomSheet(assetDetail: asset) - : ExifBottomSheet(asset: asset), ); }, ); diff --git a/mobile/lib/modules/search/ui/curated_people_row.dart b/mobile/lib/modules/search/ui/curated_people_row.dart index f85f13e602d9b..78dc1af4f1724 100644 --- a/mobile/lib/modules/search/ui/curated_people_row.dart +++ b/mobile/lib/modules/search/ui/curated_people_row.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/utils/image_url_builder.dart'; class CuratedPeopleRow extends StatelessWidget { final List content; + final EdgeInsets? padding; /// Callback with the content and the index when tapped final Function(CuratedContent, int)? onTap; @@ -16,6 +17,7 @@ class CuratedPeopleRow extends StatelessWidget { super.key, required this.content, this.onTap, + this.padding, required this.onNameTap, }); @@ -43,6 +45,7 @@ class CuratedPeopleRow extends StatelessWidget { } return ListView.builder( + padding: padding, scrollDirection: Axis.horizontal, itemBuilder: (context, index) { final person = content[index]; diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart index a61fd2c289855..f2bd02375c822 100644 --- a/mobile/lib/shared/models/exif_info.dart +++ b/mobile/lib/shared/models/exif_info.dart @@ -24,6 +24,10 @@ class ExifInfo { String? country; String? description; + @ignore + bool get hasCoordinates => + latitude != null && longitude != null && latitude != 0 && longitude != 0; + @ignore String get exposureTime { if (exposureSeconds == null) { diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index ad163d5cd38b8..bbe3cefc0bb31 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -329,7 +329,9 @@ final assetDetailProvider = yield await ref.watch(assetServiceProvider).loadExif(asset); final db = ref.watch(dbProvider); await for (final a in db.assets.watchObject(asset.id)) { - if (a != null) yield await ref.watch(assetServiceProvider).loadExif(a); + if (a != null) { + yield await ref.watch(assetServiceProvider).loadExif(a); + } } }); From 1eea547aa2b6431a147e6a7514d0edc238c21022 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 6 Mar 2024 21:36:08 -0600 Subject: [PATCH 21/47] chore(server): search filename using originalFileName (#7691) --- server/src/infra/entities/asset.entity.ts | 2 +- server/src/infra/infra.utils.ts | 8 ++++---- .../1709608140355-AddAssetOriginalPathTrigramIndex.ts | 6 +++--- .../shared-components/search-bar/search-filter-box.svelte | 2 +- web/src/routes/(user)/search/+page.svelte | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 7335c3ddf74be..96438a07df31b 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -35,7 +35,7 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum'; @Index('IDX_day_of_month', { synchronize: false }) @Index('IDX_month', { synchronize: false }) @Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId']) -@Index('idx_originalpath_trigram', { synchronize: false }) +@Index('idx_originalFileName_trigram', { synchronize: false }) // For all assets, each originalpath must be unique per user and library export class AssetEntity { @PrimaryGeneratedColumn('uuid') diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 652bced6b6ae7..636d78ab705d9 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -159,12 +159,12 @@ export function searchAssetBuilder( builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds }); } - const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'resizePath', 'webpPath']); + const path = _.pick(options, ['encodedVideoPath', 'originalPath', 'resizePath', 'webpPath']); builder.andWhere(_.omitBy(path, _.isUndefined)); - if (options.originalPath) { - builder.andWhere(`f_unaccent(${builder.alias}.originalPath) ILIKE f_unaccent(:originalPath)`, { - originalPath: `%${options.originalPath}%`, + if (options.originalFileName) { + builder.andWhere(`f_unaccent(${builder.alias}.originalFileName) ILIKE f_unaccent(:originalFileName)`, { + originalFileName: `%${options.originalFileName}%`, }); } diff --git a/server/src/infra/migrations/1709608140355-AddAssetOriginalPathTrigramIndex.ts b/server/src/infra/migrations/1709608140355-AddAssetOriginalPathTrigramIndex.ts index fdca15cbfffbc..1d4f13410aa6c 100644 --- a/server/src/infra/migrations/1709608140355-AddAssetOriginalPathTrigramIndex.ts +++ b/server/src/infra/migrations/1709608140355-AddAssetOriginalPathTrigramIndex.ts @@ -3,12 +3,12 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddAssetOriginalPathTrigramIndex1709608140355 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` - CREATE INDEX idx_originalpath_trigram + CREATE INDEX idx_originalFileName_trigram ON assets - USING gin (f_unaccent("originalPath") gin_trgm_ops)`); + USING gin (f_unaccent("originalFileName") gin_trgm_ops)`); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "idx_originalpath_trigram"`); + await queryRunner.query(`DROP INDEX "idx_originalFileName_trigram"`); } } diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte index d81ed566ca8ec..b921f2bfd76e1 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte @@ -94,7 +94,7 @@ let payload: SmartSearchDto | MetadataSearchDto = { query: filter.context || undefined, - originalPath: filter.filename, + originalFileName: filter.filename, country: filter.location.country, state: filter.location.state, city: filter.location.city, diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 3b7d6b8e6e5b1..c9893166f9a1c 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -173,7 +173,7 @@ make: 'Camera brand', model: 'Camera model', personIds: 'People', - originalPath: 'File name', + originalFileName: 'File name', }; return keyMap[key] || key; } From cd058fdafa60d6ba660dbb2f4525faf5bfea89c9 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 6 Mar 2024 22:20:04 -0600 Subject: [PATCH 22/47] chore(mobile,web): use originalFilename (#7692) * chore(mobile,web): use originalFilename * web * remove unused code --- mobile/lib/shared/models/asset.dart | 2 +- .../lib/components/asset-viewer/detail-panel.svelte | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 3c3c4df82f395..ea49d0202d20e 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -22,7 +22,7 @@ class Asset { updatedAt = remote.updatedAt, durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0, type = remote.type.toAssetType(), - fileName = p.basename(remote.originalPath), + fileName = remote.originalFileName, height = remote.exifInfo?.exifImageHeight?.toInt(), width = remote.exifInfo?.exifImageWidth?.toInt(), livePhotoVideoId = remote.livePhotoVideoId, diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 47dc9d3e9d626..aa6905a4d635a 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -8,7 +8,7 @@ import { user } from '$lib/stores/user.store'; import { websocketEvents } from '$lib/stores/websocket'; import { getAssetThumbnailUrl, getPeopleThumbnailUrl, isSharedLink, handlePromiseError } from '$lib/utils'; - import { delay, getAssetFilename } from '$lib/utils/asset-utils'; + import { delay } from '$lib/utils/asset-utils'; import { autoGrowHeight } from '$lib/utils/autogrow'; import { clickOutside } from '$lib/utils/click-outside'; import { @@ -459,13 +459,11 @@

+ {asset.originalFileName} {#if isOwner} - {asset.originalFileName} - - {:else} - {getAssetFilename(asset)} {/if}

@@ -650,7 +648,7 @@ {/if} {#if currentAlbum && currentAlbum.sharedUsers.length > 0 && asset.owner} -
+

SHARED BY

From e823b39579dc738f8434f6b56d242b6159679946 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 6 Mar 2024 22:21:10 -0600 Subject: [PATCH 23/47] fix(server): access face count when the value is undefined (#7694) --- server/src/infra/repositories/person.repository.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index 5f54bc2b355b0..14c847ef6ddc1 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -223,6 +223,10 @@ export class PersonRepository implements IPersonRepository { .having('COUNT(face.assetId) != 0') .getRawOne(); + if (items == undefined) { + return { total: 0, hidden: 0 }; + } + const result: PeopleStatistics = { total: items.total ?? 0, hidden: items.hidden ?? 0, From 2dcd0e516f5644f9a8e2357ac566d8a44cd0d5cc Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 7 Mar 2024 15:33:56 +0100 Subject: [PATCH 24/47] fix(server): add extension to filename migration (#7697) --- .../migrations/1709763765506-AddExtensionToOriginalFileName.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/infra/migrations/1709763765506-AddExtensionToOriginalFileName.ts b/server/src/infra/migrations/1709763765506-AddExtensionToOriginalFileName.ts index 526d09ccf4ba4..d1f73b4e3b2c6 100644 --- a/server/src/infra/migrations/1709763765506-AddExtensionToOriginalFileName.ts +++ b/server/src/infra/migrations/1709763765506-AddExtensionToOriginalFileName.ts @@ -10,7 +10,7 @@ export class AddExtensionToOriginalFileName1709763765506 implements MigrationInt UPDATE assets SET "originalFileName" = assets."originalFileName" || '.' || extension."ext" FROM extension - INNER JOIN assets a ON a.id = extension.id; + WHERE assets.id = extension.id; `); } From b733a294306e2a622decf9c9d83771ce78bc4e3e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 7 Mar 2024 10:14:36 -0500 Subject: [PATCH 25/47] refactor: e2e (#7703) * refactor: e2e * fix: submodule check * chore: extend startup timeout --- e2e/package.json | 3 +- e2e/src/api/specs/activity.e2e-spec.ts | 13 +- e2e/src/api/specs/album.e2e-spec.ts | 43 ++-- e2e/src/api/specs/asset.e2e-spec.ts | 95 ++++--- e2e/src/api/specs/audit.e2e-spec.ts | 15 +- e2e/src/api/specs/auth.e2e-spec.ts | 12 +- e2e/src/api/specs/download.e2e-spec.ts | 18 +- e2e/src/api/specs/library.e2e-spec.ts | 21 +- e2e/src/api/specs/oauth.e2e-spec.ts | 14 +- e2e/src/api/specs/partner.e2e-spec.ts | 13 +- e2e/src/api/specs/person.e2e-spec.ts | 31 ++- e2e/src/api/specs/server-info.e2e-spec.ts | 9 +- e2e/src/api/specs/shared-link.e2e-spec.ts | 28 +- e2e/src/api/specs/system-config.e2e-spec.ts | 9 +- e2e/src/api/specs/trash.e2e-spec.ts | 33 ++- e2e/src/api/specs/user.e2e-spec.ts | 13 +- e2e/src/cli/specs/login.e2e-spec.ts | 18 +- e2e/src/cli/specs/server-info.e2e-spec.ts | 7 +- e2e/src/cli/specs/upload.e2e-spec.ts | 9 +- e2e/src/cli/specs/version.e2e-spec.ts | 8 +- e2e/src/setup.ts | 11 +- e2e/src/utils.ts | 271 +++++++++----------- e2e/src/web/specs/auth.e2e-spec.ts | 14 +- e2e/src/web/specs/shared-link.e2e-spec.ts | 18 +- e2e/vitest.config.ts | 1 + 25 files changed, 332 insertions(+), 395 deletions(-) diff --git a/e2e/package.json b/e2e/package.json index 9f231c9dddd7d..14685df51bd5b 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,7 +5,8 @@ "main": "index.js", "type": "module", "scripts": { - "test": "vitest --config vitest.config.ts", + "test": "vitest --run", + "test:watch": "vitest", "test:web": "npx playwright test", "start:web": "npx playwright test --ui", "format": "prettier --check .", diff --git a/e2e/src/api/specs/activity.e2e-spec.ts b/e2e/src/api/specs/activity.e2e-spec.ts index 365ad66dc4ddc..5d3cf7220992c 100644 --- a/e2e/src/api/specs/activity.e2e-spec.ts +++ b/e2e/src/api/specs/activity.e2e-spec.ts @@ -9,7 +9,7 @@ import { } from '@immich/sdk'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; +import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -23,12 +23,11 @@ describe('/activity', () => { create({ activityCreateDto: dto }, { headers: asBearerAuth(accessToken || admin.accessToken) }); beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); + await utils.resetDatabase(); - admin = await apiUtils.adminSetup(); - nonOwner = await apiUtils.userSetup(admin.accessToken, createUserDto.user1); - asset = await apiUtils.createAsset(admin.accessToken); + admin = await utils.adminSetup(); + nonOwner = await utils.userSetup(admin.accessToken, createUserDto.user1); + asset = await utils.createAsset(admin.accessToken); album = await createAlbum( { createAlbumDto: { @@ -42,7 +41,7 @@ describe('/activity', () => { }); beforeEach(async () => { - await dbUtils.reset(['activity']); + await utils.resetDatabase(['activity']); }); describe('GET /activity', () => { diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 99a50106ede9a..4faa5eac3d8e0 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -7,7 +7,7 @@ import { } from '@immich/sdk'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; +import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -29,49 +29,48 @@ describe('/album', () => { let user3: LoginResponseDto; // deleted beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); + await utils.resetDatabase(); - admin = await apiUtils.adminSetup(); + admin = await utils.adminSetup(); [user1, user2, user3] = await Promise.all([ - apiUtils.userSetup(admin.accessToken, createUserDto.user1), - apiUtils.userSetup(admin.accessToken, createUserDto.user2), - apiUtils.userSetup(admin.accessToken, createUserDto.user3), + utils.userSetup(admin.accessToken, createUserDto.user1), + utils.userSetup(admin.accessToken, createUserDto.user2), + utils.userSetup(admin.accessToken, createUserDto.user3), ]); [user1Asset1, user1Asset2] = await Promise.all([ - apiUtils.createAsset(user1.accessToken, { isFavorite: true }), - apiUtils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken, { isFavorite: true }), + utils.createAsset(user1.accessToken), ]); const albums = await Promise.all([ // user 1 - apiUtils.createAlbum(user1.accessToken, { + utils.createAlbum(user1.accessToken, { albumName: user1SharedUser, sharedWithUserIds: [user2.userId], assetIds: [user1Asset1.id], }), - apiUtils.createAlbum(user1.accessToken, { + utils.createAlbum(user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset1.id], }), - apiUtils.createAlbum(user1.accessToken, { + utils.createAlbum(user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset1.id, user1Asset2.id], }), // user 2 - apiUtils.createAlbum(user2.accessToken, { + utils.createAlbum(user2.accessToken, { albumName: user2SharedUser, sharedWithUserIds: [user1.userId], assetIds: [user1Asset1.id], }), - apiUtils.createAlbum(user2.accessToken, { albumName: user2SharedLink }), - apiUtils.createAlbum(user2.accessToken, { albumName: user2NotShared }), + utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }), + utils.createAlbum(user2.accessToken, { albumName: user2NotShared }), // user 3 - apiUtils.createAlbum(user3.accessToken, { + utils.createAlbum(user3.accessToken, { albumName: 'Deleted', sharedWithUserIds: [user1.userId], }), @@ -82,12 +81,12 @@ describe('/album', () => { await Promise.all([ // add shared link to user1SharedLink album - apiUtils.createSharedLink(user1.accessToken, { + utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Album, albumId: user1Albums[1].id, }), // add shared link to user2SharedLink album - apiUtils.createSharedLink(user2.accessToken, { + utils.createSharedLink(user2.accessToken, { type: SharedLinkType.Album, albumId: user2Albums[1].id, }), @@ -366,7 +365,7 @@ describe('/album', () => { }); it('should be able to add own asset to own album', async () => { - const asset = await apiUtils.createAsset(user1.accessToken); + const asset = await utils.createAsset(user1.accessToken); const { status, body } = await request(app) .put(`/album/${user1Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) @@ -377,7 +376,7 @@ describe('/album', () => { }); it('should be able to add own asset to shared album', async () => { - const asset = await apiUtils.createAsset(user1.accessToken); + const asset = await utils.createAsset(user1.accessToken); const { status, body } = await request(app) .put(`/album/${user2Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) @@ -398,7 +397,7 @@ describe('/album', () => { }); it('should update an album', async () => { - const album = await apiUtils.createAlbum(user1.accessToken, { + const album = await utils.createAlbum(user1.accessToken, { albumName: 'New album', }); const { status, body } = await request(app) @@ -485,7 +484,7 @@ describe('/album', () => { let album: AlbumResponseDto; beforeEach(async () => { - album = await apiUtils.createAlbum(user1.accessToken, { + album = await utils.createAlbum(user1.accessToken, { albumName: 'testAlbum', }); }); diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 30dfa5d643971..f1bb355315a6e 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -12,7 +12,7 @@ import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, dbUtils, fileUtils, tempDir, testAssetDir, wsUtils } from 'src/utils'; +import { app, tempDir, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -44,42 +44,41 @@ describe('/asset', () => { let ws: Socket; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - admin = await apiUtils.adminSetup({ onboarding: false }); + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); [ws, user1, user2, userStats] = await Promise.all([ - wsUtils.connect(admin.accessToken), - apiUtils.userSetup(admin.accessToken, createUserDto.user1), - apiUtils.userSetup(admin.accessToken, createUserDto.user2), - apiUtils.userSetup(admin.accessToken, createUserDto.user3), + utils.connectWebsocket(admin.accessToken), + utils.userSetup(admin.accessToken, createUserDto.user1), + utils.userSetup(admin.accessToken, createUserDto.user2), + utils.userSetup(admin.accessToken, createUserDto.user3), ]); // asset location - assetLocation = await apiUtils.createAsset(admin.accessToken, { + assetLocation = await utils.createAsset(admin.accessToken, { assetData: { filename: 'thompson-springs.jpg', bytes: await readFile(locationAssetFilepath), }, }); - await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id }); + await utils.waitForWebsocketEvent({ event: 'upload', assetId: assetLocation.id }); user1Assets = await Promise.all([ - apiUtils.createAsset(user1.accessToken), - apiUtils.createAsset(user1.accessToken), - apiUtils.createAsset(user1.accessToken, { + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken, { isFavorite: true, isReadOnly: true, fileCreatedAt: yesterday.toISO(), fileModifiedAt: yesterday.toISO(), assetData: { filename: 'example.mp4' }, }), - apiUtils.createAsset(user1.accessToken), - apiUtils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), ]); - user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]); + user2Assets = await Promise.all([utils.createAsset(user2.accessToken)]); for (const asset of [...user1Assets, ...user2Assets]) { expect(asset.duplicate).toBe(false); @@ -87,27 +86,27 @@ describe('/asset', () => { await Promise.all([ // stats - apiUtils.createAsset(userStats.accessToken), - apiUtils.createAsset(userStats.accessToken, { isFavorite: true }), - apiUtils.createAsset(userStats.accessToken, { isArchived: true }), - apiUtils.createAsset(userStats.accessToken, { + utils.createAsset(userStats.accessToken), + utils.createAsset(userStats.accessToken, { isFavorite: true }), + utils.createAsset(userStats.accessToken, { isArchived: true }), + utils.createAsset(userStats.accessToken, { isArchived: true, isFavorite: true, assetData: { filename: 'example.mp4' }, }), ]); - const person1 = await apiUtils.createPerson(user1.accessToken, { + const person1 = await utils.createPerson(user1.accessToken, { name: 'Test Person', }); - await dbUtils.createFace({ + await utils.createFace({ assetId: user1Assets[0].id, personId: person1.id, }); }, 30_000); afterAll(() => { - wsUtils.disconnect(ws); + utils.disconnectWebsocket(ws); }); describe('GET /asset/:id', () => { @@ -142,7 +141,7 @@ describe('/asset', () => { }); it('should work with a shared link', async () => { - const sharedLink = await apiUtils.createSharedLink(user1.accessToken, { + const sharedLink = await utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, assetIds: [user1Assets[0].id], }); @@ -172,7 +171,7 @@ describe('/asset', () => { ], }); - const sharedLink = await apiUtils.createSharedLink(user1.accessToken, { + const sharedLink = await utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, assetIds: [user1Assets[0].id], }); @@ -244,12 +243,12 @@ describe('/asset', () => { describe('GET /asset/random', () => { beforeAll(async () => { await Promise.all([ - apiUtils.createAsset(user1.accessToken), - apiUtils.createAsset(user1.accessToken), - apiUtils.createAsset(user1.accessToken), - apiUtils.createAsset(user1.accessToken), - apiUtils.createAsset(user1.accessToken), - apiUtils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), + utils.createAsset(user1.accessToken), ]); }); @@ -332,7 +331,7 @@ describe('/asset', () => { }); it('should favorite an asset', async () => { - const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id); + const before = await utils.getAssetInfo(user1.accessToken, user1Assets[0].id); expect(before.isFavorite).toBe(false); const { status, body } = await request(app) @@ -344,7 +343,7 @@ describe('/asset', () => { }); it('should archive an asset', async () => { - const before = await apiUtils.getAssetInfo(user1.accessToken, user1Assets[0].id); + const before = await utils.getAssetInfo(user1.accessToken, user1Assets[0].id); expect(before.isArchived).toBe(false); const { status, body } = await request(app) @@ -472,9 +471,9 @@ describe('/asset', () => { }); it('should move an asset to the trash', async () => { - const { id: assetId } = await apiUtils.createAsset(admin.accessToken); + const { id: assetId } = await utils.createAsset(admin.accessToken); - const before = await apiUtils.getAssetInfo(admin.accessToken, assetId); + const before = await utils.getAssetInfo(admin.accessToken, assetId); expect(before.isTrashed).toBe(false); const { status } = await request(app) @@ -483,7 +482,7 @@ describe('/asset', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); - const after = await apiUtils.getAssetInfo(admin.accessToken, assetId); + const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(true); }); }); @@ -604,15 +603,15 @@ describe('/asset', () => { for (const { input, expected } of tests) { it(`should generate a thumbnail for ${input}`, async () => { const filepath = join(testAssetDir, input); - const { id, duplicate } = await apiUtils.createAsset(admin.accessToken, { + const { id, duplicate } = await utils.createAsset(admin.accessToken, { assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, }); expect(duplicate).toBe(false); - await wsUtils.waitForEvent({ event: 'upload', assetId: id }); + await utils.waitForWebsocketEvent({ event: 'upload', assetId: id }); - const asset = await apiUtils.getAssetInfo(admin.accessToken, id); + const asset = await utils.getAssetInfo(admin.accessToken, id); expect(asset.exifInfo).toBeDefined(); expect(asset.exifInfo).toMatchObject(expected.exifInfo); @@ -622,7 +621,7 @@ describe('/asset', () => { it('should handle a duplicate', async () => { const filepath = 'formats/jpeg/el_torcal_rocks.jpeg'; - const { duplicate } = await apiUtils.createAsset(admin.accessToken, { + const { duplicate } = await utils.createAsset(admin.accessToken, { assetData: { bytes: await readFile(join(testAssetDir, filepath)), filename: basename(filepath), @@ -654,21 +653,21 @@ describe('/asset', () => { for (const { filepath, checksum } of motionTests) { it(`should extract motionphoto video from ${filepath}`, async () => { - const response = await apiUtils.createAsset(admin.accessToken, { + const response = await utils.createAsset(admin.accessToken, { assetData: { bytes: await readFile(join(testAssetDir, filepath)), filename: basename(filepath), }, }); - await wsUtils.waitForEvent({ event: 'upload', assetId: response.id }); + await utils.waitForWebsocketEvent({ event: 'upload', assetId: response.id }); expect(response.duplicate).toBe(false); - const asset = await apiUtils.getAssetInfo(admin.accessToken, response.id); + const asset = await utils.getAssetInfo(admin.accessToken, response.id); expect(asset.livePhotoVideoId).toBeDefined(); - const video = await apiUtils.getAssetInfo(admin.accessToken, asset.livePhotoVideoId as string); + const video = await utils.getAssetInfo(admin.accessToken, asset.livePhotoVideoId as string); expect(video.checksum).toStrictEqual(checksum); }); } @@ -687,7 +686,7 @@ describe('/asset', () => { .get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`) .set('Authorization', `Bearer ${admin.accessToken}`); - await wsUtils.waitForEvent({ + await utils.waitForWebsocketEvent({ event: 'upload', assetId: assetLocation.id, }); @@ -733,11 +732,11 @@ describe('/asset', () => { expect(body).toBeDefined(); expect(type).toBe('image/jpeg'); - const asset = await apiUtils.getAssetInfo(admin.accessToken, assetLocation.id); + const asset = await utils.getAssetInfo(admin.accessToken, assetLocation.id); const original = await readFile(locationAssetFilepath); - const originalChecksum = fileUtils.sha1(original); - const downloadChecksum = fileUtils.sha1(body); + const originalChecksum = utils.sha1(original); + const downloadChecksum = utils.sha1(body); expect(originalChecksum).toBe(downloadChecksum); expect(downloadChecksum).toBe(asset.checksum); diff --git a/e2e/src/api/specs/audit.e2e-spec.ts b/e2e/src/api/specs/audit.e2e-spec.ts index 13c753039db06..2b551fd24c7bc 100644 --- a/e2e/src/api/specs/audit.e2e-spec.ts +++ b/e2e/src/api/specs/audit.e2e-spec.ts @@ -1,24 +1,23 @@ import { deleteAssets, getAuditFiles, updateAsset, type LoginResponseDto } from '@immich/sdk'; -import { apiUtils, asBearerAuth, dbUtils, fileUtils } from 'src/utils'; +import { asBearerAuth, utils } from 'src/utils'; import { beforeAll, describe, expect, it } from 'vitest'; describe('/audit', () => { let admin: LoginResponseDto; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - await fileUtils.reset(); + await utils.resetDatabase(); + await utils.resetFilesystem(); - admin = await apiUtils.adminSetup(); + admin = await utils.adminSetup(); }); describe('GET :/file-report', () => { it('excludes assets without issues from report', async () => { const [trashedAsset, archivedAsset] = await Promise.all([ - apiUtils.createAsset(admin.accessToken), - apiUtils.createAsset(admin.accessToken), - apiUtils.createAsset(admin.accessToken), + utils.createAsset(admin.accessToken), + utils.createAsset(admin.accessToken), + utils.createAsset(admin.accessToken), ]); await Promise.all([ diff --git a/e2e/src/api/specs/auth.e2e-spec.ts b/e2e/src/api/specs/auth.e2e-spec.ts index a58e215718033..28445f79d9ebd 100644 --- a/e2e/src/api/specs/auth.e2e-spec.ts +++ b/e2e/src/api/specs/auth.e2e-spec.ts @@ -1,19 +1,15 @@ import { LoginResponseDto, getAuthDevices, login, signUpAdmin } from '@immich/sdk'; import { loginDto, signupDto, uuidDto } from 'src/fixtures'; import { deviceDto, errorDto, loginResponseDto, signupResponseDto } from 'src/responses'; -import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; +import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; const { name, email, password } = signupDto.admin; describe(`/auth/admin-sign-up`, () => { - beforeAll(() => { - apiUtils.setup(); - }); - beforeEach(async () => { - await dbUtils.reset(); + await utils.resetDatabase(); }); describe('POST /auth/admin-sign-up', () => { @@ -84,7 +80,7 @@ describe('/auth/*', () => { let admin: LoginResponseDto; beforeEach(async () => { - await dbUtils.reset(); + await utils.resetDatabase(); await signUpAdmin({ signUpDto: signupDto.admin }); admin = await login({ loginCredentialDto: loginDto.admin }); }); diff --git a/e2e/src/api/specs/download.e2e-spec.ts b/e2e/src/api/specs/download.e2e-spec.ts index af328934b4239..ef14778dac761 100644 --- a/e2e/src/api/specs/download.e2e-spec.ts +++ b/e2e/src/api/specs/download.e2e-spec.ts @@ -1,7 +1,7 @@ import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk'; import { readFile, writeFile } from 'node:fs/promises'; import { errorDto } from 'src/responses'; -import { apiUtils, app, dbUtils, fileUtils, tempDir } from 'src/utils'; +import { app, tempDir, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -11,13 +11,9 @@ describe('/download', () => { let asset2: AssetFileUploadResponseDto; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - admin = await apiUtils.adminSetup(); - [asset1, asset2] = await Promise.all([ - apiUtils.createAsset(admin.accessToken), - apiUtils.createAsset(admin.accessToken), - ]); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + [asset1, asset2] = await Promise.all([utils.createAsset(admin.accessToken), utils.createAsset(admin.accessToken)]); }); describe('POST /download/info', () => { @@ -65,15 +61,15 @@ describe('/download', () => { expect(body instanceof Buffer).toBe(true); await writeFile(`${tempDir}/archive.zip`, body); - await fileUtils.unzip(`${tempDir}/archive.zip`, `${tempDir}/archive`); + await utils.unzip(`${tempDir}/archive.zip`, `${tempDir}/archive`); const files = [ { filename: 'example.png', id: asset1.id }, { filename: 'example+1.png', id: asset2.id }, ]; for (const { id, filename } of files) { const bytes = await readFile(`${tempDir}/archive/${filename}`); - const asset = await apiUtils.getAssetInfo(admin.accessToken, id); - expect(fileUtils.sha1(bytes)).toBe(asset.checksum); + const asset = await utils.getAssetInfo(admin.accessToken, id); + expect(utils.sha1(bytes)).toBe(asset.checksum); } }); }); diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 8213cc86eaaee..e8f9a46bb2acb 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1,7 +1,7 @@ import { LibraryResponseDto, LibraryType, LoginResponseDto, getAllLibraries } from '@immich/sdk'; import { userDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, asBearerAuth, dbUtils, testAssetDirInternal } from 'src/utils'; +import { app, asBearerAuth, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -11,11 +11,10 @@ describe('/library', () => { let library: LibraryResponseDto; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - admin = await apiUtils.adminSetup(); - user = await apiUtils.userSetup(admin.accessToken, userDto.user1); - library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External }); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + user = await utils.userSetup(admin.accessToken, userDto.user1); + library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); }); describe('GET /library', () => { @@ -303,7 +302,7 @@ describe('/library', () => { }); it('should get library by id', async () => { - const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External }); + const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); const { status, body } = await request(app) .get(`/library/${library.id}`) @@ -359,7 +358,7 @@ describe('/library', () => { }); it('should delete an external library', async () => { - const library = await apiUtils.createLibrary(admin.accessToken, { type: LibraryType.External }); + const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); const { status, body } = await request(app) .delete(`/library/${library.id}`) @@ -415,14 +414,14 @@ describe('/library', () => { }); it('should pass with no import paths', async () => { - const response = await apiUtils.validateLibrary(admin.accessToken, library.id, { importPaths: [] }); + const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [] }); expect(response.importPaths).toEqual([]); }); it('should fail if path does not exist', async () => { const pathToTest = `${testAssetDirInternal}/does/not/exist`; - const response = await apiUtils.validateLibrary(admin.accessToken, library.id, { + const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [pathToTest], }); @@ -439,7 +438,7 @@ describe('/library', () => { it('should fail if path is a file', async () => { const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`; - const response = await apiUtils.validateLibrary(admin.accessToken, library.id, { + const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [pathToTest], }); diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts index 1324d3fa7fae0..81c4b452c1ec0 100644 --- a/e2e/src/api/specs/oauth.e2e-spec.ts +++ b/e2e/src/api/specs/oauth.e2e-spec.ts @@ -1,16 +1,12 @@ import { errorDto } from 'src/responses'; -import { apiUtils, app, dbUtils } from 'src/utils'; +import { app, utils } from 'src/utils'; import request from 'supertest'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; describe(`/oauth`, () => { - beforeAll(() => { - apiUtils.setup(); - }); - - beforeEach(async () => { - await dbUtils.reset(); - await apiUtils.adminSetup(); + beforeAll(async () => { + await utils.resetDatabase(); + await utils.adminSetup(); }); describe('POST /oauth/authorize', () => { diff --git a/e2e/src/api/specs/partner.e2e-spec.ts b/e2e/src/api/specs/partner.e2e-spec.ts index 2c88391bd4013..b2fb7f4101943 100644 --- a/e2e/src/api/specs/partner.e2e-spec.ts +++ b/e2e/src/api/specs/partner.e2e-spec.ts @@ -1,7 +1,7 @@ import { LoginResponseDto, createPartner } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; +import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -12,15 +12,14 @@ describe('/partner', () => { let user3: LoginResponseDto; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); + await utils.resetDatabase(); - admin = await apiUtils.adminSetup(); + admin = await utils.adminSetup(); [user1, user2, user3] = await Promise.all([ - apiUtils.userSetup(admin.accessToken, createUserDto.user1), - apiUtils.userSetup(admin.accessToken, createUserDto.user2), - apiUtils.userSetup(admin.accessToken, createUserDto.user3), + utils.userSetup(admin.accessToken, createUserDto.user1), + utils.userSetup(admin.accessToken, createUserDto.user2), + utils.userSetup(admin.accessToken, createUserDto.user3), ]); await Promise.all([ diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index 915c04f86752d..55cb982f9dff5 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -1,7 +1,7 @@ import { LoginResponseDto, PersonResponseDto } from '@immich/sdk'; import { uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, dbUtils } from 'src/utils'; +import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -12,36 +12,35 @@ describe('/activity', () => { let multipleAssetsPerson: PersonResponseDto; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - admin = await apiUtils.adminSetup(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); }); beforeEach(async () => { - await dbUtils.reset(['person']); + await utils.resetDatabase(['person']); [visiblePerson, hiddenPerson, multipleAssetsPerson] = await Promise.all([ - apiUtils.createPerson(admin.accessToken, { + utils.createPerson(admin.accessToken, { name: 'visible_person', }), - apiUtils.createPerson(admin.accessToken, { + utils.createPerson(admin.accessToken, { name: 'hidden_person', isHidden: true, }), - apiUtils.createPerson(admin.accessToken, { + utils.createPerson(admin.accessToken, { name: 'multiple_assets_person', }), ]); - const asset1 = await apiUtils.createAsset(admin.accessToken); - const asset2 = await apiUtils.createAsset(admin.accessToken); + const asset1 = await utils.createAsset(admin.accessToken); + const asset2 = await utils.createAsset(admin.accessToken); await Promise.all([ - dbUtils.createFace({ assetId: asset1.id, personId: visiblePerson.id }), - dbUtils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }), - dbUtils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), - dbUtils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), - dbUtils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }), + utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }), + utils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }), + utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), + utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), + utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }), ]); }); @@ -194,7 +193,7 @@ describe('/activity', () => { it('should clear a date of birth', async () => { // TODO ironically this uses the update endpoint to create the person - const person = await apiUtils.createPerson(admin.accessToken, { + const person = await utils.createPerson(admin.accessToken, { birthDate: new Date('1990-01-01').toISOString(), }); diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index b8262cb68a4e6..5cfd6a8b98c13 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -1,7 +1,7 @@ import { LoginResponseDto, getServerConfig } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, dbUtils } from 'src/utils'; +import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -10,10 +10,9 @@ describe('/server-info', () => { let nonAdmin: LoginResponseDto; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - admin = await apiUtils.adminSetup({ onboarding: false }); - nonAdmin = await apiUtils.userSetup(admin.accessToken, createUserDto.user1); + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); }); describe('GET /server-info', () => { diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index 7ff4bb6bf7f4b..8b854eda00f36 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -9,7 +9,7 @@ import { } from '@immich/sdk'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; +import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -30,20 +30,16 @@ describe('/shared-link', () => { let linkWithoutMetadata: SharedLinkResponseDto; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); + await utils.resetDatabase(); - admin = await apiUtils.adminSetup(); + admin = await utils.adminSetup(); [user1, user2] = await Promise.all([ - apiUtils.userSetup(admin.accessToken, createUserDto.user1), - apiUtils.userSetup(admin.accessToken, createUserDto.user2), + utils.userSetup(admin.accessToken, createUserDto.user1), + utils.userSetup(admin.accessToken, createUserDto.user2), ]); - [asset1, asset2] = await Promise.all([ - apiUtils.createAsset(user1.accessToken), - apiUtils.createAsset(user1.accessToken), - ]); + [asset1, asset2] = await Promise.all([utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken)]); [album, deletedAlbum, metadataAlbum] = await Promise.all([ createAlbum({ createAlbumDto: { albumName: 'album' } }, { headers: asBearerAuth(user1.accessToken) }), @@ -61,29 +57,29 @@ describe('/shared-link', () => { [linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] = await Promise.all([ - apiUtils.createSharedLink(user2.accessToken, { + utils.createSharedLink(user2.accessToken, { type: SharedLinkType.Album, albumId: deletedAlbum.id, }), - apiUtils.createSharedLink(user1.accessToken, { + utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Album, albumId: album.id, }), - apiUtils.createSharedLink(user1.accessToken, { + utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, assetIds: [asset1.id], }), - apiUtils.createSharedLink(user1.accessToken, { + utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Album, albumId: album.id, password: 'foo', }), - apiUtils.createSharedLink(user1.accessToken, { + utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Album, albumId: metadataAlbum.id, showMetadata: true, }), - apiUtils.createSharedLink(user1.accessToken, { + utils.createSharedLink(user1.accessToken, { type: SharedLinkType.Album, albumId: metadataAlbum.id, showMetadata: false, diff --git a/e2e/src/api/specs/system-config.e2e-spec.ts b/e2e/src/api/specs/system-config.e2e-spec.ts index 6d8880d3fc184..c223df4874b3b 100644 --- a/e2e/src/api/specs/system-config.e2e-spec.ts +++ b/e2e/src/api/specs/system-config.e2e-spec.ts @@ -1,7 +1,7 @@ import { LoginResponseDto } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, dbUtils } from 'src/utils'; +import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -10,10 +10,9 @@ describe('/system-config', () => { let nonAdmin: LoginResponseDto; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - admin = await apiUtils.adminSetup(); - nonAdmin = await apiUtils.userSetup(admin.accessToken, createUserDto.user1); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); }); describe('GET /system-config/map/style.json', () => { diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 60ed75f1189bb..3e6c2f1fc6ef6 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -1,7 +1,7 @@ import { LoginResponseDto, getAllAssets } from '@immich/sdk'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; -import { apiUtils, app, asBearerAuth, dbUtils, wsUtils } from 'src/utils'; +import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -10,14 +10,13 @@ describe('/trash', () => { let ws: Socket; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - admin = await apiUtils.adminSetup({ onboarding: false }); - ws = await wsUtils.connect(admin.accessToken); + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + ws = await utils.connectWebsocket(admin.accessToken); }); afterAll(() => { - wsUtils.disconnect(ws); + utils.disconnectWebsocket(ws); }); describe('POST /trash/empty', () => { @@ -29,8 +28,8 @@ describe('/trash', () => { }); it('should empty the trash', async () => { - const { id: assetId } = await apiUtils.createAsset(admin.accessToken); - await apiUtils.deleteAssets(admin.accessToken, [assetId]); + const { id: assetId } = await utils.createAsset(admin.accessToken); + await utils.deleteAssets(admin.accessToken, [assetId]); const before = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) }); @@ -39,7 +38,7 @@ describe('/trash', () => { const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); - await wsUtils.waitForEvent({ event: 'delete', assetId }); + await utils.waitForWebsocketEvent({ event: 'delete', assetId }); const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) }); expect(after.length).toBe(0); @@ -55,16 +54,16 @@ describe('/trash', () => { }); it('should restore all trashed assets', async () => { - const { id: assetId } = await apiUtils.createAsset(admin.accessToken); - await apiUtils.deleteAssets(admin.accessToken, [assetId]); + const { id: assetId } = await utils.createAsset(admin.accessToken); + await utils.deleteAssets(admin.accessToken, [assetId]); - const before = await apiUtils.getAssetInfo(admin.accessToken, assetId); + const before = await utils.getAssetInfo(admin.accessToken, assetId); expect(before.isTrashed).toBe(true); const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); - const after = await apiUtils.getAssetInfo(admin.accessToken, assetId); + const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(false); }); }); @@ -78,10 +77,10 @@ describe('/trash', () => { }); it('should restore a trashed asset by id', async () => { - const { id: assetId } = await apiUtils.createAsset(admin.accessToken); - await apiUtils.deleteAssets(admin.accessToken, [assetId]); + const { id: assetId } = await utils.createAsset(admin.accessToken); + await utils.deleteAssets(admin.accessToken, [assetId]); - const before = await apiUtils.getAssetInfo(admin.accessToken, assetId); + const before = await utils.getAssetInfo(admin.accessToken, assetId); expect(before.isTrashed).toBe(true); const { status } = await request(app) @@ -90,7 +89,7 @@ describe('/trash', () => { .send({ ids: [assetId] }); expect(status).toBe(204); - const after = await apiUtils.getAssetInfo(admin.accessToken, assetId); + const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(false); }); }); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index e47e1d531c7a5..d448a605cdcde 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -1,7 +1,7 @@ import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk'; import { createUserDto, userDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; +import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -12,14 +12,13 @@ describe('/server-info', () => { let nonAdmin: LoginResponseDto; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - admin = await apiUtils.adminSetup({ onboarding: false }); + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); [deletedUser, nonAdmin, userToDelete] = await Promise.all([ - apiUtils.userSetup(admin.accessToken, createUserDto.user1), - apiUtils.userSetup(admin.accessToken, createUserDto.user2), - apiUtils.userSetup(admin.accessToken, createUserDto.user3), + utils.userSetup(admin.accessToken, createUserDto.user1), + utils.userSetup(admin.accessToken, createUserDto.user2), + utils.userSetup(admin.accessToken, createUserDto.user3), ]); await deleteUser({ id: deletedUser.userId }, { headers: asBearerAuth(admin.accessToken) }); diff --git a/e2e/src/cli/specs/login.e2e-spec.ts b/e2e/src/cli/specs/login.e2e-spec.ts index aa27bec63e45c..61702769ca887 100644 --- a/e2e/src/cli/specs/login.e2e-spec.ts +++ b/e2e/src/cli/specs/login.e2e-spec.ts @@ -1,14 +1,10 @@ import { stat } from 'node:fs/promises'; -import { apiUtils, app, dbUtils, immichCli } from 'src/utils'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { app, immichCli, utils } from 'src/utils'; +import { beforeEach, describe, expect, it } from 'vitest'; describe(`immich login-key`, () => { - beforeAll(() => { - apiUtils.setup(); - }); - beforeEach(async () => { - await dbUtils.reset(); + await utils.resetDatabase(); }); it('should require a url', async () => { @@ -30,8 +26,8 @@ describe(`immich login-key`, () => { }); it('should login and save auth.yml with 600', async () => { - const admin = await apiUtils.adminSetup(); - const key = await apiUtils.createApiKey(admin.accessToken); + const admin = await utils.adminSetup(); + const key = await utils.createApiKey(admin.accessToken); const { stdout, stderr, exitCode } = await immichCli(['login-key', app, `${key.secret}`]); expect(stdout.split('\n')).toEqual([ 'Logging in to http://127.0.0.1:2283/api', @@ -47,8 +43,8 @@ describe(`immich login-key`, () => { }); it('should login without /api in the url', async () => { - const admin = await apiUtils.adminSetup(); - const key = await apiUtils.createApiKey(admin.accessToken); + const admin = await utils.adminSetup(); + const key = await utils.createApiKey(admin.accessToken); const { stdout, stderr, exitCode } = await immichCli(['login-key', app.replaceAll('/api', ''), `${key.secret}`]); expect(stdout.split('\n')).toEqual([ 'Logging in to http://127.0.0.1:2283', diff --git a/e2e/src/cli/specs/server-info.e2e-spec.ts b/e2e/src/cli/specs/server-info.e2e-spec.ts index 038a2c2ca00c5..6efe002b8651a 100644 --- a/e2e/src/cli/specs/server-info.e2e-spec.ts +++ b/e2e/src/cli/specs/server-info.e2e-spec.ts @@ -1,11 +1,10 @@ -import { apiUtils, cliUtils, dbUtils, immichCli } from 'src/utils'; +import { immichCli, utils } from 'src/utils'; import { beforeAll, describe, expect, it } from 'vitest'; describe(`immich server-info`, () => { beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - await cliUtils.login(); + await utils.resetDatabase(); + await utils.cliLogin(); }); it('should return the server info', async () => { diff --git a/e2e/src/cli/specs/upload.e2e-spec.ts b/e2e/src/cli/specs/upload.e2e-spec.ts index bda625241e2c5..27362ef23748e 100644 --- a/e2e/src/cli/specs/upload.e2e-spec.ts +++ b/e2e/src/cli/specs/upload.e2e-spec.ts @@ -1,19 +1,18 @@ import { getAllAlbums, getAllAssets } from '@immich/sdk'; import { mkdir, readdir, rm, symlink } from 'node:fs/promises'; -import { apiUtils, asKeyAuth, cliUtils, dbUtils, immichCli, testAssetDir } from 'src/utils'; +import { asKeyAuth, immichCli, testAssetDir, utils } from 'src/utils'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe(`immich upload`, () => { let key: string; beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - key = await cliUtils.login(); + await utils.resetDatabase(); + key = await utils.cliLogin(); }); beforeEach(async () => { - await dbUtils.reset(['assets', 'albums']); + await utils.resetDatabase(['assets', 'albums']); }); describe('immich upload --recursive', () => { diff --git a/e2e/src/cli/specs/version.e2e-spec.ts b/e2e/src/cli/specs/version.e2e-spec.ts index e94ccf214f191..56a0d8b0b1557 100644 --- a/e2e/src/cli/specs/version.e2e-spec.ts +++ b/e2e/src/cli/specs/version.e2e-spec.ts @@ -1,14 +1,10 @@ import { readFileSync } from 'node:fs'; -import { apiUtils, immichCli } from 'src/utils'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { immichCli } from 'src/utils'; +import { describe, expect, it } from 'vitest'; const pkg = JSON.parse(readFileSync('../cli/package.json', 'utf8')); describe(`immich --version`, () => { - beforeAll(() => { - apiUtils.setup(); - }); - describe('immich --version', () => { it('should print the cli version', async () => { const { stdout, stderr, exitCode } = await immichCli(['--version']); diff --git a/e2e/src/setup.ts b/e2e/src/setup.ts index e0ff443566250..a3d96ac17fedd 100644 --- a/e2e/src/setup.ts +++ b/e2e/src/setup.ts @@ -1,8 +1,16 @@ import { exec, spawn } from 'node:child_process'; +import { setTimeout } from 'node:timers'; export default async () => { let _resolve: () => unknown; - const ready = new Promise((resolve) => (_resolve = resolve)); + let _reject: (error: Error) => unknown; + + const ready = new Promise((resolve, reject) => { + _resolve = resolve; + _reject = reject; + }); + + const timeout = setTimeout(() => _reject(new Error('Timeout starting e2e environment')), 60_000); const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); @@ -17,6 +25,7 @@ export default async () => { child.stderr.on('data', (data) => console.log(data.toString())); await ready; + clearTimeout(timeout); return async () => { await new Promise((resolve) => exec('docker compose down', () => resolve())); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 9be730c7e154d..5547a2c1282f9 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -26,7 +26,7 @@ import { import { BrowserContext } from '@playwright/test'; import { exec, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { access } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; @@ -36,79 +36,71 @@ import { loginDto, signupDto } from 'src/fixtures'; import { makeRandomImage } from 'src/generators'; import request from 'supertest'; -const execPromise = promisify(exec); +type CliResponse = { stdout: string; stderr: string; exitCode: number | null }; +type EventType = 'upload' | 'delete'; +type WaitOptions = { event: EventType; assetId: string; timeout?: number }; +type AdminSetupOptions = { onboarding?: boolean }; +type AssetData = { bytes?: Buffer; filename: string }; -export const app = 'http://127.0.0.1:2283/api'; - -const directoryExists = (directory: string) => - access(directory) - .then(() => true) - .catch(() => false); +const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5433/immich'; +const baseUrl = 'http://127.0.0.1:2283'; +export const app = `${baseUrl}/api`; // TODO move test assets into e2e/assets export const testAssetDir = path.resolve(`./../server/test/assets/`); export const testAssetDirInternal = '/data/assets'; export const tempDir = tmpdir(); - -const serverContainerName = 'immich-e2e-server'; -const mediaDir = '/usr/src/app/upload'; -const dirs = [ - `"${mediaDir}/thumbs"`, - `"${mediaDir}/upload"`, - `"${mediaDir}/library"`, - `"${mediaDir}/encoded-video"`, -].join(' '); - -if (!(await directoryExists(`${testAssetDir}/albums`))) { - throw new Error( - `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`, - ); -} - -export const asBearerAuth = (accessToken: string) => ({ - Authorization: `Bearer ${accessToken}`, -}); - +export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` }); export const asKeyAuth = (key: string) => ({ 'x-api-key': key }); +export const immichCli = async (args: string[]) => { + let _resolve: (value: CliResponse) => void; + const deferred = new Promise((resolve) => (_resolve = resolve)); + const _args = ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]; + const child = spawn('node', _args, { + stdio: 'pipe', + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => (stdout += data.toString())); + child.stderr.on('data', (data) => (stderr += data.toString())); + child.on('exit', (exitCode) => { + _resolve({ + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode, + }); + }); + + return deferred; +}; let client: pg.Client | null = null; -export const fileUtils = { - reset: async () => { - await execPromise(`docker exec -i "${serverContainerName}" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`); - }, - unzip: async (input: string, output: string) => { - await execPromise(`unzip -o -d "${output}" "${input}"`); - }, - sha1: (bytes: Buffer) => createHash('sha1').update(bytes).digest('base64'), +const events: Record> = { + upload: new Set(), + delete: new Set(), }; -export const dbUtils = { - createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => { - if (!client) { - return; - } +const callbacks: Record void> = {}; - const vector = Array.from({ length: 512 }, Math.random); - const embedding = `[${vector.join(',')}]`; +const execPromise = promisify(exec); - await client.query('INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)', [ - assetId, - personId, - embedding, - ]); - }, - setPersonThumbnail: async (personId: string) => { - if (!client) { - return; - } +const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => { + events[event].add(assetId); + const callback = callbacks[assetId]; + if (callback) { + callback(); + delete callbacks[assetId]; + } +}; - await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]); - }, - reset: async (tables?: string[]) => { +export const utils = { + resetDatabase: async (tables?: string[]) => { try { if (!client) { - client = new pg.Client('postgres://postgres:postgres@127.0.0.1:5433/immich'); + client = new pg.Client(dbUrl); await client.connect(); } @@ -134,83 +126,27 @@ export const dbUtils = { throw error; } }, - teardown: async () => { - try { - if (client) { - await client.end(); - client = null; - } - } catch (error) { - console.error('Failed to teardown database', error); - throw error; - } + + resetFilesystem: async () => { + const mediaInternal = '/usr/src/app/upload'; + const dirs = [ + `"${mediaInternal}/thumbs"`, + `"${mediaInternal}/upload"`, + `"${mediaInternal}/library"`, + `"${mediaInternal}/encoded-video"`, + ].join(' '); + + await execPromise(`docker exec -i "immich-e2e-server" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`); }, -}; -export interface CliResponse { - stdout: string; - stderr: string; - exitCode: number | null; -} -export const immichCli = async (args: string[]) => { - let _resolve: (value: CliResponse) => void; - const deferred = new Promise((resolve) => (_resolve = resolve)); - const _args = ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]; - const child = spawn('node', _args, { - stdio: 'pipe', - }); + unzip: async (input: string, output: string) => { + await execPromise(`unzip -o -d "${output}" "${input}"`); + }, - let stdout = ''; - let stderr = ''; + sha1: (bytes: Buffer) => createHash('sha1').update(bytes).digest('base64'), - child.stdout.on('data', (data) => (stdout += data.toString())); - child.stderr.on('data', (data) => (stderr += data.toString())); - child.on('exit', (exitCode) => { - _resolve({ - stdout: stdout.trim(), - stderr: stderr.trim(), - exitCode, - }); - }); - - return deferred; -}; - -export interface AdminSetupOptions { - onboarding?: boolean; -} - -export enum SocketEvent { - UPLOAD = 'upload', - DELETE = 'delete', -} - -export type EventType = 'upload' | 'delete'; -export interface WaitOptions { - event: EventType; - assetId: string; - timeout?: number; -} - -const events: Record> = { - upload: new Set(), - delete: new Set(), -}; - -const callbacks: Record void> = {}; - -const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => { - events[event].add(assetId); - const callback = callbacks[assetId]; - if (callback) { - callback(); - delete callbacks[assetId]; - } -}; - -export const wsUtils = { - connect: async (accessToken: string) => { - const websocket = io('http://127.0.0.1:2283', { + connectWebsocket: async (accessToken: string) => { + const websocket = io(baseUrl, { path: '/api/socket.io', transports: ['websocket'], extraHeaders: { Authorization: `Bearer ${accessToken}` }, @@ -226,7 +162,8 @@ export const wsUtils = { .connect(); }); }, - disconnect: (ws: Socket) => { + + disconnectWebsocket: (ws: Socket) => { if (ws?.connected) { ws.disconnect(); } @@ -235,14 +172,15 @@ export const wsUtils = { set.clear(); } }, - waitForEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise => { + + waitForWebsocketEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise => { const set = events[event]; if (set.has(assetId)) { return; } return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 5000); + const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000); callbacks[assetId] = () => { clearTimeout(timeout); @@ -250,12 +188,8 @@ export const wsUtils = { }; }); }, -}; -type AssetData = { bytes?: Buffer; filename: string }; - -export const apiUtils = { - setup: () => { + setApiEndpoint: () => { defaults.baseUrl = app; }, @@ -269,17 +203,21 @@ export const apiUtils = { } return response; }, + userSetup: async (accessToken: string, dto: CreateUserDto) => { await createUser({ createUserDto: dto }, { headers: asBearerAuth(accessToken) }); return login({ loginCredentialDto: { email: dto.email, password: dto.password }, }); }, + createApiKey: (accessToken: string) => { return createApiKey({ apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) }); }, + createAlbum: (accessToken: string, dto: CreateAlbumDto) => createAlbum({ createAlbumDto: dto }, { headers: asBearerAuth(accessToken) }), + createAsset: async ( accessToken: string, dto?: Partial> & { assetData?: AssetData }, @@ -308,13 +246,16 @@ export const apiUtils = { return body as AssetFileUploadResponseDto; }, + getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), + deleteAssets: (accessToken: string, ids: string[]) => deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }), + createPerson: async (accessToken: string, dto?: PersonUpdateDto) => { // TODO fix createPerson to accept a body const person = await createPerson({ headers: asBearerAuth(accessToken) }); - await dbUtils.setPersonThumbnail(person.id); + await utils.setPersonThumbnail(person.id); if (!dto) { return person; @@ -322,24 +263,39 @@ export const apiUtils = { return updatePerson({ id: person.id, personUpdateDto: dto }, { headers: asBearerAuth(accessToken) }); }, + + createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => { + if (!client) { + return; + } + + const vector = Array.from({ length: 512 }, Math.random); + const embedding = `[${vector.join(',')}]`; + + await client.query('INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)', [ + assetId, + personId, + embedding, + ]); + }, + + setPersonThumbnail: async (personId: string) => { + if (!client) { + return; + } + + await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]); + }, + createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) => createSharedLink({ sharedLinkCreateDto: dto }, { headers: asBearerAuth(accessToken) }), + createLibrary: (accessToken: string, dto: CreateLibraryDto) => createLibrary({ createLibraryDto: dto }, { headers: asBearerAuth(accessToken) }), + validateLibrary: (accessToken: string, id: string, dto: ValidateLibraryDto) => validate({ id, validateLibraryDto: dto }, { headers: asBearerAuth(accessToken) }), -}; -export const cliUtils = { - login: async () => { - const admin = await apiUtils.adminSetup(); - const key = await apiUtils.createApiKey(admin.accessToken); - await immichCli(['login-key', app, `${key.secret}`]); - return key.secret; - }, -}; - -export const webUtils = { setAuthCookies: async (context: BrowserContext, accessToken: string) => await context.addCookies([ { @@ -373,4 +329,19 @@ export const webUtils = { sameSite: 'Lax', }, ]), + + cliLogin: async () => { + const admin = await utils.adminSetup(); + const key = await utils.createApiKey(admin.accessToken); + await immichCli(['login-key', app, `${key.secret}`]); + return key.secret; + }, }; + +utils.setApiEndpoint(); + +if (!existsSync(`${testAssetDir}/albums`)) { + throw new Error( + `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`, + ); +} diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts index 23210205a399c..73d62f1b10922 100644 --- a/e2e/src/web/specs/auth.e2e-spec.ts +++ b/e2e/src/web/specs/auth.e2e-spec.ts @@ -1,17 +1,13 @@ import { expect, test } from '@playwright/test'; -import { apiUtils, dbUtils, webUtils } from 'src/utils'; +import { utils } from 'src/utils'; test.describe('Registration', () => { test.beforeAll(() => { - apiUtils.setup(); + utils.setApiEndpoint(); }); test.beforeEach(async () => { - await dbUtils.reset(); - }); - - test.afterAll(async () => { - await dbUtils.teardown(); + await utils.resetDatabase(); }); test('admin registration', async ({ page }) => { @@ -45,8 +41,8 @@ test.describe('Registration', () => { }); test('user registration', async ({ context, page }) => { - const admin = await apiUtils.adminSetup(); - await webUtils.setAuthCookies(context, admin.accessToken); + const admin = await utils.adminSetup(); + await utils.setAuthCookies(context, admin.accessToken); // create user await page.goto('/admin/user-management'); diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index 6b2dbad95c240..3540ed72e2079 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -7,7 +7,7 @@ import { createAlbum, } from '@immich/sdk'; import { test } from '@playwright/test'; -import { apiUtils, asBearerAuth, dbUtils } from 'src/utils'; +import { asBearerAuth, utils } from 'src/utils'; test.describe('Shared Links', () => { let admin: LoginResponseDto; @@ -17,10 +17,10 @@ test.describe('Shared Links', () => { let sharedLinkPassword: SharedLinkResponseDto; test.beforeAll(async () => { - apiUtils.setup(); - await dbUtils.reset(); - admin = await apiUtils.adminSetup(); - asset = await apiUtils.createAsset(admin.accessToken); + utils.setApiEndpoint(); + await utils.resetDatabase(); + admin = await utils.adminSetup(); + asset = await utils.createAsset(admin.accessToken); album = await createAlbum( { createAlbumDto: { @@ -30,21 +30,17 @@ test.describe('Shared Links', () => { }, { headers: asBearerAuth(admin.accessToken) }, ); - sharedLink = await apiUtils.createSharedLink(admin.accessToken, { + sharedLink = await utils.createSharedLink(admin.accessToken, { type: SharedLinkType.Album, albumId: album.id, }); - sharedLinkPassword = await apiUtils.createSharedLink(admin.accessToken, { + sharedLinkPassword = await utils.createSharedLink(admin.accessToken, { type: SharedLinkType.Album, albumId: album.id, password: 'test-password', }); }); - test.afterAll(async () => { - await dbUtils.teardown(); - }); - test('download from a shared link', async ({ page }) => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index b8cc098ddd241..d7dcde4c385eb 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ test: { include: ['src/{api,cli}/specs/*.e2e-spec.ts'], globalSetup, + testTimeout: 10_000, poolOptions: { threads: { singleThread: true, From 3278dcbcbeda98212fd086c33babd691dcdd27b1 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 Mar 2024 10:16:47 -0600 Subject: [PATCH 26/47] fix(web): save filename search in search filter box (#7704) --- .../shared-components/search-bar/search-filter-box.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte index b921f2bfd76e1..071cd08110bae 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte @@ -43,7 +43,7 @@ let filter: SearchFilter = { context: 'query' in searchQuery ? searchQuery.query : '', - filename: 'originalPath' in searchQuery ? searchQuery.originalPath : undefined, + filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined, personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []), location: { country: searchQuery.country, From 4cb0f379184f82a40917926a34ec35d8dd063d44 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 7 Mar 2024 18:36:53 +0100 Subject: [PATCH 27/47] chore(server): Move library watcher to microservices (#7533) * move watcher init to micro * document watcher recovery * chore: fix lint * add try lock * use global library watch lock * fix: ensure lock stays on * fix: mocks * unit test for library watch lock * move statement to correct test * fix: correct return type of try lock * fix: tests * add library teardown * add chokidar error handler * make event strings an enum * wait for event refactor * refactor event type mocks * expect correct error * don't release lock in teardown * chore: lint * use enum * fix mock * fix lint * fix watcher await * remove await * simplify typing * remove async * Revert "remove async" This reverts commit 84ab5abac487532c79a7d770869b08fbba1294bf. * can now change watch settings at runtime * fix lint * only watch libraries if enabled --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Alex Tran --- docs/docs/features/libraries.md | 10 ++++ .../jobs/specs/library-watcher.e2e-spec.ts | 25 ++++----- server/e2e/jobs/specs/library.e2e-spec.ts | 2 +- .../domain/library/library.service.spec.ts | 56 ++++++++++++------- server/src/domain/library/library.service.ts | 41 +++++++++++--- .../repositories/database.repository.ts | 2 + .../domain/repositories/storage.repository.ts | 8 +++ server/src/immich/app.service.ts | 7 --- .../infra/repositories/database.repository.ts | 10 ++++ .../infra/repositories/filesystem.provider.ts | 14 +++-- server/src/microservices/app.service.ts | 2 + server/src/test-utils/utils.ts | 42 ++++++++------ .../repositories/database.repository.mock.ts | 1 + .../repositories/storage.repository.mock.ts | 10 ++-- 14 files changed, 149 insertions(+), 81 deletions(-) diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 58dd707ea09e4..0a68a79e0bac7 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -90,6 +90,16 @@ This feature - currently hidden in the config file - is considered experimental If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes. +#### Troubleshooting + +If you encounter an `ENOSPC` error, you need to increase your file watcher limit. In sysctl, this key is called `fs.inotify.max_user_watched` and has a default value of 8192. Increase this number to a suitable value greater than the number of files you will be watching. Note that Immich has to watch all files in your import paths including any ignored files. + +``` +ERROR [LibraryService] Library watcher for library c69faf55-f96d-4aa0-b83b-2d80cbc27d98 encountered error: Error: ENOSPC: System limit for number of file watchers reached, watch '/media/photo.jpg' +``` + +In rare cases, the library watcher can hang, preventing Immich from starting up. In this case, disable the library watcher in the configuration file. If the watcher is enabled from within Immich, the app must be started without the microservices. Disable the microservices in the docker compose file, start Immich, disable the library watcher in the admin settings, close Immich, re-enable the microservices, and then Immich can be started normally. + ### Nightly job There is an automatic job that's run once a day and refreshes all modified files in all libraries as well as cleans up any libraries stuck in deletion. diff --git a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts index d22748e1c696d..4757876898f0b 100644 --- a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts +++ b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LibraryResponseDto, LibraryService, LoginResponseDto } from '@app/domain'; +import { LibraryResponseDto, LibraryService, LoginResponseDto, StorageEventType } from '@app/domain'; import { AssetType, LibraryType } from '@app/infra/entities'; import fs from 'node:fs/promises'; import path from 'node:path'; @@ -33,7 +33,7 @@ describe(`Library watcher (e2e)`, () => { }); afterEach(async () => { - await libraryService.unwatchAll(); + await libraryService.teardown(); }); afterAll(async () => { @@ -57,7 +57,7 @@ describe(`Library watcher (e2e)`, () => { `${IMMICH_TEST_ASSET_TEMP_PATH}/file.jpg`, ); - await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, StorageEventType.ADD); const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken); expect(afterAssets.length).toEqual(1); @@ -84,10 +84,7 @@ describe(`Library watcher (e2e)`, () => { `${IMMICH_TEST_ASSET_TEMP_PATH}/file5.jPg`, ); - await waitForEvent(libraryService, 'add'); - await waitForEvent(libraryService, 'add'); - await waitForEvent(libraryService, 'add'); - await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, StorageEventType.ADD, 4); const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken); expect(afterAssets.length).toEqual(4); @@ -99,7 +96,7 @@ describe(`Library watcher (e2e)`, () => { `${IMMICH_TEST_ASSET_TEMP_PATH}/file.jpg`, ); - await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, StorageEventType.ADD); const originalAssets = await api.assetApi.getAllAssets(server, admin.accessToken); expect(originalAssets.length).toEqual(1); @@ -109,7 +106,7 @@ describe(`Library watcher (e2e)`, () => { `${IMMICH_TEST_ASSET_TEMP_PATH}/file.jpg`, ); - await waitForEvent(libraryService, 'change'); + await waitForEvent(libraryService, StorageEventType.CHANGE); const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken); expect(afterAssets).toEqual([ @@ -161,9 +158,7 @@ describe(`Library watcher (e2e)`, () => { `${IMMICH_TEST_ASSET_TEMP_PATH}/dir3/file4.jpg`, ); - await waitForEvent(libraryService, 'add'); - await waitForEvent(libraryService, 'add'); - await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, StorageEventType.ADD, 3); const assets = await api.assetApi.getAllAssets(server, admin.accessToken); expect(assets.length).toEqual(3); @@ -175,14 +170,14 @@ describe(`Library watcher (e2e)`, () => { `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1/file.jpg`, ); - await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, StorageEventType.ADD); const addedAssets = await api.assetApi.getAllAssets(server, admin.accessToken); expect(addedAssets.length).toEqual(1); await fs.unlink(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1/file.jpg`); - await waitForEvent(libraryService, 'unlink'); + await waitForEvent(libraryService, StorageEventType.UNLINK); const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken); expect(afterAssets[0].isOffline).toEqual(true); @@ -220,7 +215,7 @@ describe(`Library watcher (e2e)`, () => { `${IMMICH_TEST_ASSET_TEMP_PATH}/dir4/file.jpg`, ); - await waitForEvent(libraryService, 'add'); + await waitForEvent(libraryService, StorageEventType.ADD); const afterAssets = await api.assetApi.getAllAssets(server, admin.accessToken); expect(afterAssets.length).toEqual(1); diff --git a/server/e2e/jobs/specs/library.e2e-spec.ts b/server/e2e/jobs/specs/library.e2e-spec.ts index 0657227f8dfb1..6dca783c426e6 100644 --- a/server/e2e/jobs/specs/library.e2e-spec.ts +++ b/server/e2e/jobs/specs/library.e2e-spec.ts @@ -368,7 +368,7 @@ describe(`${LibraryController.name} (e2e)`, () => { expect(body).toEqual(errorStub.unauthorized); }); - it('should remvove offline files', async () => { + it('should remove offline files', async () => { await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, { recursive: true, }); diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index 720824b672e52..a44624c43abb7 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -9,11 +9,11 @@ import { newAccessRepositoryMock, newAssetRepositoryMock, newCryptoRepositoryMock, + newDatabaseRepositoryMock, newJobRepositoryMock, newLibraryRepositoryMock, newStorageRepositoryMock, newSystemConfigRepositoryMock, - newUserRepositoryMock, systemConfigStub, userStub, } from '@test'; @@ -23,11 +23,12 @@ import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job'; import { IAssetRepository, ICryptoRepository, + IDatabaseRepository, IJobRepository, ILibraryRepository, IStorageRepository, ISystemConfigRepository, - IUserRepository, + StorageEventType, } from '../repositories'; import { SystemConfigCore } from '../system-config/system-config.core'; import { mapLibrary } from './library.dto'; @@ -40,20 +41,20 @@ describe(LibraryService.name, () => { let assetMock: jest.Mocked; let configMock: jest.Mocked; let cryptoMock: jest.Mocked; - let userMock: jest.Mocked; let jobMock: jest.Mocked; let libraryMock: jest.Mocked; let storageMock: jest.Mocked; + let databaseMock: jest.Mocked; beforeEach(() => { accessMock = newAccessRepositoryMock(); configMock = newSystemConfigRepositoryMock(); libraryMock = newLibraryRepositoryMock(); - userMock = newUserRepositoryMock(); assetMock = newAssetRepositoryMock(); jobMock = newJobRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); storageMock = newStorageRepositoryMock(); + databaseMock = newDatabaseRepositoryMock(); // Always validate owner access for library. accessMock.library.checkOwnerAccess.mockImplementation((_, libraryIds) => Promise.resolve(libraryIds)); @@ -66,8 +67,10 @@ describe(LibraryService.name, () => { jobMock, libraryMock, storageMock, - userMock, + databaseMock, ); + + databaseMock.tryLock.mockResolvedValue(true); }); it('should work', () => { @@ -125,13 +128,22 @@ describe(LibraryService.name, () => { ); }); - it('should not initialize when watching is disabled', async () => { + it('should not initialize watcher when watching is disabled', async () => { configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled); await sut.init(); expect(storageMock.watch).not.toHaveBeenCalled(); }); + + it('should not initialize watcher when lock is taken', async () => { + configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); + databaseMock.tryLock.mockResolvedValue(false); + + await sut.init(); + + expect(storageMock.watch).not.toHaveBeenCalled(); + }); }); describe('handleQueueAssetRefresh', () => { @@ -146,7 +158,6 @@ describe(LibraryService.name, () => { storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); assetMock.getPathsNotInLibrary.mockResolvedValue(['/data/user1/photo.jpg']); assetMock.getByLibraryId.mockResolvedValue([]); - userMock.get.mockResolvedValue(userStub.admin); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -173,7 +184,6 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); assetMock.getByLibraryId.mockResolvedValue([]); - userMock.get.mockResolvedValue(userStub.admin); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -224,7 +234,6 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); storageMock.crawl.mockResolvedValue([]); assetMock.getByLibraryId.mockResolvedValue([]); - userMock.get.mockResolvedValue(userStub.externalPathRoot); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -240,7 +249,6 @@ describe(LibraryService.name, () => { beforeEach(() => { mockUser = userStub.admin; - userMock.get.mockResolvedValue(mockUser); storageMock.stat.mockResolvedValue({ size: 100, @@ -1167,7 +1175,9 @@ describe(LibraryService.name, () => { it('should handle a new file event', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); + storageMock.watch.mockImplementation( + makeMockWatcher({ items: [{ event: StorageEventType.ADD, value: '/foo/photo.jpg' }] }), + ); await sut.watchAll(); @@ -1188,7 +1198,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); storageMock.watch.mockImplementation( - makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }), + makeMockWatcher({ items: [{ event: StorageEventType.CHANGE, value: '/foo/photo.jpg' }] }), ); await sut.watchAll(); @@ -1211,7 +1221,7 @@ describe(LibraryService.name, () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); storageMock.watch.mockImplementation( - makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }), + makeMockWatcher({ items: [{ event: StorageEventType.UNLINK, value: '/foo/photo.jpg' }] }), ); await sut.watchAll(); @@ -1225,17 +1235,19 @@ describe(LibraryService.name, () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); storageMock.watch.mockImplementation( makeMockWatcher({ - items: [{ event: 'error', value: 'Error!' }], + items: [{ event: StorageEventType.ERROR, value: 'Error!' }], }), ); - await sut.watchAll(); + await expect(sut.watchAll()).rejects.toThrow('Error!'); }); it('should ignore unknown extensions', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); + storageMock.watch.mockImplementation( + makeMockWatcher({ items: [{ event: StorageEventType.ADD, value: '/foo/photo.jpg' }] }), + ); await sut.watchAll(); @@ -1245,7 +1257,9 @@ describe(LibraryService.name, () => { it('should ignore excluded paths', async () => { libraryMock.get.mockResolvedValue(libraryStub.patternPath); libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/dir1/photo.txt' }] })); + storageMock.watch.mockImplementation( + makeMockWatcher({ items: [{ event: StorageEventType.ADD, value: '/dir1/photo.txt' }] }), + ); await sut.watchAll(); @@ -1255,7 +1269,9 @@ describe(LibraryService.name, () => { it('should ignore excluded paths without case sensitivity', async () => { libraryMock.get.mockResolvedValue(libraryStub.patternPath); libraryMock.getAll.mockResolvedValue([libraryStub.patternPath]); - storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/DIR1/photo.txt' }] })); + storageMock.watch.mockImplementation( + makeMockWatcher({ items: [{ event: StorageEventType.ADD, value: '/DIR1/photo.txt' }] }), + ); await sut.watchAll(); @@ -1264,7 +1280,7 @@ describe(LibraryService.name, () => { }); }); - describe('tearDown', () => { + describe('teardown', () => { it('should tear down all watchers', async () => { libraryMock.getAll.mockResolvedValue([ libraryStub.externalLibraryWithImportPaths1, @@ -1286,7 +1302,7 @@ describe(LibraryService.name, () => { storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.init(); - await sut.unwatchAll(); + await sut.teardown(); expect(mockClose).toHaveBeenCalledTimes(2); }); diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 2c509cdaaace7..c74e97ea36db6 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -13,14 +13,16 @@ import { handlePromiseError, usePagination, validateCronExpression } from '../do import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { + DatabaseLock, IAccessRepository, IAssetRepository, ICryptoRepository, + IDatabaseRepository, IJobRepository, ILibraryRepository, IStorageRepository, ISystemConfigRepository, - IUserRepository, + StorageEventType, WithProperty, } from '../repositories'; import { SystemConfigCore } from '../system-config'; @@ -43,6 +45,7 @@ export class LibraryService extends EventEmitter { private access: AccessCore; private configCore: SystemConfigCore; private watchLibraries = false; + private watchLock = false; private watchers: Record Promise> = {}; constructor( @@ -53,7 +56,7 @@ export class LibraryService extends EventEmitter { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) private repository: ILibraryRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, + @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, ) { super(); this.access = AccessCore.create(accessRepository); @@ -68,8 +71,15 @@ export class LibraryService extends EventEmitter { async init() { const config = await this.configCore.getConfig(); + const { watch, scan } = config.library; - this.watchLibraries = watch.enabled; + + // This ensures that library watching only occurs in one microservice + // TODO: we could make the lock be per-library instead of global + this.watchLock = await this.databaseRepository.tryLock(DatabaseLock.LibraryWatch); + + this.watchLibraries = this.watchLock && watch.enabled; + this.jobRepository.addCronJob( 'libraryScan', scan.cronExpression, @@ -89,6 +99,7 @@ export class LibraryService extends EventEmitter { this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); if (library.watch.enabled !== this.watchLibraries) { + // Watch configuration changed, update accordingly this.watchLibraries = library.watch.enabled; handlePromiseError(this.watchLibraries ? this.watchAll() : this.unwatchAll(), this.logger); } @@ -134,7 +145,7 @@ export class LibraryService extends EventEmitter { if (matcher(path)) { await this.scanAssets(library.id, [path], library.ownerId, false); } - this.emit('add', path); + this.emit(StorageEventType.ADD, path); }; return handlePromiseError(handler(), this.logger); }, @@ -145,7 +156,7 @@ export class LibraryService extends EventEmitter { // Note: if the changed file was not previously imported, it will be imported now. await this.scanAssets(library.id, [path], library.ownerId, false); } - this.emit('change', path); + this.emit(StorageEventType.CHANGE, path); }; return handlePromiseError(handler(), this.logger); }, @@ -156,13 +167,13 @@ export class LibraryService extends EventEmitter { if (asset && matcher(path)) { await this.assetRepository.save({ id: asset.id, isOffline: true }); } - this.emit('unlink', path); + this.emit(StorageEventType.UNLINK, path); }; return handlePromiseError(handler(), this.logger); }, onError: (error) => { - // TODO: should we log, or throw an exception? this.logger.error(`Library watcher for library ${library.id} encountered error: ${error}`); + this.emit(StorageEventType.ERROR, error); }, }, ); @@ -180,13 +191,25 @@ export class LibraryService extends EventEmitter { } } - async unwatchAll() { + async teardown() { + await this.unwatchAll(); + } + + private async unwatchAll() { + if (!this.watchLock) { + return false; + } + for (const id in this.watchers) { await this.unwatch(id); } } async watchAll() { + if (!this.watchLock) { + return false; + } + const libraries = await this.repository.getAll(false, LibraryType.EXTERNAL); for (const library of libraries) { @@ -267,7 +290,7 @@ export class LibraryService extends EventEmitter { this.logger.log(`Creating ${dto.type} library for user ${auth.user.name}`); - if (dto.type === LibraryType.EXTERNAL && this.watchLibraries) { + if (dto.type === LibraryType.EXTERNAL) { await this.watch(library.id); } diff --git a/server/src/domain/repositories/database.repository.ts b/server/src/domain/repositories/database.repository.ts index d32939fe61d77..55911e7ce5a44 100644 --- a/server/src/domain/repositories/database.repository.ts +++ b/server/src/domain/repositories/database.repository.ts @@ -19,6 +19,7 @@ export enum DatabaseLock { Migrations = 200, StorageTemplateMigration = 420, CLIPDimSize = 512, + LibraryWatch = 1337, } export const extName: Record = { @@ -46,6 +47,7 @@ export interface IDatabaseRepository { shouldReindex(name: VectorIndex): Promise; runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise; withLock(lock: DatabaseLock, callback: () => Promise): Promise; + tryLock(lock: DatabaseLock): Promise; isBusy(lock: DatabaseLock): boolean; wait(lock: DatabaseLock): Promise; } diff --git a/server/src/domain/repositories/storage.repository.ts b/server/src/domain/repositories/storage.repository.ts index d263713afa942..f4f8cab7b9690 100644 --- a/server/src/domain/repositories/storage.repository.ts +++ b/server/src/domain/repositories/storage.repository.ts @@ -31,6 +31,14 @@ export interface WatchEvents { onError(error: Error): void; } +export enum StorageEventType { + READY = 'ready', + ADD = 'add', + CHANGE = 'change', + UNLINK = 'unlink', + ERROR = 'error', +} + export interface IStorageRepository { createZipStream(): ImmichZipStream; createReadStream(filepath: string, mimeType?: string | null): Promise; diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts index be82ae4dc86ad..f3369b12103fb 100644 --- a/server/src/immich/app.service.ts +++ b/server/src/immich/app.service.ts @@ -2,7 +2,6 @@ import { AuthService, DatabaseService, JobService, - LibraryService, ONE_HOUR, OpenGraphTags, ServerInfoService, @@ -45,7 +44,6 @@ export class AppService { private authService: AuthService, private configService: SystemConfigService, private jobService: JobService, - private libraryService: LibraryService, private serverService: ServerInfoService, private sharedLinkService: SharedLinkService, private storageService: StorageService, @@ -66,15 +64,10 @@ export class AppService { await this.databaseService.init(); await this.configService.init(); this.storageService.init(); - await this.libraryService.init(); await this.serverService.init(); this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); } - async teardown() { - await this.libraryService.unwatchAll(); - } - ssr(excludePaths: string[]) { let index = ''; try { diff --git a/server/src/infra/repositories/database.repository.ts b/server/src/infra/repositories/database.repository.ts index b0e4623af5cf4..b24602b899339 100644 --- a/server/src/infra/repositories/database.repository.ts +++ b/server/src/infra/repositories/database.repository.ts @@ -210,6 +210,11 @@ export class DatabaseRepository implements IDatabaseRepository { return res as R; } + async tryLock(lock: DatabaseLock): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + return await this.acquireTryLock(lock, queryRunner); + } + isBusy(lock: DatabaseLock): boolean { return this.asyncLock.isBusy(DatabaseLock[lock]); } @@ -222,6 +227,11 @@ export class DatabaseRepository implements IDatabaseRepository { return queryRunner.query('SELECT pg_advisory_lock($1)', [lock]); } + private async acquireTryLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise { + const lockResult = await queryRunner.query('SELECT pg_try_advisory_lock($1)', [lock]); + return lockResult[0].pg_try_advisory_lock; + } + private async releaseLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise { return queryRunner.query('SELECT pg_advisory_unlock($1)', [lock]); } diff --git a/server/src/infra/repositories/filesystem.provider.ts b/server/src/infra/repositories/filesystem.provider.ts index 3ffcd8111fd66..fef184992d830 100644 --- a/server/src/infra/repositories/filesystem.provider.ts +++ b/server/src/infra/repositories/filesystem.provider.ts @@ -1,11 +1,12 @@ import { CrawlOptionsDto, DiskUsage, + IStorageRepository, ImmichReadStream, ImmichZipStream, - IStorageRepository, - mimeTypes, + StorageEventType, WatchEvents, + mimeTypes, } from '@app/domain'; import { ImmichLogger } from '@app/infra/logger'; import archiver from 'archiver'; @@ -141,10 +142,11 @@ export class FilesystemProvider implements IStorageRepository { watch(paths: string[], options: WatchOptions, events: Partial) { const watcher = chokidar.watch(paths, options); - watcher.on('ready', () => events.onReady?.()); - watcher.on('add', (path) => events.onAdd?.(path)); - watcher.on('change', (path) => events.onChange?.(path)); - watcher.on('unlink', (path) => events.onUnlink?.(path)); + watcher.on(StorageEventType.READY, () => events.onReady?.()); + watcher.on(StorageEventType.ADD, (path) => events.onAdd?.(path)); + watcher.on(StorageEventType.CHANGE, (path) => events.onChange?.(path)); + watcher.on(StorageEventType.UNLINK, (path) => events.onUnlink?.(path)); + watcher.on(StorageEventType.ERROR, (error) => events.onError?.(error)); return () => watcher.close(); } diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index df1d9938b5013..623538e594d07 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -40,6 +40,7 @@ export class AppService { async init() { await this.databaseService.init(); await this.configService.init(); + await this.libraryService.init(); await this.jobService.init({ [JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data), [JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(), @@ -86,6 +87,7 @@ export class AppService { } async teardown() { + await this.libraryService.teardown(); await this.metadataService.teardown(); } } diff --git a/server/src/test-utils/utils.ts b/server/src/test-utils/utils.ts index 7b4faf99b9d86..cf982229540c3 100644 --- a/server/src/test-utils/utils.ts +++ b/server/src/test-utils/utils.ts @@ -1,4 +1,4 @@ -import { IJobRepository, IMediaRepository, JobItem, JobItemHandler, QueueName } from '@app/domain'; +import { IJobRepository, IMediaRepository, JobItem, JobItemHandler, QueueName, StorageEventType } from '@app/domain'; import { AppModule } from '@app/immich'; import { InfraModule, InfraTestModule, dataSource } from '@app/infra'; import { MediaRepository } from '@app/infra/repositories'; @@ -48,6 +48,9 @@ export const db = { if (deleteUsers) { await em.query(`DELETE FROM "users" CASCADE;`); } + + // Release all locks + await em.query('SELECT pg_advisory_unlock_all()'); }); }, disconnect: async () => { @@ -124,34 +127,37 @@ export const testApp = { }, reset: async (options?: ResetOptions) => { await db.reset(options); - await app.get(AppService).init(); - - await app.get(MicroAppService).init(); }, get: (member: any) => app.get(member), teardown: async () => { if (app) { await app.get(MicroAppService).teardown(); - await app.get(AppService).teardown(); await app.close(); } await db.disconnect(); }, }; -export function waitForEvent(emitter: EventEmitter, event: string): Promise { - return new Promise((resolve, reject) => { - const success = (value: T) => { - emitter.off('error', fail); - resolve(value); - }; - const fail = (error: Error) => { - emitter.off(event, success); - reject(error); - }; - emitter.once(event, success); - emitter.once('error', fail); - }); +export function waitForEvent(emitter: EventEmitter, event: string, times = 1): Promise { + const promises: Promise[] = []; + + for (let i = 1; i <= times; i++) { + promises.push( + new Promise((resolve, reject) => { + const success = (value: any) => { + emitter.off(StorageEventType.ERROR, fail); + resolve(value); + }; + const fail = (error: Error) => { + emitter.off(event, success); + reject(error); + }; + emitter.once(event, success); + emitter.once(StorageEventType.ERROR, fail); + }), + ); + } + return Promise.all(promises); } const directoryExists = async (dirPath: string) => diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index f5a4d39a67a44..19e2df17a342a 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -13,6 +13,7 @@ export const newDatabaseRepositoryMock = (): jest.Mocked => shouldReindex: jest.fn(), runMigrations: jest.fn(), withLock: jest.fn().mockImplementation((_, function_: () => Promise) => function_()), + tryLock: jest.fn(), isBusy: jest.fn(), wait: jest.fn(), }; diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 1ef51fabceecf..e0b244fc2de00 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -1,4 +1,4 @@ -import { IStorageRepository, StorageCore, WatchEvents } from '@app/domain'; +import { IStorageRepository, StorageCore, StorageEventType, WatchEvents } from '@app/domain'; import { WatchOptions } from 'chokidar'; interface MockWatcherOptions { @@ -12,19 +12,19 @@ export const makeMockWatcher = events.onReady?.(); for (const item of items || []) { switch (item.event) { - case 'add': { + case StorageEventType.ADD: { events.onAdd?.(item.value); break; } - case 'change': { + case StorageEventType.CHANGE: { events.onChange?.(item.value); break; } - case 'unlink': { + case StorageEventType.UNLINK: { events.onUnlink?.(item.value); break; } - case 'error': { + case StorageEventType.ERROR: { events.onError?.(new Error(item.value)); } } From 9b1a379fa6142be3da290c41e6fd8fba85dd41d2 Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Thu, 7 Mar 2024 17:40:40 +0000 Subject: [PATCH 28/47] Version v1.98.0 --- cli/package-lock.json | 2 +- e2e/package-lock.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 15 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 4a34272439abf..9574fa26f6a05 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -46,7 +46,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index bfb64e1a9830d..fd140e5645baf 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -79,7 +79,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 0fffde12b1477..fc881d29ae204 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.97.0" +version = "1.98.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 6e051103d122a..a656dbc4cdbff 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 125, - "android.injected.version.name" => "1.97.0", + "android.injected.version.code" => 126, + "android.injected.version.name" => "1.98.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index caf1dd8e68346..328fe1536ec18 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.97.0" + version_number: "1.98.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d61ebcb65d153..1d16c60d40143 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.97.0 +- API version: 1.98.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index cf29809caa409..d0ab2a8ac8b00 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.97.0+125 +version: 1.98.0+126 isar_version: &isar_version 3.1.0+1 environment: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 676c91233ca34..1baa40179d799 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6467,7 +6467,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.97.0", + "version": "1.98.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 6b2d131e91d55..ff333b7f77eee 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index ea1b08540538f..2bd1cfddd3cd8 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "description": "", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 334037f1e6a50..4cb2e74aa5903 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.97.0 + * 1.98.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 21d1331e53361..60ed2abefacde 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.97.0", + "version": "1.98.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.97.0", + "version": "1.98.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@babel/runtime": "^7.22.11", diff --git a/server/package.json b/server/package.json index 3ae073ac9b839..da9cb0e442344 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.97.0", + "version": "1.98.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index edc07d261affc..5819c0eb7cb38 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.97.0", + "version": "1.98.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.97.0", + "version": "1.98.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -63,7 +63,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/web/package.json b/web/package.json index b82b3bfc4e631..763f4ebe7d884 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.97.0", + "version": "1.98.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 307ffc990dd3e9f97636a4616bc8f8afb9d19592 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Thu, 7 Mar 2024 12:03:21 -0600 Subject: [PATCH 29/47] fix(server): admin access to edit library --- server/src/domain/access/access.core.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 8602701072748..7063cb49a2b21 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -262,16 +262,25 @@ export class AccessCore { } case Permission.LIBRARY_READ: { + if (auth.user.isAdmin) { + return new Set(ids); + } const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids); const isPartner = await this.repository.library.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); return setUnion(isOwner, isPartner); } case Permission.LIBRARY_UPDATE: { + if (auth.user.isAdmin) { + return new Set(ids); + } return await this.repository.library.checkOwnerAccess(auth.user.id, ids); } case Permission.LIBRARY_DELETE: { + if (auth.user.isAdmin) { + return new Set(ids); + } return await this.repository.library.checkOwnerAccess(auth.user.id, ids); } From ba5d5256b1c28b96788462ee2da7b72c326b90b1 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Thu, 7 Mar 2024 12:04:54 -0600 Subject: [PATCH 30/47] Revert "Version v1.98.0" This reverts commit 9b1a379fa6142be3da290c41e6fd8fba85dd41d2. --- cli/package-lock.json | 2 +- e2e/package-lock.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 15 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 9574fa26f6a05..4a34272439abf 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -46,7 +46,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.97.0", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index fd140e5645baf..bfb64e1a9830d 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -79,7 +79,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.97.0", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index fc881d29ae204..0fffde12b1477 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.98.0" +version = "1.97.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index a656dbc4cdbff..6e051103d122a 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 126, - "android.injected.version.name" => "1.98.0", + "android.injected.version.code" => 125, + "android.injected.version.name" => "1.97.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 328fe1536ec18..caf1dd8e68346 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.98.0" + version_number: "1.97.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1d16c60d40143..d61ebcb65d153 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.98.0 +- API version: 1.97.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d0ab2a8ac8b00..cf29809caa409 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.98.0+126 +version: 1.97.0+125 isar_version: &isar_version 3.1.0+1 environment: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1baa40179d799..676c91233ca34 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6467,7 +6467,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.98.0", + "version": "1.97.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index ff333b7f77eee..6b2d131e91d55 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.97.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.97.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 2bd1cfddd3cd8..ea1b08540538f 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.97.0", "description": "", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 4cb2e74aa5903..334037f1e6a50 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.98.0 + * 1.97.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 60ed2abefacde..21d1331e53361 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.98.0", + "version": "1.97.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.98.0", + "version": "1.97.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@babel/runtime": "^7.22.11", diff --git a/server/package.json b/server/package.json index da9cb0e442344..3ae073ac9b839 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.98.0", + "version": "1.97.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 5819c0eb7cb38..edc07d261affc 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.98.0", + "version": "1.97.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.98.0", + "version": "1.97.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -63,7 +63,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.97.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/web/package.json b/web/package.json index 763f4ebe7d884..b82b3bfc4e631 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.98.0", + "version": "1.97.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 7dc5e0cc4fa4a035f1bf71564137dc35ae0679fc Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Thu, 7 Mar 2024 19:22:14 +0000 Subject: [PATCH 31/47] Version v1.98.0 --- cli/package-lock.json | 2 +- e2e/package-lock.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 15 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 4a34272439abf..9574fa26f6a05 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -46,7 +46,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index bfb64e1a9830d..fd140e5645baf 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -79,7 +79,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 0fffde12b1477..fc881d29ae204 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.97.0" +version = "1.98.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 6e051103d122a..a656dbc4cdbff 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 125, - "android.injected.version.name" => "1.97.0", + "android.injected.version.code" => 126, + "android.injected.version.name" => "1.98.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index caf1dd8e68346..328fe1536ec18 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.97.0" + version_number: "1.98.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d61ebcb65d153..1d16c60d40143 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.97.0 +- API version: 1.98.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index cf29809caa409..d0ab2a8ac8b00 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.97.0+125 +version: 1.98.0+126 isar_version: &isar_version 3.1.0+1 environment: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 676c91233ca34..1baa40179d799 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6467,7 +6467,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.97.0", + "version": "1.98.0", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 6b2d131e91d55..ff333b7f77eee 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index ea1b08540538f..2bd1cfddd3cd8 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "description": "", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 334037f1e6a50..4cb2e74aa5903 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.97.0 + * 1.98.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 21d1331e53361..60ed2abefacde 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.97.0", + "version": "1.98.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.97.0", + "version": "1.98.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@babel/runtime": "^7.22.11", diff --git a/server/package.json b/server/package.json index 3ae073ac9b839..da9cb0e442344 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.97.0", + "version": "1.98.0", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index edc07d261affc..5819c0eb7cb38 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.97.0", + "version": "1.98.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.97.0", + "version": "1.98.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -63,7 +63,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.97.0", + "version": "1.98.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/web/package.json b/web/package.json index b82b3bfc4e631..763f4ebe7d884 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.97.0", + "version": "1.98.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From a62355676223c55c626438697bf6d6aa8765d581 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 7 Mar 2024 15:31:45 -0500 Subject: [PATCH 32/47] chore(deps): update dependency @playwright/test to v1.42.1 (#7684) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- e2e/package-lock.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/e2e/package-lock.json b/e2e/package-lock.json index fd140e5645baf..338d6d2a14e94 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -923,12 +923,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.0.tgz", - "integrity": "sha512-2k1HzC28Fs+HiwbJOQDUwrWMttqSLUVdjCqitBOjdCD0svWOMQUVqrXX6iFD7POps6xXAojsX/dGBpKnjZctLA==", + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.42.1.tgz", + "integrity": "sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==", "dev": true, "dependencies": { - "playwright": "1.42.0" + "playwright": "1.42.1" }, "bin": { "playwright": "cli.js" @@ -3869,12 +3869,12 @@ } }, "node_modules/playwright": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.0.tgz", - "integrity": "sha512-Ko7YRUgj5xBHbntrgt4EIw/nE//XBHOKVKnBjO1KuZkmkhlbgyggTe5s9hjqQ1LpN+Xg+kHsQyt5Pa0Bw5XpvQ==", + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", + "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", "dev": true, "dependencies": { - "playwright-core": "1.42.0" + "playwright-core": "1.42.1" }, "bin": { "playwright": "cli.js" @@ -3887,9 +3887,9 @@ } }, "node_modules/playwright-core": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.0.tgz", - "integrity": "sha512-0HD9y8qEVlcbsAjdpBaFjmaTHf+1FeIddy8VJLeiqwhcNqGCBe4Wp2e8knpqiYbzxtxarxiXyNDw2cG8sCaNMQ==", + "version": "1.42.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", + "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==", "dev": true, "bin": { "playwright-core": "cli.js" From f1a8e385e9fe533f935b2be43311bdf7efbbb1b3 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 7 Mar 2024 21:34:10 +0100 Subject: [PATCH 33/47] deps(server): CVE-2024-28176 (#7717) bumpety bump --- server/package-lock.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 60ed2abefacde..54dd09902591e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8292,9 +8292,9 @@ } }, "node_modules/jose": { - "version": "4.15.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", - "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==", + "version": "4.15.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", + "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -9233,7 +9233,7 @@ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.4.tgz", "integrity": "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==", "dependencies": { - "jose": "^4.15.4", + "jose": "^4.15.5", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" @@ -18731,9 +18731,9 @@ } }, "jose": { - "version": "4.15.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", - "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==" + "version": "4.15.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", + "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==" }, "js-tokens": { "version": "4.0.0", @@ -19475,7 +19475,7 @@ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.4.tgz", "integrity": "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA==", "requires": { - "jose": "^4.15.4", + "jose": "^4.15.5", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" From 661409bac7c79cad5ef137fa27cadae98656abf3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 7 Mar 2024 15:34:57 -0500 Subject: [PATCH 34/47] feat(server): create a person with optional values (#7706) * feat: create person dto * chore: open api * fix: e2e * fix: web usage --- e2e/src/api/specs/person.e2e-spec.ts | 70 ++++++--- e2e/src/utils.ts | 14 +- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 1 + mobile/openapi/doc/PersonApi.md | 12 +- mobile/openapi/doc/PersonCreateDto.md | 17 +++ mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/person_api.dart | 16 +- mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/person_create_dto.dart | 138 ++++++++++++++++++ mobile/openapi/test/person_api_test.dart | 2 +- .../openapi/test/person_create_dto_test.dart | 40 +++++ open-api/immich-openapi-specs.json | 29 ++++ open-api/typescript-sdk/src/fetch-client.ts | 20 ++- server/src/domain/person/person.dto.ts | 21 +-- .../src/domain/person/person.service.spec.ts | 69 +-------- server/src/domain/person/person.service.ts | 36 ++--- .../immich/controllers/person.controller.ts | 25 ++-- .../faces-page/person-side-panel.svelte | 2 +- .../faces-page/unmerge-face-selector.svelte | 2 +- 20 files changed, 372 insertions(+), 148 deletions(-) create mode 100644 mobile/openapi/doc/PersonCreateDto.md create mode 100644 mobile/openapi/lib/model/person_create_dto.dart create mode 100644 mobile/openapi/test/person_create_dto_test.dart diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index 55cb982f9dff5..8015580430469 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -5,7 +5,15 @@ import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; -describe('/activity', () => { +const invalidBirthday = [ + // TODO: enable after replacing `@Type(() => Date)` + // { birthDate: 'false', response: 'Invalid date' }, + // { birthDate: '123567', response: 'Invalid date }, + // { birthDate: 123_567, response: ['Birth date cannot be in the future'] }, + { birthDate: new Date(9999, 0, 0).toISOString(), response: ['Birth date cannot be in the future'] }, +]; + +describe('/person', () => { let admin: LoginResponseDto; let visiblePerson: PersonResponseDto; let hiddenPerson: PersonResponseDto; @@ -14,10 +22,6 @@ describe('/activity', () => { beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - }); - - beforeEach(async () => { - await utils.resetDatabase(['person']); [visiblePerson, hiddenPerson, multipleAssetsPerson] = await Promise.all([ utils.createPerson(admin.accessToken, { @@ -141,6 +145,41 @@ describe('/activity', () => { }); }); + describe('POST /person', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post(`/person`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should not accept invalid birth dates', async () => { + for (const { birthDate, response } of invalidBirthday) { + const { status, body } = await request(app) + .post(`/person`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ birthDate }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(response)); + } + }); + + it('should create a person', async () => { + const { status, body } = await request(app) + .post(`/person`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + name: 'New Person', + birthDate: '1990-01-01T05:00:00.000Z', + }); + expect(status).toBe(201); + expect(body).toMatchObject({ + id: expect.any(String), + name: 'New Person', + birthDate: '1990-01-01T05:00:00.000Z', + }); + }); + }); + describe('PUT /person/:id', () => { it('should require authentication', async () => { const { status, body } = await request(app).put(`/person/${uuidDto.notFound}`); @@ -164,17 +203,9 @@ describe('/activity', () => { } it('should not accept invalid birth dates', async () => { - for (const { birthDate, response } of [ - { birthDate: false, response: 'Not found or no person.write access' }, - { birthDate: 'false', response: ['birthDate must be a Date instance'] }, - { - birthDate: '123567', - response: 'Not found or no person.write access', - }, - { birthDate: 123_567, response: 'Not found or no person.write access' }, - ]) { + for (const { birthDate, response } of invalidBirthday) { const { status, body } = await request(app) - .put(`/person/${uuidDto.notFound}`) + .put(`/person/${visiblePerson.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate }); expect(status).toBe(400); @@ -192,15 +223,8 @@ describe('/activity', () => { }); it('should clear a date of birth', async () => { - // TODO ironically this uses the update endpoint to create the person - const person = await utils.createPerson(admin.accessToken, { - birthDate: new Date('1990-01-01').toISOString(), - }); - - expect(person.birthDate).toBeDefined(); - const { status, body } = await request(app) - .put(`/person/${person.id}`) + .put(`/person/${visiblePerson.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate: null }); expect(status).toBe(200); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 5547a2c1282f9..49ac2b8122f55 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -5,7 +5,7 @@ import { CreateAssetDto, CreateLibraryDto, CreateUserDto, - PersonUpdateDto, + PersonCreateDto, SharedLinkCreateDto, ValidateLibraryDto, createAlbum, @@ -20,7 +20,6 @@ import { login, setAdminOnboarding, signUpAdmin, - updatePerson, validate, } from '@immich/sdk'; import { BrowserContext } from '@playwright/test'; @@ -252,16 +251,11 @@ export const utils = { deleteAssets: (accessToken: string, ids: string[]) => deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }), - createPerson: async (accessToken: string, dto?: PersonUpdateDto) => { - // TODO fix createPerson to accept a body - const person = await createPerson({ headers: asBearerAuth(accessToken) }); + createPerson: async (accessToken: string, dto?: PersonCreateDto) => { + const person = await createPerson({ personCreateDto: dto || {} }, { headers: asBearerAuth(accessToken) }); await utils.setPersonThumbnail(person.id); - if (!dto) { - return person; - } - - return updatePerson({ id: person.id, personUpdateDto: dto }, { headers: asBearerAuth(accessToken) }); + return person; }, createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => { diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 6144510b10f57..bdd8e1d4bcff2 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -104,6 +104,7 @@ doc/PeopleResponseDto.md doc/PeopleUpdateDto.md doc/PeopleUpdateItem.md doc/PersonApi.md +doc/PersonCreateDto.md doc/PersonResponseDto.md doc/PersonStatisticsResponseDto.md doc/PersonUpdateDto.md @@ -306,6 +307,7 @@ lib/model/path_type.dart lib/model/people_response_dto.dart lib/model/people_update_dto.dart lib/model/people_update_item.dart +lib/model/person_create_dto.dart lib/model/person_response_dto.dart lib/model/person_statistics_response_dto.dart lib/model/person_update_dto.dart @@ -485,6 +487,7 @@ test/people_response_dto_test.dart test/people_update_dto_test.dart test/people_update_item_test.dart test/person_api_test.dart +test/person_create_dto_test.dart test/person_response_dto_test.dart test/person_statistics_response_dto_test.dart test/person_update_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1d16c60d40143..536c671b8df40 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -303,6 +303,7 @@ Class | Method | HTTP request | Description - [PeopleResponseDto](doc//PeopleResponseDto.md) - [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md) + - [PersonCreateDto](doc//PersonCreateDto.md) - [PersonResponseDto](doc//PersonResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md) diff --git a/mobile/openapi/doc/PersonApi.md b/mobile/openapi/doc/PersonApi.md index f9e3100186232..2ade49aec7411 100644 --- a/mobile/openapi/doc/PersonApi.md +++ b/mobile/openapi/doc/PersonApi.md @@ -22,7 +22,7 @@ Method | HTTP request | Description # **createPerson** -> PersonResponseDto createPerson() +> PersonResponseDto createPerson(personCreateDto) @@ -45,9 +45,10 @@ import 'package:openapi/api.dart'; //defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); final api_instance = PersonApi(); +final personCreateDto = PersonCreateDto(); // PersonCreateDto | try { - final result = api_instance.createPerson(); + final result = api_instance.createPerson(personCreateDto); print(result); } catch (e) { print('Exception when calling PersonApi->createPerson: $e\n'); @@ -55,7 +56,10 @@ try { ``` ### Parameters -This endpoint does not need any parameter. + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **personCreateDto** | [**PersonCreateDto**](PersonCreateDto.md)| | ### Return type @@ -67,7 +71,7 @@ This endpoint does not need any parameter. ### HTTP request headers - - **Content-Type**: Not defined + - **Content-Type**: application/json - **Accept**: application/json [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/PersonCreateDto.md b/mobile/openapi/doc/PersonCreateDto.md new file mode 100644 index 0000000000000..427c23382e537 --- /dev/null +++ b/mobile/openapi/doc/PersonCreateDto.md @@ -0,0 +1,17 @@ +# openapi.model.PersonCreateDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. Note: the mobile app cannot currently set the birth date to null. | [optional] +**isHidden** | **bool** | Person visibility | [optional] +**name** | **String** | Person name. | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2dfe3a3bee225..0a093e45365b2 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -138,6 +138,7 @@ part 'model/path_type.dart'; part 'model/people_response_dto.dart'; part 'model/people_update_dto.dart'; part 'model/people_update_item.dart'; +part 'model/person_create_dto.dart'; part 'model/person_response_dto.dart'; part 'model/person_statistics_response_dto.dart'; part 'model/person_update_dto.dart'; diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index 02dae625bb91d..411c75d715d29 100644 --- a/mobile/openapi/lib/api/person_api.dart +++ b/mobile/openapi/lib/api/person_api.dart @@ -17,18 +17,21 @@ class PersonApi { final ApiClient apiClient; /// Performs an HTTP 'POST /person' operation and returns the [Response]. - Future createPersonWithHttpInfo() async { + /// Parameters: + /// + /// * [PersonCreateDto] personCreateDto (required): + Future createPersonWithHttpInfo(PersonCreateDto personCreateDto,) async { // ignore: prefer_const_declarations final path = r'/person'; // ignore: prefer_final_locals - Object? postBody; + Object? postBody = personCreateDto; final queryParams = []; final headerParams = {}; final formParams = {}; - const contentTypes = []; + const contentTypes = ['application/json']; return apiClient.invokeAPI( @@ -42,8 +45,11 @@ class PersonApi { ); } - Future createPerson() async { - final response = await createPersonWithHttpInfo(); + /// Parameters: + /// + /// * [PersonCreateDto] personCreateDto (required): + Future createPerson(PersonCreateDto personCreateDto,) async { + final response = await createPersonWithHttpInfo(personCreateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index d73b505937a2f..5e5f702996d9a 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -358,6 +358,8 @@ class ApiClient { return PeopleUpdateDto.fromJson(value); case 'PeopleUpdateItem': return PeopleUpdateItem.fromJson(value); + case 'PersonCreateDto': + return PersonCreateDto.fromJson(value); case 'PersonResponseDto': return PersonResponseDto.fromJson(value); case 'PersonStatisticsResponseDto': diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart new file mode 100644 index 0000000000000..4811de3efecea --- /dev/null +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -0,0 +1,138 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// 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 PersonCreateDto { + /// Returns a new [PersonCreateDto] instance. + PersonCreateDto({ + this.birthDate, + this.isHidden, + this.name, + }); + + /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. + DateTime? birthDate; + + /// Person visibility + /// + /// 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. + /// + bool? isHidden; + + /// Person name. + /// + /// 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. + /// + String? name; + + @override + bool operator ==(Object other) => identical(this, other) || other is PersonCreateDto && + other.birthDate == birthDate && + other.isHidden == isHidden && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (birthDate == null ? 0 : birthDate!.hashCode) + + (isHidden == null ? 0 : isHidden!.hashCode) + + (name == null ? 0 : name!.hashCode); + + @override + String toString() => 'PersonCreateDto[birthDate=$birthDate, isHidden=$isHidden, name=$name]'; + + Map toJson() { + final json = {}; + if (this.birthDate != null) { + json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); + } else { + // json[r'birthDate'] = null; + } + if (this.isHidden != null) { + json[r'isHidden'] = this.isHidden; + } else { + // json[r'isHidden'] = null; + } + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + return json; + } + + /// Returns a new [PersonCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PersonCreateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return PersonCreateDto( + birthDate: mapDateTime(json, r'birthDate', r''), + isHidden: mapValueOfType(json, r'isHidden'), + name: mapValueOfType(json, r'name'), + ); + } + 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 = PersonCreateDto.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 = PersonCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PersonCreateDto-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] = PersonCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/test/person_api_test.dart b/mobile/openapi/test/person_api_test.dart index dd112eeaaee34..959230cc590ae 100644 --- a/mobile/openapi/test/person_api_test.dart +++ b/mobile/openapi/test/person_api_test.dart @@ -17,7 +17,7 @@ void main() { // final instance = PersonApi(); group('tests for PersonApi', () { - //Future createPerson() async + //Future createPerson(PersonCreateDto personCreateDto) async test('test createPerson', () async { // TODO }); diff --git a/mobile/openapi/test/person_create_dto_test.dart b/mobile/openapi/test/person_create_dto_test.dart new file mode 100644 index 0000000000000..96f1fe6d39e0d --- /dev/null +++ b/mobile/openapi/test/person_create_dto_test.dart @@ -0,0 +1,40 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// 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 + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for PersonCreateDto +void main() { + // final instance = PersonCreateDto(); + + group('test PersonCreateDto', () { + // Person date of birth. Note: the mobile app cannot currently set the birth date to null. + // DateTime birthDate + test('to test the property `birthDate`', () async { + // TODO + }); + + // Person visibility + // bool isHidden + test('to test the property `isHidden`', () async { + // TODO + }); + + // Person name. + // String name + test('to test the property `name`', () async { + // TODO + }); + + + }); + +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1baa40179d799..bd99b24765c43 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4047,6 +4047,16 @@ "post": { "operationId": "createPerson", "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonCreateDto" + } + } + }, + "required": true + }, "responses": { "201": { "content": { @@ -8720,6 +8730,25 @@ ], "type": "object" }, + "PersonCreateDto": { + "properties": { + "birthDate": { + "description": "Person date of birth.\nNote: the mobile app cannot currently set the birth date to null.", + "format": "date", + "nullable": true, + "type": "string" + }, + "isHidden": { + "description": "Person visibility", + "type": "boolean" + }, + "name": { + "description": "Person name.", + "type": "string" + } + }, + "type": "object" + }, "PersonResponseDto": { "properties": { "birthDate": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 4cb2e74aa5903..e9ce467127c56 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -529,6 +529,15 @@ export type PeopleResponseDto = { people: PersonResponseDto[]; total: number; }; +export type PersonCreateDto = { + /** Person date of birth. + Note: the mobile app cannot currently set the birth date to null. */ + birthDate?: string | null; + /** Person visibility */ + isHidden?: boolean; + /** Person name. */ + name?: string; +}; export type PeopleUpdateItem = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ @@ -2051,14 +2060,17 @@ export function getAllPeople({ withHidden }: { ...opts })); } -export function createPerson(opts?: Oazapfts.RequestOpts) { +export function createPerson({ personCreateDto }: { + personCreateDto: PersonCreateDto; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: PersonResponseDto; - }>("/person", { + }>("/person", oazapfts.json({ ...opts, - method: "POST" - })); + method: "POST", + body: personCreateDto + }))); } export function updatePeople({ peopleUpdateDto }: { peopleUpdateDto: PeopleUpdateDto; diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index b8ad8f0451b3a..e76ce3308ee5c 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -1,11 +1,11 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, MaxDate, ValidateNested } from 'class-validator'; import { AuthDto } from '../auth'; import { Optional, ValidateUUID, toBoolean } from '../domain.util'; -export class PersonUpdateDto { +export class PersonCreateDto { /** * Person name. */ @@ -20,16 +20,10 @@ export class PersonUpdateDto { @Optional({ nullable: true }) @IsDate() @Type(() => Date) + @MaxDate(() => new Date(), { message: 'Birth date cannot be in the future' }) @ApiProperty({ format: 'date' }) birthDate?: Date | null; - /** - * Asset is used to get the feature face thumbnail. - */ - @Optional() - @IsString() - featureFaceAssetId?: string; - /** * Person visibility */ @@ -38,6 +32,15 @@ export class PersonUpdateDto { isHidden?: boolean; } +export class PersonUpdateDto extends PersonCreateDto { + /** + * Asset is used to get the feature face thumbnail. + */ + @Optional() + @IsString() + featureFaceAssetId?: string; +} + export class PeopleUpdateDto { @IsArray() @ValidateNested({ each: true }) diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index deb7474e2a231..191356d2c06c0 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -227,8 +227,7 @@ describe(PersonService.name, () => { }); it('should throw an error when personId is invalid', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( BadRequestException, ); @@ -237,20 +236,17 @@ describe(PersonService.name, () => { }); it("should update a person's name", async () => { - personMock.getById.mockResolvedValue(personStub.noName); 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.getById).toHaveBeenCalledWith('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.getById.mockResolvedValue(personStub.noBirthDate); personMock.update.mockResolvedValue(personStub.withBirthDate); personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); @@ -262,71 +258,24 @@ describe(PersonService.name, () => { thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, }); - expect(personMock.getById).toHaveBeenCalledWith('person-1'); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('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 throw BadRequestException if birthDate is in the future', async () => { - personMock.getById.mockResolvedValue(personStub.noBirthDate); - personMock.update.mockResolvedValue(personStub.withBirthDate); - personMock.getAssets.mockResolvedValue([assetStub.image]); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - - const futureDate = new Date(); - futureDate.setMinutes(futureDate.getMinutes() + 1); // Set birthDate to one minute in the future - - await expect(sut.update(authStub.admin, 'person-1', { birthDate: futureDate })).rejects.toThrow( - new BadRequestException('Date of birth cannot be in the future'), - ); - }); - - it('should not throw an error if birthdate is in the past', async () => { - const pastDate = new Date(); - pastDate.setMinutes(pastDate.getMinutes() - 1); // Set birthDate to one minute in the past - - personMock.getById.mockResolvedValue(personStub.noBirthDate); - personMock.update.mockResolvedValue({ ...personStub.withBirthDate, birthDate: pastDate }); - personMock.getAssets.mockResolvedValue([assetStub.image]); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - - const result = await sut.update(authStub.admin, 'person-1', { birthDate: pastDate }); - - expect(result.birthDate).toEqual(pastDate); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: pastDate }); - }); - - it('should not throw an error if birthdate is today', async () => { - const today = new Date(); // Set birthDate to now() - - personMock.getById.mockResolvedValue(personStub.noBirthDate); - personMock.update.mockResolvedValue({ ...personStub.withBirthDate, birthDate: today }); - personMock.getAssets.mockResolvedValue([assetStub.image]); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - - const result = await sut.update(authStub.admin, 'person-1', { birthDate: today }); - - expect(result.birthDate).toEqual(today); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: today }); - }); - it('should update a person visibility', async () => { - personMock.getById.mockResolvedValue(personStub.hidden); 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.getById).toHaveBeenCalledWith('person-1'); 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.getById.mockResolvedValue(personStub.withName); personMock.update.mockResolvedValue(personStub.withName); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); @@ -336,7 +285,6 @@ describe(PersonService.name, () => { sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), ).resolves.toEqual(responseDto); - expect(personMock.getById).toHaveBeenCalledWith('person-1'); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); expect(personMock.getFacesByIds).toHaveBeenCalledWith([ { @@ -362,12 +310,11 @@ describe(PersonService.name, () => { describe('updateAll', () => { it('should throw an error when personId is invalid', async () => { - personMock.getById.mockResolvedValue(null); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); - await expect( - sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }), - ).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]); + await expect(sut.updateAll(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] })).resolves.toEqual([ + { error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }, + ]); expect(personMock.update).not.toHaveBeenCalled(); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); @@ -488,10 +435,10 @@ describe(PersonService.name, () => { describe('createPerson', () => { it('should create a new person', async () => { personMock.create.mockResolvedValue(personStub.primaryPerson); - personMock.getFaceById.mockResolvedValue(faceStub.face1); - accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - await expect(sut.createPerson(authStub.admin)).resolves.toBe(personStub.primaryPerson); + await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson); + + expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); }); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 6300cc743c854..235867314e0cb 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -36,6 +36,7 @@ import { MergePersonDto, PeopleResponseDto, PeopleUpdateDto, + PersonCreateDto, PersonResponseDto, PersonSearchDto, PersonStatisticsResponseDto, @@ -91,10 +92,6 @@ export class PersonService { }; } - createPerson(auth: AuthDto): Promise { - return this.repository.create({ ownerId: auth.user.id }); - } - async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId); const person = await this.findOrFail(personId); @@ -199,21 +196,21 @@ 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 update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { await this.access.requirePermission(auth, Permission.PERSON_WRITE, id); - let person = await this.findOrFail(id); const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; - - // Check if the birthDate is in the future - if (birthDate && new Date(birthDate) > new Date()) { - throw new BadRequestException('Date of birth cannot be in the future'); - } - - if (name !== undefined || birthDate !== undefined || isHidden !== undefined) { - person = await this.repository.update({ id, name, birthDate, isHidden }); - } - + // TODO: set by faceId directly + let faceId: string | undefined = undefined; if (assetId) { await this.access.requirePermission(auth, Permission.ASSET_READ, assetId); const [face] = await this.repository.getFacesByIds([{ personId: id, assetId }]); @@ -221,14 +218,19 @@ export class PersonService { throw new BadRequestException('Invalid assetId for feature face'); } - person = await this.repository.update({ id, faceAssetId: face.id }); + faceId = face.id; + } + + 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 } }); } return mapPerson(person); } - async updatePeople(auth: AuthDto, dto: PeopleUpdateDto): Promise { + async updateAll(auth: AuthDto, dto: PeopleUpdateDto): Promise { const results: BulkIdResponseDto[] = []; for (const person of dto.people) { try { diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index 6582d4461f35d..2447f982b4ced 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -6,6 +6,7 @@ import { MergePersonDto, PeopleResponseDto, PeopleUpdateDto, + PersonCreateDto, PersonResponseDto, PersonSearchDto, PersonService, @@ -32,22 +33,13 @@ export class PersonController { } @Post() - createPerson(@Auth() auth: AuthDto): Promise { - return this.service.createPerson(auth); - } - - @Put(':id/reassign') - reassignFaces( - @Auth() auth: AuthDto, - @Param() { id }: UUIDParamDto, - @Body() dto: AssetFaceUpdateDto, - ): Promise { - return this.service.reassignFaces(auth, id, dto); + createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise { + return this.service.create(auth, dto); } @Put() updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise { - return this.service.updatePeople(auth, dto); + return this.service.updateAll(auth, dto); } @Get(':id') @@ -85,6 +77,15 @@ export class PersonController { return this.service.getAssets(auth, id); } + @Put(':id/reassign') + reassignFaces( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: AssetFaceUpdateDto, + ): Promise { + return this.service.reassignFaces(auth, id, dto); + } + @Post(':id/merge') mergePerson( @Auth() auth: AuthDto, diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 4b596b360144d..3cb705a1f3598 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -122,7 +122,7 @@ faceDto: { id: peopleWithFace.id }, }); } else if (selectedPersonToCreate[index]) { - const data = await createPerson(); + const data = await createPerson({ personCreateDto: {} }); numberOfPersonToCreate.push(data.id); await reassignFacesById({ id: data.id, diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte index 254594988daa2..590cc916eee2c 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -74,7 +74,7 @@ try { disableButtons = true; - const data = await createPerson(); + const data = await createPerson({ personCreateDto: {} }); await reassignFaces({ id: data.id, assetFaceUpdateDto: { data: selectedPeople } }); notificationController.show({ From a5a27594b876dcb1397d848bad15f8586178c10c Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 7 Mar 2024 22:27:02 +0100 Subject: [PATCH 35/47] docs: admin-only library settings (#7716) * document admin-only library settings * renew library guide --- docs/docs/features/libraries.md | 40 +++----------- docs/docs/guides/external-library.md | 49 +++--------------- docs/docs/guides/img/account-settings.png | Bin 26723 -> 0 bytes .../img/create-external-library-button.png | Bin 5875 -> 0 bytes .../guides/img/create-external-library.png | Bin 0 -> 3744 bytes docs/docs/guides/img/external-libraries.png | Bin 0 -> 5797 bytes docs/docs/guides/img/external-path.png | Bin 12712 -> 0 bytes docs/docs/guides/img/library-owner.png | Bin 0 -> 14072 bytes docs/docs/guides/img/pencil.png | Bin 3119 -> 0 bytes docs/docs/guides/img/users-tab.png | Bin 15020 -> 0 bytes 10 files changed, 14 insertions(+), 75 deletions(-) delete mode 100644 docs/docs/guides/img/account-settings.png delete mode 100644 docs/docs/guides/img/create-external-library-button.png create mode 100644 docs/docs/guides/img/create-external-library.png create mode 100644 docs/docs/guides/img/external-libraries.png delete mode 100644 docs/docs/guides/img/external-path.png create mode 100644 docs/docs/guides/img/library-owner.png delete mode 100644 docs/docs/guides/img/pencil.png delete mode 100644 docs/docs/guides/img/users-tab.png diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 0a68a79e0bac7..28c68ced469cb 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -38,7 +38,7 @@ Note: Either a manual or scheduled library scan must have been performed to iden In all above scan methods, Immich will check if any files are missing. This can happen if files are deleted, or if they are on a storage location that is currently unavailable, like a network drive that is not mounted, or a USB drive that has been unplugged. In order to prevent accidental deletion of assets, Immich will not immediately delete an asset from the library if the file is missing. Instead, the asset will be internally marked as offline and will still be visible in the main timeline. If the file is moved back to its original location and the library is scanned again, the asset will be restored. -Finally, files can be deleted from Immich via the `Remove Offline Files` job. This job can be found by the three dots menu for the associated external storage that was configured under user account settings > libraries (the same location described at [create external libraries](#create-external-libraries)). When this job is run, any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich. +Finally, files can be deleted from Immich via the `Remove Offline Files` job. This job can be found by the three dots menu for the associated external storage that was configured under Administration > Libraries (the same location described at [create external libraries](#create-external-libraries)). When this job is run, any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich. ### Import Paths @@ -50,8 +50,6 @@ If the import paths are edited in a way that an external file is no longer in an Sometimes, an external library will not scan correctly. This can happen if immich_server or immich_microservices can't access the files. Here are some things to check: -- Is the external path set correctly? Each import path must be contained in the external path. -- Make sure the external path does not contain spaces - In the docker-compose file, are the volumes mounted correctly? - Are the volumes identical between the `server` and `microservices` container? - Are the import paths set correctly, and do they match the path set in docker-compose file? @@ -61,18 +59,6 @@ Sometimes, an external library will not scan correctly. This can happen if immic To validate that Immich can reach your external library, start a shell inside the container. Run `docker exec -it immich_microservices /bin/bash` to a bash shell. If your import path is `/data/import/photos`, check it with `ls /data/import/photos`. Do the same check for the `immich_server` container. If you cannot access this directory in both the `microservices` and `server` containers, Immich won't be able to import files. -### Security Considerations - -:::caution - -Please read and understand this section before setting external paths, as there are important security considerations. - -::: - -For security purposes, each Immich user is disallowed to add external files by default. This is to prevent devastating [path traversal attacks](https://owasp.org/www-community/attacks/Path_Traversal). An admin can allow individual users to use external path feature via the `external path` setting found in the admin panel. Without the external path restriction, a user can add any image or video file on the Immich host filesystem to be imported into Immich, potentially allowing sensitive data to be accessed. If you are running Immich as root in your Docker setup (which is the default), all external file reads are done with root privileges. This is particularly dangerous if the Immich host is a shared server. - -With the `external path` set, a user is restricted to accessing external files to files or directories within that path. The Immich admin should still be careful not set the external path too generously. For example, `user1` wants to read their photos in to `/home/user1`. A lazy admin sets that user's external path to `/home/` since it "gets the job done". However, that user will then be able to read all photos in `/home/user2/private-photos`, too! Please set the external path as specific as possible. If multiple folders must be added, do this using the docker volume mount feature described below. - ### Exclusion Patterns By default, all files in the import paths will be added to the library. If there are files that should not be added, exclusion patterns can be used to exclude them. Exclusion patterns are glob patterns are matched against the full file path. If a file matches an exclusion pattern, it will not be added to the library. Exclusion patterns can be added in the Scan Settings page for each library. Under the hood, Immich uses the [glob](https://www.npmjs.com/package/glob) package to match patterns, so please refer to [their documentation](https://github.com/isaacs/node-glob#glob-primer) to see what patterns are supported. @@ -145,27 +131,13 @@ The `ro` flag at the end only gives read-only access to the volumes. While Immic _Remember to bring the container `docker compose down/up` to register the changes. Make sure you can see the mounted path in the container._ ::: -### Set External Path - -Only an admin can do this. - -- Navigate to `Administration > Users` page on the web. -- Click on the user edit button. -- Set `/mnt/media` to be the external path. This folder will only contain the three folders that we want to import, so nothing else can be accessed. - :::note - Spaces in the internal path aren't currently supported. - - You must import it as: - `..:/mnt/media/my-media:ro` - instead of - `..:/mnt/media/my media:ro` - ::: - ### Create External Libraries -- Click on your user name in the top right corner -> Account Settings -- Click on Libraries +These actions must be performed by the Immich administrator. + +- Click on Administration -> Libraries - Click on Create External Library +- Select which user owns the library, this can not be changed later - Click the drop-down menu on the newly created library - Click on Rename Library and rename it to "Christmas Trip" - Click Edit Import Paths @@ -176,7 +148,7 @@ NOTE: We have to use the `/mnt/media/christmas-trip` path and not the `/mnt/nas/ Next, we'll add an exclusion pattern to filter out raw files. -- Click the drop-down menu on the newly christmas library +- Click the drop-down menu on the newly-created Christmas library - Click on Manage - Click on Scan Settings - Click on Add Exclusion Pattern diff --git a/docs/docs/guides/external-library.md b/docs/docs/guides/external-library.md index 9de18f5d522b1..f1f95f942f171 100644 --- a/docs/docs/guides/external-library.md +++ b/docs/docs/guides/external-library.md @@ -11,23 +11,11 @@ Edit `docker-compose.yml` to add two new mount points under `volumes:` ``` immich-server: volumes: - - ${EXTERNAL_PATH}:/usr/src/app/external + - /path/to/photos/on/host:/usr/src/app/external ``` Be sure to add exactly the same line to both `immich-server:` and `immich-microservices:`. -Edit `.env` to define `EXTERNAL_PATH`, substituting in the correct path for your computer: - -``` -EXTERNAL_PATH= -``` - -On my computer, for example, I use this path: - -``` -EXTERNAL_PATH=/home/tenino/photos -``` - Restart Immich. ``` @@ -35,42 +23,21 @@ docker compose down docker compose up -d ``` -# Set the External Path +# Create the library In the Immich web UI: - click the **Administration** link in the upper right corner. -- Select the **Users** tab - +- Select the **External Libraries** tab + -- Select the **pencil** next to your user ID - +- Click the **Create Library** button + -- Fill in the **External Path** field with `/usr/src/app/external` - - -Notice this matches the path _inside the container_ where we mounted your photos. -The purpose of the external path field is for administrators who have multiple users -on their Immich instance. It lets you prevent other authorized users from -navigating to your external library. - -# Import the library - -In the Immich web UI: - -- Click your user avatar in the upper-right corner (circle with your initials) - - -- Click **Account Settings** - - -- Click to expand **Libraries** - - -- Click the **Create External Library** button - +- In the dialog, select which user should own the new library + - Click the three-dots menu and select **Edit Import Paths** diff --git a/docs/docs/guides/img/account-settings.png b/docs/docs/guides/img/account-settings.png deleted file mode 100644 index 8cece6f467c4ee3ae930390aa1c86693ed7c9bdf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26723 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 literal 0 HcmV?d00001 diff --git a/docs/docs/guides/img/external-libraries.png b/docs/docs/guides/img/external-libraries.png new file mode 100644 index 0000000000000000000000000000000000000000..3b8660a19cb257c1e81b3488b3fd58c60d665437 GIT binary patch literal 5797 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 literal 0 HcmV?d00001 diff --git a/docs/docs/guides/img/pencil.png b/docs/docs/guides/img/pencil.png deleted file mode 100644 index d3b8f450553c06f4d09e9c2aa510a7dde99164d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3119 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 Date: Thu, 7 Mar 2024 17:21:23 -0500 Subject: [PATCH 36/47] fix(server): remove shared links during user delete (#7696) * fix(server): remove shared links during user delete * add delete cascade for shared links --- machine-learning/.gitignore | 4 +++- server/src/domain/user/user.service.spec.ts | 16 +--------------- server/src/domain/user/user.service.ts | 4 ---- server/src/infra/entities/shared-link.entity.ts | 2 +- .../1709825430031-CascadeSharedLinksDelete.ts | 16 ++++++++++++++++ 5 files changed, 21 insertions(+), 21 deletions(-) create mode 100644 server/src/infra/migrations/1709825430031-CascadeSharedLinksDelete.ts diff --git a/machine-learning/.gitignore b/machine-learning/.gitignore index d3163ea5b0e51..a259b9f5dcf53 100644 --- a/machine-learning/.gitignore +++ b/machine-learning/.gitignore @@ -171,4 +171,6 @@ cython_debug/ .vscode *.onnx -*.zip \ No newline at end of file +*.zip + +core \ No newline at end of file diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index cba4581562afd..dba0106fb631e 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -8,7 +8,6 @@ import { import { authStub, newAlbumRepositoryMock, - newAssetRepositoryMock, newCryptoRepositoryMock, newJobRepositoryMock, newLibraryRepositoryMock, @@ -23,7 +22,6 @@ import { CacheControl, ImmichFileResponse } from '../domain.util'; import { JobName } from '../job'; import { IAlbumRepository, - IAssetRepository, ICryptoRepository, IJobRepository, ILibraryRepository, @@ -47,7 +45,6 @@ describe(UserService.name, () => { let cryptoRepositoryMock: jest.Mocked; let albumMock: jest.Mocked; - let assetMock: jest.Mocked; let jobMock: jest.Mocked; let libraryMock: jest.Mocked; let storageMock: jest.Mocked; @@ -55,7 +52,6 @@ describe(UserService.name, () => { beforeEach(() => { albumMock = newAlbumRepositoryMock(); - assetMock = newAssetRepositoryMock(); configMock = newSystemConfigRepositoryMock(); cryptoRepositoryMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); @@ -63,16 +59,7 @@ describe(UserService.name, () => { storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new UserService( - albumMock, - assetMock, - cryptoRepositoryMock, - jobMock, - libraryMock, - storageMock, - configMock, - userMock, - ); + sut = new UserService(albumMock, cryptoRepositoryMock, jobMock, libraryMock, storageMock, configMock, userMock); when(userMock.get).calledWith(authStub.admin.user.id, {}).mockResolvedValue(userStub.admin); when(userMock.get).calledWith(authStub.admin.user.id, { withDeleted: true }).mockResolvedValue(userStub.admin); @@ -537,7 +524,6 @@ describe(UserService.name, () => { expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options); expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options); expect(albumMock.deleteAll).toHaveBeenCalledWith(user.id); - expect(assetMock.deleteAll).toHaveBeenCalledWith(user.id); expect(userMock.delete).toHaveBeenCalledWith(user, true); }); diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index ace2fb5e17d16..9a862199b8dde 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -8,7 +8,6 @@ import { CacheControl, ImmichFileResponse } from '../domain.util'; import { IEntityJob, JobName } from '../job'; import { IAlbumRepository, - IAssetRepository, ICryptoRepository, IJobRepository, ILibraryRepository, @@ -31,7 +30,6 @@ export class UserService { constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @@ -185,9 +183,7 @@ export class UserService { } this.logger.warn(`Removing user from database: ${user.id}`); - await this.albumRepository.deleteAll(user.id); - await this.assetRepository.deleteAll(user.id); await this.userRepository.delete(user, true); return true; diff --git a/server/src/infra/entities/shared-link.entity.ts b/server/src/infra/entities/shared-link.entity.ts index f64ad84249862..e7cd19e53f022 100644 --- a/server/src/infra/entities/shared-link.entity.ts +++ b/server/src/infra/entities/shared-link.entity.ts @@ -27,7 +27,7 @@ export class SharedLinkEntity { @Column() userId!: string; - @ManyToOne(() => UserEntity) + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) user!: UserEntity; @Index('IDX_sharedlink_key') diff --git a/server/src/infra/migrations/1709825430031-CascadeSharedLinksDelete.ts b/server/src/infra/migrations/1709825430031-CascadeSharedLinksDelete.ts new file mode 100644 index 0000000000000..9689741929f29 --- /dev/null +++ b/server/src/infra/migrations/1709825430031-CascadeSharedLinksDelete.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CascadeSharedLinksDelete1709825430031 implements MigrationInterface { + name = 'CascadeSharedLinksDelete1709825430031' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340"`); + await queryRunner.query(`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340"`); + await queryRunner.query(`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} From 7a7475ed672586f6739bf90922e150f38ebc763a Mon Sep 17 00:00:00 2001 From: aviv926 <51673860+aviv926@users.noreply.github.com> Date: Fri, 8 Mar 2024 01:49:30 +0200 Subject: [PATCH 37/47] docs: Update External Libraries Guide (#7723) --- docs/docs/guides/external-library.md | 20 ++++++++++++++++++-- docs/docs/guides/img/external-libraries.png | Bin 5797 -> 11546 bytes 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/docs/guides/external-library.md b/docs/docs/guides/external-library.md index f1f95f942f171..b1d4b67b2e046 100644 --- a/docs/docs/guides/external-library.md +++ b/docs/docs/guides/external-library.md @@ -11,11 +11,27 @@ Edit `docker-compose.yml` to add two new mount points under `volumes:` ``` immich-server: volumes: - - /path/to/photos/on/host:/usr/src/app/external + - ${EXTERNAL_PATH}:/usr/src/app/external ``` Be sure to add exactly the same line to both `immich-server:` and `immich-microservices:`. +Edit `.env` to define `EXTERNAL_PATH`, substituting in the correct path for your computer: + +``` +EXTERNAL_PATH= +``` + +On my computer, for example, I use this path: + +``` +EXTERNAL_PATH=/home/tenino/photos +``` + +:::info EXTERNAL_PATH design +The design choice to put the EXTERNAL_PATH into .env rather than put two copies of the absolute path in the yml file in order to make everything easier, so if you have two copies of the same path that have to be kept in sync, then someday later when you move the data, update only one of the paths, without everything will break mysteriously. +::: + Restart Immich. ``` @@ -42,7 +58,7 @@ In the Immich web UI: - Click the three-dots menu and select **Edit Import Paths** -- Click \*_Add path_ +- Click Add path - Enter **/usr/src/app/external** as the path and click Add diff --git a/docs/docs/guides/img/external-libraries.png b/docs/docs/guides/img/external-libraries.png index 3b8660a19cb257c1e81b3488b3fd58c60d665437..0d0f5d237242fe4ec637687bc4b675c0cd40e0b1 100644 GIT binary patch literal 11546 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 Date: Thu, 7 Mar 2024 21:55:50 -0500 Subject: [PATCH 38/47] fix(mobile): Fixes large and small image cache (#7726) Fixes large and small image cache --- .../lib/shared/cache/custom_image_cache.dart | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/mobile/lib/shared/cache/custom_image_cache.dart b/mobile/lib/shared/cache/custom_image_cache.dart index 79338cbda5f43..3f8b35e3fa7c1 100644 --- a/mobile/lib/shared/cache/custom_image_cache.dart +++ b/mobile/lib/shared/cache/custom_image_cache.dart @@ -1,5 +1,6 @@ import 'package:flutter/painting.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_image_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; /// [ImageCache] that uses two caches for small and large images /// so that a single large image does not evict all small iamges @@ -31,9 +32,18 @@ final class CustomImageCache implements ImageCache { _large.clearLiveImages(); } + /// Gets the cache for the given key + ImageCache _cacheForKey(Object key) => + (key is ImmichLocalImageProvider || key is ImmichRemoteImageProvider) + ? _large + : _small; + @override - bool containsKey(Object key) => - (key is ImmichLocalImageProvider ? _large : _small).containsKey(key); + bool containsKey(Object key) { + // [ImmichLocalImageProvider] and [ImmichRemoteImageProvider] are both + // large size images while the other thumbnail providers are small + return _cacheForKey(key).containsKey(key); + } @override int get currentSize => _small.currentSize + _large.currentSize; @@ -43,8 +53,7 @@ final class CustomImageCache implements ImageCache { @override bool evict(Object key, {bool includeLive = true}) => - (key is ImmichLocalImageProvider ? _large : _small) - .evict(key, includeLive: includeLive); + _cacheForKey(key).evict(key, includeLive: includeLive); @override int get liveImageCount => _small.liveImageCount + _large.liveImageCount; @@ -59,10 +68,9 @@ final class CustomImageCache implements ImageCache { ImageStreamCompleter Function() loader, { ImageErrorListener? onError, }) => - (key is ImmichLocalImageProvider ? _large : _small) - .putIfAbsent(key, loader, onError: onError); + _cacheForKey(key).putIfAbsent(key, loader, onError: onError); @override ImageCacheStatus statusForKey(Object key) => - (key is ImmichLocalImageProvider ? _large : _small).statusForKey(key); + _cacheForKey(key).statusForKey(key); } From 753842745d550197996715c1bf3ef8126d2bb71f Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 Mar 2024 20:56:05 -0600 Subject: [PATCH 39/47] Localizely: Translations update (#7715) chore(mobile): translation update --- mobile/assets/i18n/ca.json | 1 + mobile/assets/i18n/cs-CZ.json | 1 + mobile/assets/i18n/da-DK.json | 13 +- mobile/assets/i18n/de-DE.json | 4 +- mobile/assets/i18n/en-US.json | 2 +- mobile/assets/i18n/es-ES.json | 121 +++++----- mobile/assets/i18n/es-MX.json | 1 + mobile/assets/i18n/es-PE.json | 1 + mobile/assets/i18n/fi-FI.json | 3 +- mobile/assets/i18n/fr-FR.json | 1 + mobile/assets/i18n/hi-IN.json | 1 + mobile/assets/i18n/hu-HU.json | 181 +++++++------- mobile/assets/i18n/it-IT.json | 176 +++++++------- mobile/assets/i18n/ja-JP.json | 363 +++++++++++++++-------------- mobile/assets/i18n/ko-KR.json | 1 + mobile/assets/i18n/lv-LV.json | 1 + mobile/assets/i18n/mn.json | 1 + mobile/assets/i18n/nb-NO.json | 31 +-- mobile/assets/i18n/nl-NL.json | 77 +++--- mobile/assets/i18n/pl-PL.json | 1 + mobile/assets/i18n/pt-PT.json | 283 +++++++++++----------- mobile/assets/i18n/ru-RU.json | 225 +++++++++--------- mobile/assets/i18n/sk-SK.json | 1 + mobile/assets/i18n/sr-Cyrl.json | 1 + mobile/assets/i18n/sr-Latn.json | 1 + mobile/assets/i18n/sv-FI.json | 1 + mobile/assets/i18n/sv-SE.json | 1 + mobile/assets/i18n/th-TH.json | 1 + mobile/assets/i18n/uk-UA.json | 401 ++++++++++++++++---------------- mobile/assets/i18n/vi-VN.json | 187 +++++++-------- mobile/assets/i18n/zh-CN.json | 15 +- mobile/assets/i18n/zh-Hans.json | 17 +- 32 files changed, 1072 insertions(+), 1043 deletions(-) diff --git a/mobile/assets/i18n/ca.json b/mobile/assets/i18n/ca.json index 306025e23fb77..2db42ad85bb1a 100644 --- a/mobile/assets/i18n/ca.json +++ b/mobile/assets/i18n/ca.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETALLS", "exif_bottom_sheet_location": "UBICACIÓ", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", diff --git a/mobile/assets/i18n/cs-CZ.json b/mobile/assets/i18n/cs-CZ.json index 68e37feb1c486..7f1a38078a7b3 100644 --- a/mobile/assets/i18n/cs-CZ.json +++ b/mobile/assets/i18n/cs-CZ.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "PODROBNOSTI", "exif_bottom_sheet_location": "LOKALITA", "exif_bottom_sheet_location_add": "Přidat polohu", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Zpracovávám", "experimental_settings_new_asset_list_title": "Povolení experimentální mřížky fotografií", "experimental_settings_subtitle": "Používejte na vlastní riziko!", diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index a21044b371b2c..bb59fddf83baa 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -35,8 +35,8 @@ "app_bar_signout_dialog_title": "Log ud", "archive_page_no_archived_assets": "Ingen arkiverede elementer blev fundet", "archive_page_title": "Arkivér ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_action_delete_err_read_only": "Kan ikke slette kun læselige elementer. Springer over", + "asset_action_share_err_offline": "Kan ikke hente offline element(er). Springer over", "asset_list_layout_settings_dynamic_layout_title": "Dynamisk layout", "asset_list_layout_settings_group_automatically": "Automatisk", "asset_list_layout_settings_group_by": "Gruppér elementer pr. ", @@ -150,7 +150,7 @@ "control_bottom_app_bar_share": "Del", "control_bottom_app_bar_share_to": "Del til", "control_bottom_app_bar_stack": "Stak", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_trash_from_immich": "Flyt til papirkurv", "control_bottom_app_bar_unarchive": "Afakivér", "control_bottom_app_bar_unfavorite": "Fjern favorit", "control_bottom_app_bar_upload": "Upload", @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETALJER", "exif_bottom_sheet_location": "LOKATION", "exif_bottom_sheet_location_add": "Tilføj en placering", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Under udarbejdelse", "experimental_settings_new_asset_list_title": "Aktiver eksperimentelt fotogitter", "experimental_settings_subtitle": "Brug på eget ansvar!", @@ -199,7 +200,7 @@ "home_page_archive_err_partner": "Kan endnu ikke arkivere partners elementer. Springer over", "home_page_building_timeline": "Bygger tidslinjen", "home_page_delete_err_partner": "Kan endnu ikke slette partners elementer. Springer over", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_delete_remote_err_local": "Lokale elementer i fjernsletningssektion. Springer over", "home_page_favorite_err_local": "Kan endnu ikke gøre lokale elementer til favoritter. Springer over..", "home_page_favorite_err_partner": "Kan endnu ikke tilføje partners elementer som favoritter. Springer over", "home_page_first_time_notice": "Hvis det er din første gang i appen, bedes du vælge en sikkerhedskopi af albummer så tidlinjen kan blive fyldt med billeder og videoer fra albummerne.", @@ -279,8 +280,8 @@ "map_zoom_to_see_photos": "Zoom ud for at vise billeder", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Bevægelsesbilleder", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "Kan ikke redigere datoen på kun læselige elementer. Springer over", + "multiselect_grid_edit_gps_err_read_only": "Kan ikke redigere lokation af kun læselige elementer. Springer over", "notification_permission_dialog_cancel": "Annuller", "notification_permission_dialog_content": "Gå til indstillinger for at slå notifikationer til.", "notification_permission_dialog_settings": "Indstillinger", diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index 9b36e360b899c..298847cd7ff3e 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -185,7 +185,7 @@ "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "STANDORT", "exif_bottom_sheet_location_add": "Aufnahmeort hinzufügen", - "exif_bottom_sheet_people": "PERSONEN", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "In Arbeit", "experimental_settings_new_asset_list_title": "Experimentelles Fotogitter aktivieren", "experimental_settings_subtitle": "Benutzung auf eigene Gefahr!", @@ -477,4 +477,4 @@ "viewer_remove_from_stack": "Aus Stapel entfernen", "viewer_stack_use_as_main_asset": "An Stapelanfang", "viewer_unstack": "Stapel aufheben" -} +} \ No newline at end of file diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index b32ce5f493683..eda7838913707 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -477,4 +477,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/assets/i18n/es-ES.json b/mobile/assets/i18n/es-ES.json index d15b5c96e7fa1..7c0ae71c761a1 100644 --- a/mobile/assets/i18n/es-ES.json +++ b/mobile/assets/i18n/es-ES.json @@ -1,6 +1,6 @@ { - "action_common_cancel": "Cancel", - "action_common_update": "Update", + "action_common_cancel": "Cancelar", + "action_common_update": "Actualizar", "add_to_album_bottom_sheet_added": "Agregado a {album}", "add_to_album_bottom_sheet_already_exists": "Ya se encuentra en {album}", "advanced_settings_log_level_title": "Nivel de log: {}", @@ -35,7 +35,7 @@ "app_bar_signout_dialog_title": "Cerrar sesión", "archive_page_no_archived_assets": "No se encontraron recursos archivados", "archive_page_title": "Archivo ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", + "asset_action_delete_err_read_only": "No se pueden borrar los archivos de solo lectura. Saltando.", "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", "asset_list_layout_settings_dynamic_layout_title": "Diseño dinámico", "asset_list_layout_settings_group_automatically": "Automatico", @@ -142,15 +142,15 @@ "control_bottom_app_bar_archive": "Archivar", "control_bottom_app_bar_create_new_album": "Crear nuevo álbum", "control_bottom_app_bar_delete": "Eliminar", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_delete_from_immich": "Borrar de Immich", + "control_bottom_app_bar_delete_from_local": "Borrar del dispositivo", "control_bottom_app_bar_edit_location": "Editar ubicación", "control_bottom_app_bar_edit_time": "Editar fecha y hora", "control_bottom_app_bar_favorite": "Favorito", "control_bottom_app_bar_share": "Compartir", "control_bottom_app_bar_share_to": "Enviar", "control_bottom_app_bar_stack": "Apilar", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_trash_from_immich": "Mover a la papelera", "control_bottom_app_bar_unarchive": "Desarchivar", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Subir", @@ -165,9 +165,9 @@ "daily_title_text_date_year": "E dd de MMM, yyyy", "date_format": "E d, LLL y • h:mm a", "delete_dialog_alert": "Estos elementos serán eliminados permanentemente de Immich y de tu dispositivo", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "Estas imágenes van a ser borradas de tu dispositivo, pero seguirán disponibles en el servidor Immich", + "delete_dialog_alert_local_non_backed_up": "Algunas de las imágenes no tienen copia de seguridad y serán borradas de forma permanente de tu dispositivo", + "delete_dialog_alert_remote": "Estas imágenes van a ser borradas de forma permanente del servidor Immich", "delete_dialog_cancel": "Cancelar", "delete_dialog_ok": "Eliminar", "delete_dialog_ok_force": "Delete Anyway", @@ -178,13 +178,14 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", - "edit_date_time_dialog_date_time": "Date and Time", - "edit_date_time_dialog_timezone": "Timezone", - "edit_location_dialog_title": "Location", + "edit_date_time_dialog_date_time": "Fecha y Hora", + "edit_date_time_dialog_timezone": "Zona horaria", + "edit_location_dialog_title": "Ubicación", "exif_bottom_sheet_description": "Agregar Descripción...", "exif_bottom_sheet_details": "DETALLES", "exif_bottom_sheet_location": "UBICACIÓN", "exif_bottom_sheet_location_add": "Añadir ubicación", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Trabajo en progreso", "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "Úsalo bajo tu responsabilidad", @@ -220,13 +221,13 @@ "library_page_sort_most_oldest_photo": "Foto más antigua", "library_page_sort_most_recent_photo": "Foto más reciente", "library_page_sort_title": "Título del álbum", - "location_picker_choose_on_map": "Choose on map", - "location_picker_latitude": "Latitude", - "location_picker_latitude_error": "Enter a valid latitude", - "location_picker_latitude_hint": "Enter your latitude here", - "location_picker_longitude": "Longitude", - "location_picker_longitude_error": "Enter a valid longitude", - "location_picker_longitude_hint": "Enter your longitude here", + "location_picker_choose_on_map": "Elegir en el mapa", + "location_picker_latitude": "Latitud", + "location_picker_latitude_error": "Introduce una latitud válida", + "location_picker_latitude_hint": "Introduce tu latitud aquí", + "location_picker_longitude": "Longitud", + "location_picker_longitude_error": "Introduce una longitud válida", + "location_picker_longitude_hint": "Introduce tu longitud aquí", "login_disabled": "El inicio de sesión ha sido desactivado", "login_form_api_exception": "Excepción producida por API. Por favor, verifica el URL del servidor e inténtalo de nuevo.", "login_form_back_button_text": "Atrás", @@ -252,12 +253,12 @@ "login_form_server_error": "No se pudo conectar al servidor.", "login_password_changed_error": "Hubo un error actualizando la contraseña", "login_password_changed_success": "Contraseña cambiado con éxito", - "map_assets_in_bound": "{} photo", - "map_assets_in_bounds": "{} photos", + "map_assets_in_bound": "{} foto", + "map_assets_in_bounds": "{} fotos", "map_cannot_get_user_location": "No se pudo obtener la posición del usuario", "map_location_dialog_cancel": "Cancelar", "map_location_dialog_yes": "Sí", - "map_location_picker_page_use_location": "Use this location", + "map_location_picker_page_use_location": "Usar esta ubicación", "map_location_service_disabled_content": "Los servicios de ubicación deben estar activados para mostrar elementos de tu ubicación actual. Deseas activarlos ahora?", "map_location_service_disabled_title": "Servicios de ubicación desactivados", "map_no_assets_in_bounds": "No hay fotos en esta zona", @@ -265,22 +266,22 @@ "map_no_location_permission_title": "Permisos de ubicación denegados", "map_settings_dark_mode": "Modo oscuro", "map_settings_date_range_option_all": "Todo", - "map_settings_date_range_option_day": "Past 24 hours", - "map_settings_date_range_option_days": "Past {} days", - "map_settings_date_range_option_year": "Past year", - "map_settings_date_range_option_years": "Past {} years", + "map_settings_date_range_option_day": "Últimas 24 horas", + "map_settings_date_range_option_days": "Últimos {} días", + "map_settings_date_range_option_year": "Último año", + "map_settings_date_range_option_years": "Últimos {} años", "map_settings_dialog_cancel": "Cancelar", "map_settings_dialog_save": "Guardar", "map_settings_dialog_title": "Ajustes mapa", "map_settings_include_show_archived": "Incluir archivados", "map_settings_only_relative_range": "Rango de fechas", "map_settings_only_show_favorites": "Mostrar solo favoritas", - "map_settings_theme_settings": "Map Theme", + "map_settings_theme_settings": "Apariencia del Mapa", "map_zoom_to_see_photos": "Alejar para ver fotos", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Foto en Movimiento", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "No se puede cambiar la fecha de archivos de solo lectura. Saltando.", + "multiselect_grid_edit_gps_err_read_only": "No se puede cambiar la localización de archivos de solo lectura. Saltando.", "notification_permission_dialog_cancel": "Cancelar", "notification_permission_dialog_content": "Para activar las notificaciones, ve a Configuración y selecciona permitir.", "notification_permission_dialog_settings": "Ajustes", @@ -318,7 +319,7 @@ "profile_drawer_sign_out": "Cerrar Sesión", "profile_drawer_trash": "Papelera", "recently_added_page_title": "Recién Agregadas", - "scaffold_body_error_occurred": "Error occurred", + "scaffold_body_error_occurred": "Ha ocurrido un error", "search_bar_hint": "Busca tus fotos", "search_page_categories": "Categorías", "search_page_favorites": "Favoritos", @@ -330,9 +331,9 @@ "search_page_person_add_name_dialog_hint": "Nombre", "search_page_person_add_name_dialog_save": "Guardar", "search_page_person_add_name_dialog_title": "Añadir nombre", - "search_page_person_add_name_subtitle": "Find them fast by name with search", - "search_page_person_add_name_title": "Add a name", - "search_page_person_edit_name": "Edit name", + "search_page_person_add_name_subtitle": "Encuéntralos rápido buscando por nombre", + "search_page_person_add_name_title": "Añadir nombre", + "search_page_person_edit_name": "Cambiar nombre", "search_page_places": "Lugares", "search_page_recently_added": "Recién agregadas", "search_page_screenshots": "Capturas de pantalla", @@ -341,7 +342,7 @@ "search_page_videos": "Videos", "search_page_view_all_button": "Ver todo", "search_page_your_activity": "Tu actividad", - "search_page_your_map": "Your Map", + "search_page_your_map": "Tu Mapa", "search_result_page_new_search_hint": "Nueva Busqueda", "search_suggestion_list_smart_search_hint_1": "La búsqueda inteligente está habilitada por defecto, para buscar metadatos utiliza esta sintaxis ", "search_suggestion_list_smart_search_hint_2": "m:tu-término-de-búsqueda", @@ -381,17 +382,17 @@ "shared_album_activity_remove_title": "Eliminar Actividad", "shared_album_activity_setting_subtitle": "Permitir que otros respondan", "shared_album_activity_setting_title": "Comentarios y me gusta", - "shared_album_section_people_action_error": "Error leaving/removing from album", - "shared_album_section_people_action_leave": "Remove user from album", - "shared_album_section_people_action_remove_user": "Remove user from album", - "shared_album_section_people_owner_label": "Owner", - "shared_album_section_people_title": "PEOPLE", + "shared_album_section_people_action_error": "Error dejando/eliminando del album", + "shared_album_section_people_action_leave": "Eliminar usuario del album", + "shared_album_section_people_action_remove_user": "Eliminar usuario del album", + "shared_album_section_people_owner_label": "Propietario", + "shared_album_section_people_title": "GENTE", "share_dialog_preparing": "Preparando...", "shared_link_app_bar_title": "Enlaces compartidos", - "shared_link_clipboard_copied_massage": "Copied to clipboard", - "shared_link_clipboard_text": "Link: {}\nPassword: {}", + "shared_link_clipboard_copied_massage": "Copiado al portapapeles", + "shared_link_clipboard_text": "Enlace: {}\nContraseña: {}", "shared_link_create_app_bar_title": "Crear enlace compartido", - "shared_link_create_error": "Error while creating shared link", + "shared_link_create_error": "Error creando el enlace compartido", "shared_link_create_info": "Cualquier persona con el enlace puede ver las fotos seleccionadas", "shared_link_create_submit_button": "Crear enlace", "shared_link_edit_allow_download": "Permitir descargar a usuarios públicos", @@ -401,32 +402,32 @@ "shared_link_edit_description": "Descripción", "shared_link_edit_description_hint": "Introduce la descripción del enlace", "shared_link_edit_expire_after": "Expirar después de", - "shared_link_edit_expire_after_option_day": "1 day", - "shared_link_edit_expire_after_option_days": "{} days", - "shared_link_edit_expire_after_option_hour": "1 hour", - "shared_link_edit_expire_after_option_hours": "{} hours", - "shared_link_edit_expire_after_option_minute": "1 minute", - "shared_link_edit_expire_after_option_minutes": "{} minutes", - "shared_link_edit_expire_after_option_never": "Never", + "shared_link_edit_expire_after_option_day": "1 día", + "shared_link_edit_expire_after_option_days": "{} días", + "shared_link_edit_expire_after_option_hour": "1 hora", + "shared_link_edit_expire_after_option_hours": "{} horas", + "shared_link_edit_expire_after_option_minute": "1 minuto", + "shared_link_edit_expire_after_option_minutes": "{} minutos", + "shared_link_edit_expire_after_option_never": "Nunca", "shared_link_edit_password": "Contraseña", "shared_link_edit_password_hint": "Introduce la contraseña del enlace", "shared_link_edit_show_meta": "Mostrar metadatos", "shared_link_edit_submit_button": "Actualizar enlace", "shared_link_empty": "No tienes enlaces compartidos", - "shared_link_error_server_url_fetch": "Cannot fetch the server url", - "shared_link_expired": "Expired", - "shared_link_expires_day": "Expires in {} day", - "shared_link_expires_days": "Expires in {} days", - "shared_link_expires_hour": "Expires in {} hour", - "shared_link_expires_hours": "Expires in {} hours", - "shared_link_expires_minute": "Expires in {} minute", + "shared_link_error_server_url_fetch": "No se puede adquirir la URL del servidor", + "shared_link_expired": "Caducado", + "shared_link_expires_day": "Caduca en {} día", + "shared_link_expires_days": "Caduca en {} días", + "shared_link_expires_hour": "Caduca en {} hora", + "shared_link_expires_hours": "Caduca en {} horas", + "shared_link_expires_minute": "Caduca en {} minuto", "shared_link_expires_minutes": "Caduca en {} minutos", - "shared_link_expires_never": "Expires ∞", - "shared_link_expires_second": "Expires in {} second", + "shared_link_expires_never": "Caduca ∞", + "shared_link_expires_second": "Caduca en {} segundo", "shared_link_expires_seconds": "Caduca en {} segundos", - "shared_link_info_chip_download": "Download", + "shared_link_info_chip_download": "Descargar", "shared_link_info_chip_metadata": "EXIF", - "shared_link_info_chip_upload": "Upload", + "shared_link_info_chip_upload": "Subir", "shared_link_manage_links": "Administrar enlaces compartidos", "share_done": "Hecho", "share_invite": "Invitar al álbum", diff --git a/mobile/assets/i18n/es-MX.json b/mobile/assets/i18n/es-MX.json index b855153150d00..c96f1adaccdce 100644 --- a/mobile/assets/i18n/es-MX.json +++ b/mobile/assets/i18n/es-MX.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETALLES", "exif_bottom_sheet_location": "UBICACIÓN", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Trabajo en progreso", "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "Úsalo bajo tu responsabilidad", diff --git a/mobile/assets/i18n/es-PE.json b/mobile/assets/i18n/es-PE.json index 76b1a5a0047a0..753ee01ee4011 100644 --- a/mobile/assets/i18n/es-PE.json +++ b/mobile/assets/i18n/es-PE.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETALLES", "exif_bottom_sheet_location": "UBICACIÓN", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Trabajo en progreso", "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "Úsalo bajo tu responsabilidad", diff --git a/mobile/assets/i18n/fi-FI.json b/mobile/assets/i18n/fi-FI.json index e462bb3477325..b5fa0e2631567 100644 --- a/mobile/assets/i18n/fi-FI.json +++ b/mobile/assets/i18n/fi-FI.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "TIEDOT", "exif_bottom_sheet_location": "SIJAINTI", "exif_bottom_sheet_location_add": "Lisää sijainti", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Työn alla", "experimental_settings_new_asset_list_title": "Ota käyttöön kokeellinen kuvaruudukko", "experimental_settings_subtitle": "Käyttö omalla vastuulla!", @@ -199,7 +200,7 @@ "home_page_archive_err_partner": "Kumppanin kohteita ei voi arkistoida. Hypätään yli", "home_page_building_timeline": "Rakennetaan aikajanaa", "home_page_delete_err_partner": "Kumppanin kohteita ei voi poistaa.Hypätään yli", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_delete_remote_err_local": "Paikallisia kohteita etäkohdevalintojen joukossa, ohitetaan", "home_page_favorite_err_local": "Paikallisten kohteiden lisääminen suosikkeihin ei ole mahdollista, ohitetaan", "home_page_favorite_err_partner": "Kumppanin kohteita ei voi vielä merkitä suosikiksi. Hypätään yli", "home_page_first_time_notice": "Jos käytät sovellusta ensimmäistä kertaa, muista valita varmuuskopioitavat albumi(t), jotta aikajanalla voi olla kuvia ja videoita.", diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index 7e77774ec3061..5bc61112376b7 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DÉTAILS", "exif_bottom_sheet_location": "LOCALISATION", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "En cours de développement", "experimental_settings_new_asset_list_title": "Activer la grille de photos expérimentale", "experimental_settings_subtitle": "Utilisez à vos dépends !", diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index 1017fce243597..24763bd4f87a4 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATION", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", diff --git a/mobile/assets/i18n/hu-HU.json b/mobile/assets/i18n/hu-HU.json index 168340ad19e6f..2d18c4b6a1a2e 100644 --- a/mobile/assets/i18n/hu-HU.json +++ b/mobile/assets/i18n/hu-HU.json @@ -1,74 +1,74 @@ { - "action_common_cancel": "Cancel", - "action_common_update": "Update", + "action_common_cancel": "Mégsem", + "action_common_update": "Frissít", "add_to_album_bottom_sheet_added": "Hozzáadva a(z) {album} nevű albumhoz", "add_to_album_bottom_sheet_already_exists": "Már eleme a(z) {album} nevű albumnak", - "advanced_settings_log_level_title": "Log level: {}", - "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", - "advanced_settings_prefer_remote_title": "Prefer remote images", - "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", - "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", + "advanced_settings_log_level_title": "Naplózás szintje: {}", + "advanced_settings_prefer_remote_subtitle": "Néhány eszköz fájdalmasan lassan tölti be az eszközön lévő elemeket. Ezzel a beállítással inkább a távoli képeket töltjük be helyette.", + "advanced_settings_prefer_remote_title": "Távoli képek preferálása", + "advanced_settings_self_signed_ssl_subtitle": "SSL tanúsítvány ellenőrzésének kihagyása a szerver végponthoz. Ehhez saját aláírt tanúsítványok szükségesek.", + "advanced_settings_self_signed_ssl_title": "Saját aláírt SSL tanúsítványok engedélyezése", "advanced_settings_tile_subtitle": "Haladó felhasználói beállítások", "advanced_settings_tile_title": "Haladó", "advanced_settings_troubleshooting_subtitle": "További funkciók engedélyezése hibaelhárítás céljából", "advanced_settings_troubleshooting_title": "Hibaelhárítás", - "album_info_card_backup_album_excluded": "EXCLUDED", - "album_info_card_backup_album_included": "INCLUDED", + "album_info_card_backup_album_excluded": "KIZÁRVA", + "album_info_card_backup_album_included": "BELEÉRTVE", "album_thumbnail_card_item": "1 elem", "album_thumbnail_card_items": "{} elem", "album_thumbnail_card_shared": "· Megosztott", "album_thumbnail_owned": "Tulajdonos", "album_thumbnail_shared_by": "Megosztotta: {}", "album_viewer_appbar_share_delete": "Album törlése", - "album_viewer_appbar_share_err_delete": "Hiba az album törlése közben", - "album_viewer_appbar_share_err_leave": "Hiba az albumból való kilépés közben", - "album_viewer_appbar_share_err_remove": "Hiba az elemek törlése közben", - "album_viewer_appbar_share_err_title": "Hiba az album átnevezése közben", + "album_viewer_appbar_share_err_delete": "Nem sikerült törölni az albumot", + "album_viewer_appbar_share_err_leave": "Nem sikerült kilépni az albumból", + "album_viewer_appbar_share_err_remove": "Néhány elemet nem sikerült törölni az albumból", + "album_viewer_appbar_share_err_title": "Nem sikerült átnevezni az albumot", "album_viewer_appbar_share_leave": "Kilépés az albumból", - "album_viewer_appbar_share_remove": "Törlés az albumból", - "album_viewer_appbar_share_to": "Share To", + "album_viewer_appbar_share_remove": "Eltávolítás az albumból", + "album_viewer_appbar_share_to": "Megosztás Ide", "album_viewer_page_share_add_users": "Felhasználók hozzáadása", "all_people_page_title": "Emberek", "all_videos_page_title": "Videók", - "app_bar_signout_dialog_content": "Are you sure you want to sign out?", - "app_bar_signout_dialog_ok": "Yes", - "app_bar_signout_dialog_title": "Sign out", - "archive_page_no_archived_assets": "Nem található archivált média", + "app_bar_signout_dialog_content": "Biztos, hogy ki szeretnél jelentkezni?", + "app_bar_signout_dialog_ok": "Igen", + "app_bar_signout_dialog_title": "Kijelentkezés", + "archive_page_no_archived_assets": "Nem található archivált elem", "archive_page_title": "Archívum ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", - "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_action_delete_err_read_only": "Nem sikerült törölni a csak-olvasható elem(ek)et, így ezeket átugorjuk", + "asset_action_share_err_offline": "Nem sikerült betölteni az offline elem(ek)et, így ezeket kihagyjuk", + "asset_list_layout_settings_dynamic_layout_title": "Dinamikus elrendezés", "asset_list_layout_settings_group_automatically": "Automatikus", - "asset_list_layout_settings_group_by": "Group assets by", - "asset_list_layout_settings_group_by_month": "Hónap", - "asset_list_layout_settings_group_by_month_day": "Hónap + nap", - "asset_list_settings_subtitle": "Photo grid layout settings", - "asset_list_settings_title": "Photo Grid", - "backup_album_selection_page_albums_device": "Az eszközön lévő albumok ({})", - "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", - "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", - "backup_album_selection_page_select_albums": "Albumok kiválasztása", - "backup_album_selection_page_selection_info": "Selection Info", + "asset_list_layout_settings_group_by": "Elemek csoportosítása", + "asset_list_layout_settings_group_by_month": "hónapok szerint", + "asset_list_layout_settings_group_by_month_day": "hónap és nap szerint", + "asset_list_settings_subtitle": "Fotórács elrendezése", + "asset_list_settings_title": "Fotórács", + "backup_album_selection_page_albums_device": "Ezen az eszközön lévő albumok ({})", + "backup_album_selection_page_albums_tap": "Koppincs a hozzáadáshoz, duplán koppincs az eltávolításhoz", + "backup_album_selection_page_assets_scatter": "Egy elem több albumban is lehet. Ezért a mentéshez albumokat lehet hozzáadni vagy azokat a mentésből kihagyni.", + "backup_album_selection_page_select_albums": "Válassz albumokat", + "backup_album_selection_page_selection_info": "Összegzés", "backup_album_selection_page_total_assets": "Összes egyedi elem", "backup_all": "Összes", "backup_background_service_backup_failed_message": "HIba a mentés közben. Újrapróbálkozás...", "backup_background_service_connection_failed_message": "HIba a szerverhez való csatlakozás közben. Újrapróbálkozás...", "backup_background_service_current_upload_notification": "Feltöltés {}", - "backup_background_service_default_notification": "Keresés új elemek után...", + "backup_background_service_default_notification": "Új elemek keresése...", "backup_background_service_error_title": "Hiba mentés közben", "backup_background_service_in_progress_notification": "Elemek mentés alatt..", "backup_background_service_upload_failure_notification": "Hiba feltöltés közben {}", - "backup_controller_page_albums": "Albumok mentése", - "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", - "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_albums": "Albumok Mentése", + "backup_controller_page_background_app_refresh_disabled_content": "Engedélyezd a háttérben történő frissítést a Beállítások > Általános > Háttérben Frissítés menüpontban.", + "backup_controller_page_background_app_refresh_disabled_title": "Háttérben frissítés kikapcsolva", "backup_controller_page_background_app_refresh_enable_button_text": "Beállítások megnyitása", "backup_controller_page_background_battery_info_link": "Mutasd meg hogyan", - "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_message": "A sikeres háttérben történő mentéshez kérjük, tiltsd le az Immich akkumulátor optimalizálását.\n\nMivel ezt a különféle eszközökön máshogy kell, ezért kérjük, az eszközöd gyártójától tudd meg, hogyan kell.", "backup_controller_page_background_battery_info_ok": "OK", - "backup_controller_page_background_battery_info_title": "Akkumulátoroptimalizálás", + "backup_controller_page_background_battery_info_title": "Akkumulátor optimalizálás", "backup_controller_page_background_charging": "Csak töltés közben", - "backup_controller_page_background_configure_error": "Failed to configure the background service", - "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_configure_error": "Nem sikerült beállítani a háttér szolgáltatást", + "backup_controller_page_background_delay": "Új elemek mentésének késleltetése: {}", "backup_controller_page_background_description": "Kapcsold be a háttérfolyamatot, hogy automatikusan mentsen elemeket az applikáció megnyitása nélkül", "backup_controller_page_background_is_off": "Automatikus mentés a háttérben ki van kapcsolva", "backup_controller_page_background_is_on": "Automatikus mentés a háttérben bekapcsolva", @@ -78,63 +78,63 @@ "backup_controller_page_backup": "Mentés", "backup_controller_page_backup_selected": "Kiválasztva:", "backup_controller_page_backup_sub": "Mentett fotók és videók", - "backup_controller_page_cancel": "Megszakít", + "backup_controller_page_cancel": "Mégsem", "backup_controller_page_created": "Létrehozva: {}", - "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_desc_backup": "Ha engedélyezed az előtérben mentést, akkor az új elemek automatikusan feltöltődnek a szerverre, amikor megyitod az alkalmazást.", "backup_controller_page_excluded": "Kivéve:", "backup_controller_page_failed": "Sikertelen ({})", "backup_controller_page_filename": "Fájlnév: {}[{}]", "backup_controller_page_id": "Azonosító: {}", "backup_controller_page_info": "Mentésinformációk", "backup_controller_page_none_selected": "Egy sincs kiválasztva", - "backup_controller_page_remainder": "Maradék", + "backup_controller_page_remainder": "Hátralévő", "backup_controller_page_remainder_sub": "Hátralévő fotók és videók a kijelöltek közül", "backup_controller_page_select": "Kiválaszt", - "backup_controller_page_server_storage": "Szerver tárhely", - "backup_controller_page_start_backup": "Mentés elindítása", - "backup_controller_page_status_off": "Autoatikus mentés az előtérben kikapcsolva", - "backup_controller_page_status_on": "Autoatikus mentés az előtérben bekapcsolva", + "backup_controller_page_server_storage": "Szerver Tárhely", + "backup_controller_page_start_backup": "Mentés Elindítása", + "backup_controller_page_status_off": "Automatikus mentés az előtérben kikapcsolva", + "backup_controller_page_status_on": "Automatikus mentés az előtérben bekapcsolva", "backup_controller_page_storage_format": "{} / {} felhasználva", - "backup_controller_page_to_backup": "Albumok amiket mentesz", - "backup_controller_page_total": "Összes", + "backup_controller_page_to_backup": "Mentésre kijelölt albumok", + "backup_controller_page_total": "Összesen", "backup_controller_page_total_sub": "Minden egyedi fotó és videó a kijelölt albumokból", - "backup_controller_page_turn_off": "Turn off foreground backup", - "backup_controller_page_turn_on": "Turn on foreground backup", - "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_controller_page_turn_off": "Előtérben mentés kikapcsolása", + "backup_controller_page_turn_on": "Előtérben mentés bekapcsolása", + "backup_controller_page_uploading_file_info": "Fájl információk feltöltése", "backup_err_only_album": "Az utolsó albumot nem tudod törölni", "backup_info_card_assets": "elemek", "backup_manual_cancelled": "Megszakítva", - "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_failed": "Sikertelen", + "backup_manual_in_progress": "Feltöltés már folyamatban. Próbáld meg később", "backup_manual_success": "Sikeres", - "backup_manual_title": "Upload status", + "backup_manual_title": "Feltöltés állapota", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", - "cache_settings_clear_cache_button": "Gyorsítótár törlése", + "cache_settings_clear_cache_button": "Gyorsítótár kiürítése", "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", - "cache_settings_duplicated_assets_clear_button": "CLEAR", - "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", - "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", - "cache_settings_image_cache_size": "Image cache size ({} assets)", - "cache_settings_statistics_album": "Library thumbnails", - "cache_settings_statistics_assets": "{} assets ({})", - "cache_settings_statistics_full": "Teljes képek", - "cache_settings_statistics_shared": "Shared album thumbnails", - "cache_settings_statistics_thumbnail": "Előnézeti képek", - "cache_settings_statistics_title": "Gyorsítótár által használt terület", - "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", - "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", - "cache_settings_tile_subtitle": "Control the local storage behaviour", - "cache_settings_tile_title": "Local Storage", - "cache_settings_title": "Gyorsítótár beállítások", + "cache_settings_duplicated_assets_clear_button": "KIÜRÍT", + "cache_settings_duplicated_assets_subtitle": "Fotók és videók, amiket az alkalmazás fekete listára tett", + "cache_settings_duplicated_assets_title": "Duplikált Elemek ({})", + "cache_settings_image_cache_size": "Kép gyorsítótár mérete ({} elem)", + "cache_settings_statistics_album": "Mappa bélyegképei", + "cache_settings_statistics_assets": "{} elem ({})", + "cache_settings_statistics_full": "Teljes méretű képek", + "cache_settings_statistics_shared": "Megosztott album bélyegképei", + "cache_settings_statistics_thumbnail": "Bélyegképek", + "cache_settings_statistics_title": "Gyorsítótár használata", + "cache_settings_subtitle": "Az Immich mobilalkalmazás gyorsítótár viselkedésének beállítása", + "cache_settings_thumbnail_size": "Bélyegkép gyorsítótár mérete ({} elem)", + "cache_settings_tile_subtitle": "Helyi tárhely viselkedésének beállítása", + "cache_settings_tile_title": "Helyi Tárhely", + "cache_settings_title": "Gyorsítótár Beállítások", "change_password_form_confirm_password": "Jelszó Megerősítése", - "change_password_form_description": "Kedves {name}!\n\nMost jelentkezel be először a rendszerbe vagy más okból szükséfes a jelszavad meváltoztatása. Kérjük, add meg új jelszavad.", + "change_password_form_description": "Kedves {name}!\n\nMost jelentkezel be először a rendszerbe vagy más okból szükséges a jelszavad meváltoztatása. Kérjük, add meg új jelszavad.", "change_password_form_new_password": "Új Jelszó", "change_password_form_password_mismatch": "A két beírt jelszó nem egyezik", "change_password_form_reenter_new_password": "Jelszó (még egyszer)", "common_add_to_album": "Albumhoz ad", "common_change_password": "Jelszócsere", "common_create_new_album": "Új album létrehozása", - "common_server_error": "Kérjük, ellenőrizd a hálózati kapcsolatot, gondoskodj róla, hogy a szerver elérhető legyen, valamint az app és a szerver kompatibilis verziójú legyen.", + "common_server_error": "Kérjük, ellenőrizd a hálózati kapcsolatot, gondoskodj róla, hogy a szerver elérhető legyen, valamint az alkalmazás és a szerver kompatibilis verziójú legyen.", "common_shared": "Megosztva", "control_bottom_app_bar_add_to_album": "Hozzáadás az albumhoz", "control_bottom_app_bar_album_info": "{} elem", @@ -142,23 +142,23 @@ "control_bottom_app_bar_archive": "Archivál", "control_bottom_app_bar_create_new_album": "Album létrehozása", "control_bottom_app_bar_delete": "Törlés", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", - "control_bottom_app_bar_edit_location": "Edit Location", - "control_bottom_app_bar_edit_time": "Edit Date & Time", + "control_bottom_app_bar_delete_from_immich": "Törlés az Immich-ből", + "control_bottom_app_bar_delete_from_local": "Törlés az eszközről", + "control_bottom_app_bar_edit_location": "Hely Módosítása", + "control_bottom_app_bar_edit_time": "Dátum és Idő Módosítása", "control_bottom_app_bar_favorite": "Kedvenc", "control_bottom_app_bar_share": "Megosztás", - "control_bottom_app_bar_share_to": "Share To", + "control_bottom_app_bar_share_to": "Megosztás Ide", "control_bottom_app_bar_stack": "Stack", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", - "control_bottom_app_bar_unarchive": "Archiválás megszüntetése", - "control_bottom_app_bar_unfavorite": "Unfavorite", - "control_bottom_app_bar_upload": "Upload", + "control_bottom_app_bar_trash_from_immich": "Lomtárba Helyez", + "control_bottom_app_bar_unarchive": "Nem Archivált", + "control_bottom_app_bar_unfavorite": "Nem Kedvenc", + "control_bottom_app_bar_upload": "Feltöltés", "create_album_page_untitled": "Névtelen", "create_shared_album_page_create": "Létrehoz", "create_shared_album_page_share": "Megosztás", "create_shared_album_page_share_add_assets": "ELEMEK HOZZÁADÁSA", - "create_shared_album_page_share_select_photos": "Fotók kiválasztása", + "create_shared_album_page_share_select_photos": "Fotók választása", "curated_location_page_title": "Helyek", "curated_object_page_title": "Dolgok", "daily_title_text_date": "E, MMM dd", @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "RÉSZLETEK", "exif_bottom_sheet_location": "HELYSZÍN", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Fejlesztés alatt", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Csak saját felelősségre használd", @@ -230,10 +231,10 @@ "login_disabled": "A bejelentkezés letiltva", "login_form_api_exception": "API hiba. Kérljük, ellenőrid a szerver címét, majd próbáld újra.", "login_form_back_button_text": "Back", - "login_form_button_text": "Belépés", - "login_form_email_hint": "teemailed@email.com", + "login_form_button_text": "Bejelentkezés", + "login_form_email_hint": "email@cimed.hu", "login_form_endpoint_hint": "http://szerver-címe:port/api", - "login_form_endpoint_url": "Kiszolgáló végpont címe", + "login_form_endpoint_url": "Szerver címe", "login_form_err_http": "Kérem, adjon meg egy http:// vagy https:// címet", "login_form_err_invalid_email": "Érvénytelen email cím", "login_form_err_invalid_url": "Érvénytelen cím", @@ -346,7 +347,7 @@ "search_suggestion_list_smart_search_hint_1": "Az intelligens keresés alapértelmezetten be van kapcsolva, metaadatokat így kereshetsz", "search_suggestion_list_smart_search_hint_2": "m:keresési-kifejezés", "select_additional_user_for_sharing_page_suggestions": "Javaslatok", - "select_user_for_sharing_page_err_album": "Hiba az album létrehozása közben", + "select_user_for_sharing_page_err_album": "Nem sikerült létrehozni az albumot", "select_user_for_sharing_page_share_suggestions": "Javaslatok", "server_info_box_app_version": "Alkalmazás Verzió", "server_info_box_latest_release": "Latest Version", @@ -373,7 +374,7 @@ "settings_require_restart": "Kérlek indítsd újra az Immich-et hogy alkalmazd ezt a beállítást", "share_add": "Hozzáadás", "share_add_photos": "Fotók hozzáadása", - "share_add_title": "Cím hozzáadása", + "share_add_title": "Album neve", "share_create_album": "Album létrehozása", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", @@ -431,11 +432,11 @@ "share_done": "Done", "share_invite": "Meghívás az albumba", "sharing_page_album": "Megosztott albumok", - "sharing_page_description": "Hozzon létre megosztott albumokat, hogy megoszthasson fényképeket és videókat a hálózatában lévő emberekkel.", + "sharing_page_description": "Megosztott albumok létrehozásával fényképeket és videókatoszthatsz meg a hálózatodban lévő emberekkel.", "sharing_page_empty_list": "ÜRES LISTA", "sharing_silver_appbar_create_shared_album": "Megosztott album létrehozása", "sharing_silver_appbar_shared_links": "Shared links", - "sharing_silver_appbar_share_partner": "Megosztás másokkal", + "sharing_silver_appbar_share_partner": "Megosztás partnerrel", "tab_controller_nav_library": "Könyvtár", "tab_controller_nav_photos": "Képek", "tab_controller_nav_search": "Keresés", diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index 4f38ba868e90e..0d109f6d1a130 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -1,13 +1,13 @@ { - "action_common_cancel": "Cancel", - "action_common_update": "Update", + "action_common_cancel": "Annulla", + "action_common_update": "Aggiorna", "add_to_album_bottom_sheet_added": "Aggiunto in {album}", "add_to_album_bottom_sheet_already_exists": "Già presente in {album}", - "advanced_settings_log_level_title": "Log level: {}", + "advanced_settings_log_level_title": "Livello log: {}", "advanced_settings_prefer_remote_subtitle": "Alcuni dispositivi sono molto lenti a caricare le anteprime delle immagini dal dispositivo. Attivare questa impostazione per caricare invece le immagini remote.", "advanced_settings_prefer_remote_title": "Preferisci immagini remote.", - "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", - "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", + "advanced_settings_self_signed_ssl_subtitle": "Salta la verifica dei certificati SSL del server. Richiesto con l'uso di certificati self-signed.", + "advanced_settings_self_signed_ssl_title": "Consenti certificati SSL self-signed", "advanced_settings_tile_subtitle": "Impostazioni aggiuntive utenti", "advanced_settings_tile_title": "Avanzato", "advanced_settings_troubleshooting_subtitle": "Attiva funzioni addizionali per la risoluzione dei problemi", @@ -35,7 +35,7 @@ "app_bar_signout_dialog_title": "Disconnetti", "archive_page_no_archived_assets": "Nessuna oggetto archiviato", "archive_page_title": "Archivia ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", + "asset_action_delete_err_read_only": "Non posso eliminare degli elementi in sola lettura, ignorato", "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", "asset_list_layout_settings_dynamic_layout_title": "Layout dinamico", "asset_list_layout_settings_group_automatically": "Automatico", @@ -111,9 +111,9 @@ "cache_settings_album_thumbnails": "Anteprime pagine librerie ({} assets)", "cache_settings_clear_cache_button": "Cancella cache", "cache_settings_clear_cache_button_title": "Cancella la cache dell'app. Questo impatterà significativamente le prestazioni dell''app fino a quando la cache non sarà rigenerata.", - "cache_settings_duplicated_assets_clear_button": "CLEAR", - "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", - "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", + "cache_settings_duplicated_assets_clear_button": "ELIMINA", + "cache_settings_duplicated_assets_subtitle": "Foto e video che sono nella black list dell'applicazione", + "cache_settings_duplicated_assets_title": "Elementi duplicati ({})", "cache_settings_image_cache_size": "Dimensione cache delle foto ({} assets)", "cache_settings_statistics_album": "Anteprime librerie", "cache_settings_statistics_assets": "{} contenuti ({})", @@ -142,15 +142,15 @@ "control_bottom_app_bar_archive": "Archivia", "control_bottom_app_bar_create_new_album": "Crea nuovo album", "control_bottom_app_bar_delete": "Elimina", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", - "control_bottom_app_bar_edit_location": "Edit Location", - "control_bottom_app_bar_edit_time": "Edit Date & Time", + "control_bottom_app_bar_delete_from_immich": "Elimina da Immich", + "control_bottom_app_bar_delete_from_local": "Elimina dal dispositivo", + "control_bottom_app_bar_edit_location": "Modifica posizione", + "control_bottom_app_bar_edit_time": "Modifica data e ora", "control_bottom_app_bar_favorite": "Preferiti", "control_bottom_app_bar_share": "Condividi", "control_bottom_app_bar_share_to": "Share To", "control_bottom_app_bar_stack": "Stack", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_trash_from_immich": "Sposta nel cestino", "control_bottom_app_bar_unarchive": "Rimuovi dagli archivi", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", @@ -165,27 +165,27 @@ "daily_title_text_date_year": "E, dd MMM, yyyy", "date_format": "E, d LLL, y • hh:mm", "delete_dialog_alert": "Questi oggetti saranno cancellati definitivamente da Immich e dal tuo device", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "Questi elementi verranno eliminati definitivamente dal dispositivo, ma saranno ancora disponibili sul server Immich", + "delete_dialog_alert_local_non_backed_up": "Alcuni degli elementi non sono stati caricati su Immich e saranno rimossi definitivamente dal tuo dispositivo", + "delete_dialog_alert_remote": "Questi elementi verranno eliminati permanentemente dal server Immich", "delete_dialog_cancel": "Annulla", "delete_dialog_ok": "Elimina", - "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_ok_force": "Elimina comunque", "delete_dialog_title": "Cancella definitivamente", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", + "delete_local_dialog_ok_backed_up_only": "Elimina solo quelli con backup", + "delete_local_dialog_ok_force": "Elimina comunque", "delete_shared_link_dialog_content": "Sei sicuro di voler eliminare questo link condiviso?", "delete_shared_link_dialog_title": "Elimina link condiviso", "description_input_hint_text": "Aggiungi descrizione...", "description_input_submit_error": "Errore modificare descrizione, controlli I log per maggiori dettagli", - "edit_date_time_dialog_date_time": "Date and Time", - "edit_date_time_dialog_timezone": "Timezone", - "edit_location_dialog_title": "Location", + "edit_date_time_dialog_date_time": "Data e ora", + "edit_date_time_dialog_timezone": "Fuso orario", + "edit_location_dialog_title": "Posizione", "exif_bottom_sheet_description": "Aggiungi una descrizione...", "exif_bottom_sheet_details": "DETTAGLI", "exif_bottom_sheet_location": "POSIZIONE", - "exif_bottom_sheet_location_add": "Add a location", - "exif_bottom_sheet_people": "PERSONE", + "exif_bottom_sheet_location_add": "Aggiungi una posizione", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Attiva griglia di foto sperimentale", "experimental_settings_subtitle": "Usalo a tuo rischio!", @@ -200,7 +200,7 @@ "home_page_archive_err_partner": "Can not archive partner assets, skipping", "home_page_building_timeline": "Costruendo il Timeline", "home_page_delete_err_partner": "Can not delete partner assets, skipping", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_delete_remote_err_local": "Immagini sul disco locale presenti pure nella selezione degli elementi remoti, skippando", "home_page_favorite_err_local": "Non puoi aggiungere tra i preferiti le foto ancora non caricate", "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", "home_page_first_time_notice": "Se è la prima volta che usi l'app, assicurati di scegliere gli album per avere il Timeline con immagini e video", @@ -215,22 +215,22 @@ "library_page_favorites": "Preferiti", "library_page_new_album": "Nuovo Album", "library_page_sharing": "Condividendo", - "library_page_sort_asset_count": "Number of assets", + "library_page_sort_asset_count": "Numero di elementi", "library_page_sort_created": "Creato il più recente", "library_page_sort_last_modified": "Ultima modifica", - "library_page_sort_most_oldest_photo": "Oldest photo", + "library_page_sort_most_oldest_photo": "Foto più vecchia", "library_page_sort_most_recent_photo": "Più recente", "library_page_sort_title": "Titolo album", - "location_picker_choose_on_map": "Choose on map", - "location_picker_latitude": "Latitude", - "location_picker_latitude_error": "Enter a valid latitude", - "location_picker_latitude_hint": "Enter your latitude here", - "location_picker_longitude": "Longitude", - "location_picker_longitude_error": "Enter a valid longitude", - "location_picker_longitude_hint": "Enter your longitude here", + "location_picker_choose_on_map": "Scegli una mappa", + "location_picker_latitude": "Latitudine", + "location_picker_latitude_error": "Inserisci una latitudine valida", + "location_picker_latitude_hint": "Inserisci la tua latitudine qui", + "location_picker_longitude": "Longitudine", + "location_picker_longitude_error": "Inserisci una longitudine valida", + "location_picker_longitude_hint": "Inserisci la longitudine qui", "login_disabled": "L'accesso è stato disattivato", "login_form_api_exception": "API error, per favore ricontrolli URL del server e riprovi", - "login_form_back_button_text": "Back", + "login_form_back_button_text": "Indietro", "login_form_button_text": "Login", "login_form_email_hint": "tuaemail@email.com", "login_form_endpoint_hint": "http://ip-del-tuo-server:port/api", @@ -253,35 +253,35 @@ "login_form_server_error": "Non è possibile connettersi al server", "login_password_changed_error": "C'è stato un errore durante l'aggiornamento della password", "login_password_changed_success": "Password aggiornata con successo", - "map_assets_in_bound": "{} photo", - "map_assets_in_bounds": "{} photos", + "map_assets_in_bound": "{} foto", + "map_assets_in_bounds": "{} foto", "map_cannot_get_user_location": "Cannot get user's location", "map_location_dialog_cancel": "Annulla", "map_location_dialog_yes": "Si", - "map_location_picker_page_use_location": "Use this location", - "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", + "map_location_picker_page_use_location": "Usa questa posizione", + "map_location_service_disabled_content": "I servizi di geolocalizzazione devono essere attivati per visualizzare gli elementi per la tua posizione attuale. Vuoi attivarli adesso?", "map_location_service_disabled_title": "Location Service disabled", "map_no_assets_in_bounds": "Nessuna foto in questa zona", - "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", + "map_no_location_permission_content": "L'accesso alla posizione è necessario per visualizzare gli elementi per la tua posizione attuale. Vuoi consentirlo adesso?", "map_no_location_permission_title": "Location Permission denied", "map_settings_dark_mode": "Modalità scura", "map_settings_date_range_option_all": "All", - "map_settings_date_range_option_day": "Past 24 hours", - "map_settings_date_range_option_days": "Past {} days", - "map_settings_date_range_option_year": "Past year", - "map_settings_date_range_option_years": "Past {} years", + "map_settings_date_range_option_day": "Ultime 24 ore", + "map_settings_date_range_option_days": "Ultimi {} giorni", + "map_settings_date_range_option_year": "Ultimo anno", + "map_settings_date_range_option_years": "Ultimi {} anni", "map_settings_dialog_cancel": "Cancel", "map_settings_dialog_save": "Salva", "map_settings_dialog_title": "Map Settings", "map_settings_include_show_archived": "Include Archived", "map_settings_only_relative_range": "Date range", "map_settings_only_show_favorites": "Show Favorite Only", - "map_settings_theme_settings": "Map Theme", + "map_settings_theme_settings": "Tema della mappa", "map_zoom_to_see_photos": "Zoom out to see photos", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Motion Foto", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "Non posso modificare la data degli elementi in sola lettura, ignorato", + "multiselect_grid_edit_gps_err_read_only": "Non posso modificare la posizione degli elementi in sola lettura, ignorato", "notification_permission_dialog_cancel": "Annulla", "notification_permission_dialog_content": "Per attivare le notifiche, vai alle Impostazioni e seleziona concedi", "notification_permission_dialog_settings": "Impostazioni", @@ -308,18 +308,18 @@ "permission_onboarding_permission_limited": "Permessi limitati. Perché Immich possa controllare e fare i backup di tutte le foto, concedere i permessi all'intera galleria dalle impostazioni ", "permission_onboarding_request": "Immich richiede i permessi per vedere le tue foto e video", "profile_drawer_app_logs": "Logs", - "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", - "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", + "profile_drawer_client_out_of_date_major": "L'applicazione non è aggiornata. Per favore aggiorna all'ultima versione principale.", + "profile_drawer_client_out_of_date_minor": "L'applicazione non è aggiornata. Per favore aggiorna all'ultima versione minore.", "profile_drawer_client_server_up_to_date": "Client e server sono aggiornati", "profile_drawer_documentation": "Documentazione", "profile_drawer_github": "GitHub", - "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", - "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_server_out_of_date_major": "Il server non è aggiornato. Per favore aggiorna all'ultima versione principale.", + "profile_drawer_server_out_of_date_minor": "Il server non è aggiornato. Per favore aggiorna all'ultima versione minore.", "profile_drawer_settings": "Impostazioni ", "profile_drawer_sign_out": "Logout", "profile_drawer_trash": "Trash", "recently_added_page_title": "Aggiunti di recente", - "scaffold_body_error_occurred": "Error occurred", + "scaffold_body_error_occurred": "Si è verificato un errore.", "search_bar_hint": "Cerca le tue foto", "search_page_categories": "Categoria", "search_page_favorites": "Preferiti", @@ -327,13 +327,13 @@ "search_page_no_objects": "Nessuna informazione relativa all'oggetto disponibile", "search_page_no_places": "Nessun informazione sul luogo disponibile", "search_page_people": "Persone", - "search_page_person_add_name_dialog_cancel": "Cancel", - "search_page_person_add_name_dialog_hint": "Name", - "search_page_person_add_name_dialog_save": "Save", - "search_page_person_add_name_dialog_title": "Add a name", + "search_page_person_add_name_dialog_cancel": "Annulla", + "search_page_person_add_name_dialog_hint": "Nome", + "search_page_person_add_name_dialog_save": "Salva", + "search_page_person_add_name_dialog_title": "Aggiungi un nome", "search_page_person_add_name_subtitle": "Find them fast by name with search", - "search_page_person_add_name_title": "Add a name", - "search_page_person_edit_name": "Edit name", + "search_page_person_add_name_title": "Aggiungi un nome", + "search_page_person_edit_name": "Modifica nome", "search_page_places": "Luoghi", "search_page_recently_added": "Aggiunte di recente", "search_page_screenshots": "Screenshot", @@ -342,7 +342,7 @@ "search_page_videos": "Video", "search_page_view_all_button": "Guarda tutto", "search_page_your_activity": "Tua attività ", - "search_page_your_map": "Your Map", + "search_page_your_map": "La tua mappa", "search_result_page_new_search_hint": "Nuova ricerca ", "search_suggestion_list_smart_search_hint_1": "\nRicerca Smart è attiva di default, per usare la ricerca con i metadata usare la seguente sintassi", "search_suggestion_list_smart_search_hint_2": "m:your-search-term", @@ -382,17 +382,17 @@ "shared_album_activity_remove_title": "Elimina attività", "shared_album_activity_setting_subtitle": "Let others respond", "shared_album_activity_setting_title": "Commenti e Mi piace", - "shared_album_section_people_action_error": "Error leaving/removing from album", - "shared_album_section_people_action_leave": "Remove user from album", - "shared_album_section_people_action_remove_user": "Remove user from album", - "shared_album_section_people_owner_label": "Owner", - "shared_album_section_people_title": "PEOPLE", + "shared_album_section_people_action_error": "Errore durante la rimozione/uscita dell'album", + "shared_album_section_people_action_leave": "Rimuovi utente dall'album", + "shared_album_section_people_action_remove_user": "Rimuovi utente dall'album", + "shared_album_section_people_owner_label": "Proprietario", + "shared_album_section_people_title": "PERSONE", "share_dialog_preparing": "Preparo…", "shared_link_app_bar_title": "Link condivisi", - "shared_link_clipboard_copied_massage": "Copied to clipboard", + "shared_link_clipboard_copied_massage": "Copiato negli appunti", "shared_link_clipboard_text": "Link: {}\nPassword: {}", "shared_link_create_app_bar_title": "Crea link di condivisione", - "shared_link_create_error": "Error while creating shared link", + "shared_link_create_error": "Si è verificato un errore durante la creazione del link condiviso", "shared_link_create_info": "Consenti a chiunque abbia il link di vedere le foto selezionate", "shared_link_create_submit_button": "Crea link di condivisione", "shared_link_edit_allow_download": "Consenti ad utenti pubblici di scaricare i contenuti", @@ -402,32 +402,32 @@ "shared_link_edit_description": "Descrizione", "shared_link_edit_description_hint": "Inserisci la descrizione della condivisione", "shared_link_edit_expire_after": "Scade dopo", - "shared_link_edit_expire_after_option_day": "1 day", - "shared_link_edit_expire_after_option_days": "{} days", - "shared_link_edit_expire_after_option_hour": "1 hour", - "shared_link_edit_expire_after_option_hours": "{} hours", - "shared_link_edit_expire_after_option_minute": "1 minute", - "shared_link_edit_expire_after_option_minutes": "{} minutes", - "shared_link_edit_expire_after_option_never": "Never", + "shared_link_edit_expire_after_option_day": "1 giorno", + "shared_link_edit_expire_after_option_days": "{} giorni", + "shared_link_edit_expire_after_option_hour": "1 ora", + "shared_link_edit_expire_after_option_hours": "{} ore", + "shared_link_edit_expire_after_option_minute": "1 minuto", + "shared_link_edit_expire_after_option_minutes": "{} minuti", + "shared_link_edit_expire_after_option_never": "Mai", "shared_link_edit_password": "Password", "shared_link_edit_password_hint": "Inserire la password di condivisione", "shared_link_edit_show_meta": "Visualizza metadati", "shared_link_edit_submit_button": "Aggiorna link", "shared_link_empty": "Non hai alcun link condiviso", "shared_link_error_server_url_fetch": "Cannot fetch the server url", - "shared_link_expired": "Expired", - "shared_link_expires_day": "Expires in {} day", - "shared_link_expires_days": "Expires in {} days", - "shared_link_expires_hour": "Expires in {} hour", - "shared_link_expires_hours": "Expires in {} hours", - "shared_link_expires_minute": "Expires in {} minute", - "shared_link_expires_minutes": "Expires in {} minutes", - "shared_link_expires_never": "Expires ∞", - "shared_link_expires_second": "Expires in {} second", - "shared_link_expires_seconds": "Expires in {} seconds", - "shared_link_info_chip_download": "Download", + "shared_link_expired": "Scaduto", + "shared_link_expires_day": "Scade tra {} giorno", + "shared_link_expires_days": "Scade tra {} giorni", + "shared_link_expires_hour": "Scade tra {} ora", + "shared_link_expires_hours": "Scade tra {} ore", + "shared_link_expires_minute": "Scade tra {} minuto", + "shared_link_expires_minutes": "Scade tra {} minuti", + "shared_link_expires_never": "Scadenza ∞", + "shared_link_expires_second": "Scade tra {} secondo", + "shared_link_expires_seconds": "Scade tra {} secondi", + "shared_link_info_chip_download": "Scarica", "shared_link_info_chip_metadata": "EXIF", - "shared_link_info_chip_upload": "Upload", + "shared_link_info_chip_upload": "Carica", "shared_link_manage_links": "Gestisci link condivisi", "share_done": "Done", "share_invite": "Invita nell'album ", @@ -455,7 +455,7 @@ "trash_page_delete": "Elimina", "trash_page_delete_all": "Elimina tutti", "trash_page_empty_trash_btn": "Svuota cestino", - "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", + "trash_page_empty_trash_dialog_content": "Vuoi eliminare gli elementi nel cestino? Questi elementi saranno eliminati definitivamente da Immich", "trash_page_empty_trash_dialog_ok": "Ok", "trash_page_info": "Gli elementi cestinati saranno eliminati definitivamente dopo {} giorni", "trash_page_no_assets": "Nessun elemento cestinato", @@ -477,4 +477,4 @@ "viewer_remove_from_stack": "Rimuovi dalla pila", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} +} \ No newline at end of file diff --git a/mobile/assets/i18n/ja-JP.json b/mobile/assets/i18n/ja-JP.json index dee773843c071..b4b686c74db32 100644 --- a/mobile/assets/i18n/ja-JP.json +++ b/mobile/assets/i18n/ja-JP.json @@ -1,13 +1,13 @@ { - "action_common_cancel": "Cancel", - "action_common_update": "Update", + "action_common_cancel": "キャンセル", + "action_common_update": "更新", "add_to_album_bottom_sheet_added": "{album}に追加", "add_to_album_bottom_sheet_already_exists": "{album}に追加済み", - "advanced_settings_log_level_title": "Log level: {}", - "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", - "advanced_settings_prefer_remote_title": "Prefer remote images", - "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", - "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", + "advanced_settings_log_level_title": "ログレベル: {}", + "advanced_settings_prefer_remote_subtitle": "端末によっては端末上に存在するサムネイルのロードに非常に時間がかかります。このオプションをに有効にする事によってサーバーから直接画像をロードすることが可能です", + "advanced_settings_prefer_remote_title": "リモートを優先する", + "advanced_settings_self_signed_ssl_subtitle": "SSLのチェックをスキップする。Self-signedな署名で必要です", + "advanced_settings_self_signed_ssl_title": "Self-signed署名を許可する", "advanced_settings_tile_subtitle": "追加ユーザー設定", "advanced_settings_tile_title": "詳細設定", "advanced_settings_troubleshooting_subtitle": "トラブルシューティング用の詳細設定をオンにする", @@ -26,17 +26,17 @@ "album_viewer_appbar_share_err_title": "タイトル変更の失敗", "album_viewer_appbar_share_leave": "アルバムから脱退", "album_viewer_appbar_share_remove": "アルバムから削除", - "album_viewer_appbar_share_to": "Share To", + "album_viewer_appbar_share_to": "次の方々と共有します", "album_viewer_page_share_add_users": "ユーザーを追加", - "all_people_page_title": "People", + "all_people_page_title": "ピープル", "all_videos_page_title": "ビデオ", "app_bar_signout_dialog_content": " サインアウトしますか?", "app_bar_signout_dialog_ok": "はい", "app_bar_signout_dialog_title": " サインアウト", "archive_page_no_archived_assets": "アーカイブ済みの写真またはビデオがありません", "archive_page_title": "アーカイブ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_action_delete_err_read_only": "読み取り専用の項目は削除できません。スキップします", + "asset_action_share_err_offline": "オフラインの項目をゲットできません。スキップします", "asset_list_layout_settings_dynamic_layout_title": "ダイナミックレイアウト", "asset_list_layout_settings_group_automatically": "自動", "asset_list_layout_settings_group_by": "写真のグループ分け", @@ -103,17 +103,17 @@ "backup_controller_page_uploading_file_info": "アップロード中のファイル", "backup_err_only_album": "最低1つのアルバムを選択してください", "backup_info_card_assets": "写真と動画", - "backup_manual_cancelled": "Cancelled", - "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", - "backup_manual_success": "Success", - "backup_manual_title": "Upload status", + "backup_manual_cancelled": "キャンセルされました", + "backup_manual_failed": "失敗", + "backup_manual_in_progress": "アップロードが進行中です。後でもう一度試してください", + "backup_manual_success": "成功", + "backup_manual_title": "アップロード状況", "cache_settings_album_thumbnails": "ライブラリのサムネイル ({}枚)", "cache_settings_clear_cache_button": "キャッシュをクリア", "cache_settings_clear_cache_button_title": "キャッシュを削除(キャッシュ再生成までアプリのパフォーマンスが著しく低下)", - "cache_settings_duplicated_assets_clear_button": "CLEAR", - "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", - "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", + "cache_settings_duplicated_assets_clear_button": "クリア", + "cache_settings_duplicated_assets_subtitle": "アプリがブラックリストに追加している項目", + "cache_settings_duplicated_assets_title": "{}項目が重複", "cache_settings_image_cache_size": "キャッシュのサイズ ({}枚) ", "cache_settings_statistics_album": "ライブラリのサムネイル", "cache_settings_statistics_assets": "{} 枚 ({}枚中)", @@ -123,8 +123,8 @@ "cache_settings_statistics_title": "キャッシュ", "cache_settings_subtitle": "キャッシュの動作を変更する", "cache_settings_thumbnail_size": "サムネイルのキャッシュのサイズ ({}枚)", - "cache_settings_tile_subtitle": "Control the local storage behaviour", - "cache_settings_tile_title": "Local Storage", + "cache_settings_tile_subtitle": "ローカルストレージの挙動を確認する", + "cache_settings_tile_title": "ローカルストレージ", "cache_settings_title": "キャッシュの設定", "change_password_form_confirm_password": "確定", "change_password_form_description": "{name}さん こんにちは\n\nサーバーにアクセスするのが初めてか、パスワードリセットのリクエストがされました。新しいパスワードを入力してください", @@ -142,18 +142,18 @@ "control_bottom_app_bar_archive": "アーカイブ", "control_bottom_app_bar_create_new_album": "アルバムを作成", "control_bottom_app_bar_delete": "削除", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", - "control_bottom_app_bar_edit_location": "Edit Location", - "control_bottom_app_bar_edit_time": "Edit Date & Time", + "control_bottom_app_bar_delete_from_immich": "Immichから削除", + "control_bottom_app_bar_delete_from_local": "端末から削除", + "control_bottom_app_bar_edit_location": "位置情報を編集", + "control_bottom_app_bar_edit_time": "日時を変更", "control_bottom_app_bar_favorite": "お気に入り", "control_bottom_app_bar_share": "共有", - "control_bottom_app_bar_share_to": "Share To", - "control_bottom_app_bar_stack": "Stack", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_share_to": "次のユーザーに共有: ", + "control_bottom_app_bar_stack": "スタック", + "control_bottom_app_bar_trash_from_immich": "ゴミ箱に捨てる", "control_bottom_app_bar_unarchive": "アーカイブを解除", - "control_bottom_app_bar_unfavorite": "Unfavorite", - "control_bottom_app_bar_upload": "Upload", + "control_bottom_app_bar_unfavorite": "お気に入りから外す", + "control_bottom_app_bar_upload": "アップロード", "create_album_page_untitled": "タイトルなし", "create_shared_album_page_create": "作成", "create_shared_album_page_share": "共有", @@ -165,26 +165,27 @@ "daily_title_text_date_year": "yyyy年 MM月 DD日, EE", "date_format": "MM月 DD日, EE • hh時mm分", "delete_dialog_alert": "サーバーとデバイスの両方から永久的に削除されます!", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "選択された項目は端末から削除されますがImmichには残ります", + "delete_dialog_alert_local_non_backed_up": "Immichにバックアップされていない項目があります。それらの項目はデバイスからも永久に削除されます", + "delete_dialog_alert_remote": "選択された項目はImmichから永久に削除されます", "delete_dialog_cancel": "キャンセル", "delete_dialog_ok": "削除", - "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_ok_force": "削除します", "delete_dialog_title": "永久的に削除", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", + "delete_local_dialog_ok_backed_up_only": "バックアップ済みのみを削除", + "delete_local_dialog_ok_force": "削除します", "delete_shared_link_dialog_content": "本当にこの共有リンクを消しますか?", "delete_shared_link_dialog_title": "共有リンクを消す", "description_input_hint_text": "説明を追加", "description_input_submit_error": "説明の編集に失敗、詳細の確認はログで行ってください", - "edit_date_time_dialog_date_time": "Date and Time", - "edit_date_time_dialog_timezone": "Timezone", - "edit_location_dialog_title": "Location", + "edit_date_time_dialog_date_time": "日付と時間", + "edit_date_time_dialog_timezone": "タイムゾーン", + "edit_location_dialog_title": "位置情報", "exif_bottom_sheet_description": "説明を追加", "exif_bottom_sheet_details": "詳細", "exif_bottom_sheet_location": "撮影場所", - "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_location_add": "位置情報を追加", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "製作途中(WIP)", "experimental_settings_new_asset_list_title": "試験的なグリッドを有効化", "experimental_settings_subtitle": "試験的機能につき自己責任で!", @@ -194,42 +195,42 @@ "home_page_add_to_album_conflicts": "{album}に{added}枚写真を追加しました。追加済みの{failed}枚はスキップしました。", "home_page_add_to_album_err_local": "まだアップロードされてない項目はアルバムに登録できません", "home_page_add_to_album_success": "{album}に{added}枚写真を追加しました", - "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping", + "home_page_album_err_partner": "まだパートナーの写真はアルバムに追加できません。スキップします(アップデート待ってね)", "home_page_archive_err_local": "まだアップロードされてない項目はアーカイブできません", - "home_page_archive_err_partner": "Can not archive partner assets, skipping", + "home_page_archive_err_partner": "パートナーの写真はアーカイブできません。スキップします", "home_page_building_timeline": "タイムライン構築中", - "home_page_delete_err_partner": "Can not delete partner assets, skipping", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_delete_err_partner": "パートナーの写真は削除できません。スキップします", + "home_page_delete_remote_err_local": "リモート削除の選択にローカルなアイテムが含まれています。スキップします", "home_page_favorite_err_local": "まだアップロードされてない項目はお気に入り登録できません", - "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", + "home_page_favorite_err_partner": "まだパートナーの写真をお気に入り登録できません。スキップします(アップデート待ってね)", "home_page_first_time_notice": "はじめてアプリを使う場合、タイムラインに写真を表示するためにアルバムを選択してください", - "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", + "home_page_share_err_local": "ローカルのみの項目をリンクで共有はできません。スキップします", + "home_page_upload_err_limit": "一回でアップロードできる写真の数は30枚です。スキップします", "image_viewer_page_state_provider_download_error": "ダウンロード失敗", "image_viewer_page_state_provider_download_success": "ダウンロード成功", - "image_viewer_page_state_provider_share_error": "Share Error", + "image_viewer_page_state_provider_share_error": "共有エラー", "library_page_albums": "アルバム", "library_page_archive": "アーカイブ", "library_page_device_albums": "デバイス上のアルバム", "library_page_favorites": "お気に入り", "library_page_new_album": "新しいアルバム", "library_page_sharing": "共有中", - "library_page_sort_asset_count": "Number of assets", + "library_page_sort_asset_count": "項目の数", "library_page_sort_created": "作成日時", - "library_page_sort_last_modified": "Last modified", - "library_page_sort_most_oldest_photo": "Oldest photo", - "library_page_sort_most_recent_photo": "Most recent photo", + "library_page_sort_last_modified": "最終変更", + "library_page_sort_most_oldest_photo": "一番古い項目", + "library_page_sort_most_recent_photo": "最近の項目", "library_page_sort_title": "アルバム名", - "location_picker_choose_on_map": "Choose on map", - "location_picker_latitude": "Latitude", - "location_picker_latitude_error": "Enter a valid latitude", - "location_picker_latitude_hint": "Enter your latitude here", - "location_picker_longitude": "Longitude", - "location_picker_longitude_error": "Enter a valid longitude", - "location_picker_longitude_hint": "Enter your longitude here", - "login_disabled": "Login has been disabled", + "location_picker_choose_on_map": "マップを選択", + "location_picker_latitude": "緯度", + "location_picker_latitude_error": "有効な緯度を入力してください", + "location_picker_latitude_hint": "緯度をここに入力", + "location_picker_longitude": "経度", + "location_picker_longitude_error": "有効な経度を入力してください", + "location_picker_longitude_hint": "経度をここに入力", + "login_disabled": "ログインは無効化されました", "login_form_api_exception": "APIエラー。URLをチェックしてもう一度試してください", - "login_form_back_button_text": "Back", + "login_form_back_button_text": "戻る", "login_form_button_text": "ログイン", "login_form_email_hint": "hoge@email.com", "login_form_endpoint_hint": "https://example.com:port/api", @@ -242,7 +243,7 @@ "login_form_failed_get_oauth_server_config": "OAuthログインに失敗しました。サーバーのURLを確認してください。", "login_form_failed_get_oauth_server_disable": "このサーバーではOAuthが使えません", "login_form_failed_login": "ログインエラー。サーバーのURL・メールアドレス・パスワードを再確認してください。", - "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", + "login_form_handshake_exception": "Handshake Exceptionエラー。self-signed署名を設定で有効にしてください", "login_form_label_email": "メールアドレス", "login_form_label_password": "パスワード", "login_form_next_button": "次", @@ -250,53 +251,53 @@ "login_form_save_login": "ログインを保持", "login_form_server_empty": "URLを入力", "login_form_server_error": "サーバーに接続できません", - "login_password_changed_error": "There was an error updating your password", - "login_password_changed_success": "Password updated successfully", - "map_assets_in_bound": "{} photo", - "map_assets_in_bounds": "{} photos", - "map_cannot_get_user_location": "Cannot get user's location", - "map_location_dialog_cancel": "Cancel", - "map_location_dialog_yes": "Yes", - "map_location_picker_page_use_location": "Use this location", - "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", - "map_location_service_disabled_title": "Location Service disabled", - "map_no_assets_in_bounds": "No photos in this area", - "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", - "map_no_location_permission_title": "Location Permission denied", - "map_settings_dark_mode": "Dark mode", - "map_settings_date_range_option_all": "All", - "map_settings_date_range_option_day": "Past 24 hours", - "map_settings_date_range_option_days": "Past {} days", - "map_settings_date_range_option_year": "Past year", - "map_settings_date_range_option_years": "Past {} years", - "map_settings_dialog_cancel": "Cancel", - "map_settings_dialog_save": "Save", - "map_settings_dialog_title": "Map Settings", - "map_settings_include_show_archived": "Include Archived", + "login_password_changed_error": "パスワードの変更でエラーが発生しました", + "login_password_changed_success": "パスワードの変更に成功", + "map_assets_in_bound": "{}項目", + "map_assets_in_bounds": "{}項目", + "map_cannot_get_user_location": "位置情報がゲットできません", + "map_location_dialog_cancel": "キャンセル", + "map_location_dialog_yes": "はい", + "map_location_picker_page_use_location": "この位置情報を使う", + "map_location_service_disabled_content": "現在地の項目を表示するには位置情報がオンである必要があります。有効化しますか?", + "map_location_service_disabled_title": "位置情報がオフです", + "map_no_assets_in_bounds": "このエリアに写真はありません", + "map_no_location_permission_content": "現在地の項目を表示するには位置情報へのアクセスが必要です。許可しますか?", + "map_no_location_permission_title": "位置情報へのアクセスが拒否されました", + "map_settings_dark_mode": "ダークモード", + "map_settings_date_range_option_all": "全て", + "map_settings_date_range_option_day": "過去24時間", + "map_settings_date_range_option_days": "過去{}日間", + "map_settings_date_range_option_year": "過去1年", + "map_settings_date_range_option_years": "過去{}年間", + "map_settings_dialog_cancel": "キャンセル", + "map_settings_dialog_save": "セーブ", + "map_settings_dialog_title": "マップの設定", + "map_settings_include_show_archived": "アーカイブ済みを含める", "map_settings_only_relative_range": "Date range", - "map_settings_only_show_favorites": "Show Favorite Only", - "map_settings_theme_settings": "Map Theme", - "map_zoom_to_see_photos": "Zoom out to see photos", + "map_settings_only_show_favorites": "お気に入りのみを表示", + "map_settings_theme_settings": "マップの見た目", + "map_zoom_to_see_photos": "写真を見るにはズームアウト", "monthly_title_text_date_format": "yyyy年 MM月", "motion_photos_page_title": "モーションフォト", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "読み取り専用の項目の日付を変更できません", + "multiselect_grid_edit_gps_err_read_only": "読み取り専用の項目の位置情報を変更できません", "notification_permission_dialog_cancel": "キャンセル", "notification_permission_dialog_content": "通知を許可するには設定を開いてオンにしてください", "notification_permission_dialog_settings": "設定", "notification_permission_list_tile_content": "通知の許可 をオンにしてください", "notification_permission_list_tile_enable_button": "通知をオンにする", "notification_permission_list_tile_title": "通知の許可", - "partner_page_add_partner": "Add partner", - "partner_page_empty_message": "Your photos are not yet shared with any partner.", - "partner_page_no_more_users": "No more users to add", - "partner_page_partner_add_failed": "Failed to add partner", - "partner_page_select_partner": "Select partner", - "partner_page_shared_to_title": "Shared to", - "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", - "partner_page_stop_sharing_title": "Stop sharing your photos?", - "partner_page_title": "Partner", - "permission_onboarding_back": "Back", + "partner_page_add_partner": "パートナーを追加", + "partner_page_empty_message": "まだどのパートナーとも写真を共有してません", + "partner_page_no_more_users": "追加できるユーザーがもういません", + "partner_page_partner_add_failed": "パートナーの追加に失敗", + "partner_page_select_partner": "パートナーを選択", + "partner_page_shared_to_title": "次のユーザーと共有しす: ", + "partner_page_stop_sharing_content": "{}は写真へのアクセスができなくなります", + "partner_page_stop_sharing_title": "写真の共有を無効化しますか?", + "partner_page_title": "パートナー", + "permission_onboarding_back": "戻る", "permission_onboarding_continue_anyway": "無視して続行", "permission_onboarding_get_started": "はじめる", "permission_onboarding_go_to_settings": "システム設定", @@ -307,32 +308,32 @@ "permission_onboarding_permission_limited": "写真へのアクセスが制限されています。Immichに写真のバックアップと管理を行わせるにはシステム設定から写真と動画のアクセス権限を変更してください。", "permission_onboarding_request": "Immichは写真へのアクセス許可が必要です", "profile_drawer_app_logs": "ログ", - "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", - "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", + "profile_drawer_client_out_of_date_major": "アプリが更新されてません。最新のバージョンに更新してください", + "profile_drawer_client_out_of_date_minor": "アプリが更新されてません。最新のマイナーバージョンに更新してください", "profile_drawer_client_server_up_to_date": "すべて最新です", - "profile_drawer_documentation": "Documentation", + "profile_drawer_documentation": "Immcihの説明書", "profile_drawer_github": "GitHub", - "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", - "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_server_out_of_date_major": "サーバーが更新されてません。最新のバージョンに更新してください", + "profile_drawer_server_out_of_date_minor": "サーバーが更新されてません。最新のマイナーバージョンに更新してください", "profile_drawer_settings": "設定", "profile_drawer_sign_out": "サインアウト", - "profile_drawer_trash": "Trash", + "profile_drawer_trash": "ゴミ箱", "recently_added_page_title": "最近", - "scaffold_body_error_occurred": "Error occurred", + "scaffold_body_error_occurred": "エラーが発生しました", "search_bar_hint": "写真を検索", "search_page_categories": "カテゴリ", "search_page_favorites": "お気に入り", "search_page_motion_photos": "モーションフォト", "search_page_no_objects": "被写体に関するデータがなし", "search_page_no_places": "場所に関するデータなし", - "search_page_people": "People", - "search_page_person_add_name_dialog_cancel": "Cancel", - "search_page_person_add_name_dialog_hint": "Name", - "search_page_person_add_name_dialog_save": "Save", - "search_page_person_add_name_dialog_title": "Add a name", - "search_page_person_add_name_subtitle": "Find them fast by name with search", - "search_page_person_add_name_title": "Add a name", - "search_page_person_edit_name": "Edit name", + "search_page_people": "ピープル", + "search_page_person_add_name_dialog_cancel": "キャンセル", + "search_page_person_add_name_dialog_hint": "名前", + "search_page_person_add_name_dialog_save": "セーブ", + "search_page_person_add_name_dialog_title": "名前を追加", + "search_page_person_add_name_subtitle": "名前で検索して高速に探す", + "search_page_person_add_name_title": "名前を追加", + "search_page_person_edit_name": "名前を変更", "search_page_places": "撮影地", "search_page_recently_added": "最近追加", "search_page_screenshots": "スクリーンショット", @@ -341,7 +342,7 @@ "search_page_videos": "ビデオ", "search_page_view_all_button": "すべて表示", "search_page_your_activity": "アクティビティ", - "search_page_your_map": "Your Map", + "search_page_your_map": "あなたのマップ", "search_result_page_new_search_hint": "検索", "search_suggestion_list_smart_search_hint_1": "スマート検索はデフォルトでオンになっています。メタデータで検索を行う場合:", "search_suggestion_list_smart_search_hint_2": "m:単語", @@ -349,7 +350,7 @@ "select_user_for_sharing_page_err_album": "アルバム作成に失敗", "select_user_for_sharing_page_share_suggestions": "ユーザ一覧", "server_info_box_app_version": "アプリVer.", - "server_info_box_latest_release": "Latest Version", + "server_info_box_latest_release": "最新バージョン", "server_info_box_server_url": " サーバのURL", "server_info_box_server_version": "サーバーVer.", "setting_image_viewer_help": "写真をタップするとサムネイル・中画質(要設定)・オリジナル(要設定)の順に読み込みます", @@ -375,66 +376,66 @@ "share_add_photos": "写真を追加", "share_add_title": "タイトルを追加", "share_create_album": "アルバムを作成", - "shared_album_activities_input_disable": "Comment is disabled", - "shared_album_activities_input_hint": "Say something", - "shared_album_activity_remove_content": "Do you want to delete this activity?", - "shared_album_activity_remove_title": "Delete Activity", - "shared_album_activity_setting_subtitle": "Let others respond", - "shared_album_activity_setting_title": "Comments & likes", - "shared_album_section_people_action_error": "Error leaving/removing from album", - "shared_album_section_people_action_leave": "Remove user from album", - "shared_album_section_people_action_remove_user": "Remove user from album", - "shared_album_section_people_owner_label": "Owner", - "shared_album_section_people_title": "PEOPLE", + "shared_album_activities_input_disable": "コメントはオフになってます", + "shared_album_activities_input_hint": "何か書き込みましょう", + "shared_album_activity_remove_content": "このアクティビティを削除しますか", + "shared_album_activity_remove_title": "アクティビティを削除します", + "shared_album_activity_setting_subtitle": "他のユーザーの返信を許可する", + "shared_album_activity_setting_title": "お気に入りとコメント", + "shared_album_section_people_action_error": "アルバムからの退出に失敗", + "shared_album_section_people_action_leave": "ユーザーをアルバムから退出", + "shared_album_section_people_action_remove_user": "ユーザーをアルバムから退出", + "shared_album_section_people_owner_label": "オーナー", + "shared_album_section_people_title": "ピープル", "share_dialog_preparing": "準備中", "shared_link_app_bar_title": "共有リンク", - "shared_link_clipboard_copied_massage": "Copied to clipboard", - "shared_link_clipboard_text": "Link: {}\nPassword: {}", + "shared_link_clipboard_copied_massage": "クリップボードにコピーしました", + "shared_link_clipboard_text": "リンク: {}\nパスワード: {}", "shared_link_create_app_bar_title": "共有リンクを作る", - "shared_link_create_error": "Error while creating shared link", - "shared_link_create_info": "Let anyone with the link see the selected photo(s)", + "shared_link_create_error": "共有用のリンク作成時にエラーが発生しました", + "shared_link_create_info": "誰でも写真を見れるようにする", "shared_link_create_submit_button": "リンクを作る", - "shared_link_edit_allow_download": "Allow public user to download", - "shared_link_edit_allow_upload": "Allow public user to upload", + "shared_link_edit_allow_download": "写真のダウンロードの許可", + "shared_link_edit_allow_upload": "写真のアップロードを許可", "shared_link_edit_app_bar_title": " リンクを編集する", - "shared_link_edit_change_expiry": "Change expiration time", - "shared_link_edit_description": " デスクリプション ", - "shared_link_edit_description_hint": "Enter the share description", - "shared_link_edit_expire_after": "Expire after", - "shared_link_edit_expire_after_option_day": "1 day", - "shared_link_edit_expire_after_option_days": "{} days", - "shared_link_edit_expire_after_option_hour": "1 hour", - "shared_link_edit_expire_after_option_hours": "{} hours", - "shared_link_edit_expire_after_option_minute": "1 minute", - "shared_link_edit_expire_after_option_minutes": "{} minutes", - "shared_link_edit_expire_after_option_never": "Never", + "shared_link_edit_change_expiry": "有効期限を変更", + "shared_link_edit_description": "概要欄", + "shared_link_edit_description_hint": "概要を追加", + "shared_link_edit_expire_after": "有効期限は", + "shared_link_edit_expire_after_option_day": "1日", + "shared_link_edit_expire_after_option_days": "{}日", + "shared_link_edit_expire_after_option_hour": "1時間", + "shared_link_edit_expire_after_option_hours": "{}時間", + "shared_link_edit_expire_after_option_minute": "1分", + "shared_link_edit_expire_after_option_minutes": "{}分", + "shared_link_edit_expire_after_option_never": "有効期限なし", "shared_link_edit_password": " パスワード", "shared_link_edit_password_hint": "共有パスワードを入力する", "shared_link_edit_show_meta": " メタデータを見る", "shared_link_edit_submit_button": "リンクをアップデートする", "shared_link_empty": "共有リンクはありません ", - "shared_link_error_server_url_fetch": "Cannot fetch the server url", - "shared_link_expired": "Expired", - "shared_link_expires_day": "Expires in {} day", - "shared_link_expires_days": "Expires in {} days", - "shared_link_expires_hour": "Expires in {} hour", - "shared_link_expires_hours": "Expires in {} hours", - "shared_link_expires_minute": "Expires in {} minute", - "shared_link_expires_minutes": "Expires in {} minutes", - "shared_link_expires_never": "Expires ∞", - "shared_link_expires_second": "Expires in {} second", - "shared_link_expires_seconds": "Expires in {} seconds", - "shared_link_info_chip_download": "Download", + "shared_link_error_server_url_fetch": "サーバーのURLがゲットできません", + "shared_link_expired": "有効期限が切れました", + "shared_link_expires_day": "{}日間で切れます", + "shared_link_expires_days": "{}日間で有効期限が切れます", + "shared_link_expires_hour": "{}時間で切れます", + "shared_link_expires_hours": "{}時間で有効期限が切れます", + "shared_link_expires_minute": "{}分で切れます", + "shared_link_expires_minutes": "{}分で切れます", + "shared_link_expires_never": "有効期限はありません", + "shared_link_expires_second": "{}秒で切れます", + "shared_link_expires_seconds": "{}秒で切れます", + "shared_link_info_chip_download": "ダウンロード", "shared_link_info_chip_metadata": "EXIF", - "shared_link_info_chip_upload": "Upload", - "shared_link_manage_links": "Manage Shared links", - "share_done": "Done", + "shared_link_info_chip_upload": "アップロード", + "shared_link_manage_links": "共有済みのリンクを管理", + "share_done": "完了", "share_invite": "アルバムに招待", "sharing_page_album": "共有アルバム", "sharing_page_description": "共有アルバムを作成して同じネットワークにいる人たちに写真を共有", "sharing_page_empty_list": "共有アルバムなし", "sharing_silver_appbar_create_shared_album": "共有アルバムを作成", - "sharing_silver_appbar_shared_links": "Shared links", + "sharing_silver_appbar_shared_links": "共有リンク", "sharing_silver_appbar_share_partner": "パートナーと共有", "tab_controller_nav_library": "ライブラリ", "tab_controller_nav_photos": "写真", @@ -450,30 +451,30 @@ "theme_setting_theme_title": "テーマ", "theme_setting_three_stage_loading_subtitle": "三段階読み込みを有効にするとパフォーマンスが改善する可能性がありますが、ネットワーク負荷が著しく増加します", "theme_setting_three_stage_loading_title": "三段階読み込みをオンにする", - "translated_text_options": "Options", - "trash_page_delete": "Delete", - "trash_page_delete_all": "Delete All", - "trash_page_empty_trash_btn": "Empty trash", - "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", - "trash_page_empty_trash_dialog_ok": "Ok", - "trash_page_info": "Trashed items will be permanently deleted after {} days", - "trash_page_no_assets": "No trashed assets", - "trash_page_restore": "Restore", - "trash_page_restore_all": "Restore All", - "trash_page_select_assets_btn": "Select assets", - "trash_page_select_btn": "Select", - "trash_page_title": "Trash ({})", - "upload_dialog_cancel": "Cancel", - "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", - "upload_dialog_ok": "Upload", - "upload_dialog_title": "Upload Asset", + "translated_text_options": "オプション", + "trash_page_delete": "削除", + "trash_page_delete_all": "全て削除", + "trash_page_empty_trash_btn": "コミ箱を空にする", + "trash_page_empty_trash_dialog_content": "ゴミ箱を空にしますか?選択された項目は完全に削除されます。この操作は取り消せません", + "trash_page_empty_trash_dialog_ok": "了解", + "trash_page_info": "ゴミ箱に移動したアイテムは{}日後に削除されます", + "trash_page_no_assets": "ゴミ箱は空です", + "trash_page_restore": "復元", + "trash_page_restore_all": "全て復元", + "trash_page_select_assets_btn": "項目を選択", + "trash_page_select_btn": "選択", + "trash_page_title": "削除({})", + "upload_dialog_cancel": "キャンセル", + "upload_dialog_info": "選択した項目のバックアップをしますか?", + "upload_dialog_ok": "アップロード", + "upload_dialog_title": "アップロード", "version_announcement_overlay_ack": "了解", "version_announcement_overlay_release_notes": "更新情報", "version_announcement_overlay_text_1": "こんにちは、またはこんばんは!新しい", "version_announcement_overlay_text_2": "のバージョンが公開中です。", "version_announcement_overlay_text_3": "を確認してみてください。docker-composeや.envファイルが最新の状態に更新されているか、特にWatchTowerなどのツールを使ってDockerイメージを自動アップデートしてる人は確認してください。", "version_announcement_overlay_title": "サーバーの新バージョンリリース\uD83C\uDF89", - "viewer_remove_from_stack": "Remove from Stack", - "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" + "viewer_remove_from_stack": "スタックから外す", + "viewer_stack_use_as_main_asset": "メインの画像として使用する", + "viewer_unstack": "スタックを解除" } \ No newline at end of file diff --git a/mobile/assets/i18n/ko-KR.json b/mobile/assets/i18n/ko-KR.json index d87ba3b75229f..257a7ea9a7a50 100644 --- a/mobile/assets/i18n/ko-KR.json +++ b/mobile/assets/i18n/ko-KR.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "상세정보", "exif_bottom_sheet_location": "위치", "exif_bottom_sheet_location_add": "위치 지정", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "진행중", "experimental_settings_new_asset_list_title": "실험적 사진 그리드 적용", "experimental_settings_subtitle": "문제시 책임지지 않습니다!", diff --git a/mobile/assets/i18n/lv-LV.json b/mobile/assets/i18n/lv-LV.json index cc1b52616c942..76b8e07d2531e 100644 --- a/mobile/assets/i18n/lv-LV.json +++ b/mobile/assets/i18n/lv-LV.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "INFORMĀCIJA", "exif_bottom_sheet_location": "ATRAŠANĀS VIETA", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Izstrādes posmā", "experimental_settings_new_asset_list_title": "Iespējot eksperimentālo fotorežģi", "experimental_settings_subtitle": "Izmanto uzņemoties risku!", diff --git a/mobile/assets/i18n/mn.json b/mobile/assets/i18n/mn.json index 722ea272276eb..584be40aabaf9 100644 --- a/mobile/assets/i18n/mn.json +++ b/mobile/assets/i18n/mn.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATION", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", diff --git a/mobile/assets/i18n/nb-NO.json b/mobile/assets/i18n/nb-NO.json index ff835ba40942a..f2306e0945892 100644 --- a/mobile/assets/i18n/nb-NO.json +++ b/mobile/assets/i18n/nb-NO.json @@ -35,8 +35,8 @@ "app_bar_signout_dialog_title": "Logg ut", "archive_page_no_archived_assets": "Ingen arkiverte objekter funnet", "archive_page_title": "Arkiv ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_action_delete_err_read_only": "Kan ikke slette objekt(er) med kun lese-rettighet, hopper over", + "asset_action_share_err_offline": "Kan ikke hente offline objekt(er), hopper over", "asset_list_layout_settings_dynamic_layout_title": "Dynamisk bildeorganisering", "asset_list_layout_settings_group_automatically": "Automatisk", "asset_list_layout_settings_group_by": "Grupper bilder etter", @@ -142,15 +142,15 @@ "control_bottom_app_bar_archive": "Arkiver", "control_bottom_app_bar_create_new_album": "Lag nytt album", "control_bottom_app_bar_delete": "Slett", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_delete_from_immich": "Slett fra Immich", + "control_bottom_app_bar_delete_from_local": "Slett fra enhet", "control_bottom_app_bar_edit_location": "Endre lokasjon", "control_bottom_app_bar_edit_time": "Endre Dato og tid", "control_bottom_app_bar_favorite": "Favoritt", "control_bottom_app_bar_share": "Del", "control_bottom_app_bar_share_to": "Del til", "control_bottom_app_bar_stack": "Stable", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_trash_from_immich": "Flytt til søppelkasse", "control_bottom_app_bar_unarchive": "Fjern fra arkiv", "control_bottom_app_bar_unfavorite": "Fjern favoritt", "control_bottom_app_bar_upload": "Last opp", @@ -165,15 +165,15 @@ "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", "delete_dialog_alert": "Disse objektene vil bli slettet permanent fra Immich og fra enheten din", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "Disse objektene vil bli permanent slettet fra enheten din, men vil fortsatt være tilgjengelige fra Immich serveren", + "delete_dialog_alert_local_non_backed_up": "Noen av objektene er ikke sikkerhetskopiert til Immich og vil bli permanent fjernet fra enheten din", + "delete_dialog_alert_remote": "Disse objektene vil bli permanent slettet fra Immich serveren", "delete_dialog_cancel": "Avbryt", "delete_dialog_ok": "Slett", - "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_ok_force": "Slett uansett", "delete_dialog_title": "Slett permanent", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", + "delete_local_dialog_ok_backed_up_only": "Slett kun sikkerhetskopierte objekter", + "delete_local_dialog_ok_force": "Slett uansett", "delete_shared_link_dialog_content": "Er du sikker på at du vil slette denne delte linken?", "delete_shared_link_dialog_title": "Slett delt link", "description_input_hint_text": "Legg til beskrivelse ...", @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETALJER", "exif_bottom_sheet_location": "PLASSERING", "exif_bottom_sheet_location_add": "Legg til lokasjon", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Under utvikling", "experimental_settings_new_asset_list_title": "Aktiver eksperimentell rutenettsvisning", "experimental_settings_subtitle": "Bruk på egen risiko!", @@ -199,7 +200,7 @@ "home_page_archive_err_partner": "Kan ikke arkivere partnerobjekter, hopper over", "home_page_building_timeline": "Genererer tidslinjen", "home_page_delete_err_partner": "Kan ikke slette partnerobjekter, hopper over", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_delete_remote_err_local": "Lokale objekter i fjernslettingsvalgene, hopper over", "home_page_favorite_err_local": "Kan ikke sette favoritt på lokale objekter enda, hopper over", "home_page_favorite_err_partner": "Kan ikke merke partnerobjekter som favoritt enda, hopper over", "home_page_first_time_notice": "Hvis dette er første gangen du benytter appen, velg et album (eller flere) for sikkerhetskopiering, slik at tidslinjen kan fylles med dine bilder og videoer.", @@ -275,12 +276,12 @@ "map_settings_include_show_archived": "Inkluder arkiverte", "map_settings_only_relative_range": "Datoområde", "map_settings_only_show_favorites": "Vis kun favoritter", - "map_settings_theme_settings": "Map Theme", + "map_settings_theme_settings": "Karttema", "map_zoom_to_see_photos": "Zoom ut for å se bilder", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Bevegelige bilder", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "Kan ikke endre dato på objekt(er) med kun lese-rettigheter, hopper over", + "multiselect_grid_edit_gps_err_read_only": "Kan ikke endre lokasjon på objekt(er) med kun lese-rettigheter, hopper over", "notification_permission_dialog_cancel": "Avbryt", "notification_permission_dialog_content": "For å aktivere notifikasjoner, gå til Innstillinger og velg tillat.", "notification_permission_dialog_settings": "Innstillinger", diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index 557eadcb0b3af..df505269a3c56 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -1,6 +1,6 @@ { "action_common_cancel": "Annuleren", - "action_common_update": "Updaten", + "action_common_update": "Bijwerken", "add_to_album_bottom_sheet_added": "Toegevoegd aan {album}", "add_to_album_bottom_sheet_already_exists": "Staat al in {album}", "advanced_settings_log_level_title": "Log niveau: {}", @@ -26,17 +26,17 @@ "album_viewer_appbar_share_err_title": "Albumtitel wijzigen mislukt", "album_viewer_appbar_share_leave": "Verlaat album", "album_viewer_appbar_share_remove": "Verwijder uit album", - "album_viewer_appbar_share_to": "Deel Naar", + "album_viewer_appbar_share_to": "Delen met", "album_viewer_page_share_add_users": "Gebruikers toevoegen", "all_people_page_title": "Personen", "all_videos_page_title": "Video's", - "app_bar_signout_dialog_content": "Weet je zeker dat je je wilt afmelden?", + "app_bar_signout_dialog_content": "Weet je zeker dat je wilt uitloggen?", "app_bar_signout_dialog_ok": "Ja", "app_bar_signout_dialog_title": "Log uit", "archive_page_no_archived_assets": "Geen gearchiveerde assets gevonden", "archive_page_title": "Archief ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_action_delete_err_read_only": "Kan alleen-lezen asset(s) niet verwijderen, overslaan", + "asset_action_share_err_offline": "Kan offline asset(s) niet ophalen, overslaan", "asset_list_layout_settings_dynamic_layout_title": "Dynamische layout", "asset_list_layout_settings_group_automatically": "Automatisch", "asset_list_layout_settings_group_by": "Groupeer assets per", @@ -104,7 +104,7 @@ "backup_err_only_album": "Kan het enige album niet verwijderen", "backup_info_card_assets": "assets", "backup_manual_cancelled": "Geannuleerd", - "backup_manual_failed": "Gefaald", + "backup_manual_failed": "Mislukt", "backup_manual_in_progress": "Het uploaden is al bezig. Probeer het na een tijdje", "backup_manual_success": "Succes", "backup_manual_title": "Uploadstatus", @@ -113,7 +113,7 @@ "cache_settings_clear_cache_button_title": "Wist de cache van de app. Dit zal de presentaties van de app aanzienlijk beïnvloeden totdat de cache opnieuw is opgebouwd.", "cache_settings_duplicated_assets_clear_button": "MAAK VRIJ", "cache_settings_duplicated_assets_subtitle": "Foto's en video's op de zwarte lijst van de app", - "cache_settings_duplicated_assets_title": "Gedupliceerde Assets ({})", + "cache_settings_duplicated_assets_title": "Gedupliceerde assets ({})", "cache_settings_image_cache_size": "Grootte afbeeldingscache ({} assets)", "cache_settings_statistics_album": "Bibliotheekthumbnails", "cache_settings_statistics_assets": "{} assets ({})", @@ -124,7 +124,7 @@ "cache_settings_subtitle": "Beheer het cachegedrag van de Immich app", "cache_settings_thumbnail_size": "Thumbnail-cachegrootte ({} assets)", "cache_settings_tile_subtitle": "Beheer het gedrag van lokale opslag", - "cache_settings_tile_title": "Lokale Opslag", + "cache_settings_tile_title": "Lokale opslag", "cache_settings_title": "Cache-instellingen", "change_password_form_confirm_password": "Bevestig wachtwoord", "change_password_form_description": "Hallo {name},\n\nDit is ofwel de eerste keer dat je inlogt, of er is een verzoek gedaan om je wachtwoord te wijzigen. Vul hieronder een nieuw wachtwoord in.", @@ -142,15 +142,15 @@ "control_bottom_app_bar_archive": "Archiveren", "control_bottom_app_bar_create_new_album": "Nieuw album maken", "control_bottom_app_bar_delete": "Verwijderen", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", - "control_bottom_app_bar_edit_location": "Locatie Bewerken", - "control_bottom_app_bar_edit_time": "Datum & Tijd Bewerken", + "control_bottom_app_bar_delete_from_immich": "Verwijderen van Immich", + "control_bottom_app_bar_delete_from_local": "Verwijderen van apparaat", + "control_bottom_app_bar_edit_location": "Locatie bewerken", + "control_bottom_app_bar_edit_time": "Datum & tijd bewerken", "control_bottom_app_bar_favorite": "Favoriet", "control_bottom_app_bar_share": "Delen", - "control_bottom_app_bar_share_to": "Deel Naar", + "control_bottom_app_bar_share_to": "Delen met", "control_bottom_app_bar_stack": "Stapel", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_trash_from_immich": "Verplaatsen naar prullenbak", "control_bottom_app_bar_unarchive": "Herstellen", "control_bottom_app_bar_unfavorite": "Onfavoriet", "control_bottom_app_bar_upload": "Uploaden", @@ -165,26 +165,27 @@ "daily_title_text_date_year": "E dd MMM yyyy", "date_format": "E d LLL y • H:mm", "delete_dialog_alert": "Deze items zullen permanent verwijderd worden van Immich en je apparaat", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "Deze items worden permanent verwijderd van je apparaat, maar blijven beschikbaar op de Immich server", + "delete_dialog_alert_local_non_backed_up": "Van sommige items is geen back-up gemaakt in Immich en zullen permanent van je apparaat worden verwijderd", + "delete_dialog_alert_remote": "Deze items worden permanent verwijderd van de Immich server", "delete_dialog_cancel": "Annuleren", "delete_dialog_ok": "Verwijderen", - "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_ok_force": "Toch verwijderen", "delete_dialog_title": "Permanent verwijderen", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", + "delete_local_dialog_ok_backed_up_only": "Verwijder alleen met back-up", + "delete_local_dialog_ok_force": "Toch verwijderen", "delete_shared_link_dialog_content": "Weet je zeker dat je deze gedeelde link wilt verwijderen?", - "delete_shared_link_dialog_title": "Verwijder Gedeelde Link", + "delete_shared_link_dialog_title": "Verwijder gedeelde link", "description_input_hint_text": "Beschrijving toevoegen...", "description_input_submit_error": "Beschrijving bijwerken mislukt, controleer het logboek voor meer details", - "edit_date_time_dialog_date_time": "Datum en Tijd", + "edit_date_time_dialog_date_time": "Datum en tijd", "edit_date_time_dialog_timezone": "Tijdzone", "edit_location_dialog_title": "Locatie", "exif_bottom_sheet_description": "Beschrijving toevoegen...", "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATIE", "exif_bottom_sheet_location_add": "Locatie toevoegen", + "exif_bottom_sheet_people": "MENSEN", "experimental_settings_new_asset_list_subtitle": "Werk in uitvoering", "experimental_settings_new_asset_list_title": "Experimenteel fotoraster inschakelen", "experimental_settings_subtitle": "Gebruik op eigen risico!", @@ -199,7 +200,7 @@ "home_page_archive_err_partner": "Partner assets kunnen niet gearchiveerd worden, overslaan", "home_page_building_timeline": "Tijdlijn opbouwen", "home_page_delete_err_partner": "Partner assets kunnen niet verwijderd worden, overslaan", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_delete_remote_err_local": "Lokale assets staan in verwijder selectie externe assets, overslaan", "home_page_favorite_err_local": "Lokale assets kunnen nog niet als favoriet worden aangemerkt, overslaan", "home_page_favorite_err_partner": "Partner assets kunnen nog niet ge-favoriet worden, overslaan", "home_page_first_time_notice": "Als dit de eerste keer is dat je de app gebruikt, zorg er dan voor dat je een back-up album kiest, zodat de tijdlijn gevuld kan worden met foto's en video's uit het album.", @@ -259,10 +260,10 @@ "map_location_dialog_yes": "Ja", "map_location_picker_page_use_location": "Gebruik deze locatie", "map_location_service_disabled_content": "Locatie service moet ingeschakeld zijn om assets van je huidige locatie weer te geven. Wil je het nu inschakelen?", - "map_location_service_disabled_title": "Locatie Service uitgeschakeld", + "map_location_service_disabled_title": "Locatie service uitgeschakeld", "map_no_assets_in_bounds": "Geen foto's in dit gebied", "map_no_location_permission_content": "Locatie toestemming is nodig om assets van je huidige locatie weer te geven. Wil je het nu toestaan?", - "map_no_location_permission_title": "Locatie Toestemming geweigerd", + "map_no_location_permission_title": "Locatie toestemming geweigerd", "map_settings_dark_mode": "Donkere modus", "map_settings_date_range_option_all": "Alle", "map_settings_date_range_option_day": "Afgelopen 24 uur", @@ -270,17 +271,17 @@ "map_settings_date_range_option_year": "Afgelopen jaar", "map_settings_date_range_option_years": "Afgelopen {} jaar", "map_settings_dialog_cancel": "Annuleren", - "map_settings_dialog_save": "Sla op", + "map_settings_dialog_save": "Opslaan", "map_settings_dialog_title": "Kaart Instellingen", - "map_settings_include_show_archived": "Weergeef Gearchiveerden", + "map_settings_include_show_archived": "Toon gearchiveerde", "map_settings_only_relative_range": "Datum bereik", "map_settings_only_show_favorites": "Toon enkel favorieten", - "map_settings_theme_settings": "Map Theme", + "map_settings_theme_settings": "Kaart thema", "map_zoom_to_see_photos": "Zoom uit om foto's te zien", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Bewegende foto's", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "Kan datum van alleen-lezen asset(s) niet wijzigen, overslaan", + "multiselect_grid_edit_gps_err_read_only": "Kan locatie van alleen-lezen asset(s) niet wijzigen, overslaan", "notification_permission_dialog_cancel": "Annuleren", "notification_permission_dialog_content": "Om meldingen in te schakelen, ga naar Instellingen en selecteer toestaan.", "notification_permission_dialog_settings": "Instellingen", @@ -390,7 +391,7 @@ "shared_link_app_bar_title": "Gedeelde links", "shared_link_clipboard_copied_massage": "Gekopieerd naar klembord", "shared_link_clipboard_text": "Link: {}\nWachtwoord: {}", - "shared_link_create_app_bar_title": "Link maken om te delen", + "shared_link_create_app_bar_title": "Gedeelde link maken", "shared_link_create_error": "Fout bij het maken van een gedeelde link", "shared_link_create_info": "Laat iedereen met de link de geselecteerde foto(s) zien", "shared_link_create_submit_button": "Link maken", @@ -399,7 +400,7 @@ "shared_link_edit_app_bar_title": "Bewerk link", "shared_link_edit_change_expiry": "Bewerk vervaltijd", "shared_link_edit_description": "Beschrijving", - "shared_link_edit_description_hint": "Geef de deel beschrijving", + "shared_link_edit_description_hint": "Voer beschrijving voor de gedeelde link in", "shared_link_edit_expire_after": "Verval na", "shared_link_edit_expire_after_option_day": "1 dag", "shared_link_edit_expire_after_option_days": "{} dagen", @@ -409,9 +410,9 @@ "shared_link_edit_expire_after_option_minutes": "{} minuten", "shared_link_edit_expire_after_option_never": "Nooit", "shared_link_edit_password": "Wachtwoord", - "shared_link_edit_password_hint": "Voer het deel wachtwoord in", + "shared_link_edit_password_hint": "Voer wachtwoord voor de gedeelde link in", "shared_link_edit_show_meta": "Toon metadata", - "shared_link_edit_submit_button": "Update link", + "shared_link_edit_submit_button": "Link bijwerken", "shared_link_empty": "Je hebt geen gedeelde links", "shared_link_error_server_url_fetch": "Kan de server url niet ophalen", "shared_link_expired": "Verlopen", @@ -452,19 +453,19 @@ "theme_setting_three_stage_loading_title": "Laden in drie fasen inschakelen", "translated_text_options": "Opties", "trash_page_delete": "Verwijderen", - "trash_page_delete_all": "Verwijder Alle", + "trash_page_delete_all": "Verwijder alle", "trash_page_empty_trash_btn": "Leeg prullenbak", - "trash_page_empty_trash_dialog_content": "Wil je je weggegooide assets leegmaken? Deze items worden permanent verwijderd van Immich", + "trash_page_empty_trash_dialog_content": "Wil je de prullenbak leegmaken? Deze items worden permanent verwijderd van Immich", "trash_page_empty_trash_dialog_ok": "Ok", "trash_page_info": "Verwijderde items worden permanent verwijderd na {} dagen", "trash_page_no_assets": "Geen verwijderde assets", "trash_page_restore": "Herstellen", - "trash_page_restore_all": "Herstel Alle", + "trash_page_restore_all": "Herstel alle", "trash_page_select_assets_btn": "Selecteer assets", "trash_page_select_btn": "Selecteren", "trash_page_title": "Prullenbak ({})", "upload_dialog_cancel": "Annuleren", - "upload_dialog_info": "Wilt u een backup maken van de geselecteerde Asset(s) op de server?", + "upload_dialog_info": "Wil je een backup maken van de geselecteerde asset(s) op de server?", "upload_dialog_ok": "Uploaden", "upload_dialog_title": "Asset uploaden", "version_announcement_overlay_ack": "Bevestig", diff --git a/mobile/assets/i18n/pl-PL.json b/mobile/assets/i18n/pl-PL.json index 920dd4f86b60d..555e4dfba5e3f 100644 --- a/mobile/assets/i18n/pl-PL.json +++ b/mobile/assets/i18n/pl-PL.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "SZCZEGÓŁY", "exif_bottom_sheet_location": "LOKALIZACJA", "exif_bottom_sheet_location_add": "Dodaj lokalizację", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Praca w toku", "experimental_settings_new_asset_list_title": "Włącz eksperymentalną układ zdjęć", "experimental_settings_subtitle": "Używaj na własne ryzyko!", diff --git a/mobile/assets/i18n/pt-PT.json b/mobile/assets/i18n/pt-PT.json index a86a853739be2..67fdc1059c7fe 100644 --- a/mobile/assets/i18n/pt-PT.json +++ b/mobile/assets/i18n/pt-PT.json @@ -1,8 +1,8 @@ { - "action_common_cancel": "Cancel", - "action_common_update": "Update", - "add_to_album_bottom_sheet_added": "Added to {album}", - "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "action_common_cancel": "Cancelar", + "action_common_update": "Atualizar", + "add_to_album_bottom_sheet_added": "Adicionar a {album}", + "add_to_album_bottom_sheet_already_exists": "Já pertence a {album}", "advanced_settings_log_level_title": "Log level: {}", "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", "advanced_settings_prefer_remote_title": "Prefer remote images", @@ -16,7 +16,7 @@ "album_info_card_backup_album_included": "INCLUÍDO", "album_thumbnail_card_item": "1 item", "album_thumbnail_card_items": "{} itens", - "album_thumbnail_card_shared": "Compartilhado", + "album_thumbnail_card_shared": " · Partilhado", "album_thumbnail_owned": "Owned", "album_thumbnail_shared_by": "Shared by {}", "album_viewer_appbar_share_delete": "Deletar álbum", @@ -29,21 +29,21 @@ "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Adicionar usuários", "all_people_page_title": "People", - "all_videos_page_title": "Videos", - "app_bar_signout_dialog_content": "Are you sure you want to sign out?", + "all_videos_page_title": "Vídeos", + "app_bar_signout_dialog_content": "Tem a certeza que deseja sair?", "app_bar_signout_dialog_ok": "Yes", - "app_bar_signout_dialog_title": "Sign out", + "app_bar_signout_dialog_title": "Sair", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", - "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_action_delete_err_read_only": "Não é possível eliminar o(s) recurso(s) só de leitura, ignorando", + "asset_action_share_err_offline": "Não é possível obter recurso(s) offline, ignorando", + "asset_list_layout_settings_dynamic_layout_title": "Disposição dinâmica", "asset_list_layout_settings_group_automatically": "Automatic", - "asset_list_layout_settings_group_by": "Group assets by", - "asset_list_layout_settings_group_by_month": "Month", - "asset_list_layout_settings_group_by_month_day": "Month + day", - "asset_list_settings_subtitle": "Configurações de layout da grade de fotos", - "asset_list_settings_title": "Grade de fotos", + "asset_list_layout_settings_group_by": "Agrupar recursos por", + "asset_list_layout_settings_group_by_month": "Mês", + "asset_list_layout_settings_group_by_month_day": "Mês + dia", + "asset_list_settings_subtitle": "Configurações de layout da grelha de fotos", + "asset_list_settings_title": "Grelha de fotos", "backup_album_selection_page_albums_device": "Álbuns no dispositivo ({})", "backup_album_selection_page_albums_tap": "Toque para incluir, duplo toque para exluir", "backup_album_selection_page_assets_scatter": "Os itens podem estar espalhados por vários álbuns. Assim, os álbuns podem ser incluídos ou excluídos durante o processo de backup.", @@ -90,11 +90,11 @@ "backup_controller_page_remainder": "Restante", "backup_controller_page_remainder_sub": "Fotos e vídeos restantes para fazer backup da seleção", "backup_controller_page_select": "Selecione", - "backup_controller_page_server_storage": "Espaço no Servidor", + "backup_controller_page_server_storage": "Armazenamento no servidor", "backup_controller_page_start_backup": "Iniciar Backup", "backup_controller_page_status_off": "Backup está desligado", "backup_controller_page_status_on": "Backup está ligado", - "backup_controller_page_storage_format": "{} de {} usado", + "backup_controller_page_storage_format": "{} de {} usados", "backup_controller_page_to_backup": "Álbuns para fazer backup", "backup_controller_page_total": "Total", "backup_controller_page_total_sub": "Todas as fotos e vídeos dos álbuns selecionados", @@ -118,91 +118,92 @@ "cache_settings_statistics_album": "Miniaturas da biblioteca", "cache_settings_statistics_assets": "{} itens ({})", "cache_settings_statistics_full": "Imagens completas", - "cache_settings_statistics_shared": "Miniaturas de álbuns compartilhados", + "cache_settings_statistics_shared": "Miniaturas de álbuns partilhados", "cache_settings_statistics_thumbnail": "Miniaturas", "cache_settings_statistics_title": "Uso de cache", "cache_settings_subtitle": "Controle o comportamento de cache do aplicativo Immich", "cache_settings_thumbnail_size": "Tamanho do cache de miniaturas ({} itens)", - "cache_settings_tile_subtitle": "Control the local storage behaviour", - "cache_settings_tile_title": "Local Storage", + "cache_settings_tile_subtitle": "Controlar o comportamento do armazenamento local", + "cache_settings_tile_title": "Armazenamento local", "cache_settings_title": "Configurações de cache", - "change_password_form_confirm_password": "Confirm Password", - "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", - "change_password_form_new_password": "New Password", - "change_password_form_password_mismatch": "Passwords do not match", - "change_password_form_reenter_new_password": "Re-enter New Password", - "common_add_to_album": "Add to album", - "common_change_password": "Change Password", - "common_create_new_album": "Create new album", - "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "change_password_form_confirm_password": "Confirme a senha", + "change_password_form_description": "Olá {name},\n\nÉ a primeira vez que entra no sistema ou foi-lhe pedido que alterasse a sua palavra-passe. Introduza a nova palavra-passe abaixo.", + "change_password_form_new_password": "Nova senha", + "change_password_form_password_mismatch": "As senhas não coincidem", + "change_password_form_reenter_new_password": "Re-introduza a nova senha", + "common_add_to_album": "Adicionar ao álbum", + "common_change_password": "Mudar a senha", + "common_create_new_album": "Criar novo álbum", + "common_server_error": "Verifique a sua ligação de rede, certifique-se de que o servidor está acessível e de que as versões da aplicação/servidor são compatíveis.", "common_shared": "Shared", "control_bottom_app_bar_add_to_album": "Adicionar ao álbum", "control_bottom_app_bar_album_info": "{} itens", - "control_bottom_app_bar_album_info_shared": "{} itens · Compartilhado", + "control_bottom_app_bar_album_info_shared": "{} itens · Partilhado", "control_bottom_app_bar_archive": "Archive", "control_bottom_app_bar_create_new_album": "Criar novo álbum", "control_bottom_app_bar_delete": "Deletar", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_delete_from_immich": "Apagar do Immich", + "control_bottom_app_bar_delete_from_local": "Apagar do dispositivo", "control_bottom_app_bar_edit_location": "Edit Location", "control_bottom_app_bar_edit_time": "Edit Date & Time", "control_bottom_app_bar_favorite": "Favorite", - "control_bottom_app_bar_share": "Compartilhar", + "control_bottom_app_bar_share": "Partilhar", "control_bottom_app_bar_share_to": "Share To", "control_bottom_app_bar_stack": "Stack", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_trash_from_immich": "Mover para o lixo", "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", "create_album_page_untitled": "Sem título", "create_shared_album_page_create": "Criar", - "create_shared_album_page_share": "Compartilhar", + "create_shared_album_page_share": "Partilhar", "create_shared_album_page_share_add_assets": "ADICIONAR ITENS", "create_shared_album_page_share_select_photos": "Selecionar Fotos", - "curated_location_page_title": "Places", - "curated_object_page_title": "Things", + "curated_location_page_title": "Sítios", + "curated_object_page_title": "Objetos", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", "delete_dialog_alert": "Esses itens serão permanentemente deletados do Immich e do seu dispositivo", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "Estes itens serão removidos permanentemente do seu dispositivo, mas continuarão disponíveis no servidor Immich", + "delete_dialog_alert_local_non_backed_up": "Alguns dos itens não estão guardados no Immich e serão removidos permanentemente do seu dispositivo", + "delete_dialog_alert_remote": "Estes itens serão permanentemente eliminados do servidor Immich", "delete_dialog_cancel": "Cancelar", "delete_dialog_ok": "Deletar", - "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_ok_force": "Apagar de qualquer forma", "delete_dialog_title": "Deletar Permanentemente", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", + "delete_local_dialog_ok_backed_up_only": "Eliminar apenas existentes na cópia de segurança", + "delete_local_dialog_ok_force": "Apagar de qualquer forma", "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", - "edit_date_time_dialog_date_time": "Date and Time", - "edit_date_time_dialog_timezone": "Timezone", + "edit_date_time_dialog_date_time": "Data e Hora", + "edit_date_time_dialog_timezone": "Fuso horário", "edit_location_dialog_title": "Location", "exif_bottom_sheet_description": "Adicionar Descrição...", "exif_bottom_sheet_details": "DETALHES", "exif_bottom_sheet_location": "LOCALIZAÇÃO", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Trabalho em andamento", - "experimental_settings_new_asset_list_title": "Ativar visualização de grade experimental", + "experimental_settings_new_asset_list_title": "Ativar visualização de grelha experimental", "experimental_settings_subtitle": "Use por sua conta e risco!", "experimental_settings_title": "Experimental", "favorites_page_no_favorites": "No favorite assets found", - "favorites_page_title": "Favorites", + "favorites_page_title": "Favoritos", "home_page_add_to_album_conflicts": "Ativos {added} adicionados ao álbum {album}. {failed} ativos já estão no álbum.", - "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_err_local": "Ainda não é possível adicionar recursos locais aos álbuns, ignorando", "home_page_add_to_album_success": "Ativos {added} adicionados ao álbum {album}.", "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping", "home_page_archive_err_local": "Can not archive local assets yet, skipping", "home_page_archive_err_partner": "Can not archive partner assets, skipping", - "home_page_building_timeline": "Building the timeline", + "home_page_building_timeline": "A construir a timeline", "home_page_delete_err_partner": "Can not delete partner assets, skipping", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", - "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_delete_remote_err_local": "Recursos locais na seleção remota de eliminação, ignorando", + "home_page_favorite_err_local": "Ainda não é possível adicionar recursos locais favoritos, ignorando", "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", - "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "home_page_first_time_notice": "Se for a primeira vez que utiliza a aplicação, certifique-se de que escolhe um álbum ou álbuns de cópia de segurança, para que a linha cronológica possa preencher as fotografias e os vídeos no(s) álbum(s).", "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_viewer_page_state_provider_download_error": "Download Error", @@ -210,16 +211,16 @@ "image_viewer_page_state_provider_share_error": "Share Error", "library_page_albums": "Álbuns", "library_page_archive": "Archive", - "library_page_device_albums": "Albums on Device", - "library_page_favorites": "Favorites", - "library_page_new_album": "Novo Album", - "library_page_sharing": "Sharing", - "library_page_sort_asset_count": "Number of assets", - "library_page_sort_created": "Created date", + "library_page_device_albums": "Álbuns no dispositivo", + "library_page_favorites": "Favoritos", + "library_page_new_album": "Novo álbum", + "library_page_sharing": "Partilhar", + "library_page_sort_asset_count": "Número de recursos", + "library_page_sort_created": "Data de criação", "library_page_sort_last_modified": "Last modified", - "library_page_sort_most_oldest_photo": "Oldest photo", + "library_page_sort_most_oldest_photo": "Foto mais antiga", "library_page_sort_most_recent_photo": "Most recent photo", - "library_page_sort_title": "Album title", + "library_page_sort_title": "Título do álbum", "location_picker_choose_on_map": "Choose on map", "location_picker_latitude": "Latitude", "location_picker_latitude_error": "Enter a valid latitude", @@ -228,8 +229,8 @@ "location_picker_longitude_error": "Enter a valid longitude", "location_picker_longitude_hint": "Enter your longitude here", "login_disabled": "Login has been disabled", - "login_form_api_exception": "API exception. Please check the server URL and try again.", - "login_form_back_button_text": "Back", + "login_form_api_exception": "Excepção de API. Verifique o URL do servidor e tente novamente.", + "login_form_back_button_text": "Voltar", "login_form_button_text": "Login", "login_form_email_hint": "seuemail@email.com", "login_form_endpoint_hint": "http://ip-do-seu-servidor:porta/api", @@ -245,15 +246,15 @@ "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", "login_form_label_email": "Email", "login_form_label_password": "Senha", - "login_form_next_button": "Next", + "login_form_next_button": "Avançar", "login_form_password_hint": "senha", "login_form_save_login": "Permanecer logado", - "login_form_server_empty": "Enter a server URL.", - "login_form_server_error": "Could not connect to server.", + "login_form_server_empty": "Introduzir um URL de servidor.", + "login_form_server_error": "Não foi possível ligar ao servidor.", "login_password_changed_error": "There was an error updating your password", "login_password_changed_success": "Password updated successfully", "map_assets_in_bound": "{} photo", - "map_assets_in_bounds": "{} photos", + "map_assets_in_bounds": "{} fotos", "map_cannot_get_user_location": "Cannot get user's location", "map_location_dialog_cancel": "Cancel", "map_location_dialog_yes": "Yes", @@ -264,83 +265,83 @@ "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", "map_no_location_permission_title": "Location Permission denied", "map_settings_dark_mode": "Dark mode", - "map_settings_date_range_option_all": "All", - "map_settings_date_range_option_day": "Past 24 hours", - "map_settings_date_range_option_days": "Past {} days", - "map_settings_date_range_option_year": "Past year", - "map_settings_date_range_option_years": "Past {} years", + "map_settings_date_range_option_all": "Tudo", + "map_settings_date_range_option_day": "Últimas 24 horas", + "map_settings_date_range_option_days": "Últimos {} dias", + "map_settings_date_range_option_year": "Último ano", + "map_settings_date_range_option_years": "Últimos {} anos", "map_settings_dialog_cancel": "Cancel", "map_settings_dialog_save": "Save", "map_settings_dialog_title": "Map Settings", "map_settings_include_show_archived": "Include Archived", "map_settings_only_relative_range": "Date range", "map_settings_only_show_favorites": "Show Favorite Only", - "map_settings_theme_settings": "Map Theme", + "map_settings_theme_settings": "Tema do mapa", "map_zoom_to_see_photos": "Zoom out to see photos", "monthly_title_text_date_format": "MMMM y", - "motion_photos_page_title": "Motion Photos", + "motion_photos_page_title": "Fotos com movimento", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", - "notification_permission_dialog_cancel": "Cancel", - "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_cancel": "Cancelar", + "notification_permission_dialog_content": "Para ativar as notificações, vá a Definições e selecione permitir.", "notification_permission_dialog_settings": "Settings", "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", - "notification_permission_list_tile_title": "Notification Permission", - "partner_page_add_partner": "Add partner", + "notification_permission_list_tile_title": "Permissão de notificações", + "partner_page_add_partner": "Adicionar parceiro", "partner_page_empty_message": "Your photos are not yet shared with any partner.", "partner_page_no_more_users": "No more users to add", "partner_page_partner_add_failed": "Failed to add partner", - "partner_page_select_partner": "Select partner", + "partner_page_select_partner": "Selecionar parceiro", "partner_page_shared_to_title": "Shared to", "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Partner", "permission_onboarding_back": "Back", - "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_continue_anyway": "Continuar de qualquer maneira", "permission_onboarding_get_started": "Get started", "permission_onboarding_go_to_settings": "Go to settings", "permission_onboarding_grant_permission": "Grant permission", - "permission_onboarding_log_out": "Log out", - "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", - "permission_onboarding_permission_granted": "Permission granted! You are all set.", - "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", - "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "permission_onboarding_log_out": "Sair", + "permission_onboarding_permission_denied": "Permissão negada. Para utilizar o Immich, conceda permissões de fotografia e vídeo nas Definições.", + "permission_onboarding_permission_granted": "Autorização concedida! Está tudo pronto.", + "permission_onboarding_permission_limited": "Permissão limitada. Para permitir que o Immich faça cópias de segurança e gira toda a sua coleção de galerias, conceda permissões para fotografias e vídeos nas Definições.", + "permission_onboarding_request": "O Immich requer autorização para ver as suas fotografias e vídeos.", "profile_drawer_app_logs": "Logs", - "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", - "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", + "profile_drawer_client_out_of_date_major": "A aplicação móvel está desatualizada. Atualize para a versão principal mais recente.", + "profile_drawer_client_out_of_date_minor": "A aplicação móvel está desatualizada. Por favor, atualize para a versão mais recente.", "profile_drawer_client_server_up_to_date": "Cliente e Servidor atualizados", "profile_drawer_documentation": "Documentation", "profile_drawer_github": "GitHub", - "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", - "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_server_out_of_date_major": "O servidor está desatualizado. Atualize para a versão principal mais recente.", + "profile_drawer_server_out_of_date_minor": "O servidor está desatualizado. Atualize para a versão mais recente.", "profile_drawer_settings": "Configurações", "profile_drawer_sign_out": "Sair", "profile_drawer_trash": "Trash", - "recently_added_page_title": "Recently Added", - "scaffold_body_error_occurred": "Error occurred", + "recently_added_page_title": "Adicionado recentemente", + "scaffold_body_error_occurred": "Ocorreu um erro", "search_bar_hint": "Busque suas fotos", "search_page_categories": "Categories", - "search_page_favorites": "Favorites", - "search_page_motion_photos": "Motion Photos", + "search_page_favorites": "Favoritos", + "search_page_motion_photos": "Fotos com movimento", "search_page_no_objects": "Nenhuma informação de objeto disponível", - "search_page_no_places": "Nenhuma informação de lugares disponível", + "search_page_no_places": "Nenhuma informação de sítios disponível", "search_page_people": "People", - "search_page_person_add_name_dialog_cancel": "Cancel", - "search_page_person_add_name_dialog_hint": "Name", - "search_page_person_add_name_dialog_save": "Save", - "search_page_person_add_name_dialog_title": "Add a name", - "search_page_person_add_name_subtitle": "Find them fast by name with search", - "search_page_person_add_name_title": "Add a name", - "search_page_person_edit_name": "Edit name", - "search_page_places": "Lugares", - "search_page_recently_added": "Recently added", + "search_page_person_add_name_dialog_cancel": "Cancelar", + "search_page_person_add_name_dialog_hint": "Nome", + "search_page_person_add_name_dialog_save": "Guardar", + "search_page_person_add_name_dialog_title": "Adicionar um nome", + "search_page_person_add_name_subtitle": "Encontre-os rapidamente pelo nome com a pesquisa", + "search_page_person_add_name_title": "Adicionar um nome", + "search_page_person_edit_name": "Editar nome", + "search_page_places": "Sítios", + "search_page_recently_added": "Adicionado recentemente", "search_page_screenshots": "Screenshots", "search_page_selfies": "Selfies", "search_page_things": "Objetos", - "search_page_videos": "Videos", - "search_page_view_all_button": "View all", - "search_page_your_activity": "Your activity", + "search_page_videos": "Vídeos", + "search_page_view_all_button": "Ver tudo", + "search_page_your_activity": "A sua atividade", "search_page_your_map": "Your Map", "search_result_page_new_search_hint": "Nova Busca", "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", @@ -348,10 +349,10 @@ "select_additional_user_for_sharing_page_suggestions": "Sugestões", "select_user_for_sharing_page_err_album": "Falha ao criar o álbum", "select_user_for_sharing_page_share_suggestions": "Sugestões", - "server_info_box_app_version": "App Version", - "server_info_box_latest_release": "Latest Version", + "server_info_box_app_version": "Versão da app", + "server_info_box_latest_release": "Última versão", "server_info_box_server_url": "Server URL", - "server_info_box_server_version": "Server Version", + "server_info_box_server_version": "Versão do servidor", "setting_image_viewer_help": "O visualizador de detalhes carrega primeiro a miniatura pequena, depois carrega a visualização de tamanho médio (se ativado) e, finalmente, carrega o original (se ativado).", "setting_image_viewer_original_subtitle": "Ative para carregar a imagem original em resolução total (grande!). Desative para reduzir o uso de dados (na rede e no cache do dispositivo).", "setting_image_viewer_original_title": "Carregar imagem original", @@ -381,65 +382,65 @@ "shared_album_activity_remove_title": "Delete Activity", "shared_album_activity_setting_subtitle": "Let others respond", "shared_album_activity_setting_title": "Comments & likes", - "shared_album_section_people_action_error": "Error leaving/removing from album", - "shared_album_section_people_action_leave": "Remove user from album", - "shared_album_section_people_action_remove_user": "Remove user from album", + "shared_album_section_people_action_error": "Erro ao sair/remover do álbum", + "shared_album_section_people_action_leave": "Remover utilizador do álbum", + "shared_album_section_people_action_remove_user": "Remover utilizador do álbum", "shared_album_section_people_owner_label": "Owner", "shared_album_section_people_title": "PEOPLE", "share_dialog_preparing": "Preparando...", - "shared_link_app_bar_title": "Shared Links", + "shared_link_app_bar_title": "Links partilhados", "shared_link_clipboard_copied_massage": "Copied to clipboard", "shared_link_clipboard_text": "Link: {}\nPassword: {}", "shared_link_create_app_bar_title": "Create link to share", "shared_link_create_error": "Error while creating shared link", "shared_link_create_info": "Let anyone with the link see the selected photo(s)", "shared_link_create_submit_button": "Create link", - "shared_link_edit_allow_download": "Allow public user to download", - "shared_link_edit_allow_upload": "Allow public user to upload", + "shared_link_edit_allow_download": "Permitir que um utilizador público descarregue", + "shared_link_edit_allow_upload": "Permitir que um utilizador público carregue", "shared_link_edit_app_bar_title": "Edit link", - "shared_link_edit_change_expiry": "Change expiration time", + "shared_link_edit_change_expiry": "Alterar o prazo de validade", "shared_link_edit_description": "Description", "shared_link_edit_description_hint": "Enter the share description", "shared_link_edit_expire_after": "Expire after", - "shared_link_edit_expire_after_option_day": "1 day", - "shared_link_edit_expire_after_option_days": "{} days", - "shared_link_edit_expire_after_option_hour": "1 hour", - "shared_link_edit_expire_after_option_hours": "{} hours", - "shared_link_edit_expire_after_option_minute": "1 minute", - "shared_link_edit_expire_after_option_minutes": "{} minutes", + "shared_link_edit_expire_after_option_day": "1 dia", + "shared_link_edit_expire_after_option_days": "{} dias", + "shared_link_edit_expire_after_option_hour": "1 hora", + "shared_link_edit_expire_after_option_hours": "{} horas", + "shared_link_edit_expire_after_option_minute": "1 minuto", + "shared_link_edit_expire_after_option_minutes": "{} minutos", "shared_link_edit_expire_after_option_never": "Never", "shared_link_edit_password": "Password", "shared_link_edit_password_hint": "Enter the share password", - "shared_link_edit_show_meta": "Show metadata", - "shared_link_edit_submit_button": "Update link", - "shared_link_empty": "You don't have any shared links", + "shared_link_edit_show_meta": "Mostrar metadados", + "shared_link_edit_submit_button": "Atualizar link", + "shared_link_empty": "Não tem links partilhados", "shared_link_error_server_url_fetch": "Cannot fetch the server url", "shared_link_expired": "Expired", - "shared_link_expires_day": "Expires in {} day", - "shared_link_expires_days": "Expires in {} days", - "shared_link_expires_hour": "Expires in {} hour", - "shared_link_expires_hours": "Expires in {} hours", - "shared_link_expires_minute": "Expires in {} minute", + "shared_link_expires_day": "Expira em {} dia", + "shared_link_expires_days": "Expira em {} dias", + "shared_link_expires_hour": "Expira em {} hora", + "shared_link_expires_hours": "Expira em {} horas", + "shared_link_expires_minute": "Expira em {} minuto", "shared_link_expires_minutes": "Expires in {} minutes", "shared_link_expires_never": "Expires ∞", - "shared_link_expires_second": "Expires in {} second", + "shared_link_expires_second": "Expira em {} segundo", "shared_link_expires_seconds": "Expires in {} seconds", - "shared_link_info_chip_download": "Download", + "shared_link_info_chip_download": "Descarregar", "shared_link_info_chip_metadata": "EXIF", "shared_link_info_chip_upload": "Upload", - "shared_link_manage_links": "Manage Shared links", + "shared_link_manage_links": "Gerir links partilhados", "share_done": "Done", "share_invite": "Convidar para álbum", - "sharing_page_album": "Álbuns compartilhados", - "sharing_page_description": "Criar álbuns compartilhados para compartilhas fotos e vídeos com pessoas na sua rede.", + "sharing_page_album": "Álbuns partilhados", + "sharing_page_description": "Crie álbuns partilhados para partilhar fotografias e vídeos com pessoas da sua rede.", "sharing_page_empty_list": "LISTA VAZIA", - "sharing_silver_appbar_create_shared_album": "Criar um álgum compartilhado", + "sharing_silver_appbar_create_shared_album": "Criar álbum partilhado", "sharing_silver_appbar_shared_links": "Shared links", - "sharing_silver_appbar_share_partner": "Compartilhar com parceiro", + "sharing_silver_appbar_share_partner": "Partilhar com parceiro", "tab_controller_nav_library": "Biblioteca", "tab_controller_nav_photos": "Fotos", - "tab_controller_nav_search": "Buscar", - "tab_controller_nav_sharing": "Compartilhando", + "tab_controller_nav_search": "Procurar", + "tab_controller_nav_sharing": "Partilhar", "theme_setting_asset_list_storage_indicator_title": "Mostrar indicador de armazenamento em blocos de ativos", "theme_setting_asset_list_tiles_per_row_title": "Número de itens por linha ({})", "theme_setting_dark_mode_switch": "Modo escuro", @@ -467,11 +468,11 @@ "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_ok": "Upload", "upload_dialog_title": "Upload Asset", - "version_announcement_overlay_ack": "Need Context", + "version_announcement_overlay_ack": "Aceitar", "version_announcement_overlay_release_notes": "notas de lançamento", "version_announcement_overlay_text_1": "Olá, há um novo lançamento de", "version_announcement_overlay_text_2": "por favor, tome o seu tempo para visitar o", - "version_announcement_overlay_text_3": "e certifique-se de que a configuração do docker-compose e do .env estejam atualizadas para evitar configurações incorretas, especialmente se você usar o WatchTower ou qualquer mecanismo que lide com a atualização automática do aplicativo do servidor.", + "version_announcement_overlay_text_3": "e certifique-se de que a configuração do docker-compose e do .env estejam atualizadas para evitar configurações incorretas, especialmente se usar o WatchTower ou qualquer mecanismo que lide com a atualização automática do servidor.", "version_announcement_overlay_title": "Nova versão do servidor disponível \uD83C\uDF89", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", diff --git a/mobile/assets/i18n/ru-RU.json b/mobile/assets/i18n/ru-RU.json index d8756108f9ba3..23028f9ef5a0f 100644 --- a/mobile/assets/i18n/ru-RU.json +++ b/mobile/assets/i18n/ru-RU.json @@ -1,12 +1,12 @@ { - "action_common_cancel": "Cancel", - "action_common_update": "Update", + "action_common_cancel": "Отмена", + "action_common_update": "Обновить", "add_to_album_bottom_sheet_added": "Добавлено в {album}", "add_to_album_bottom_sheet_already_exists": "Уже в {album}", "advanced_settings_log_level_title": "Log level: {}", - "advanced_settings_prefer_remote_subtitle": "Некоторые устройства очень медленно загружают предпросмотр объектов, находящихся на устройстве. Активируйте эту настройку, чтобы вместо них загружались изображени с сервера.", + "advanced_settings_prefer_remote_subtitle": "Некоторые устройства очень медленно загружают предпросмотр объектов, находящихся на устройстве. Активируйте эту настройку, чтобы вместо них загружались изображения с сервера.", "advanced_settings_prefer_remote_title": "Предпочитать фото на сервере", - "advanced_settings_self_signed_ssl_subtitle": "Пропускает проверку сертификата SSL для конечной точки сервера. Требуется для самоподписанных сертификатов.", + "advanced_settings_self_signed_ssl_subtitle": "Пропускает проверку SSL-сертификата сервера. Требуется для самоподписанных сертификатов.", "advanced_settings_self_signed_ssl_title": "Разрешить самоподписанные SSL-сертификаты", "advanced_settings_tile_subtitle": "Расширенные настройки пользователя", "advanced_settings_tile_title": "Расширенные", @@ -35,14 +35,14 @@ "app_bar_signout_dialog_title": "Выйти из системы", "archive_page_no_archived_assets": "В архиве сейчас пусто", "archive_page_title": "Архив ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_action_delete_err_read_only": "Невозможно удалить объект(ы) только для чтения, пропуск...", + "asset_action_share_err_offline": "Невозможно получить оффлайн-объект(ы), пропуск...", "asset_list_layout_settings_dynamic_layout_title": "Динамическое расположение", "asset_list_layout_settings_group_automatically": "Автоматически", "asset_list_layout_settings_group_by": "Группировать объекты по:", "asset_list_layout_settings_group_by_month": "Месяцу", "asset_list_layout_settings_group_by_month_day": "Месяцу и дню", - "asset_list_settings_subtitle": "Настройки макета сетки фотографий", + "asset_list_settings_subtitle": "Настройка макета сетки фотографий", "asset_list_settings_title": "Сетка фотографий", "backup_album_selection_page_albums_device": "Альбомов на устройстве ({})", "backup_album_selection_page_albums_tap": "Нажмите, чтобы включить, нажмите дважды, чтобы исключить", @@ -65,7 +65,7 @@ "backup_controller_page_background_battery_info_link": "Показать как", "backup_controller_page_background_battery_info_message": "Для наилучшего фонового резервного копирования отключите любые настройки оптимизации батареи, ограничивающие фоновую активность для Immich.\n\nПоскольку это зависит от устройства, найдите необходимую информацию для производителя вашего устройства.", "backup_controller_page_background_battery_info_ok": "ОК", - "backup_controller_page_background_battery_info_title": "\nОптимизация батареи", + "backup_controller_page_background_battery_info_title": "Оптимизация батареи", "backup_controller_page_background_charging": "Только во время зарядки", "backup_controller_page_background_configure_error": "Не удалось настроить фоновую службу", "backup_controller_page_background_delay": "Отложить резервное копирование новых объектов: {}", @@ -112,8 +112,8 @@ "cache_settings_clear_cache_button": "Очистить кэш", "cache_settings_clear_cache_button_title": "Очищает кэш приложения. Это значительно повлияет на производительность приложения, до тех пор, пока кэш не будет перестроен заново.", "cache_settings_duplicated_assets_clear_button": "ОЧИСТИТЬ", - "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", - "cache_settings_duplicated_assets_title": "Дублированные ресурсы", + "cache_settings_duplicated_assets_subtitle": "Фото и видео, занесенные приложением в черный список", + "cache_settings_duplicated_assets_title": "Дублирующиеся объекты ({})", "cache_settings_image_cache_size": "Размер кэша изображений ({} объектов)", "cache_settings_statistics_album": "Миниатюры библиотеки", "cache_settings_statistics_assets": "{} объектов ({})", @@ -140,19 +140,19 @@ "control_bottom_app_bar_album_info": "{} файлов", "control_bottom_app_bar_album_info_shared": "{} файлов · Общий", "control_bottom_app_bar_archive": "Архив", - "control_bottom_app_bar_create_new_album": "\nСоздать новый альбом", + "control_bottom_app_bar_create_new_album": "Создать новый альбом", "control_bottom_app_bar_delete": "Удалить", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", - "control_bottom_app_bar_edit_location": "Изменить местоположение", - "control_bottom_app_bar_edit_time": "Изменить дату и время", - "control_bottom_app_bar_favorite": "Избранное", + "control_bottom_app_bar_delete_from_immich": "Удалить из Immich\n", + "control_bottom_app_bar_delete_from_local": "Удалить с устройства", + "control_bottom_app_bar_edit_location": "Редактировать местоположение", + "control_bottom_app_bar_edit_time": "Редактировать дату и время", + "control_bottom_app_bar_favorite": "В избранное", "control_bottom_app_bar_share": "Поделиться", "control_bottom_app_bar_share_to": "Поделиться", "control_bottom_app_bar_stack": "Стек", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_trash_from_immich": "Переместить в корзину", "control_bottom_app_bar_unarchive": "Восстановить", - "control_bottom_app_bar_unfavorite": "Исключить из избранного", + "control_bottom_app_bar_unfavorite": "Удалить из избранного", "control_bottom_app_bar_upload": "Загрузить", "create_album_page_untitled": "Без названия", "create_shared_album_page_create": "Создать", @@ -165,26 +165,27 @@ "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", "delete_dialog_alert": "Эти элементы будут безвозвратно удалены из приложения, а также с вашего устройства", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "Эти объекты будут безвозвратно удалены с Вашего устройства, но по-прежнему будут доступны на сервере Immich", + "delete_dialog_alert_local_non_backed_up": "Резервные копии некоторых объектов не были загружены в Immich и будут безвозвратно удалены с Вашего устройства", + "delete_dialog_alert_remote": "Эти объекты будут безвозвратно удалены с сервера Immich", "delete_dialog_cancel": "Отменить", "delete_dialog_ok": "Удалить", - "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_ok_force": "Все равно удалить", "delete_dialog_title": "Удалить навсегда", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", + "delete_local_dialog_ok_backed_up_only": "Удалить только резервные копии", + "delete_local_dialog_ok_force": "Все равно удалить", "delete_shared_link_dialog_content": "Вы уверены, что хотите удалить эту общую ссылку?", "delete_shared_link_dialog_title": "Удалить общую ссылку", "description_input_hint_text": "Добавить описание...", "description_input_submit_error": "Не удалось обновить описание, проверьте логи, чтобы узнать причину", - "edit_date_time_dialog_date_time": "Date and Time", - "edit_date_time_dialog_timezone": "Timezone", - "edit_location_dialog_title": "Location", + "edit_date_time_dialog_date_time": "Дата и время", + "edit_date_time_dialog_timezone": "Часовой пояс", + "edit_location_dialog_title": "Местоположение", "exif_bottom_sheet_description": "Добавить описание...", "exif_bottom_sheet_details": "ПОДРОБНОСТИ", - "exif_bottom_sheet_location": "МЕСТОПОЛОЖЕНИЕ", + "exif_bottom_sheet_location": "Местоположение", "exif_bottom_sheet_location_add": "Добавить местоположение", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Ведутся работы", "experimental_settings_new_asset_list_title": "Включить экспериментальную сетку фотографий", "experimental_settings_subtitle": "Используйте на свой страх и риск!", @@ -194,39 +195,39 @@ "home_page_add_to_album_conflicts": "Добавлено {added} объектов в альбом {album}. Объекты {failed} уже есть в альбоме.", "home_page_add_to_album_err_local": "Пока нельзя добавлять локальные объекты в альбомы, пропускаем", "home_page_add_to_album_success": "Добавлено {added} объектов в альбом {album}.", - "home_page_album_err_partner": "Пока не удается добавить партнерские активы в альбом, пропуск...", + "home_page_album_err_partner": "Пока не удается добавить объекты партнера в альбом, пропуск...", "home_page_archive_err_local": "Пока невозможно добавить локальные объекты в архив, пропускаем", - "home_page_archive_err_partner": "Невозможно архивировать активы партнеров, пропуск...", + "home_page_archive_err_partner": "Невозможно архивировать объекты партнера, пропуск...", "home_page_building_timeline": "Построение временной шкалы", - "home_page_delete_err_partner": "Невозможно удалить активы партнера, пропуск...", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", - "home_page_favorite_err_local": "Пока не удается добавить в избранное локальные объекты, пропускаем", - "home_page_favorite_err_partner": "Пока не удается выделить партнерские активы, пропуск...", + "home_page_delete_err_partner": "Невозможно удалить объекты партнера, пропуск...", + "home_page_delete_remote_err_local": "Локальные объект(ы) уже в процессе удаления с сервера, пропуск...", + "home_page_favorite_err_local": "Пока не удается добавить в избранное локальные объекты, пропуск...", + "home_page_favorite_err_partner": "Пока не удается добавить в избранное объекты партнера, пропуск...", "home_page_first_time_notice": "Если вы используете приложение впервые, убедитесь, что вы выбрали резервный(е) альбом(ы), чтобы временная шкала могла заполнить фотографии и видео в альбоме(ах).", "home_page_share_err_local": "Невозможно поделиться локальными данными по ссылке, пропуск...", "home_page_upload_err_limit": "Вы можете выгрузить максимум 30 файлов за раз", "image_viewer_page_state_provider_download_error": "Ошибка загрузки", "image_viewer_page_state_provider_download_success": "Успешно загружено", - "image_viewer_page_state_provider_share_error": "Ошибка при публикации", + "image_viewer_page_state_provider_share_error": "Ошибка общего доступа", "library_page_albums": "Альбомы", "library_page_archive": "Архив", "library_page_device_albums": "Альбомы на устройстве", "library_page_favorites": "Избранное", "library_page_new_album": "Новый альбом", "library_page_sharing": "Общие", - "library_page_sort_asset_count": "Number of assets", - "library_page_sort_created": "По новизне", + "library_page_sort_asset_count": "Количество объектов", + "library_page_sort_created": "Недавно созданные", "library_page_sort_last_modified": "Последнее изменение", - "library_page_sort_most_oldest_photo": "Oldest photo", - "library_page_sort_most_recent_photo": "Последняя фотография", - "library_page_sort_title": "По названию альбома", - "location_picker_choose_on_map": "Choose on map", - "location_picker_latitude": "Latitude", - "location_picker_latitude_error": "Enter a valid latitude", - "location_picker_latitude_hint": "Enter your latitude here", - "location_picker_longitude": "Longitude", - "location_picker_longitude_error": "Enter a valid longitude", - "location_picker_longitude_hint": "Enter your longitude here", + "library_page_sort_most_oldest_photo": "Самые старые фото", + "library_page_sort_most_recent_photo": "Самые последние фото", + "library_page_sort_title": "Название альбома", + "location_picker_choose_on_map": "Выбрать на карте", + "location_picker_latitude": "Широта", + "location_picker_latitude_error": "Укажите правильную широту", + "location_picker_latitude_hint": "Укажите широту", + "location_picker_longitude": "Долгота", + "location_picker_longitude_error": "Укажите правильную долготу", + "location_picker_longitude_hint": "Укажите долготу", "login_disabled": "Вход отключен", "login_form_api_exception": "Ошибка при попытке взаимодействия с сервером. Проверьте URL-адрес до него и попробуйте еще раз.", "login_form_back_button_text": "Назад", @@ -252,35 +253,35 @@ "login_form_server_error": "Нет соединения с сервером.", "login_password_changed_error": "Произошла ошибка при обновлении пароля", "login_password_changed_success": "Пароль успешно обновлен", - "map_assets_in_bound": "{} photo", - "map_assets_in_bounds": "{} photos", + "map_assets_in_bound": "{} фото", + "map_assets_in_bounds": "{} фото", "map_cannot_get_user_location": "Невозможно получить местоположение пользователя", "map_location_dialog_cancel": "Отмена", "map_location_dialog_yes": "Да", - "map_location_picker_page_use_location": "Use this location", + "map_location_picker_page_use_location": "Это местоположение", "map_location_service_disabled_content": "Для отображения объектов в данном месте необходимо включить службу определения местоположения. Хотите включить ее сейчас?", "map_location_service_disabled_title": "Служба определения местоположения отключена", "map_no_assets_in_bounds": "Нет фотографий в этой области", "map_no_location_permission_content": "Для отображения объектов из текущего местоположения необходимо разрешение на определение местоположения. Хотите ли вы разрешить его сейчас?", "map_no_location_permission_title": "Доступ к местоположению отклонен", "map_settings_dark_mode": "Темный режим", - "map_settings_date_range_option_all": "All", - "map_settings_date_range_option_day": "Past 24 hours", - "map_settings_date_range_option_days": "Past {} days", - "map_settings_date_range_option_year": "Past year", - "map_settings_date_range_option_years": "Past {} years", + "map_settings_date_range_option_all": "Все", + "map_settings_date_range_option_day": "Прошлые 24 часа", + "map_settings_date_range_option_days": "Прошлые {} дней", + "map_settings_date_range_option_year": "Прошлый год", + "map_settings_date_range_option_years": "Прошлые {} года", "map_settings_dialog_cancel": "Отмена", "map_settings_dialog_save": "Сохранить", "map_settings_dialog_title": "Настройки карты", "map_settings_include_show_archived": "Включить архивные данные", "map_settings_only_relative_range": "Период времени", "map_settings_only_show_favorites": "Показать только избранное", - "map_settings_theme_settings": "Map Theme", + "map_settings_theme_settings": "Тема карты", "map_zoom_to_see_photos": "Уменьшение масштаба для просмотра фотографий", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Динамические фото", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "Невозможно редактировать дату объектов только для чтения, пропуск...", + "multiselect_grid_edit_gps_err_read_only": "Невозможно редактировать местоположение объектов только для чтения, пропуск...", "notification_permission_dialog_cancel": "Отмена", "notification_permission_dialog_content": "Чтобы включить уведомления, перейдите в «Настройки» и выберите «Разрешить».", "notification_permission_dialog_settings": "Настройки", @@ -318,7 +319,7 @@ "profile_drawer_sign_out": "Выйти", "profile_drawer_trash": "Корзина", "recently_added_page_title": "Недавно добавленные", - "scaffold_body_error_occurred": "Error occurred", + "scaffold_body_error_occurred": "Возникла ошибка", "search_bar_hint": "Поиск фотографий", "search_page_categories": "Категории", "search_page_favorites": "Избранное", @@ -332,31 +333,31 @@ "search_page_person_add_name_dialog_title": "Добавить имя", "search_page_person_add_name_subtitle": "Быстро найдите их по имени с помощью поиска", "search_page_person_add_name_title": "Добавить имя", - "search_page_person_edit_name": "Изменить имя", + "search_page_person_edit_name": "Редактировать имя", "search_page_places": "Места", "search_page_recently_added": "Недавно добавленные", - "search_page_screenshots": "Скриншоты", + "search_page_screenshots": "Снимки экрана", "search_page_selfies": "Селфи", "search_page_things": "Предметы", "search_page_videos": "Видео", "search_page_view_all_button": "Посмотреть все", - "search_page_your_activity": "Ваша активность", + "search_page_your_activity": "Ваши действия", "search_page_your_map": "Ваша карта", "search_result_page_new_search_hint": "Новый поиск", "search_suggestion_list_smart_search_hint_1": "Интеллектуальный поиск включен по умолчанию, для поиска метаданных используйте специальный синтаксис", - "search_suggestion_list_smart_search_hint_2": "m:ваш-запрос", + "search_suggestion_list_smart_search_hint_2": "m:ваш-поисковый-запрос", "select_additional_user_for_sharing_page_suggestions": "Предложения", - "select_user_for_sharing_page_err_album": "\nНе удалось создать альбом", + "select_user_for_sharing_page_err_album": "Не удалось создать альбом", "select_user_for_sharing_page_share_suggestions": "Предложения", "server_info_box_app_version": "Версия приложения", - "server_info_box_latest_release": "Крайняя версия", + "server_info_box_latest_release": "Последняя версия", "server_info_box_server_url": "URL сервера", "server_info_box_server_version": "Версия сервера", - "setting_image_viewer_help": "Средство просмотра деталей сначала загружает маленькую миниатюру, затем загружает предварительный просмотр среднего размера (если включено) и, наконец, загружает оригинал (если включено).", - "setting_image_viewer_original_subtitle": "Включите загрузку оригинального изображения в полном разрешении (большое!). Отключите, чтобы уменьшить объем данных (как в сети, так и в кеше устройства).", - "setting_image_viewer_original_title": "Загрузить исходное изображение", - "setting_image_viewer_preview_subtitle": "Включите загрузку изображения среднего разрешения. Отключите, чтобы загрузить оригинал напрямую или использовать только миниатюру.", - "setting_image_viewer_preview_title": "Загрузить изображение для предварительного просмотра", + "setting_image_viewer_help": "Полноэкранный просмотрщик сначала загружает изображение для предпросмотра в низком разрешении, затем загружает изображение в уменьшенном разрешении относительно оригинала (если включено) и в конце концов загружает оригинал (если включено).", + "setting_image_viewer_original_subtitle": "Включите для загрузки исходного изображения в полном разрешении (большое!).\nОтключите, чтобы уменьшить объем данных (как сети, так и кэша устройства).", + "setting_image_viewer_original_title": "Загружать исходное изображение", + "setting_image_viewer_preview_subtitle": "Включите для загрузки изображения среднего разрешения.\nОтключите, чтобы загружать оригинал напрямую или использовать только миниатюру.", + "setting_image_viewer_preview_title": "Загружать изображение для предварительного просмотра", "setting_notifications_notify_failures_grace_period": "Уведомлять об ошибках фонового резервного копирования: {}", "setting_notifications_notify_hours": "{} часов", "setting_notifications_notify_immediately": "немедленно", @@ -365,7 +366,7 @@ "setting_notifications_notify_seconds": "{} секунд", "setting_notifications_single_progress_subtitle": "Подробная информация о ходе загрузки для каждого объекта", "setting_notifications_single_progress_title": "Показать ход выполнения фонового резервного копирования", - "setting_notifications_subtitle": "Настроить параметры уведомлений", + "setting_notifications_subtitle": "Настройка параметров уведомлени", "setting_notifications_title": "Уведомления", "setting_notifications_total_progress_subtitle": "Общий прогресс загрузки (выполнено/всего объектов)", "setting_notifications_total_progress_title": "Показать общий прогресс фонового резервного копирования", @@ -375,61 +376,61 @@ "share_add_photos": "Добавить фото", "share_add_title": "Добавить название", "share_create_album": "Создать альбом", - "shared_album_activities_input_disable": "Комментарий отключен", + "shared_album_activities_input_disable": "Комментирование отключено", "shared_album_activities_input_hint": "Скажите что-нибудь", - "shared_album_activity_remove_content": "Хотите ли Вы удалить это действие?", - "shared_album_activity_remove_title": "Удалить действие", - "shared_album_activity_setting_subtitle": "Предоставьте другим возможность отвечать", + "shared_album_activity_remove_content": "Хотите ли Вы удалить это сообщение?", + "shared_album_activity_remove_title": "Удалить сообщение", + "shared_album_activity_setting_subtitle": "Разрешить другим отвечат", "shared_album_activity_setting_title": "Комментарии и лайки", - "shared_album_section_people_action_error": "Error leaving/removing from album", + "shared_album_section_people_action_error": "Ошибка при выходе/удалении из альбома", "shared_album_section_people_action_leave": "Удалить пользователя из альбома", "shared_album_section_people_action_remove_user": "Удалить пользователя из альбома", - "shared_album_section_people_owner_label": "Owner", - "shared_album_section_people_title": "PEOPLE", + "shared_album_section_people_owner_label": "Владелец", + "shared_album_section_people_title": "ЛЮДИ", "share_dialog_preparing": "Подготовка...", "shared_link_app_bar_title": "Общие ссылки", "shared_link_clipboard_copied_massage": "Скопировано в буфер обмена", - "shared_link_clipboard_text": "Link: {}\nPassword: {}", - "shared_link_create_app_bar_title": "Создать ссылку для совместного использования", - "shared_link_create_error": "Error while creating shared link", - "shared_link_create_info": "Позволить любому человеку, имеющему ссылку, увидеть выбранную фотографию (фотографии)", + "shared_link_clipboard_text": "Ссылка: {}\nПароль: {}", + "shared_link_create_app_bar_title": "Создать ссылку общего доступа", + "shared_link_create_error": "Ошибка при создании общей ссылки", + "shared_link_create_info": "Разрешить всем, у кого есть ссылка, просматривать выбранные фото", "shared_link_create_submit_button": "Создать ссылку", - "shared_link_edit_allow_download": "Разрешить публичному пользователю скачивать", + "shared_link_edit_allow_download": "Разрешить публичному пользователю скачивать файлы", "shared_link_edit_allow_upload": "Разрешить публичному пользователю загружать файлы", "shared_link_edit_app_bar_title": "Редактировать ссылку", "shared_link_edit_change_expiry": "Изменить срок действия доступа", "shared_link_edit_description": "Описание", - "shared_link_edit_description_hint": "Введите описание совместного доступа", - "shared_link_edit_expire_after": "Истекает после", - "shared_link_edit_expire_after_option_day": "1 day", - "shared_link_edit_expire_after_option_days": "{} days", - "shared_link_edit_expire_after_option_hour": "1 hour", - "shared_link_edit_expire_after_option_hours": "{} hours", - "shared_link_edit_expire_after_option_minute": "1 minute", - "shared_link_edit_expire_after_option_minutes": "{} minutes", + "shared_link_edit_description_hint": "Введите описание для общего доступа", + "shared_link_edit_expire_after": "Истекает через", + "shared_link_edit_expire_after_option_day": "1 день", + "shared_link_edit_expire_after_option_days": "{} дней", + "shared_link_edit_expire_after_option_hour": "1 час", + "shared_link_edit_expire_after_option_hours": "{} часов", + "shared_link_edit_expire_after_option_minute": "1 минуту", + "shared_link_edit_expire_after_option_minutes": "{} минут", "shared_link_edit_expire_after_option_never": "Никогда", "shared_link_edit_password": "Пароль", - "shared_link_edit_password_hint": "Введите пароль общего доступа", - "shared_link_edit_show_meta": "Показать метаданные", + "shared_link_edit_password_hint": "Введите пароль для общего доступа", + "shared_link_edit_show_meta": "Показывать метаданные", "shared_link_edit_submit_button": "Обновить ссылку", "shared_link_empty": "У вас нет общих ссылок", - "shared_link_error_server_url_fetch": "Cannot fetch the server url", - "shared_link_expired": "Expired", - "shared_link_expires_day": "Expires in {} day", - "shared_link_expires_days": "Expires in {} days", - "shared_link_expires_hour": "Expires in {} hour", - "shared_link_expires_hours": "Expires in {} hours", - "shared_link_expires_minute": "Expires in {} minute", + "shared_link_error_server_url_fetch": "Невозможно запросить URL с сервера", + "shared_link_expired": "Срок действия истек", + "shared_link_expires_day": "Истекает через {} день", + "shared_link_expires_days": "Истекает через {} дней", + "shared_link_expires_hour": "Истекает через {} час", + "shared_link_expires_hours": "Истекает через {} часов", + "shared_link_expires_minute": "Истекает через {} минуту", "shared_link_expires_minutes": "Истекает через {} минут", - "shared_link_expires_never": "Expires ∞", - "shared_link_expires_second": "Expires in {} second", + "shared_link_expires_never": "Истекает ∞", + "shared_link_expires_second": "Истекает через {} секунду", "shared_link_expires_seconds": "Истекает через {} секунд", - "shared_link_info_chip_download": "Download", + "shared_link_info_chip_download": "Скачать", "shared_link_info_chip_metadata": "EXIF", - "shared_link_info_chip_upload": "Upload", + "shared_link_info_chip_upload": "Загрузить", "shared_link_manage_links": "Управление общими ссылками", - "share_done": "Выполнено", - "share_invite": "\nПригласить в альбом", + "share_done": "Готово", + "share_invite": "Пригласить в альбом", "sharing_page_album": "Общие альбомы", "sharing_page_description": "Создавайте общие альбомы, чтобы делиться фотографиями и видео с людьми в вашей сети.", "sharing_page_empty_list": "ПУСТОЙ СПИСОК", @@ -443,10 +444,10 @@ "theme_setting_asset_list_storage_indicator_title": "Показать индикатор хранилища на плитках объектов", "theme_setting_asset_list_tiles_per_row_title": "Количество объектов в строке ({})", "theme_setting_dark_mode_switch": "Тёмная тема", - "theme_setting_image_viewer_quality_subtitle": "Настройка качества детального просмотра изображения", + "theme_setting_image_viewer_quality_subtitle": "Настройка качества просмотра полноэкранных изображения", "theme_setting_image_viewer_quality_title": "Качество просмотра изображений", - "theme_setting_system_theme_switch": "Автоматически (Как в системе)", - "theme_setting_theme_subtitle": "Выберите настройки темы приложения", + "theme_setting_system_theme_switch": "Автоматически (как в системе)", + "theme_setting_theme_subtitle": "Настройка темы приложения", "theme_setting_theme_title": "Тема", "theme_setting_three_stage_loading_subtitle": "Трехэтапная загрузка может повысить производительность загрузки, но вызывает значительно более высокую нагрузку на сеть", "theme_setting_three_stage_loading_title": "Включить трехэтапную загрузку", @@ -454,10 +455,10 @@ "trash_page_delete": "Удалить", "trash_page_delete_all": "Удалить все", "trash_page_empty_trash_btn": "Очистить корзину", - "trash_page_empty_trash_dialog_content": "Вы хотите очистить свою корзину? Эти объекты будут навсегда удалены из Immich", + "trash_page_empty_trash_dialog_content": "Вы хотите очистить свою корзину? Эти объекты будут навсегда удалены из Immich.", "trash_page_empty_trash_dialog_ok": "ОК", "trash_page_info": "Удаленные элементы будут окончательно удалены через {} дней", - "trash_page_no_assets": "Отсутствие удаленных объектов", + "trash_page_no_assets": "Удаленные объекты отсутсвуют", "trash_page_restore": "Восстановить", "trash_page_restore_all": "Восстановить все", "trash_page_select_assets_btn": "Выбранные объекты", @@ -474,6 +475,6 @@ "version_announcement_overlay_text_3": " и убедитесь, что ваши настройки docker-compose и .env обновлены, чтобы предотвратить любые неправильные настройки, особенно если вы используете WatchTower или любой другой механизм, который обрабатывает обновление вашего серверного приложения автоматически.", "version_announcement_overlay_title": "Доступна новая версия сервера \uD83C\uDF89", "viewer_remove_from_stack": "Удалить из стека", - "viewer_stack_use_as_main_asset": "Использование в качестве основного объекта", + "viewer_stack_use_as_main_asset": "Использовать в качестве основного объекта", "viewer_unstack": "Разобрать стек" } \ No newline at end of file diff --git a/mobile/assets/i18n/sk-SK.json b/mobile/assets/i18n/sk-SK.json index 9557fa0ac6558..19235cbe27c12 100644 --- a/mobile/assets/i18n/sk-SK.json +++ b/mobile/assets/i18n/sk-SK.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "PODROBNOSTI", "exif_bottom_sheet_location": "LOKALITA", "exif_bottom_sheet_location_add": "Nastaviť polohu", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Prebiehajúca práca", "experimental_settings_new_asset_list_title": "Povolenie experimentálnej mriežky fotografií", "experimental_settings_subtitle": "Používajte na vlastné riziko!", diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index d855502ef03e8..eda7838913707 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATION", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json index 67fecb434bebd..6ad1119581ea1 100644 --- a/mobile/assets/i18n/sr-Latn.json +++ b/mobile/assets/i18n/sr-Latn.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETALJI", "exif_bottom_sheet_location": "LOKACIJA", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "U izradi", "experimental_settings_new_asset_list_title": "Aktiviraj eksperimentalni mrežni prikaz fotografija", "experimental_settings_subtitle": "Koristiti na sopstvenu odgovornost!", diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index d855502ef03e8..eda7838913707 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETAILS", "exif_bottom_sheet_location": "LOCATION", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", diff --git a/mobile/assets/i18n/sv-SE.json b/mobile/assets/i18n/sv-SE.json index 7d9b3dadde60b..3341cf66b218a 100644 --- a/mobile/assets/i18n/sv-SE.json +++ b/mobile/assets/i18n/sv-SE.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "DETALJER", "exif_bottom_sheet_location": "PLATS", "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Under uppbyggnad", "experimental_settings_new_asset_list_title": "Aktivera experimentellt fotorutnät", "experimental_settings_subtitle": "Använd på egen risk!", diff --git a/mobile/assets/i18n/th-TH.json b/mobile/assets/i18n/th-TH.json index cbafb9805986e..cd70c7adac82b 100644 --- a/mobile/assets/i18n/th-TH.json +++ b/mobile/assets/i18n/th-TH.json @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "รายละเอียด", "exif_bottom_sheet_location": "ตำแหน่ง", "exif_bottom_sheet_location_add": "เพิ่มตำแหน่ง", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "กำลังพัฒนา", "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", diff --git a/mobile/assets/i18n/uk-UA.json b/mobile/assets/i18n/uk-UA.json index b8d3f1a1b4f90..c0d48d0fe393c 100644 --- a/mobile/assets/i18n/uk-UA.json +++ b/mobile/assets/i18n/uk-UA.json @@ -1,13 +1,13 @@ { - "action_common_cancel": "Cancel", - "action_common_update": "Update", + "action_common_cancel": "Скасувати", + "action_common_update": "Оновити", "add_to_album_bottom_sheet_added": "Додати до {album}", "add_to_album_bottom_sheet_already_exists": "Вже є в {album}", "advanced_settings_log_level_title": "Log level: {}", "advanced_settings_prefer_remote_subtitle": "Деякі пристрої вельми повільно завантажують мініатюри із елементів на пристрої. Активуйте для завантаження віддалених мініатюр натомість.", "advanced_settings_prefer_remote_title": "Перевага віддаленим зображенням", - "advanced_settings_self_signed_ssl_subtitle": "Пропускає SSL-сертифікат для точки доступу сервера. Потрібне для власноруч підписаних сертифікатів.", - "advanced_settings_self_signed_ssl_title": "Дозволити власноруч підписані SSL-сертифікати", + "advanced_settings_self_signed_ssl_subtitle": "Пропускає перевірку SSL-сертифіката сервера. Потрібне для самопідписаних сертифікатів.", + "advanced_settings_self_signed_ssl_title": "Дозволити самопідписані SSL-сертифікати", "advanced_settings_tile_subtitle": "Розширені користувацькі налаштування", "advanced_settings_tile_title": "Розширені", "advanced_settings_troubleshooting_subtitle": "Увімкніть додаткові функції для усунення несправностей", @@ -26,17 +26,17 @@ "album_viewer_appbar_share_err_title": "Не вдалося змінити назву альбому", "album_viewer_appbar_share_leave": "Вийти з альбому", "album_viewer_appbar_share_remove": "Видалити з альбому", - "album_viewer_appbar_share_to": "Share To", + "album_viewer_appbar_share_to": "Поділитися", "album_viewer_page_share_add_users": "Додати користувачів", "all_people_page_title": "Люди", "all_videos_page_title": "Відео", - "app_bar_signout_dialog_content": "Are you sure you want to sign out?", - "app_bar_signout_dialog_ok": "Yes", - "app_bar_signout_dialog_title": "Sign out", + "app_bar_signout_dialog_content": "Ви впевнені, що бажаєте вийти з аккаунта?", + "app_bar_signout_dialog_ok": "Так", + "app_bar_signout_dialog_title": "Вийти з аккаунта", "archive_page_no_archived_assets": "Немає архівних елементів", "archive_page_title": "Архів ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_action_delete_err_read_only": "Неможливо видалити елемент(и) лише для читання, пропущено", + "asset_action_share_err_offline": "Неможливо отримати оффлайн-елемент(и), пропущено", "asset_list_layout_settings_dynamic_layout_title": "Динамічне компонування", "asset_list_layout_settings_group_automatically": "Автоматично", "asset_list_layout_settings_group_by": "Групувати елементи по", @@ -50,7 +50,7 @@ "backup_album_selection_page_select_albums": "Оберіть альбоми", "backup_album_selection_page_selection_info": "Інформація про обране", "backup_album_selection_page_total_assets": "Загальна кількість унікальних елементів", - "backup_all": "Все", + "backup_all": "Усі", "backup_background_service_backup_failed_message": "Не вдалося зробити резервну копію елементів. Повторюю...", "backup_background_service_connection_failed_message": "Не вдалося зв'язатися із сервером. Повторюю...", "backup_background_service_current_upload_notification": "Завантажується {}", @@ -65,7 +65,7 @@ "backup_controller_page_background_battery_info_link": "Покажіть мені як", "backup_controller_page_background_battery_info_message": "Для найкращого фонового резервного копіювання вимкніть будь-яку оптимізацію акумулятора, яка обмежує фонову активність для Immich.\n\nСпосіб залежить від конкретного пристрою, тому шукайте необхідну інформацію у виробника вашого пристрою.", "backup_controller_page_background_battery_info_ok": "ОК", - "backup_controller_page_background_battery_info_title": "Оптимізації акамулятора", + "backup_controller_page_background_battery_info_title": "Оптимізація батареї", "backup_controller_page_background_charging": "Лише під час заряджання", "backup_controller_page_background_configure_error": "Не вдалося налаштувати фоновий сервіс", "backup_controller_page_background_delay": "Затримка перед резервним копіюванням нових елементів: {}", @@ -111,9 +111,9 @@ "cache_settings_album_thumbnails": "Мініатюри сторінок бібліотеки ({} елементи)", "cache_settings_clear_cache_button": "Очистити кеш", "cache_settings_clear_cache_button_title": "Очищає кеш програми. Це суттєво знизить продуктивність програми, доки кеш не буде перебудовано.", - "cache_settings_duplicated_assets_clear_button": "CLEAR", - "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", - "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", + "cache_settings_duplicated_assets_clear_button": "ОЧИСТИТИ", + "cache_settings_duplicated_assets_subtitle": "Фото та відео, занесені додатком у чорний список", + "cache_settings_duplicated_assets_title": "Дубльовані елементи ({})", "cache_settings_image_cache_size": "Розмір кешованих зображень ({} елементи)", "cache_settings_statistics_album": "Бібліотечні мініатюри", "cache_settings_statistics_assets": "{} елементи ({})", @@ -123,16 +123,16 @@ "cache_settings_statistics_title": "Використання кешу", "cache_settings_subtitle": "Контролює кешування у мобільному застосунку", "cache_settings_thumbnail_size": "Розмір кешованих мініатюр ({} елементи)", - "cache_settings_tile_subtitle": "Control the local storage behaviour", - "cache_settings_tile_title": "Local Storage", - "cache_settings_title": "Налаштування Кешування", + "cache_settings_tile_subtitle": "Керування поведінкою локального сховища", + "cache_settings_tile_title": "Локальне сховище", + "cache_settings_title": "Налаштування кешування", "change_password_form_confirm_password": "Підтвердити пароль", "change_password_form_description": "Привіт {name},\n\nВи або або вперше входите у систему, або було зроблено запит на зміну вашого пароля. \nВведіть ваш новий пароль.", - "change_password_form_new_password": "Новий Пароль", + "change_password_form_new_password": "Новий пароль", "change_password_form_password_mismatch": "Паролі не співпадають", - "change_password_form_reenter_new_password": "Повторіть Новий Пароль", + "change_password_form_reenter_new_password": "Повторіть новий пароль", "common_add_to_album": "Додати в альбом", - "common_change_password": "Змінити Пароль", + "common_change_password": "Змінити пароль", "common_create_new_album": "Створити новий альбом", "common_server_error": "Будь ласка, перевірте з'єднання, переконайтеся, що сервер доступний і версія програми/сервера сумісна.", "common_shared": "Спільні", @@ -142,18 +142,18 @@ "control_bottom_app_bar_archive": "Архівувати", "control_bottom_app_bar_create_new_album": "Створити новий альбом", "control_bottom_app_bar_delete": "Видалити", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", - "control_bottom_app_bar_edit_location": "Edit Location", - "control_bottom_app_bar_edit_time": "Edit Date & Time", - "control_bottom_app_bar_favorite": "Уподобати", + "control_bottom_app_bar_delete_from_immich": "Видалити з Immich", + "control_bottom_app_bar_delete_from_local": "Видалити з пристрою", + "control_bottom_app_bar_edit_location": "Редагувати місцезнаходження", + "control_bottom_app_bar_edit_time": "Редагувати дату та час", + "control_bottom_app_bar_favorite": "До улюблених", "control_bottom_app_bar_share": "Поділитися", - "control_bottom_app_bar_share_to": "Share To", - "control_bottom_app_bar_stack": "Stack", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_share_to": "Поділитися", + "control_bottom_app_bar_stack": "Стек", + "control_bottom_app_bar_trash_from_immich": "Перемістити до кошику", "control_bottom_app_bar_unarchive": "Розархівувати", - "control_bottom_app_bar_unfavorite": "Unfavorite", - "control_bottom_app_bar_upload": "Upload", + "control_bottom_app_bar_unfavorite": "Видалити з улюблених", + "control_bottom_app_bar_upload": "Завантажити", "create_album_page_untitled": "Без назви", "create_shared_album_page_create": "Створити", "create_shared_album_page_share": "Поділитися", @@ -165,26 +165,27 @@ "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", "delete_dialog_alert": "Ці елементи будуть остаточно видалені з Immich та вашого пристрою", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "Ці елементи будуть видалені видалені з Вашого пристрою, але залишаться доступними на сервері Immich", + "delete_dialog_alert_local_non_backed_up": "Резервні копії деяких елементів не були завантажені в Immich і будуть видалені видалені з Вашого пристрою", + "delete_dialog_alert_remote": "Ці елементи будуть назавжди видалені з сервера Immich", "delete_dialog_cancel": "Скасувати", "delete_dialog_ok": "Видалити", - "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_ok_force": "Все одно видалити", "delete_dialog_title": "Видалити остаточно", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", - "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", - "delete_shared_link_dialog_title": "Delete Shared Link", + "delete_local_dialog_ok_backed_up_only": "Видалити лише резервні копії", + "delete_local_dialog_ok_force": "Все одно видалити", + "delete_shared_link_dialog_content": "Ви впевнені, що хочете видалити це спільне посилання?", + "delete_shared_link_dialog_title": "Видалити спільне посилання", "description_input_hint_text": "Додати опис...", "description_input_submit_error": "Помилка оновлення опису, перевірте логи для подробиць", - "edit_date_time_dialog_date_time": "Date and Time", - "edit_date_time_dialog_timezone": "Timezone", - "edit_location_dialog_title": "Location", + "edit_date_time_dialog_date_time": "Дата і час", + "edit_date_time_dialog_timezone": "Часовий пояс", + "edit_location_dialog_title": "Місцезнаходження", "exif_bottom_sheet_description": "Додати опис...", "exif_bottom_sheet_details": "ПОДРОБИЦІ", "exif_bottom_sheet_location": "МІСЦЕ", - "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_location_add": "Додати місцезнаходження", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "В розробці", "experimental_settings_new_asset_list_title": "Експериментальний макет знімків", "experimental_settings_subtitle": "На власний ризик!", @@ -194,42 +195,42 @@ "home_page_add_to_album_conflicts": "Додано {added} елементів у альбом {album}. {failed} елементів вже було в альбомі.", "home_page_add_to_album_err_local": "Неможливо додати локальні елементи до альбомів, пропущено", "home_page_add_to_album_success": "Додано {added} елементів у альбом {album}.", - "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping", + "home_page_album_err_partner": "Поки що не вдається додати елементи партнера до альбому, пропущено", "home_page_archive_err_local": "Поки що неможливо заархівувати локальні елементи, пропущено", - "home_page_archive_err_partner": "Can not archive partner assets, skipping", + "home_page_archive_err_partner": "Неможливо архівувати елементи партнера, пропущено", "home_page_building_timeline": "Побудова хронології", - "home_page_delete_err_partner": "Can not delete partner assets, skipping", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", - "home_page_favorite_err_local": "Неможливо отримати улюблені локальні елементи, пропущено", - "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", + "home_page_delete_err_partner": "Неможливо видалити елементи партнера, пропущено", + "home_page_delete_remote_err_local": "Локальні елемент(и) вже в процесі видалення з сервера, пропущено", + "home_page_favorite_err_local": "Поки що не можна додати до улюблених локальні елементи, пропущено", + "home_page_favorite_err_partner": "Поки що не можна додати до улюблених елементи партнера, пропущено", "home_page_first_time_notice": "Якщо ви вперше користуєтеся програмою, переконайтеся, що ви вибрали альбоми для резервування, щоб могти заповнювати хронологію знімків та відео в альбомах.", - "home_page_share_err_local": "Can not share local assets via link, skipping", + "home_page_share_err_local": "Неможливо поділитися локальними елементами через посилання, пропущено", "home_page_upload_err_limit": "Можна вантажити не більше 30 елементів водночас, пропущено", "image_viewer_page_state_provider_download_error": "Помилка завантаження", "image_viewer_page_state_provider_download_success": "Усіпшно завантажено", - "image_viewer_page_state_provider_share_error": "Share Error", + "image_viewer_page_state_provider_share_error": "Помилка спільного доступу", "library_page_albums": "Альбоми", "library_page_archive": "Архів", - "library_page_device_albums": "Альбоми на Пристрої", + "library_page_device_albums": "Альбоми на пристрої", "library_page_favorites": "Улюблені", "library_page_new_album": "Новий альбом", "library_page_sharing": "Спільні", - "library_page_sort_asset_count": "Number of assets", + "library_page_sort_asset_count": "Кількість елементів", "library_page_sort_created": "Нещодавно створені", - "library_page_sort_last_modified": "Last modified", - "library_page_sort_most_oldest_photo": "Oldest photo", - "library_page_sort_most_recent_photo": "Most recent photo", + "library_page_sort_last_modified": "Остання зміна", + "library_page_sort_most_oldest_photo": "Найдавніші фото", + "library_page_sort_most_recent_photo": "Найновіші фото", "library_page_sort_title": "Назва альбому", - "location_picker_choose_on_map": "Choose on map", - "location_picker_latitude": "Latitude", - "location_picker_latitude_error": "Enter a valid latitude", - "location_picker_latitude_hint": "Enter your latitude here", - "location_picker_longitude": "Longitude", - "location_picker_longitude_error": "Enter a valid longitude", - "location_picker_longitude_hint": "Enter your longitude here", + "location_picker_choose_on_map": "Обрати на мапі", + "location_picker_latitude": "Широта", + "location_picker_latitude_error": "Вкажіть дійсну широту", + "location_picker_latitude_hint": "Вкажіть широту", + "location_picker_longitude": "Довгота", + "location_picker_longitude_error": "Вкажіть дійсну довготу", + "location_picker_longitude_hint": "Вкажіть довготу", "login_disabled": "Авторизація була відключена", "login_form_api_exception": "Помилка API. Перевірте адресу сервера і спробуйте знову", - "login_form_back_button_text": "Back", + "login_form_back_button_text": "Назад", "login_form_button_text": "Увійти", "login_form_email_hint": "youremail@email.com", "login_form_endpoint_hint": "http://your-server-ip:port/api", @@ -239,10 +240,10 @@ "login_form_err_invalid_url": "Хибний URL", "login_form_err_leading_whitespace": "Пробіл на початку", "login_form_err_trailing_whitespace": "Пробіл в кінці", - "login_form_failed_get_oauth_server_config": "Помилка входу через OAuth, перевірте адресу сервера\n", - "login_form_failed_get_oauth_server_disable": "OAuth недоступний на цьому сервері\n", + "login_form_failed_get_oauth_server_config": "Помилка входу через OAuth, перевірте адресу сервера", + "login_form_failed_get_oauth_server_disable": "OAuth недоступний на цьому сервері", "login_form_failed_login": "Помилка входу, перевірте URL-адресу сервера, електронну пошту та пароль", - "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", + "login_form_handshake_exception": "Виняток рукостискання з сервером. Увімкніть підтримку самопідписаного сертифіката в налаштуваннях, якщо ви використовуєте самопідписаний сертифікат.", "login_form_label_email": "Електронна пошта", "login_form_label_password": "Пароль", "login_form_next_button": "Далі", @@ -252,35 +253,35 @@ "login_form_server_error": "Неможливо з'єднатися із сервером", "login_password_changed_error": "Помилка у оновлені вашого пароля", "login_password_changed_success": "Пароль оновлено успішно", - "map_assets_in_bound": "{} photo", - "map_assets_in_bounds": "{} photos", - "map_cannot_get_user_location": "Не можу отримати місцеперебування", + "map_assets_in_bound": "{} фото", + "map_assets_in_bounds": "{} фото", + "map_cannot_get_user_location": "Не можу отримати місцезнаходження", "map_location_dialog_cancel": "Скасувати", "map_location_dialog_yes": "Так", - "map_location_picker_page_use_location": "Use this location", - "map_location_service_disabled_content": "Служба локації має бути ввімкненою, щоб відображати елементи з вашого поточного місцеперебування. Увімкнути її зараз?", - "map_location_service_disabled_title": "Служба місцеперебування вимкнена", + "map_location_picker_page_use_location": "Це місцезнаходження", + "map_location_service_disabled_content": "Служба локації має бути ввімкненою, щоб відображати елементи з вашого поточного місцезнаходження. Увімкнути її зараз?", + "map_location_service_disabled_title": "Служба місцезнаходження вимкнена", "map_no_assets_in_bounds": "Немає знімків із цього місця", - "map_no_location_permission_content": "Потрібен дозвіл, аби показувати елементи із поточного місцеперебування. Надати його зараз?", - "map_no_location_permission_title": "Помилка доступу до місцеперебування", + "map_no_location_permission_content": "Потрібен дозвіл, аби показувати елементи із поточного місцезнаходження. Надати його зараз?", + "map_no_location_permission_title": "Помилка доступу до місцезнаходження", "map_settings_dark_mode": "Темний режим", - "map_settings_date_range_option_all": "All", - "map_settings_date_range_option_day": "Past 24 hours", - "map_settings_date_range_option_days": "Past {} days", - "map_settings_date_range_option_year": "Past year", - "map_settings_date_range_option_years": "Past {} years", + "map_settings_date_range_option_all": "Усі", + "map_settings_date_range_option_day": "Минулі 24 години", + "map_settings_date_range_option_days": "Минулих {} днів", + "map_settings_date_range_option_year": "Минулий рік", + "map_settings_date_range_option_years": "Минулі {} роки", "map_settings_dialog_cancel": "Скасувати", "map_settings_dialog_save": "Зберегти", "map_settings_dialog_title": "Налаштування мапи", - "map_settings_include_show_archived": "Include Archived", - "map_settings_only_relative_range": "Діапазон дат", + "map_settings_include_show_archived": "Включити архівні дані", + "map_settings_only_relative_range": "Проміжок часу", "map_settings_only_show_favorites": "Лише улюбені", - "map_settings_theme_settings": "Map Theme", + "map_settings_theme_settings": "Тема карти", "map_zoom_to_see_photos": "Зменшіть, аби переглянути знімки", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Рухомі Знімки", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату елементів лише для читання, пропущено", + "multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати місцезнаходження елементів лише для читання, пропущено", "notification_permission_dialog_cancel": "Скасувати", "notification_permission_dialog_content": "Щоб увімкнути сповіщення, перейдіть до Налаштувань і надайте дозвіл.", "notification_permission_dialog_settings": "Налаштування", @@ -296,7 +297,7 @@ "partner_page_stop_sharing_content": "{} втратить доступ до ваших знімків.", "partner_page_stop_sharing_title": "Припинити надання ваших знімків?", "partner_page_title": "Партнер", - "permission_onboarding_back": "Back", + "permission_onboarding_back": "Назад", "permission_onboarding_continue_anyway": "Все одно продовжити", "permission_onboarding_get_started": "Розпочати", "permission_onboarding_go_to_settings": "Перейти до налаштувань", @@ -307,56 +308,56 @@ "permission_onboarding_permission_limited": "Обмежений доступ. Аби дозволити Immich резервне копіювання та керування вашою галереєю, надайте доступ до знімків та відео у Налаштуваннях", "permission_onboarding_request": "Immich потребує доступу до ваших знімків та відео.", "profile_drawer_app_logs": "Журнал", - "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", - "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", - "profile_drawer_client_server_up_to_date": "Клієнт та Сервер — актуальні", - "profile_drawer_documentation": "Documentation", + "profile_drawer_client_out_of_date_major": "Мобільний додаток застарів. Будь ласка, оновіть до останньої мажорної версії.", + "profile_drawer_client_out_of_date_minor": "Мобільний додаток застарів. Будь ласка, оновіть до останньої мінорної версії.", + "profile_drawer_client_server_up_to_date": "Клієнт та сервер — актуальні", + "profile_drawer_documentation": "Документація", "profile_drawer_github": "GitHub", - "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", - "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_server_out_of_date_major": "Сервер застарів. Будь ласка, оновіть до останньої мажорної версії.", + "profile_drawer_server_out_of_date_minor": "Сервер застарів. Будь ласка, оновіть до останньої мінорної версії.", "profile_drawer_settings": "Налаштування", "profile_drawer_sign_out": "Вийти", - "profile_drawer_trash": "Trash", + "profile_drawer_trash": "Кошик", "recently_added_page_title": "Нещодавні", - "scaffold_body_error_occurred": "Error occurred", + "scaffold_body_error_occurred": "Виникла помилка", "search_bar_hint": "Шукати ваші знімки", "search_page_categories": "Категорії", "search_page_favorites": "Улюблені", - "search_page_motion_photos": "Рухомі Знімки", + "search_page_motion_photos": "Рухомі знімки", "search_page_no_objects": "Немає інформації про об'єкти", - "search_page_no_places": "No Places Info Available", - "search_page_people": "People", - "search_page_person_add_name_dialog_cancel": "Cancel", - "search_page_person_add_name_dialog_hint": "Name", - "search_page_person_add_name_dialog_save": "Save", - "search_page_person_add_name_dialog_title": "Add a name", - "search_page_person_add_name_subtitle": "Find them fast by name with search", - "search_page_person_add_name_title": "Add a name", - "search_page_person_edit_name": "Edit name", - "search_page_places": "Places", - "search_page_recently_added": "Recently added", - "search_page_screenshots": "Screenshots", - "search_page_selfies": "Selfies", - "search_page_things": "Things", - "search_page_videos": "Videos", - "search_page_view_all_button": "View all", - "search_page_your_activity": "Your activity", - "search_page_your_map": "Your Map", - "search_result_page_new_search_hint": "New Search", - "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", - "search_suggestion_list_smart_search_hint_2": "m:your-search-term", - "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "search_page_no_places": "Інформація про місця недоступна", + "search_page_people": "Люди", + "search_page_person_add_name_dialog_cancel": "Скасувати", + "search_page_person_add_name_dialog_hint": "Ім'я", + "search_page_person_add_name_dialog_save": "Зберегти", + "search_page_person_add_name_dialog_title": "Додати ім'я", + "search_page_person_add_name_subtitle": "Швидко знайдіть їх за назвою за допомогою пошуку", + "search_page_person_add_name_title": "Додати ім'я", + "search_page_person_edit_name": "Відредагувати ім'я", + "search_page_places": "Місця", + "search_page_recently_added": "Нещодавно додані", + "search_page_screenshots": "Знімки екрану", + "search_page_selfies": "Селфі", + "search_page_things": "Речі", + "search_page_videos": "Відео", + "search_page_view_all_button": "Переглянути усі", + "search_page_your_activity": "Ваші дії", + "search_page_your_map": "Ваша мапа", + "search_result_page_new_search_hint": "Новий пошук", + "search_suggestion_list_smart_search_hint_1": "Інтелектуальний пошук увімкнено за замовчуванням, для пошуку метаданих використовуйте синтаксис", + "search_suggestion_list_smart_search_hint_2": "m:ваш-пошуковий-термін", + "select_additional_user_for_sharing_page_suggestions": "Пропозиції", "select_user_for_sharing_page_err_album": "Не вдалося створити альбом", - "select_user_for_sharing_page_share_suggestions": "Suggestions", - "server_info_box_app_version": "App Version", - "server_info_box_latest_release": "Latest Version", - "server_info_box_server_url": "Server URL", - "server_info_box_server_version": "Server Version", - "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", - "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", - "setting_image_viewer_original_title": "Завантажити оригінальне зображення", - "setting_image_viewer_preview_subtitle": "Увімкніть для завантаження зображення середньої роздільної здатності. Вимкніть або для можливості безпосереднього завантаження оригіналу, або для використання лише мініатюр.", - "setting_image_viewer_preview_title": "Завантажити попередній перегляд зображення", + "select_user_for_sharing_page_share_suggestions": "Пропозиції", + "server_info_box_app_version": "Версія додатка", + "server_info_box_latest_release": "Остання версія", + "server_info_box_server_url": "URL сервера", + "server_info_box_server_version": "Версія сервера", + "setting_image_viewer_help": "Повноекранний переглядач спочатку завантажує зображення для попереднього перегляду в низькій роздільній здатності, потім завантажує зображення в зменшеній роздільній здатності відносно оригіналу (якщо включено) і зрештою завантажує оригінал (якщо включено).", + "setting_image_viewer_original_subtitle": "Увімкніть для завантаження оригінального зображення з повною роздільною здатністю (велике!).\nВимкніть, щоб зменшити використання даних (мережі та кешу пристрою).", + "setting_image_viewer_original_title": "Завантажувати оригінальне зображення", + "setting_image_viewer_preview_subtitle": "Увімкніть для завантаження зображень середньої роздільної здатності.\nВимкніть для безпосереднього завантаження оригіналу або використовувати лише мініатюру.", + "setting_image_viewer_preview_title": "Завантажувати зображення попереднього перегляду", "setting_notifications_notify_failures_grace_period": "Повідомити про помилки фонового резервного копіювання: {}", "setting_notifications_notify_hours": "{} годин", "setting_notifications_notify_immediately": "негайно", @@ -365,9 +366,9 @@ "setting_notifications_notify_seconds": "{} секунд", "setting_notifications_single_progress_subtitle": "Детальна інформація про хід завантаження для кожного елементу", "setting_notifications_single_progress_title": "Показати хід фонового резервного копіювання", - "setting_notifications_subtitle": "Налаштуйте свої параметри сповіщень", + "setting_notifications_subtitle": "Налаштування параметрів сповіщень", "setting_notifications_title": "Сповіщення", - "setting_notifications_total_progress_subtitle": "Загальний прогрес (готово/загалом)", + "setting_notifications_total_progress_subtitle": "Загальний прогрес (виконано/загалом)", "setting_notifications_total_progress_title": "Показати загальний хід фонового резервного копіювання", "setting_pages_app_bar_settings": "Налаштування", "settings_require_restart": "Перезавантажте програму для застосування цього налаштування", @@ -375,66 +376,66 @@ "share_add_photos": "Додати знімки", "share_add_title": "Додати назву", "share_create_album": "Створити альбом", - "shared_album_activities_input_disable": "Comment is disabled", - "shared_album_activities_input_hint": "Say something", - "shared_album_activity_remove_content": "Do you want to delete this activity?", - "shared_album_activity_remove_title": "Delete Activity", - "shared_album_activity_setting_subtitle": "Let others respond", - "shared_album_activity_setting_title": "Comments & likes", - "shared_album_section_people_action_error": "Error leaving/removing from album", - "shared_album_section_people_action_leave": "Remove user from album", - "shared_album_section_people_action_remove_user": "Remove user from album", - "shared_album_section_people_owner_label": "Owner", - "shared_album_section_people_title": "PEOPLE", + "shared_album_activities_input_disable": "Коментування вимкнено", + "shared_album_activities_input_hint": "Скажіть що-небудь", + "shared_album_activity_remove_content": "Ви бажаєте видалити це повідомлення?", + "shared_album_activity_remove_title": "Видалити повідомлення", + "shared_album_activity_setting_subtitle": "Дозволити іншим відповідати", + "shared_album_activity_setting_title": "Коментарі та лайки", + "shared_album_section_people_action_error": "Помилка виходу/видалення з альбому", + "shared_album_section_people_action_leave": "Видалити користувача з альбому", + "shared_album_section_people_action_remove_user": "Видалити користувача з альбому", + "shared_album_section_people_owner_label": "Власник", + "shared_album_section_people_title": "ЛЮДИ", "share_dialog_preparing": "Підготовка...", - "shared_link_app_bar_title": "Shared Links", - "shared_link_clipboard_copied_massage": "Copied to clipboard", - "shared_link_clipboard_text": "Link: {}\nPassword: {}", - "shared_link_create_app_bar_title": "Create link to share", - "shared_link_create_error": "Error while creating shared link", - "shared_link_create_info": "Let anyone with the link see the selected photo(s)", - "shared_link_create_submit_button": "Create link", - "shared_link_edit_allow_download": "Allow public user to download", - "shared_link_edit_allow_upload": "Allow public user to upload", - "shared_link_edit_app_bar_title": "Edit link", - "shared_link_edit_change_expiry": "Change expiration time", - "shared_link_edit_description": "Description", - "shared_link_edit_description_hint": "Enter the share description", - "shared_link_edit_expire_after": "Expire after", - "shared_link_edit_expire_after_option_day": "1 day", - "shared_link_edit_expire_after_option_days": "{} days", - "shared_link_edit_expire_after_option_hour": "1 hour", - "shared_link_edit_expire_after_option_hours": "{} hours", - "shared_link_edit_expire_after_option_minute": "1 minute", - "shared_link_edit_expire_after_option_minutes": "{} minutes", - "shared_link_edit_expire_after_option_never": "Never", - "shared_link_edit_password": "Password", - "shared_link_edit_password_hint": "Enter the share password", - "shared_link_edit_show_meta": "Show metadata", - "shared_link_edit_submit_button": "Update link", - "shared_link_empty": "You don't have any shared links", - "shared_link_error_server_url_fetch": "Cannot fetch the server url", - "shared_link_expired": "Expired", - "shared_link_expires_day": "Expires in {} day", - "shared_link_expires_days": "Expires in {} days", - "shared_link_expires_hour": "Expires in {} hour", - "shared_link_expires_hours": "Expires in {} hours", - "shared_link_expires_minute": "Expires in {} minute", - "shared_link_expires_minutes": "Expires in {} minutes", - "shared_link_expires_never": "Expires ∞", - "shared_link_expires_second": "Expires in {} second", - "shared_link_expires_seconds": "Expires in {} seconds", - "shared_link_info_chip_download": "Download", + "shared_link_app_bar_title": "Спільні посилання", + "shared_link_clipboard_copied_massage": "Скопійовано в буфер обміну", + "shared_link_clipboard_text": "Посилання: {}\nПароль: {}", + "shared_link_create_app_bar_title": "Створити посилання спільного доступу", + "shared_link_create_error": "Помилка під час створення спільного посилання", + "shared_link_create_info": "Дозволити всім, хто має посилання, переглянути вибрані фото", + "shared_link_create_submit_button": "Створити посилання", + "shared_link_edit_allow_download": "Дозволити публічному користувачеві скачувати файли", + "shared_link_edit_allow_upload": "Дозволити публічному користувачеві завантажувати файли", + "shared_link_edit_app_bar_title": "Редагувати посилання", + "shared_link_edit_change_expiry": "Змінити термін дії", + "shared_link_edit_description": "Опис", + "shared_link_edit_description_hint": "Введіть опис для спільного доступу", + "shared_link_edit_expire_after": "Термін дії закінчується через", + "shared_link_edit_expire_after_option_day": "1 день", + "shared_link_edit_expire_after_option_days": "{} днів", + "shared_link_edit_expire_after_option_hour": "1 годину", + "shared_link_edit_expire_after_option_hours": "{} годин", + "shared_link_edit_expire_after_option_minute": "1 хвилину", + "shared_link_edit_expire_after_option_minutes": "{} хвилин", + "shared_link_edit_expire_after_option_never": "Ніколи", + "shared_link_edit_password": "Пароль", + "shared_link_edit_password_hint": "Введіть пароль для спільного доступу", + "shared_link_edit_show_meta": "Показувати метадані", + "shared_link_edit_submit_button": "Оновити посилання", + "shared_link_empty": "У вас немає спільних посилань", + "shared_link_error_server_url_fetch": "Неможливо запитати URL із сервера", + "shared_link_expired": "Закінчився термін дії", + "shared_link_expires_day": "Закінчується через {} день", + "shared_link_expires_days": "Закінчується через {} днів", + "shared_link_expires_hour": "Закінчується через {} годину", + "shared_link_expires_hours": "Закінчується через {} годин", + "shared_link_expires_minute": "Закінчується через {} хвилину", + "shared_link_expires_minutes": "Закінчується через {} хвилин", + "shared_link_expires_never": "Закінчується ∞", + "shared_link_expires_second": "Закінчується через {} секунду", + "shared_link_expires_seconds": "Закінчується через {} секунд", + "shared_link_info_chip_download": "Скачати", "shared_link_info_chip_metadata": "EXIF", - "shared_link_info_chip_upload": "Upload", - "shared_link_manage_links": "Manage Shared links", - "share_done": "Done", + "shared_link_info_chip_upload": "Завантажити", + "shared_link_manage_links": "Керування спільними посиланнями", + "share_done": "Готово", "share_invite": "Запросити в альбом", "sharing_page_album": "Спільні альбоми", "sharing_page_description": "Створюйте спільні альбоми, щоб ділитися знімками та відео з людьми у вашій мережі.", "sharing_page_empty_list": "ПОРОЖНІЙ СПИСОК", "sharing_silver_appbar_create_shared_album": "Створити спільний альбом", - "sharing_silver_appbar_shared_links": "Shared links", + "sharing_silver_appbar_shared_links": "Спільні посилання", "sharing_silver_appbar_share_partner": "Поділитися з партнером", "tab_controller_nav_library": "Бібліотека", "tab_controller_nav_photos": "Знімки", @@ -443,26 +444,26 @@ "theme_setting_asset_list_storage_indicator_title": "Показувати піктограму сховища на плитках елементів", "theme_setting_asset_list_tiles_per_row_title": "Кількість елементів у рядку ({})", "theme_setting_dark_mode_switch": "Темна тема", - "theme_setting_image_viewer_quality_subtitle": "Налаштувати якість перегляду повних зображень", + "theme_setting_image_viewer_quality_subtitle": "Налаштування якості перегляду повноекранних зображень", "theme_setting_image_viewer_quality_title": "Якість перегляду зображень", - "theme_setting_system_theme_switch": "Автоматично (як система)", - "theme_setting_theme_subtitle": "Виберіть налаштування теми програми", + "theme_setting_system_theme_switch": "Автоматично (як у системі)", + "theme_setting_theme_subtitle": "Налаштування теми додатка", "theme_setting_theme_title": "Тема", "theme_setting_three_stage_loading_subtitle": "Триетапне завантаження може підвищити продуктивність завантаження, але спричинить значно більше навантаження на мережу", "theme_setting_three_stage_loading_title": "Увімкнути триетапне завантаження", "translated_text_options": "Налаштування", - "trash_page_delete": "Delete", - "trash_page_delete_all": "Delete All", - "trash_page_empty_trash_btn": "Empty trash", - "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", - "trash_page_empty_trash_dialog_ok": "Ok", - "trash_page_info": "Trashed items will be permanently deleted after {} days", - "trash_page_no_assets": "No trashed assets", - "trash_page_restore": "Restore", - "trash_page_restore_all": "Restore All", - "trash_page_select_assets_btn": "Select assets", - "trash_page_select_btn": "Select", - "trash_page_title": "Trash ({})", + "trash_page_delete": "Видалити", + "trash_page_delete_all": "Видалити усі", + "trash_page_empty_trash_btn": "Очистити кошик", + "trash_page_empty_trash_dialog_content": "Бажаєте очистити ваші елементи в кошику? Ці елементи буде остаточно видалено з Immich.", + "trash_page_empty_trash_dialog_ok": "OK", + "trash_page_info": "Поміщені у кошик елементи буде остаточно видалено через {} днів", + "trash_page_no_assets": "Віддалені елементи відсутні", + "trash_page_restore": "Відновити", + "trash_page_restore_all": "Відновити усі", + "trash_page_select_assets_btn": "Вибрані елементи", + "trash_page_select_btn": "Вибрати", + "trash_page_title": "Кошик ({})", "upload_dialog_cancel": "Скасувати", "upload_dialog_info": "Бажаєте створити резервну копію вибраних елементів на сервері?", "upload_dialog_ok": "Завантажити", @@ -473,7 +474,7 @@ "version_announcement_overlay_text_2": "знайдіть хвильку навідатися на ", "version_announcement_overlay_text_3": "і переконайтеся, що ваші налаштування docker-compose та .env оновлені, аби запобігти будь-якій неправильній конфігурації, особливо, якщо ви використовуєте WatchTower або інший механізм, для автоматичних оновлень вашої серверної частини.", "version_announcement_overlay_title": "Доступна нова версія сервера \uD83C\uDF89", - "viewer_remove_from_stack": "Remove from Stack", - "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" + "viewer_remove_from_stack": "Видалити зі стеку", + "viewer_stack_use_as_main_asset": "Використовувати як основний елементи", + "viewer_unstack": "Розібрати стек" } \ No newline at end of file diff --git a/mobile/assets/i18n/vi-VN.json b/mobile/assets/i18n/vi-VN.json index 9684d5a743ca2..aadb63d3e3488 100644 --- a/mobile/assets/i18n/vi-VN.json +++ b/mobile/assets/i18n/vi-VN.json @@ -1,9 +1,9 @@ { - "action_common_cancel": "Cancel", - "action_common_update": "Update", + "action_common_cancel": "Từ chối", + "action_common_update": "Cập nhật", "add_to_album_bottom_sheet_added": "Thêm vào {album}", "add_to_album_bottom_sheet_already_exists": "Đã có sẵn trong {album}", - "advanced_settings_log_level_title": "Log level: {}", + "advanced_settings_log_level_title": "Phân loại nhật ký: {}", "advanced_settings_prefer_remote_subtitle": "Trên một số thiết bị, việc tải hình thu nhỏ từ ảnh trên thiết bị diễn ra chậm. Kích hoạt cài đặt này để tải ảnh từ máy chủ.", "advanced_settings_prefer_remote_title": "Ưu tiên ảnh từ máy chủ", "advanced_settings_self_signed_ssl_subtitle": "Bỏ qua xác minh chứng chỉ SSL cho máy chủ cuối. Yêu cầu cho chứng chỉ tự ký.", @@ -35,8 +35,8 @@ "app_bar_signout_dialog_title": "Đăng xuất", "archive_page_no_archived_assets": "Không tìm thấy ảnh đã lưu trữ", "archive_page_title": "Kho lưu trữ ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_action_delete_err_read_only": "Không thể xoá ảnh chỉ có quyền đọc, bỏ qua", + "asset_action_share_err_offline": "Không thể tải ảnh ngoại tuyến, bỏ qua", "asset_list_layout_settings_dynamic_layout_title": "Bố cục động", "asset_list_layout_settings_group_automatically": "Tự động", "asset_list_layout_settings_group_by": " Nhóm ảnh theo", @@ -111,9 +111,9 @@ "cache_settings_album_thumbnails": "Trang thư viện hình thu nhỏ ({} ảnh)", "cache_settings_clear_cache_button": "Xoá bộ nhớ đệm", "cache_settings_clear_cache_button_title": "Xóa bộ nhớ đệm của ứng dụng. Điều này sẽ ảnh hưởng đến hiệu suất của ứng dụng đến khi bộ nhớ đệm được tạo lại.", - "cache_settings_duplicated_assets_clear_button": "CLEAR", - "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", - "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", + "cache_settings_duplicated_assets_clear_button": "XOÁ", + "cache_settings_duplicated_assets_subtitle": "Ảnh và video không được phép hiển thị trên ứng dụng", + "cache_settings_duplicated_assets_title": "Ảnh bị trùng lặp ({})", "cache_settings_image_cache_size": "Kích thước bộ nhớ đệm ảnh ({} ảnh)", "cache_settings_statistics_album": "Thư viện hình thu nhỏ", "cache_settings_statistics_assets": "{} ảnh ({})", @@ -142,17 +142,17 @@ "control_bottom_app_bar_archive": "Kho lưu trữ", "control_bottom_app_bar_create_new_album": "Tạo album mới", "control_bottom_app_bar_delete": "Xoá", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", - "control_bottom_app_bar_edit_location": "Edit Location", - "control_bottom_app_bar_edit_time": "Edit Date & Time", + "control_bottom_app_bar_delete_from_immich": "Xóa khỏi Immich", + "control_bottom_app_bar_delete_from_local": "Xóa khỏi thiết bị\n", + "control_bottom_app_bar_edit_location": "Chỉnh sửa vị trí", + "control_bottom_app_bar_edit_time": "Chỉnh sửa Ngày và Giờ", "control_bottom_app_bar_favorite": "Yêu thích", "control_bottom_app_bar_share": "Chia sẻ", "control_bottom_app_bar_share_to": "Chia sẻ với", - "control_bottom_app_bar_stack": "Ngắn xếp", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_stack": "Xếp nhóm", + "control_bottom_app_bar_trash_from_immich": "Di chuyển tới thùng rác", "control_bottom_app_bar_unarchive": "Huỷ lưu trữ", - "control_bottom_app_bar_unfavorite": "Unfavorite", + "control_bottom_app_bar_unfavorite": "Bỏ yêu thích", "control_bottom_app_bar_upload": "Tải lên", "create_album_page_untitled": "Không tiêu đề", "create_shared_album_page_create": "Tạo", @@ -165,26 +165,27 @@ "daily_title_text_date_year": "E, MMM dd, yyyy", "date_format": "E, LLL d, y • h:mm a", "delete_dialog_alert": "Những mục này sẽ bị xóa vĩnh viễn khỏi Immich và thiết bị của bạn", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_alert_local": "Những mục này sẽ bị xóa vĩnh viễn khỏi thiết bị của bạn nhưng vẫn còn lưu trữ trên máy chủ Immich", + "delete_dialog_alert_local_non_backed_up": "Một số mục chưa được sao lưu lên Immich và sẽ bị xóa vĩnh viễn khỏi thiết bị của bạn.", + "delete_dialog_alert_remote": "Những mục này sẽ bị xóa vĩnh viễn khỏi máy chủ Immich", "delete_dialog_cancel": "Từ chối", "delete_dialog_ok": "Xoá", - "delete_dialog_ok_force": "Delete Anyway", + "delete_dialog_ok_force": "Xóa Vĩnh Viễn", "delete_dialog_title": "Xoá vĩnh viễn", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", + "delete_local_dialog_ok_backed_up_only": "Xoá ảnh đã sao lưu", + "delete_local_dialog_ok_force": "Vẫn xoá", "delete_shared_link_dialog_content": "Bạn có muốn xóa liên kết đã chia sẻ này không?", "delete_shared_link_dialog_title": "Xoá liên kết đã chia sẻ", "description_input_hint_text": "Thêm mô tả...", "description_input_submit_error": "Cập nhật mô tả không thành công, vui lòng kiểm tra nhật ký để biết thêm chi tiết", - "edit_date_time_dialog_date_time": "Date and Time", - "edit_date_time_dialog_timezone": "Timezone", - "edit_location_dialog_title": "Location", + "edit_date_time_dialog_date_time": "Ngày và Giờ", + "edit_date_time_dialog_timezone": "Múi giờ", + "edit_location_dialog_title": "Vị trí", "exif_bottom_sheet_description": "Thêm mô tả...", "exif_bottom_sheet_details": "CHI TIẾT", "exif_bottom_sheet_location": "VỊ TRÍ", - "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_location_add": "Thêm vị trí", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "Đang trong quá trình phát triển", "experimental_settings_new_asset_list_title": "Bật lưới ảnh thử nghiệm", "experimental_settings_subtitle": "Sử dụng có thể rủi ro!", @@ -214,22 +215,22 @@ "library_page_favorites": "Ảnh yêu thích", "library_page_new_album": "Album mới", "library_page_sharing": "Chia sẻ", - "library_page_sort_asset_count": "Number of assets", + "library_page_sort_asset_count": "Số lượng ảnh", "library_page_sort_created": "Mới tạo gần đây", "library_page_sort_last_modified": "Sửa đổi lần cuối", - "library_page_sort_most_oldest_photo": "Oldest photo", + "library_page_sort_most_oldest_photo": "Ảnh cũ nhất", "library_page_sort_most_recent_photo": "Ảnh gần đây nhất", "library_page_sort_title": "Tiêu đề album", - "location_picker_choose_on_map": "Choose on map", - "location_picker_latitude": "Latitude", - "location_picker_latitude_error": "Enter a valid latitude", - "location_picker_latitude_hint": "Enter your latitude here", - "location_picker_longitude": "Longitude", - "location_picker_longitude_error": "Enter a valid longitude", - "location_picker_longitude_hint": "Enter your longitude here", + "location_picker_choose_on_map": "Chọn trên bản đồ", + "location_picker_latitude": "Vĩ độ", + "location_picker_latitude_error": "Nhập vĩ độ hợp lệ", + "location_picker_latitude_hint": "Nhập vĩ độ của bạn", + "location_picker_longitude": "Kinh độ", + "location_picker_longitude_error": "Nhập kinh độ hợp lệ", + "location_picker_longitude_hint": "Nhập kinh độ của bạn", "login_disabled": "Đăng nhập bị vô hiệu hoá", "login_form_api_exception": "Lỗi API. Vui lòng kiểm tra địa chỉ máy chủ và thử lại.", - "login_form_back_button_text": "Back", + "login_form_back_button_text": "Quay lại", "login_form_button_text": "Đăng nhập", "login_form_email_hint": "emailcuaban@email.com", "login_form_endpoint_hint": "http://địa-chỉ-ip-máy-chủ-bạn:cổng/api", @@ -252,35 +253,35 @@ "login_form_server_error": "Không thể kết nối tới máy chủ.", "login_password_changed_error": "Thay đổi mật khẩu không thành công", "login_password_changed_success": "Cập nhật mật khẩu thành công", - "map_assets_in_bound": "{} photo", - "map_assets_in_bounds": "{} photos", + "map_assets_in_bound": "{} ảnh", + "map_assets_in_bounds": "{} ảnh", "map_cannot_get_user_location": "Không thể xác định vị trí của bạn", "map_location_dialog_cancel": "Từ chối", "map_location_dialog_yes": "Có", - "map_location_picker_page_use_location": "Use this location", + "map_location_picker_page_use_location": "Dùng vị trí này", "map_location_service_disabled_content": "Cần bật dịch vụ định vị để hiển thị ảnh hoặc video từ vị trí hiện tại của bạn. Bạn có muốn bật nó ngay bây giờ không?", "map_location_service_disabled_title": "Dịch vụ vị trí bị vô hiệu hoá", "map_no_assets_in_bounds": "Không có ảnh trong khu vực này", "map_no_location_permission_content": "Cần quyền truy cập vị trí để hiển thị ảnh từ vị trí hiện tại của bạn. Bạn có muốn cho phép ngay bây giờ không?", "map_no_location_permission_title": "Ứng dụng không được phép truy cập vị trí", "map_settings_dark_mode": "Chế độ tối", - "map_settings_date_range_option_all": "All", - "map_settings_date_range_option_day": "Past 24 hours", - "map_settings_date_range_option_days": "Past {} days", - "map_settings_date_range_option_year": "Past year", - "map_settings_date_range_option_years": "Past {} years", + "map_settings_date_range_option_all": "Tất cả", + "map_settings_date_range_option_day": "Trong vòng 24 giờ qua", + "map_settings_date_range_option_days": "Trong {} ngày qua", + "map_settings_date_range_option_year": "Năm ngoái", + "map_settings_date_range_option_years": "Trong {} năm qua", "map_settings_dialog_cancel": "Từ chối", "map_settings_dialog_save": "Lưu", "map_settings_dialog_title": "Cài đặt bản đồ", "map_settings_include_show_archived": "Bao gồm ảnh đã lưu trữ", "map_settings_only_relative_range": "Khoảng thời gian", "map_settings_only_show_favorites": "Chỉ hiển thị mục yêu thích", - "map_settings_theme_settings": "Map Theme", + "map_settings_theme_settings": "Giao diện bản đồ", "map_zoom_to_see_photos": "Thu nhỏ để xem ảnh", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Ảnh động", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "multiselect_grid_edit_date_time_err_read_only": "Không thể chỉnh sửa ngày của ảnh chỉ có quyền đọc, bỏ qua", + "multiselect_grid_edit_gps_err_read_only": "Không thể chỉnh sửa vị trí của ảnh chỉ có quyền đọc, bỏ qua", "notification_permission_dialog_cancel": "Từ chối", "notification_permission_dialog_content": "Để bật thông báo, chuyển tới Cài đặt và chọn cho phép", "notification_permission_dialog_settings": "Cài đặt", @@ -307,18 +308,18 @@ "permission_onboarding_permission_limited": "Quyền truy cập vào ảnh của bạn bị hạn chế. Để Immich sao lưu và quản lý toàn bộ thư viện ảnh của bạn, hãy cấp quyền truy cập toàn bộ ảnh trong Cài đặt.", "permission_onboarding_request": "Immich cần quyền để xem ảnh và video của bạn", "profile_drawer_app_logs": "Nhật ký", - "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", - "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", + "profile_drawer_client_out_of_date_major": "Ứng dụng đã lỗi thời. Vui lòng cập nhật lên phiên bản chính mới nhất.", + "profile_drawer_client_out_of_date_minor": "Ứng dụng đã lỗi thời. Vui lòng cập nhật lên phiên bản phụ mới nhất.", "profile_drawer_client_server_up_to_date": "Máy khách và máy chủ đã cập nhật", "profile_drawer_documentation": "Tài liệu", "profile_drawer_github": "GitHub", - "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", - "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_server_out_of_date_major": "Máy chủ đã lỗi thời. Vui lòng cập nhật lên phiên bản chính mới nhất.", + "profile_drawer_server_out_of_date_minor": "Máy chủ đã lỗi thời. Vui lòng cập nhật lên phiên bản phụ mới nhất.", "profile_drawer_settings": "Cài đặt", "profile_drawer_sign_out": "Đăng xuất", "profile_drawer_trash": "Thùng rác", "recently_added_page_title": "Mới thêm gần đây", - "scaffold_body_error_occurred": "Error occurred", + "scaffold_body_error_occurred": "Xảy ra lỗi", "search_bar_hint": "Tìm kiếm ảnh của bạn", "search_page_categories": "Danh mục", "search_page_favorites": "Ảnh yêu thích", @@ -326,13 +327,13 @@ "search_page_no_objects": "Không có thông tin sự vật", "search_page_no_places": "Không có thông tin địa điểm nào", "search_page_people": "Mọi người", - "search_page_person_add_name_dialog_cancel": "Cancel", - "search_page_person_add_name_dialog_hint": "Name", - "search_page_person_add_name_dialog_save": "Save", - "search_page_person_add_name_dialog_title": "Add a name", - "search_page_person_add_name_subtitle": "Find them fast by name with search", - "search_page_person_add_name_title": "Add a name", - "search_page_person_edit_name": "Edit name", + "search_page_person_add_name_dialog_cancel": "Từ chối", + "search_page_person_add_name_dialog_hint": "Tên", + "search_page_person_add_name_dialog_save": "Lưu", + "search_page_person_add_name_dialog_title": "Thêm tên", + "search_page_person_add_name_subtitle": "Tìm nhanh theo tên với chức năng tìm kiếm", + "search_page_person_add_name_title": "Thêm tên", + "search_page_person_edit_name": "Chỉnh sửa tên", "search_page_places": "Địa điểm", "search_page_recently_added": "Mới thêm gần đây", "search_page_screenshots": "Ảnh màn hình", @@ -341,7 +342,7 @@ "search_page_videos": "Video", "search_page_view_all_button": "Xem tất cả", "search_page_your_activity": "Hoạt động của bạn", - "search_page_your_map": "Your Map", + "search_page_your_map": "Bản đồ của bạn", "search_result_page_new_search_hint": "Tìm kiếm mới", "search_suggestion_list_smart_search_hint_1": "Tìm kiếm thông minh được bật mặc định, để tìm kiếm siêu dữ liệu hãy sử dụng cú pháp", "search_suggestion_list_smart_search_hint_2": "m:cụm-từ-tìm-kiếm-của-bạn", @@ -381,17 +382,17 @@ "shared_album_activity_remove_title": "Xoá hoạt động", "shared_album_activity_setting_subtitle": "Cho phép người khác phản hồi", "shared_album_activity_setting_title": "Bình luận và lượt thích", - "shared_album_section_people_action_error": "Error leaving/removing from album", - "shared_album_section_people_action_leave": "Remove user from album", - "shared_album_section_people_action_remove_user": "Remove user from album", - "shared_album_section_people_owner_label": "Owner", - "shared_album_section_people_title": "PEOPLE", + "shared_album_section_people_action_error": "Lỗi khi xoá khỏi album", + "shared_album_section_people_action_leave": "Xóa người dùng khỏi album", + "shared_album_section_people_action_remove_user": "Xóa người dùng khỏi album", + "shared_album_section_people_owner_label": "Chủ sở hữu", + "shared_album_section_people_title": "MỌI NGƯỜI", "share_dialog_preparing": "Đang xử lý...", "shared_link_app_bar_title": "Đường liên kết chia sẻ", - "shared_link_clipboard_copied_massage": "Copied to clipboard", - "shared_link_clipboard_text": "Link: {}\nPassword: {}", + "shared_link_clipboard_copied_massage": "Đã sao chép tới bản ghi tạm", + "shared_link_clipboard_text": "Liên kết: {}\nMật khẩu: {}", "shared_link_create_app_bar_title": "Tạo liên kết để chia sẻ", - "shared_link_create_error": "Error while creating shared link", + "shared_link_create_error": "Tạo liên kết chia sẻ không thành công", "shared_link_create_info": "Cho phép bất cứ ai có liên kết xem (các) ảnh đã chọn", "shared_link_create_submit_button": "Tạo liên kết", "shared_link_edit_allow_download": "Cho phép bất cứ ai đều có thể tải xuống", @@ -401,32 +402,32 @@ "shared_link_edit_description": "Mô tả", "shared_link_edit_description_hint": "Nhập mô tả chia sẻ", "shared_link_edit_expire_after": "Hết hạn sau", - "shared_link_edit_expire_after_option_day": "1 day", - "shared_link_edit_expire_after_option_days": "{} days", - "shared_link_edit_expire_after_option_hour": "1 hour", - "shared_link_edit_expire_after_option_hours": "{} hours", - "shared_link_edit_expire_after_option_minute": "1 minute", - "shared_link_edit_expire_after_option_minutes": "{} minutes", - "shared_link_edit_expire_after_option_never": "Never", + "shared_link_edit_expire_after_option_day": "1 ngày", + "shared_link_edit_expire_after_option_days": "{} ngày", + "shared_link_edit_expire_after_option_hour": "1 giờ", + "shared_link_edit_expire_after_option_hours": "{} giờ", + "shared_link_edit_expire_after_option_minute": "1 phút", + "shared_link_edit_expire_after_option_minutes": "{} phút", + "shared_link_edit_expire_after_option_never": "Không bao giờ", "shared_link_edit_password": "Mật khẩu", "shared_link_edit_password_hint": "Nhập mật khẩu chia sẻ", "shared_link_edit_show_meta": "Hiện thị siêu dữ liệu", "shared_link_edit_submit_button": "Cập nhật liên kết", "shared_link_empty": "Bạn không có liên kết được chia sẻ nào", - "shared_link_error_server_url_fetch": "Cannot fetch the server url", - "shared_link_expired": "Expired", - "shared_link_expires_day": "Expires in {} day", - "shared_link_expires_days": "Expires in {} days", - "shared_link_expires_hour": "Expires in {} hour", - "shared_link_expires_hours": "Expires in {} hours", - "shared_link_expires_minute": "Expires in {} minute", - "shared_link_expires_minutes": "Expires in {} minutes", - "shared_link_expires_never": "Expires ∞", - "shared_link_expires_second": "Expires in {} second", - "shared_link_expires_seconds": "Expires in {} seconds", - "shared_link_info_chip_download": "Download", - "shared_link_info_chip_metadata": "EXIF", - "shared_link_info_chip_upload": "Upload", + "shared_link_error_server_url_fetch": "Không thể kết nối địa chỉ máy chủ", + "shared_link_expired": "Đã hết hạn", + "shared_link_expires_day": "Hết hạn trong {} ngày", + "shared_link_expires_days": "Hết hạn trong {} ngày", + "shared_link_expires_hour": "Hết hạn trong {} giờ", + "shared_link_expires_hours": "Hết hạn trong {} giờ", + "shared_link_expires_minute": "Hết hạn trong {} phút", + "shared_link_expires_minutes": "Hết hạn trong {} phút", + "shared_link_expires_never": "Hết hạn ∞\n", + "shared_link_expires_second": "Hết hạn trong {} giây", + "shared_link_expires_seconds": "Hết hạn trong {} giây", + "shared_link_info_chip_download": "Tải xuống", + "shared_link_info_chip_metadata": "Dữ liệu EXIF", + "shared_link_info_chip_upload": "Tải lên", "shared_link_manage_links": "Quản lý liên kết được chia sẻ", "share_done": "Hoàn tất", "share_invite": "Mời vào album", @@ -441,11 +442,11 @@ "tab_controller_nav_search": "Tìm kiếm", "tab_controller_nav_sharing": "Chia sẻ", "theme_setting_asset_list_storage_indicator_title": "Hiện thị trạng thái sao lưu ảnh trên hình thu nhỏ ", - "theme_setting_asset_list_tiles_per_row_title": "Số ảnh và video trên một dòng ({})", + "theme_setting_asset_list_tiles_per_row_title": "Số lượng ảnh trên một dòng ({})", "theme_setting_dark_mode_switch": "Chế độ tối", "theme_setting_image_viewer_quality_subtitle": "Điều chỉnh chất lượng của trình xem ảnh", "theme_setting_image_viewer_quality_title": "Chất lượng trình xem ảnh", - "theme_setting_system_theme_switch": "Tư động (Theo cài đặt hệ thống)", + "theme_setting_system_theme_switch": "Tự động (Theo cài đặt hệ thống)", "theme_setting_theme_subtitle": "Chọn cài đặt giao diện ứng dụng", "theme_setting_theme_title": "Giao diện", "theme_setting_three_stage_loading_subtitle": "Tải ba giai doạn có thể tăng hiệu năng tải ảnh nhưng sẽ tốn dữ liệu mạng đáng kể.", @@ -473,7 +474,7 @@ "version_announcement_overlay_text_2": "vui lòng dành thời gian của bạn để đến thăm", "version_announcement_overlay_text_3": "và đảm bảo cài đặt docker-compose và tệp .env của bạn đã cập nhật để tránh bất kỳ cấu hình sai sót, đặc biệt nếu bạn dùng WatchTower hoặc bất kỳ cơ chế nào xử lý việc cập nhật ứng dụng máy chủ của bạn tự động.", "version_announcement_overlay_title": "Phiên bản máy chủ có bản cập nhật mới", - "viewer_remove_from_stack": "Loại bỏ khỏi ngăn xếp", - "viewer_stack_use_as_main_asset": "Sử dụng làm ảnh bìa ngăn xếp", - "viewer_unstack": "Hoàn tác ngăn xếp" + "viewer_remove_from_stack": "Xoá khỏi nhóm", + "viewer_stack_use_as_main_asset": "Đặt làm lựa chọn hàng đầu", + "viewer_unstack": "Huỷ xếp nhóm" } \ No newline at end of file diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index 2a6b951018f64..c133f1041d71b 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -80,7 +80,7 @@ "backup_controller_page_backup_sub": "已备份的照片和视频", "backup_controller_page_cancel": "取消", "backup_controller_page_created": "创建时间: {}", - "backup_controller_page_desc_backup": "打开前台备份,以在程序运行时自动备份。", + "backup_controller_page_desc_backup": "打开前台备份,以在程序运行时自动备份新项目。", "backup_controller_page_excluded": "已排除:", "backup_controller_page_failed": "失败({})", "backup_controller_page_filename": "文件名称: {} [{}]", @@ -121,7 +121,7 @@ "cache_settings_statistics_shared": "共享相册缩略图", "cache_settings_statistics_thumbnail": "缩略图", "cache_settings_statistics_title": "缓存使用情况", - "cache_settings_subtitle": "控制 Immich 的缓存行为", + "cache_settings_subtitle": "控制 Immich app 的缓存行为", "cache_settings_thumbnail_size": "缩略图缓存大小({} 项)", "cache_settings_tile_subtitle": "设置本地存储行为", "cache_settings_tile_title": "本地存储", @@ -134,7 +134,7 @@ "common_add_to_album": "添加到相册", "common_change_password": "更改密码", "common_create_new_album": "新建相册", - "common_server_error": "请检查您的网络连接,确保服务器可访问且该应用程序或服务器版本兼容。", + "common_server_error": "请检查您的网络连接,确保服务器可访问且该应用程序与服务器版本兼容。", "common_shared": "共享", "control_bottom_app_bar_add_to_album": "添加到相册", "control_bottom_app_bar_album_info": "{} 项", @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "详情", "exif_bottom_sheet_location": "位置", "exif_bottom_sheet_location_add": "添加位置信息", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "正在处理", "experimental_settings_new_asset_list_title": "启用实验性照片网格", "experimental_settings_subtitle": "使用风险自负!", @@ -207,7 +208,7 @@ "home_page_upload_err_limit": "一次最多只能上传 30 个项目,跳过", "image_viewer_page_state_provider_download_error": "下载出现错误", "image_viewer_page_state_provider_download_success": "下载成功", - "image_viewer_page_state_provider_share_error": "共享错误", + "image_viewer_page_state_provider_share_error": "共享出错", "library_page_albums": "相册", "library_page_archive": "归档", "library_page_device_albums": "设备上的相册", @@ -248,7 +249,7 @@ "login_form_next_button": "下一个", "login_form_password_hint": "密码", "login_form_save_login": "保持登录", - "login_form_server_empty": "输入服务器地址。", + "login_form_server_empty": "输入服务器地址", "login_form_server_error": "无法连接到服务器。", "login_password_changed_error": "更新密码时出错\n", "login_password_changed_success": "密码更新成功", @@ -267,7 +268,7 @@ "map_settings_date_range_option_all": "所有", "map_settings_date_range_option_day": "过去24小时", "map_settings_date_range_option_days": "{}天前", - "map_settings_date_range_option_year": "去年", + "map_settings_date_range_option_year": "1年前", "map_settings_date_range_option_years": "{}年前", "map_settings_dialog_cancel": "取消", "map_settings_dialog_save": "保存", @@ -343,7 +344,7 @@ "search_page_your_activity": "您的活动", "search_page_your_map": "足迹", "search_result_page_new_search_hint": "搜索新的", - "search_suggestion_list_smart_search_hint_1": "默认情况下启用智能搜索;要搜索元数据,请使用相关语法", + "search_suggestion_list_smart_search_hint_1": "默认情况下启用智能搜索,要搜索元数据,请使用相关语法", "search_suggestion_list_smart_search_hint_2": "m:您的搜索关键词", "select_additional_user_for_sharing_page_suggestions": "建议", "select_user_for_sharing_page_err_album": "创建相册失败", diff --git a/mobile/assets/i18n/zh-Hans.json b/mobile/assets/i18n/zh-Hans.json index c5359c09f8ba4..811e56ea69896 100644 --- a/mobile/assets/i18n/zh-Hans.json +++ b/mobile/assets/i18n/zh-Hans.json @@ -80,7 +80,7 @@ "backup_controller_page_backup_sub": "已备份的照片和视频", "backup_controller_page_cancel": "取消", "backup_controller_page_created": "创建时间: {}", - "backup_controller_page_desc_backup": "打开前台备份,以在程序运行时自动备份。", + "backup_controller_page_desc_backup": "打开前台备份,以在程序运行时自动备份新项目。", "backup_controller_page_excluded": "已排除:", "backup_controller_page_failed": "失败({})", "backup_controller_page_filename": "文件名称: {} [{}]", @@ -121,7 +121,7 @@ "cache_settings_statistics_shared": "共享相册缩略图", "cache_settings_statistics_thumbnail": "缩略图", "cache_settings_statistics_title": "缓存使用情况", - "cache_settings_subtitle": "控制 Immich 的缓存行为", + "cache_settings_subtitle": "控制 Immich app 的缓存行为", "cache_settings_thumbnail_size": "缩略图缓存大小({} 项)", "cache_settings_tile_subtitle": "设置本地存储行为", "cache_settings_tile_title": "本地存储", @@ -134,7 +134,7 @@ "common_add_to_album": "添加到相册", "common_change_password": "更改密码", "common_create_new_album": "新建相册", - "common_server_error": "请检查您的网络连接,确保服务器可访问且该应用程序或服务器版本兼容。", + "common_server_error": "请检查您的网络连接,确保服务器可访问且该应用程序与服务器版本兼容。", "common_shared": "共享", "control_bottom_app_bar_add_to_album": "添加到相册", "control_bottom_app_bar_album_info": "{} 项", @@ -185,6 +185,7 @@ "exif_bottom_sheet_details": "详情", "exif_bottom_sheet_location": "位置", "exif_bottom_sheet_location_add": "添加位置信息", + "exif_bottom_sheet_people": "PEOPLE", "experimental_settings_new_asset_list_subtitle": "正在处理", "experimental_settings_new_asset_list_title": "启用实验性照片网格", "experimental_settings_subtitle": "使用风险自负!", @@ -192,7 +193,7 @@ "favorites_page_no_favorites": "未找到收藏项目", "favorites_page_title": "收藏", "home_page_add_to_album_conflicts": "已向相册 {album} 中添加 {added} 项。\n其中 {failed} 项在相册中已存在。", - "home_page_add_to_album_err_local": "暂不能将本地资项目添加到相册中,跳过", + "home_page_add_to_album_err_local": "暂不能将本地项目添加到相册中,跳过", "home_page_add_to_album_success": "已向相册 {album} 中添加 {added} 项。", "home_page_album_err_partner": "暂无法将同伴的项目添加到相册,跳过", "home_page_archive_err_local": "暂无法归档本地项目,跳过", @@ -207,7 +208,7 @@ "home_page_upload_err_limit": "一次最多只能上传 30 个项目,跳过", "image_viewer_page_state_provider_download_error": "下载出现错误", "image_viewer_page_state_provider_download_success": "下载成功", - "image_viewer_page_state_provider_share_error": "共享错误", + "image_viewer_page_state_provider_share_error": "共享出错", "library_page_albums": "相册", "library_page_archive": "归档", "library_page_device_albums": "设备上的相册", @@ -248,7 +249,7 @@ "login_form_next_button": "下一个", "login_form_password_hint": "密码", "login_form_save_login": "保持登录", - "login_form_server_empty": "输入服务器地址。", + "login_form_server_empty": "输入服务器地址", "login_form_server_error": "无法连接到服务器。", "login_password_changed_error": "更新密码时出错\n", "login_password_changed_success": "密码更新成功", @@ -267,7 +268,7 @@ "map_settings_date_range_option_all": "所有", "map_settings_date_range_option_day": "过去24小时", "map_settings_date_range_option_days": "{}天前", - "map_settings_date_range_option_year": "去年", + "map_settings_date_range_option_year": "1年前", "map_settings_date_range_option_years": "{}年前", "map_settings_dialog_cancel": "取消", "map_settings_dialog_save": "保存", @@ -343,7 +344,7 @@ "search_page_your_activity": "您的活动", "search_page_your_map": "足迹", "search_result_page_new_search_hint": "搜索新的", - "search_suggestion_list_smart_search_hint_1": "默认情况下启用智能搜索;要搜索元数据,请使用相关语法", + "search_suggestion_list_smart_search_hint_1": "默认情况下启用智能搜索,要搜索元数据,请使用相关语法", "search_suggestion_list_smart_search_hint_2": "m:您的搜索关键词", "select_additional_user_for_sharing_page_suggestions": "建议", "select_user_for_sharing_page_err_album": "创建相册失败", From a50f125dd18711e41f582db8ab36ed44b727f1e4 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 7 Mar 2024 22:59:02 -0500 Subject: [PATCH 40/47] refactor: api validators (boolean and date) (#7709) * refactor: api validators (boolean and date) * chore: open api * revert: time bucket change --- cli/src/commands/upload.command.ts | 4 +- e2e/src/api/specs/person.e2e-spec.ts | 23 +++-- mobile/openapi/doc/PersonApi.md | 2 +- mobile/openapi/doc/ScanLibraryDto.md | 2 +- mobile/openapi/doc/SharedLinkCreateDto.md | 2 +- .../openapi/lib/model/scan_library_dto.dart | 18 +++- .../lib/model/shared_link_create_dto.dart | 18 +++- .../openapi/test/scan_library_dto_test.dart | 2 +- .../test/shared_link_create_dto_test.dart | 2 +- open-api/immich-openapi-specs.json | 3 - .../src/domain/album/dto/album-update.dto.ts | 7 +- server/src/domain/album/dto/album.dto.ts | 8 +- server/src/domain/album/dto/get-albums.dto.ts | 10 +- .../domain/asset/dto/asset-statistics.dto.ts | 16 +--- server/src/domain/asset/dto/asset.dto.ts | 53 +++-------- server/src/domain/asset/dto/map-marker.dto.ts | 28 ++---- .../src/domain/asset/dto/time-bucket.dto.ts | 26 ++--- server/src/domain/audit/audit.dto.ts | 7 +- server/src/domain/domain.util.ts | 95 +++++++++++-------- server/src/domain/job/job.dto.ts | 7 +- server/src/domain/library/library.dto.ts | 21 ++-- server/src/domain/person/person.dto.ts | 19 ++-- server/src/domain/search/dto/search.dto.ts | 74 ++++++--------- .../src/domain/shared-link/shared-link.dto.ts | 27 ++---- .../domain/smart-info/dto/model-config.dto.ts | 6 +- .../dto/system-config-ffmpeg.dto.ts | 7 +- .../dto/system-config-library.dto.ts | 7 +- .../dto/system-config-logging.dto.ts | 5 +- .../dto/system-config-machine-learning.dto.ts | 7 +- .../dto/system-config-map.dto.ts | 5 +- .../system-config-new-version-check.dto.ts | 4 +- .../dto/system-config-oauth.dto.ts | 11 ++- .../dto/system-config-password-login.dto.ts | 4 +- .../system-config-reverse-geocoding.dto.ts | 4 +- .../dto/system-config-storage-template.dto.ts | 9 +- .../dto/system-config-trash.dto.ts | 5 +- server/src/domain/user/dto/create-user.dto.ts | 10 +- server/src/domain/user/dto/update-user.dto.ts | 13 +-- .../api-v1/asset/dto/asset-search.dto.ts | 24 ++--- .../api-v1/asset/dto/create-asset.dto.ts | 33 ++----- .../immich/api-v1/asset/dto/serve-file.dto.ts | 16 +--- 41 files changed, 276 insertions(+), 368 deletions(-) diff --git a/cli/src/commands/upload.command.ts b/cli/src/commands/upload.command.ts index 8029b1313fb53..250fd79c62e1c 100644 --- a/cli/src/commands/upload.command.ts +++ b/cli/src/commands/upload.command.ts @@ -66,8 +66,8 @@ class Asset { assetData: new File([await fs.openAsBlob(this.path)], basename(this.path)), deviceAssetId: this.deviceAssetId, deviceId: 'CLI', - fileCreatedAt: this.fileCreatedAt, - fileModifiedAt: this.fileModifiedAt, + fileCreatedAt: this.fileCreatedAt.toISOString(), + fileModifiedAt: this.fileModifiedAt.toISOString(), isFavorite: String(false), }; const formData = new FormData(); diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index 8015580430469..54fbfa9be5e85 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -6,10 +6,9 @@ import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; const invalidBirthday = [ - // TODO: enable after replacing `@Type(() => Date)` - // { birthDate: 'false', response: 'Invalid date' }, - // { birthDate: '123567', response: 'Invalid date }, - // { birthDate: 123_567, response: ['Birth date cannot be in the future'] }, + { birthDate: 'false', response: 'birthDate must be a date string' }, + { birthDate: '123567', response: 'birthDate must be a date string' }, + { birthDate: 123_567, response: 'birthDate must be a date string' }, { birthDate: new Date(9999, 0, 0).toISOString(), response: ['Birth date cannot be in the future'] }, ]; @@ -152,16 +151,16 @@ describe('/person', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should not accept invalid birth dates', async () => { - for (const { birthDate, response } of invalidBirthday) { + for (const { birthDate, response } of invalidBirthday) { + it(`should not accept an invalid birth date [${birthDate}]`, async () => { const { status, body } = await request(app) .post(`/person`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate }); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest(response)); - } - }); + }); + } it('should create a person', async () => { const { status, body } = await request(app) @@ -202,16 +201,16 @@ describe('/person', () => { }); } - it('should not accept invalid birth dates', async () => { - for (const { birthDate, response } of invalidBirthday) { + for (const { birthDate, response } of invalidBirthday) { + it(`should not accept an invalid birth date [${birthDate}]`, async () => { const { status, body } = await request(app) .put(`/person/${visiblePerson.id}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ birthDate }); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest(response)); - } - }); + }); + } it('should update a date of birth', async () => { const { status, body } = await request(app) diff --git a/mobile/openapi/doc/PersonApi.md b/mobile/openapi/doc/PersonApi.md index 2ade49aec7411..48c1c3cc4a839 100644 --- a/mobile/openapi/doc/PersonApi.md +++ b/mobile/openapi/doc/PersonApi.md @@ -114,7 +114,7 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- - **withHidden** | **bool**| | [optional] [default to false] + **withHidden** | **bool**| | [optional] ### Return type diff --git a/mobile/openapi/doc/ScanLibraryDto.md b/mobile/openapi/doc/ScanLibraryDto.md index 39f55290dcca6..e2c489d852b23 100644 --- a/mobile/openapi/doc/ScanLibraryDto.md +++ b/mobile/openapi/doc/ScanLibraryDto.md @@ -8,7 +8,7 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**refreshAllFiles** | **bool** | | [optional] [default to false] +**refreshAllFiles** | **bool** | | [optional] **refreshModifiedFiles** | **bool** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SharedLinkCreateDto.md b/mobile/openapi/doc/SharedLinkCreateDto.md index 8f845dfa4985a..78e2089121772 100644 --- a/mobile/openapi/doc/SharedLinkCreateDto.md +++ b/mobile/openapi/doc/SharedLinkCreateDto.md @@ -10,7 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **albumId** | **String** | | [optional] **allowDownload** | **bool** | | [optional] [default to true] -**allowUpload** | **bool** | | [optional] [default to false] +**allowUpload** | **bool** | | [optional] **assetIds** | **List** | | [optional] [default to const []] **description** | **String** | | [optional] **expiresAt** | [**DateTime**](DateTime.md) | | [optional] diff --git a/mobile/openapi/lib/model/scan_library_dto.dart b/mobile/openapi/lib/model/scan_library_dto.dart index 2b34e2bbeb07b..0f5dedf64a587 100644 --- a/mobile/openapi/lib/model/scan_library_dto.dart +++ b/mobile/openapi/lib/model/scan_library_dto.dart @@ -13,11 +13,17 @@ part of openapi.api; class ScanLibraryDto { /// Returns a new [ScanLibraryDto] instance. ScanLibraryDto({ - this.refreshAllFiles = false, + this.refreshAllFiles, this.refreshModifiedFiles, }); - bool refreshAllFiles; + /// + /// 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. + /// + bool? refreshAllFiles; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -35,7 +41,7 @@ class ScanLibraryDto { @override int get hashCode => // ignore: unnecessary_parenthesis - (refreshAllFiles.hashCode) + + (refreshAllFiles == null ? 0 : refreshAllFiles!.hashCode) + (refreshModifiedFiles == null ? 0 : refreshModifiedFiles!.hashCode); @override @@ -43,7 +49,11 @@ class ScanLibraryDto { Map toJson() { final json = {}; + if (this.refreshAllFiles != null) { json[r'refreshAllFiles'] = this.refreshAllFiles; + } else { + // json[r'refreshAllFiles'] = null; + } if (this.refreshModifiedFiles != null) { json[r'refreshModifiedFiles'] = this.refreshModifiedFiles; } else { @@ -60,7 +70,7 @@ class ScanLibraryDto { final json = value.cast(); return ScanLibraryDto( - refreshAllFiles: mapValueOfType(json, r'refreshAllFiles') ?? false, + refreshAllFiles: mapValueOfType(json, r'refreshAllFiles'), refreshModifiedFiles: mapValueOfType(json, r'refreshModifiedFiles'), ); } diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index 14dc109a93a4c..920e62e52e667 100644 --- a/mobile/openapi/lib/model/shared_link_create_dto.dart +++ b/mobile/openapi/lib/model/shared_link_create_dto.dart @@ -15,7 +15,7 @@ class SharedLinkCreateDto { SharedLinkCreateDto({ this.albumId, this.allowDownload = true, - this.allowUpload = false, + this.allowUpload, this.assetIds = const [], this.description, this.expiresAt, @@ -34,7 +34,13 @@ class SharedLinkCreateDto { bool allowDownload; - bool allowUpload; + /// + /// 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. + /// + bool? allowUpload; List assetIds; @@ -77,7 +83,7 @@ class SharedLinkCreateDto { // ignore: unnecessary_parenthesis (albumId == null ? 0 : albumId!.hashCode) + (allowDownload.hashCode) + - (allowUpload.hashCode) + + (allowUpload == null ? 0 : allowUpload!.hashCode) + (assetIds.hashCode) + (description == null ? 0 : description!.hashCode) + (expiresAt == null ? 0 : expiresAt!.hashCode) + @@ -96,7 +102,11 @@ class SharedLinkCreateDto { // json[r'albumId'] = null; } json[r'allowDownload'] = this.allowDownload; + if (this.allowUpload != null) { json[r'allowUpload'] = this.allowUpload; + } else { + // json[r'allowUpload'] = null; + } json[r'assetIds'] = this.assetIds; if (this.description != null) { json[r'description'] = this.description; @@ -128,7 +138,7 @@ class SharedLinkCreateDto { return SharedLinkCreateDto( albumId: mapValueOfType(json, r'albumId'), allowDownload: mapValueOfType(json, r'allowDownload') ?? true, - allowUpload: mapValueOfType(json, r'allowUpload') ?? false, + allowUpload: mapValueOfType(json, r'allowUpload'), assetIds: json[r'assetIds'] is Iterable ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) : const [], diff --git a/mobile/openapi/test/scan_library_dto_test.dart b/mobile/openapi/test/scan_library_dto_test.dart index 975a6d757f5a8..2b3c7586703f3 100644 --- a/mobile/openapi/test/scan_library_dto_test.dart +++ b/mobile/openapi/test/scan_library_dto_test.dart @@ -16,7 +16,7 @@ void main() { // final instance = ScanLibraryDto(); group('test ScanLibraryDto', () { - // bool refreshAllFiles (default value: false) + // bool refreshAllFiles test('to test the property `refreshAllFiles`', () async { // TODO }); diff --git a/mobile/openapi/test/shared_link_create_dto_test.dart b/mobile/openapi/test/shared_link_create_dto_test.dart index df57e089f54b5..982d72a1408fa 100644 --- a/mobile/openapi/test/shared_link_create_dto_test.dart +++ b/mobile/openapi/test/shared_link_create_dto_test.dart @@ -26,7 +26,7 @@ void main() { // TODO }); - // bool allowUpload (default value: false) + // bool allowUpload test('to test the property `allowUpload`', () async { // TODO }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bd99b24765c43..8819825b9175d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4012,7 +4012,6 @@ "required": false, "in": "query", "schema": { - "default": false, "type": "boolean" } } @@ -8937,7 +8936,6 @@ "ScanLibraryDto": { "properties": { "refreshAllFiles": { - "default": false, "type": "boolean" }, "refreshModifiedFiles": { @@ -9346,7 +9344,6 @@ "type": "boolean" }, "allowUpload": { - "default": false, "type": "boolean" }, "assetIds": { diff --git a/server/src/domain/album/dto/album-update.dto.ts b/server/src/domain/album/dto/album-update.dto.ts index 3b1858ba109b9..1b6c754f02fcb 100644 --- a/server/src/domain/album/dto/album-update.dto.ts +++ b/server/src/domain/album/dto/album-update.dto.ts @@ -1,5 +1,5 @@ -import { IsBoolean, IsString } from 'class-validator'; -import { Optional, ValidateUUID } from '../../domain.util'; +import { IsString } from 'class-validator'; +import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util'; export class UpdateAlbumDto { @Optional() @@ -13,7 +13,6 @@ export class UpdateAlbumDto { @ValidateUUID({ optional: true }) albumThumbnailAssetId?: string; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) isActivityEnabled?: boolean; } diff --git a/server/src/domain/album/dto/album.dto.ts b/server/src/domain/album/dto/album.dto.ts index d1fc701a0e87d..b7aad98b5c317 100644 --- a/server/src/domain/album/dto/album.dto.ts +++ b/server/src/domain/album/dto/album.dto.ts @@ -1,10 +1,6 @@ -import { Transform } from 'class-transformer'; -import { IsBoolean } from 'class-validator'; -import { Optional, toBoolean } from '../../domain.util'; +import { ValidateBoolean } from '../../domain.util'; export class AlbumInfoDto { - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) withoutAssets?: boolean; } diff --git a/server/src/domain/album/dto/get-albums.dto.ts b/server/src/domain/album/dto/get-albums.dto.ts index ce037e18998cb..2628a3fc72935 100644 --- a/server/src/domain/album/dto/get-albums.dto.ts +++ b/server/src/domain/album/dto/get-albums.dto.ts @@ -1,13 +1,7 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean } from 'class-validator'; -import { Optional, toBoolean, ValidateUUID } from '../../domain.util'; +import { ValidateBoolean, ValidateUUID } from '../../domain.util'; export class GetAlbumsDto { - @Optional() - @IsBoolean() - @Transform(toBoolean) - @ApiProperty() + @ValidateBoolean({ optional: true }) /** * true: only shared albums * false: only non-shared own albums diff --git a/server/src/domain/asset/dto/asset-statistics.dto.ts b/server/src/domain/asset/dto/asset-statistics.dto.ts index a53e774f44134..c313ccdf47c1d 100644 --- a/server/src/domain/asset/dto/asset-statistics.dto.ts +++ b/server/src/domain/asset/dto/asset-statistics.dto.ts @@ -1,24 +1,16 @@ import { AssetType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean } from 'class-validator'; -import { Optional, toBoolean } from '../../domain.util'; +import { ValidateBoolean } from '../../domain.util'; import { AssetStats } from '../../repositories'; export class AssetStatsDto { - @IsBoolean() - @Transform(toBoolean) - @Optional() + @ValidateBoolean({ optional: true }) isArchived?: boolean; - @IsBoolean() - @Transform(toBoolean) - @Optional() + @ValidateBoolean({ optional: true }) isFavorite?: boolean; - @IsBoolean() - @Transform(toBoolean) - @Optional() + @ValidateBoolean({ optional: true }) isTrashed?: boolean; } diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index 0244ecd90eb25..8b5c675d89094 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -1,6 +1,5 @@ import { Type } from 'class-transformer'; import { - IsBoolean, IsDateString, IsInt, IsLatitude, @@ -10,7 +9,7 @@ import { IsString, ValidateIf, } from 'class-validator'; -import { Optional, ValidateUUID } from '../../domain.util'; +import { Optional, ValidateBoolean, ValidateUUID } from '../../domain.util'; import { BulkIdsDto } from '../response-dto'; export class DeviceIdDto { @@ -28,23 +27,13 @@ const hasGPS = (o: { latitude: undefined; longitude: undefined }) => o.latitude !== undefined || o.longitude !== undefined; const ValidateGPS = () => ValidateIf(hasGPS); -export class AssetBulkUpdateDto extends BulkIdsDto { - @Optional() - @IsBoolean() +export class UpdateAssetBase { + @ValidateBoolean({ optional: true }) isFavorite?: boolean; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) isArchived?: boolean; - @Optional() - @ValidateUUID() - stackParentId?: string; - - @Optional() - @IsBoolean() - removeParent?: boolean; - @Optional() @IsDateString() dateTimeOriginal?: string; @@ -60,32 +49,21 @@ export class AssetBulkUpdateDto extends BulkIdsDto { longitude?: number; } -export class UpdateAssetDto { - @Optional() - @IsBoolean() - isFavorite?: boolean; +export class AssetBulkUpdateDto extends UpdateAssetBase { + @ValidateUUID({ each: true }) + ids!: string[]; - @Optional() - @IsBoolean() - isArchived?: boolean; + @ValidateUUID({ optional: true }) + stackParentId?: string; + @ValidateBoolean({ optional: true }) + removeParent?: boolean; +} + +export class UpdateAssetDto extends UpdateAssetBase { @Optional() @IsString() description?: string; - - @Optional() - @IsDateString() - dateTimeOriginal?: string; - - @ValidateGPS() - @IsLatitude() - @IsNotEmpty() - latitude?: number; - - @ValidateGPS() - @IsLongitude() - @IsNotEmpty() - longitude?: number; } export class RandomAssetsDto { @@ -97,7 +75,6 @@ export class RandomAssetsDto { } export class AssetBulkDeleteDto extends BulkIdsDto { - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) force?: boolean; } diff --git a/server/src/domain/asset/dto/map-marker.dto.ts b/server/src/domain/asset/dto/map-marker.dto.ts index b703d6e73e297..4fe6c16b846cc 100644 --- a/server/src/domain/asset/dto/map-marker.dto.ts +++ b/server/src/domain/asset/dto/map-marker.dto.ts @@ -1,34 +1,18 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsDate } from 'class-validator'; -import { Optional, toBoolean } from '../../domain.util'; +import { ValidateBoolean, ValidateDate } from '../../domain.util'; export class MapMarkerDto { - @ApiProperty() - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isArchived?: boolean; - @ApiProperty() - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isFavorite?: boolean; - @Optional() - @IsDate() - @Type(() => Date) + @ValidateDate({ optional: true }) fileCreatedAfter?: Date; - @Optional() - @IsDate() - @Type(() => Date) + @ValidateDate({ optional: true }) fileCreatedBefore?: Date; - @ApiProperty() - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) withPartners?: boolean; } diff --git a/server/src/domain/asset/dto/time-bucket.dto.ts b/server/src/domain/asset/dto/time-bucket.dto.ts index 849b8713f0d6c..597a5de3563d8 100644 --- a/server/src/domain/asset/dto/time-bucket.dto.ts +++ b/server/src/domain/asset/dto/time-bucket.dto.ts @@ -1,7 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { Optional, ValidateUUID, toBoolean } from '../../domain.util'; +import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { ValidateBoolean, ValidateUUID } from '../../domain.util'; import { TimeBucketSize } from '../../repositories'; export class TimeBucketDto { @@ -19,34 +18,23 @@ export class TimeBucketDto { @ValidateUUID({ optional: true }) personId?: string; - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isArchived?: boolean; - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isFavorite?: boolean; - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isTrashed?: boolean; - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) withStacked?: boolean; - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) withPartners?: boolean; } export class TimeBucketAssetDto extends TimeBucketDto { @IsString() - @IsNotEmpty() timeBucket!: string; } diff --git a/server/src/domain/audit/audit.dto.ts b/server/src/domain/audit/audit.dto.ts index d941f9a1dfc59..0f3f04dab2c71 100644 --- a/server/src/domain/audit/audit.dto.ts +++ b/server/src/domain/audit/audit.dto.ts @@ -1,14 +1,13 @@ import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsDate, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator'; -import { Optional, ValidateUUID } from '../domain.util'; +import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Optional, ValidateDate, ValidateUUID } from '../domain.util'; const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType }); export class AuditDeletesDto { - @IsDate() - @Type(() => Date) + @ValidateDate() after!: Date; @ApiProperty({ enum: EntityType, enumName: 'EntityType' }) diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts index 1dadf03aed956..a079ff6bf6d27 100644 --- a/server/src/domain/domain.util.ts +++ b/server/src/domain/domain.util.ts @@ -1,7 +1,7 @@ import { ImmichLogger } from '@app/infra/logger'; -import { applyDecorators } from '@nestjs/common'; +import { BadRequestException, applyDecorators } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; +import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, @@ -12,6 +12,7 @@ import { IsUUID, ValidateIf, ValidationOptions, + isDateString, } from 'class-validator'; import { CronJob } from 'cron'; import _ from 'lodash'; @@ -40,14 +41,10 @@ export interface OpenGraphTags { imageUrl?: string; } -export type Options = { - optional?: boolean; - each?: boolean; -}; - export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; -export function ValidateUUID(options?: Options) { +type UUIDOptions = { optional?: boolean; each?: boolean }; +export const ValidateUUID = (options?: UUIDOptions) => { const { optional, each } = { optional: false, each: false, ...options }; return applyDecorators( IsUUID('4', { each }), @@ -55,7 +52,58 @@ export function ValidateUUID(options?: Options) { optional ? Optional() : IsNotEmpty(), each ? IsArray() : IsString(), ); -} +}; + +type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' }; +export const ValidateDate = (options?: DateOptions) => { + const { optional, nullable, format } = { optional: false, nullable: false, format: 'date-time', ...options }; + + const decorators = [ + ApiProperty({ format }), + IsDate(), + optional ? Optional({ nullable: true }) : IsNotEmpty(), + Transform(({ key, value }) => { + if (value === null || value === undefined) { + return value; + } + + if (!isDateString(value)) { + throw new BadRequestException(`${key} must be a date string`); + } + + return new Date(value as string); + }), + ]; + + if (optional) { + decorators.push(Optional({ nullable })); + } + + return applyDecorators(...decorators); +}; + +type BooleanOptions = { optional?: boolean }; +export const ValidateBoolean = (options?: BooleanOptions) => { + const { optional } = { optional: false, ...options }; + const decorators = [ + // ApiProperty(), + IsBoolean(), + Transform(({ value }) => { + if (value == 'true') { + return true; + } else if (value == 'false') { + return false; + } + return value; + }), + ]; + + if (optional) { + decorators.push(Optional()); + } + + return applyDecorators(...decorators); +}; export function validateCronExpression(expression: string) { try { @@ -67,34 +115,7 @@ export function validateCronExpression(expression: string) { return true; } -interface IValue { - value?: string; -} - -export const QueryBoolean = ({ optional }: { optional?: boolean }) => { - const decorators = [IsBoolean(), Transform(toBoolean)]; - if (optional) { - decorators.push(Optional()); - } - return applyDecorators(...decorators); -}; - -export const QueryDate = ({ optional }: { optional?: boolean }) => { - const decorators = [IsDate(), Type(() => Date)]; - if (optional) { - decorators.push(Optional()); - } - return applyDecorators(...decorators); -}; - -export const toBoolean = ({ value }: IValue) => { - if (value == 'true') { - return true; - } else if (value == 'false') { - return false; - } - return value; -}; +type IValue = { value: string }; export const toEmail = ({ value }: IValue) => value?.toLowerCase(); diff --git a/server/src/domain/job/job.dto.ts b/server/src/domain/job/job.dto.ts index db0bd8dc43533..87be1332f7d12 100644 --- a/server/src/domain/job/job.dto.ts +++ b/server/src/domain/job/job.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsEnum, IsNotEmpty } from 'class-validator'; -import { Optional } from '../domain.util'; +import { IsEnum, IsNotEmpty } from 'class-validator'; +import { ValidateBoolean } from '../domain.util'; import { JobCommand, QueueName } from './job.constants'; export class JobIdParamDto { @@ -16,8 +16,7 @@ export class JobCommandDto { @ApiProperty({ type: 'string', enum: JobCommand, enumName: 'JobCommand' }) command!: JobCommand; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) force!: boolean; } diff --git a/server/src/domain/library/library.dto.ts b/server/src/domain/library/library.dto.ts index b57d56e7b27d5..b11bc9998722b 100644 --- a/server/src/domain/library/library.dto.ts +++ b/server/src/domain/library/library.dto.ts @@ -1,7 +1,7 @@ import { LibraryEntity, LibraryType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; -import { ArrayMaxSize, ArrayUnique, IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { Optional, ValidateUUID } from '../domain.util'; +import { ArrayMaxSize, ArrayUnique, IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { Optional, ValidateBoolean, ValidateUUID } from '../domain.util'; export class CreateLibraryDto { @IsEnum(LibraryType) @@ -16,8 +16,7 @@ export class CreateLibraryDto { @IsNotEmpty() name?: string; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) isVisible?: boolean; @Optional() @@ -34,8 +33,7 @@ export class CreateLibraryDto { @ArrayMaxSize(128) exclusionPatterns?: string[]; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) isWatched?: boolean; } @@ -45,8 +43,7 @@ export class UpdateLibraryDto { @IsNotEmpty() name?: string; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) isVisible?: boolean; @Optional() @@ -102,13 +99,11 @@ export class LibrarySearchDto { } export class ScanLibraryDto { - @IsBoolean() - @Optional() + @ValidateBoolean({ optional: true }) refreshModifiedFiles?: boolean; - @IsBoolean() - @Optional() - refreshAllFiles?: boolean = false; + @ValidateBoolean({ optional: true }) + refreshAllFiles?: boolean; } export class SearchLibraryDto { diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index e76ce3308ee5c..a00971c6bece9 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -1,9 +1,9 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, MaxDate, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsArray, IsNotEmpty, IsString, MaxDate, ValidateNested } from 'class-validator'; import { AuthDto } from '../auth'; -import { Optional, ValidateUUID, toBoolean } from '../domain.util'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from '../domain.util'; export class PersonCreateDto { /** @@ -17,18 +17,14 @@ export class PersonCreateDto { * Person date of birth. * Note: the mobile app cannot currently set the birth date to null. */ - @Optional({ nullable: true }) - @IsDate() - @Type(() => Date) @MaxDate(() => new Date(), { message: 'Birth date cannot be in the future' }) - @ApiProperty({ format: 'date' }) + @ValidateDate({ optional: true, nullable: true, format: 'date' }) birthDate?: Date | null; /** * Person visibility */ - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) isHidden?: boolean; } @@ -63,9 +59,8 @@ export class MergePersonDto { } export class PersonSearchDto { - @IsBoolean() - @Transform(toBoolean) - withHidden?: boolean = false; + @ValidateBoolean({ optional: true }) + withHidden?: boolean; } export class PersonResponseDto { diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index c529f6887b139..70d8ee2884308 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -1,9 +1,9 @@ import { AssetOrder } from '@app/domain/asset/dto/asset.dto'; import { AssetType, GeodataPlacesEntity } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; -import { Optional, QueryBoolean, QueryDate, ValidateUUID, toBoolean } from '../../domain.util'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from '../../domain.util'; class BaseSearchDto { @ValidateUUID({ optional: true }) @@ -19,62 +19,62 @@ class BaseSearchDto { @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) type?: AssetType; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) isArchived?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) @ApiProperty({ default: false }) withArchived?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) isEncoded?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) isExternal?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) isFavorite?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) isMotion?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) isOffline?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) isReadOnly?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) isVisible?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) withDeleted?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) withExif?: boolean; - @QueryDate({ optional: true }) + @ValidateDate({ optional: true }) createdBefore?: Date; - @QueryDate({ optional: true }) + @ValidateDate({ optional: true }) createdAfter?: Date; - @QueryDate({ optional: true }) + @ValidateDate({ optional: true }) updatedBefore?: Date; - @QueryDate({ optional: true }) + @ValidateDate({ optional: true }) updatedAfter?: Date; - @QueryDate({ optional: true }) + @ValidateDate({ optional: true }) trashedBefore?: Date; - @QueryDate({ optional: true }) + @ValidateDate({ optional: true }) trashedAfter?: Date; - @QueryDate({ optional: true }) + @ValidateDate({ optional: true }) takenBefore?: Date; - @QueryDate({ optional: true }) + @ValidateDate({ optional: true }) takenAfter?: Date; @IsString() @@ -120,7 +120,7 @@ class BaseSearchDto { @Optional() size?: number; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) isNotInAlbum?: boolean; @Optional() @@ -141,10 +141,10 @@ export class MetadataSearchDto extends BaseSearchDto { @Optional() checksum?: string; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) withStacked?: boolean; - @QueryBoolean({ optional: true }) + @ValidateBoolean({ optional: true }) withPeople?: boolean; @IsString() @@ -197,34 +197,24 @@ export class SearchDto { @Optional() query?: string; - @IsBoolean() - @Optional() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) smart?: boolean; /** @deprecated */ - @IsBoolean() - @Optional() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) clip?: boolean; @IsEnum(AssetType) @Optional() type?: AssetType; - @IsBoolean() - @Optional() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) recent?: boolean; - @IsBoolean() - @Optional() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) motion?: boolean; - @IsBoolean() - @Optional() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) withArchived?: boolean; @IsInt() @@ -252,9 +242,7 @@ export class SearchPeopleDto { @IsNotEmpty() name!: string; - @IsBoolean() - @Transform(toBoolean) - @Optional() + @ValidateBoolean({ optional: true }) withHidden?: boolean; } diff --git a/server/src/domain/shared-link/shared-link.dto.ts b/server/src/domain/shared-link/shared-link.dto.ts index bb5b61820ac40..550ed70ea1a23 100644 --- a/server/src/domain/shared-link/shared-link.dto.ts +++ b/server/src/domain/shared-link/shared-link.dto.ts @@ -1,8 +1,7 @@ import { SharedLinkType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsBoolean, IsDate, IsEnum, IsString } from 'class-validator'; -import { Optional, ValidateUUID } from '../domain.util'; +import { IsEnum, IsString } from 'class-validator'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from '../domain.util'; export class SharedLinkCreateDto { @IsEnum(SharedLinkType) @@ -23,21 +22,16 @@ export class SharedLinkCreateDto { @Optional() password?: string; - @IsDate() - @Type(() => Date) - @Optional({ nullable: true }) + @ValidateDate({ optional: true, nullable: true }) expiresAt?: Date | null = null; - @Optional() - @IsBoolean() - allowUpload?: boolean = false; + @ValidateBoolean({ optional: true }) + allowUpload?: boolean; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) allowDownload?: boolean = true; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) showMetadata?: boolean = true; } @@ -54,10 +48,10 @@ export class SharedLinkEditDto { @Optional() allowUpload?: boolean; - @Optional() + @ValidateBoolean({ optional: true }) allowDownload?: boolean; - @Optional() + @ValidateBoolean({ optional: true }) showMetadata?: boolean; /** @@ -65,8 +59,7 @@ export class SharedLinkEditDto { * Setting this flag and not sending expiryAt is considered as null instead. * Clients that can send null values can ignore this. */ - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) changeExpiryTime?: boolean; } diff --git a/server/src/domain/smart-info/dto/model-config.dto.ts b/server/src/domain/smart-info/dto/model-config.dto.ts index 64f8c1d0094be..b9e27669f340c 100644 --- a/server/src/domain/smart-info/dto/model-config.dto.ts +++ b/server/src/domain/smart-info/dto/model-config.dto.ts @@ -1,11 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator'; -import { Optional } from '../../domain.util'; +import { IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator'; +import { Optional, ValidateBoolean } from '../../domain.util'; import { CLIPMode, ModelType } from '../../repositories'; export class ModelConfig { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; @IsString() diff --git a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts index 2783e35e67e9f..3a219888fb896 100644 --- a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts +++ b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts @@ -1,7 +1,8 @@ import { AudioCodec, CQMode, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsEnum, IsInt, IsString, Max, Min } from 'class-validator'; +import { IsEnum, IsInt, IsString, Max, Min } from 'class-validator'; +import { ValidateBoolean } from '../../domain.util'; export class SystemConfigFFmpegDto { @IsInt() @@ -68,14 +69,14 @@ export class SystemConfigFFmpegDto { @ApiProperty({ type: 'integer' }) npl!: number; - @IsBoolean() + @ValidateBoolean() temporalAQ!: boolean; @IsEnum(CQMode) @ApiProperty({ enumName: 'CQMode', enum: CQMode }) cqMode!: CQMode; - @IsBoolean() + @ValidateBoolean() twoPass!: boolean; @IsString() diff --git a/server/src/domain/system-config/dto/system-config-library.dto.ts b/server/src/domain/system-config/dto/system-config-library.dto.ts index fdbae600f6eb3..85ab62634531b 100644 --- a/server/src/domain/system-config/dto/system-config-library.dto.ts +++ b/server/src/domain/system-config/dto/system-config-library.dto.ts @@ -1,7 +1,5 @@ -import { validateCronExpression } from '@app/domain'; import { Type } from 'class-transformer'; import { - IsBoolean, IsNotEmpty, IsObject, IsString, @@ -11,6 +9,7 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; +import { ValidateBoolean, validateCronExpression } from '../../domain.util'; const isEnabled = (config: SystemConfigLibraryScanDto) => config.enabled; @@ -22,7 +21,7 @@ class CronValidator implements ValidatorConstraintInterface { } export class SystemConfigLibraryScanDto { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; @ValidateIf(isEnabled) @@ -33,7 +32,7 @@ export class SystemConfigLibraryScanDto { } export class SystemConfigLibraryWatchDto { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; } diff --git a/server/src/domain/system-config/dto/system-config-logging.dto.ts b/server/src/domain/system-config/dto/system-config-logging.dto.ts index d280df53568bd..09f78fc862b33 100644 --- a/server/src/domain/system-config/dto/system-config-logging.dto.ts +++ b/server/src/domain/system-config/dto/system-config-logging.dto.ts @@ -1,9 +1,10 @@ import { LogLevel } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; -import { IsBoolean, IsEnum } from 'class-validator'; +import { IsEnum } from 'class-validator'; +import { ValidateBoolean } from '../../domain.util'; export class SystemConfigLoggingDto { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; @ApiProperty({ enum: LogLevel, enumName: 'LogLevel' }) diff --git a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts index ed331eb6a1b1e..435e688268caf 100644 --- a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts +++ b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts @@ -1,9 +1,10 @@ -import { CLIPConfig, RecognitionConfig } from '@app/domain'; import { Type } from 'class-transformer'; -import { IsBoolean, IsObject, IsUrl, ValidateIf, ValidateNested } from 'class-validator'; +import { IsObject, IsUrl, ValidateIf, ValidateNested } from 'class-validator'; +import { ValidateBoolean } from '../../domain.util'; +import { CLIPConfig, RecognitionConfig } from '../../smart-info/dto/model-config.dto'; export class SystemConfigMachineLearningDto { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; @IsUrl({ require_tld: false, allow_underscores: true }) diff --git a/server/src/domain/system-config/dto/system-config-map.dto.ts b/server/src/domain/system-config/dto/system-config-map.dto.ts index 07700d98cd4a8..9e21e2d5de42f 100644 --- a/server/src/domain/system-config/dto/system-config-map.dto.ts +++ b/server/src/domain/system-config/dto/system-config-map.dto.ts @@ -1,7 +1,8 @@ -import { IsBoolean, IsString } from 'class-validator'; +import { IsString } from 'class-validator'; +import { ValidateBoolean } from '../../domain.util'; export class SystemConfigMapDto { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; @IsString() diff --git a/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts b/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts index c2767392438c6..379f5643dea9a 100644 --- a/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts +++ b/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts @@ -1,6 +1,6 @@ -import { IsBoolean } from 'class-validator'; +import { ValidateBoolean } from '../../domain.util'; export class SystemConfigNewVersionCheckDto { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; } diff --git a/server/src/domain/system-config/dto/system-config-oauth.dto.ts b/server/src/domain/system-config/dto/system-config-oauth.dto.ts index 04159b8d345a6..99779bdfe489b 100644 --- a/server/src/domain/system-config/dto/system-config-oauth.dto.ts +++ b/server/src/domain/system-config/dto/system-config-oauth.dto.ts @@ -1,13 +1,14 @@ -import { IsBoolean, IsNotEmpty, IsNumber, IsString, IsUrl, Min, ValidateIf } from 'class-validator'; +import { IsNotEmpty, IsNumber, IsString, IsUrl, Min, ValidateIf } from 'class-validator'; +import { ValidateBoolean } from '../../domain.util'; const isEnabled = (config: SystemConfigOAuthDto) => config.enabled; const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled; export class SystemConfigOAuthDto { - @IsBoolean() + @ValidateBoolean() autoLaunch!: boolean; - @IsBoolean() + @ValidateBoolean() autoRegister!: boolean; @IsString() @@ -27,7 +28,7 @@ export class SystemConfigOAuthDto { @Min(0) defaultStorageQuota!: number; - @IsBoolean() + @ValidateBoolean() enabled!: boolean; @ValidateIf(isEnabled) @@ -35,7 +36,7 @@ export class SystemConfigOAuthDto { @IsString() issuerUrl!: string; - @IsBoolean() + @ValidateBoolean() mobileOverrideEnabled!: boolean; @ValidateIf(isOverrideEnabled) diff --git a/server/src/domain/system-config/dto/system-config-password-login.dto.ts b/server/src/domain/system-config/dto/system-config-password-login.dto.ts index 119de65f6135f..279bcc5a61ead 100644 --- a/server/src/domain/system-config/dto/system-config-password-login.dto.ts +++ b/server/src/domain/system-config/dto/system-config-password-login.dto.ts @@ -1,6 +1,6 @@ -import { IsBoolean } from 'class-validator'; +import { ValidateBoolean } from '../../domain.util'; export class SystemConfigPasswordLoginDto { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; } diff --git a/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts b/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts index aa224ccc6ce1e..11e0ae289d0b7 100644 --- a/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts +++ b/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts @@ -1,6 +1,6 @@ -import { IsBoolean } from 'class-validator'; +import { ValidateBoolean } from '../../domain.util'; export class SystemConfigReverseGeocodingDto { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; } diff --git a/server/src/domain/system-config/dto/system-config-storage-template.dto.ts b/server/src/domain/system-config/dto/system-config-storage-template.dto.ts index c09b5564aa461..615fd8521cf7d 100644 --- a/server/src/domain/system-config/dto/system-config-storage-template.dto.ts +++ b/server/src/domain/system-config/dto/system-config-storage-template.dto.ts @@ -1,10 +1,13 @@ -import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { ValidateBoolean } from '../../domain.util'; export class SystemConfigStorageTemplateDto { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; - @IsBoolean() + + @ValidateBoolean() hashVerificationEnabled!: boolean; + @IsNotEmpty() @IsString() template!: string; diff --git a/server/src/domain/system-config/dto/system-config-trash.dto.ts b/server/src/domain/system-config/dto/system-config-trash.dto.ts index bfbdb3941521e..48241070326b0 100644 --- a/server/src/domain/system-config/dto/system-config-trash.dto.ts +++ b/server/src/domain/system-config/dto/system-config-trash.dto.ts @@ -1,9 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsInt, Min } from 'class-validator'; +import { IsInt, Min } from 'class-validator'; +import { ValidateBoolean } from '../../domain.util'; export class SystemConfigTrashDto { - @IsBoolean() + @ValidateBoolean() enabled!: boolean; @IsInt() diff --git a/server/src/domain/user/dto/create-user.dto.ts b/server/src/domain/user/dto/create-user.dto.ts index 737b68c036ede..f0cc7938c6f01 100644 --- a/server/src/domain/user/dto/create-user.dto.ts +++ b/server/src/domain/user/dto/create-user.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; -import { Optional, toEmail, toSanitized } from '../../domain.util'; +import { IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; +import { Optional, ValidateBoolean, toEmail, toSanitized } from '../../domain.util'; export class CreateUserDto { @IsEmail({ require_tld: false }) @@ -21,8 +21,7 @@ export class CreateUserDto { @Transform(toSanitized) storageLabel?: string | null; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) memoriesEnabled?: boolean; @Optional({ nullable: true }) @@ -31,8 +30,7 @@ export class CreateUserDto { @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes?: number | null; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) shouldChangePassword?: boolean; } diff --git a/server/src/domain/user/dto/update-user.dto.ts b/server/src/domain/user/dto/update-user.dto.ts index 1cab11627b82e..e8cce22141d46 100644 --- a/server/src/domain/user/dto/update-user.dto.ts +++ b/server/src/domain/user/dto/update-user.dto.ts @@ -1,8 +1,8 @@ import { UserAvatarColor } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator'; -import { Optional, toEmail, toSanitized } from '../../domain.util'; +import { IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator'; +import { Optional, ValidateBoolean, toEmail, toSanitized } from '../../domain.util'; export class UpdateUserDto { @Optional() @@ -30,16 +30,13 @@ export class UpdateUserDto { @ApiProperty({ format: 'uuid' }) id!: string; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) isAdmin?: boolean; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) shouldChangePassword?: boolean; - @Optional() - @IsBoolean() + @ValidateBoolean({ optional: true }) memoriesEnabled?: boolean; @Optional() diff --git a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts index d73856ab9aff6..719018488180d 100644 --- a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts +++ b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts @@ -1,19 +1,13 @@ -import { Optional, toBoolean } from '@app/domain'; +import { Optional, ValidateBoolean, ValidateDate } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsDate, IsInt, IsNotEmpty, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsInt, IsUUID } from 'class-validator'; export class AssetSearchDto { - @Optional() - @IsNotEmpty() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isFavorite?: boolean; - @Optional() - @IsNotEmpty() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isArchived?: boolean; @Optional() @@ -33,13 +27,9 @@ export class AssetSearchDto { @ApiProperty({ format: 'uuid' }) userId?: string; - @Optional() - @IsDate() - @Type(() => Date) + @ValidateDate({ optional: true }) updatedAfter?: Date; - @Optional() - @IsDate() - @Type(() => Date) + @ValidateDate({ optional: true }) updatedBefore?: Date; } diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts index 9850384d96c8e..1b140d69f8529 100644 --- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts +++ b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts @@ -1,7 +1,6 @@ -import { Optional, toBoolean, UploadFieldName, ValidateUUID } from '@app/domain'; +import { Optional, UploadFieldName, ValidateBoolean, ValidateDate, ValidateUUID } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsDate, IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; export class CreateAssetDto { @ValidateUUID({ optional: true }) @@ -15,43 +14,29 @@ export class CreateAssetDto { @IsString() deviceId!: string; - @IsNotEmpty() - @IsDate() - @Type(() => Date) + @ValidateDate() fileCreatedAt!: Date; - @IsNotEmpty() - @IsDate() - @Type(() => Date) + @ValidateDate() fileModifiedAt!: Date; @Optional() @IsString() duration?: string; - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isFavorite?: boolean; - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isArchived?: boolean; - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isVisible?: boolean; - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isOffline?: boolean; - @Optional() - @IsBoolean() - @Transform(toBoolean) + @ValidateBoolean({ optional: true }) isReadOnly?: boolean; // The properties below are added to correctly generate the API docs diff --git a/server/src/immich/api-v1/asset/dto/serve-file.dto.ts b/server/src/immich/api-v1/asset/dto/serve-file.dto.ts index dd13c6dfbc0a0..72e22860147ca 100644 --- a/server/src/immich/api-v1/asset/dto/serve-file.dto.ts +++ b/server/src/immich/api-v1/asset/dto/serve-file.dto.ts @@ -1,18 +1,12 @@ -import { Optional, toBoolean } from '@app/domain'; +import { ValidateBoolean } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean } from 'class-validator'; export class ServeFileDto { - @Optional() - @IsBoolean() - @Transform(toBoolean) - @ApiProperty({ type: Boolean, title: 'Is serve thumbnail (resize) file' }) + @ValidateBoolean({ optional: true }) + @ApiProperty({ title: 'Is serve thumbnail (resize) file' }) isThumb?: boolean; - @Optional() - @IsBoolean() - @Transform(toBoolean) - @ApiProperty({ type: Boolean, title: 'Is request made from web' }) + @ValidateBoolean({ optional: true }) + @ApiProperty({ title: 'Is request made from web' }) isWeb?: boolean; } From 9b27a091317785cc61b68e924e4351884e656197 Mon Sep 17 00:00:00 2001 From: mmomjian <50788000+mmomjian@users.noreply.github.com> Date: Thu, 7 Mar 2024 23:28:51 -0500 Subject: [PATCH 41/47] docs: Update DB queries (#7730) --- docs/docs/guides/database-queries.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index 49b7330fb96f2..fe369f899eee1 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -13,7 +13,7 @@ Run `docker exec -it immich_postgres psql immich ` to connect to th ## Assets :::note -The `"originalFileName"` column is the name of the uploaded file _without_ the extension. +The `"originalFileName"` column is the name of the file at time of upload, including the extension. ::: ```sql title="Find by original filename" @@ -40,6 +40,10 @@ SELECT * FROM "assets" where "livePhotoVideoId" IS NOT NULL; SELECT "assets".* FROM "exif" LEFT JOIN "assets" ON "assets"."id" = "exif"."assetId" WHERE "exif"."assetId" IS NULL; ``` +```sql title="size < 100,000 bytes, smallest to largest" +SELECT * FROM "assets" JOIN "exif" ON "assets"."id" = "exif"."assetId" WHERE "exif"."fileSizeInByte" < 100000 ORDER BY "exif"."fileSizeInByte" ASC; +``` + ```sql title="Without thumbnails" SELECT * FROM "assets" WHERE "assets"."resizePath" IS NULL OR "assets"."webpPath" IS NULL; ``` From 46597aac971d3fe59b22a1c52b205150465deadd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Mar 2024 08:41:39 -0500 Subject: [PATCH 42/47] chore(deps): bump docker/build-push-action from 5.1.0 to 5.2.0 (#7737) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5.1.0 to 5.2.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v5.1.0...v5.2.0) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cli.yml | 2 +- .github/workflows/docker.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 07e07f422ade1..13c2df7402c1e 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -87,7 +87,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }} - name: Build and push image - uses: docker/build-push-action@v5.1.0 + uses: docker/build-push-action@v5.2.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dd1c53468aed8..5b92e44fff0a5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -121,7 +121,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v5.1.0 + uses: docker/build-push-action@v5.2.0 with: context: ${{ matrix.context }} file: ${{ matrix.file }} From ffdd504008ec9752dfff714b5c856b45606998f9 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Fri, 8 Mar 2024 14:45:41 +0100 Subject: [PATCH 43/47] refactor(web): admin and user signup forms (#7739) --- .../forms/admin-registration-form.svelte | 28 ++++-------- .../components/forms/create-user-form.svelte | 44 ++++++++----------- 2 files changed, 28 insertions(+), 44 deletions(-) diff --git a/web/src/lib/components/forms/admin-registration-form.svelte b/web/src/lib/components/forms/admin-registration-form.svelte index 0781ef226bd75..0481dbf458457 100644 --- a/web/src/lib/components/forms/admin-registration-form.svelte +++ b/web/src/lib/components/forms/admin-registration-form.svelte @@ -6,9 +6,12 @@ import Button from '../elements/buttons/button.svelte'; import PasswordField from '../shared-components/password-field.svelte'; - let errorMessage: string; + let email = ''; let password = ''; let confirmPassword = ''; + let name = ''; + + let errorMessage: string; let canRegister = false; $: { @@ -21,25 +24,12 @@ } } - async function registerAdmin(event: SubmitEvent & { currentTarget: HTMLFormElement }) { + async function registerAdmin() { if (canRegister) { errorMessage = ''; - const form = new FormData(event.currentTarget); - - const email = form.get('email'); - const password = form.get('password'); - const name = form.get('name'); - try { - await signUpAdmin({ - signUpDto: { - email: String(email), - password: String(password), - name: String(name), - }, - }); - + await signUpAdmin({ signUpDto: { email, password, name } }); await goto(AppRoute.AUTH_LOGIN); } catch (error) { handleError(error, 'Unable to create admin account'); @@ -52,12 +42,12 @@
- +
- +
@@ -67,7 +57,7 @@
- +
{#if errorMessage} diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte index d3b52dbd8632f..00188ec2ebc20 100644 --- a/web/src/lib/components/forms/create-user-form.svelte +++ b/web/src/lib/components/forms/create-user-form.svelte @@ -12,15 +12,18 @@ let error: string; let success: string; + let email = ''; let password = ''; let confirmPassword = ''; + let name = ''; let shouldChangePassword = true; let canCreateUser = false; let quotaSize: number | undefined; let isCreatingUser = false; - $: quotaSizeWarning = quotaSize && convertToBytes(Number(quotaSize), 'GiB') > $serverInfo.diskSizeRaw; + $: quotaSizeInBytes = quotaSize ? convertToBytes(quotaSize, 'GiB') : null; + $: quotaSizeWarning = quotaSizeInBytes && quotaSizeInBytes > $serverInfo.diskSizeRaw; $: { if (password !== confirmPassword && confirmPassword.length > 0) { @@ -36,29 +39,19 @@ cancel: void; }>(); - async function registerUser(event: SubmitEvent) { + async function registerUser() { if (canCreateUser && !isCreatingUser) { isCreatingUser = true; - error = ''; - const formElement = event.target as HTMLFormElement; - - const form = new FormData(formElement); - - const email = form.get('email'); - const password = form.get('password'); - const name = form.get('name'); - const quotaSize = form.get('quotaSize'); - try { await createUser({ createUserDto: { - email: String(email), - password: String(password), - shouldChangePassword: Boolean(shouldChangePassword), - name: String(name), - quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), 'GiB') : null, + email, + password, + shouldChangePassword, + name, + quotaSizeInBytes, }, }); @@ -87,12 +80,12 @@
- +
- +
@@ -109,16 +102,17 @@
- +
- +
{#if error} From 89f6190fb0884a82ef2caf29de9278c2cfcd0b79 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 8 Mar 2024 11:20:54 -0500 Subject: [PATCH 44/47] refactor: search e2e (#7732) --- e2e/src/api/specs/search.e2e-spec.ts | 224 ++++++++++++++ e2e/src/utils.ts | 5 + server/e2e/api/specs/search.e2e-spec.ts | 292 ------------------ server/src/domain/search/dto/search.dto.ts | 2 +- server/src/domain/search/search.service.ts | 2 - .../immich/controllers/search.controller.ts | 16 +- server/test/fixtures/index.ts | 1 - server/test/fixtures/search.stub.ts | 36 --- 8 files changed, 239 insertions(+), 339 deletions(-) create mode 100644 e2e/src/api/specs/search.e2e-spec.ts delete mode 100644 server/e2e/api/specs/search.e2e-spec.ts delete mode 100644 server/test/fixtures/search.stub.ts diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts new file mode 100644 index 0000000000000..de7d9ef4c5a22 --- /dev/null +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -0,0 +1,224 @@ +import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { Socket } from 'socket.io-client'; +import { errorDto } from 'src/responses'; +import { app, testAssetDir, utils } from 'src/utils'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const albums = { total: 0, count: 0, items: [], facets: [] }; + +describe('/search', () => { + let admin: LoginResponseDto; + let assetFalcon: AssetFileUploadResponseDto; + let assetDenali: AssetFileUploadResponseDto; + let websocket: Socket; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup(); + websocket = await utils.connectWebsocket(admin.accessToken); + + const files: string[] = [ + '/albums/nature/prairie_falcon.jpg', + '/formats/webp/denali.webp', + '/formats/raw/Nikon/D700/philadelphia.nef', + '/albums/nature/orychophragmus_violaceus.jpg', + '/albums/nature/notocactus_minimus.jpg', + '/albums/nature/silver_fir.jpg', + '/albums/nature/tanners_ridge.jpg', + '/albums/nature/cyclamen_persicum.jpg', + '/albums/nature/polemonium_reptans.jpg', + '/albums/nature/wood_anemones.jpg', + '/formats/heic/IMG_2682.heic', + '/formats/jpg/el_torcal_rocks.jpg', + '/formats/png/density_plot.png', + '/formats/motionphoto/Samsung One UI 6.jpg', + '/formats/motionphoto/Samsung One UI 6.heic', + '/formats/motionphoto/Samsung One UI 5.jpg', + '/formats/raw/Nikon/D80/glarus.nef', + '/metadata/gps-position/thompson-springs.jpg', + ]; + const assets: AssetFileUploadResponseDto[] = []; + for (const filename of files) { + const bytes = await readFile(join(testAssetDir, filename)); + assets.push( + await utils.createAsset(admin.accessToken, { + deviceAssetId: `test-${filename}`, + assetData: { bytes, filename }, + }), + ); + } + + for (const asset of assets) { + await utils.waitForWebsocketEvent({ event: 'upload', assetId: asset.id }); + } + + [assetFalcon, assetDenali] = assets; + }); + + afterAll(async () => { + await utils.disconnectWebsocket(websocket); + }); + + describe('POST /search/metadata', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/search/metadata'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should search by camera make', async () => { + const { status, body } = await request(app) + .post('/search/metadata') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ make: 'Canon' }); + expect(status).toBe(200); + expect(body).toEqual({ + albums, + assets: { + count: 2, + items: expect.arrayContaining([ + expect.objectContaining({ id: assetDenali.id }), + expect.objectContaining({ id: assetFalcon.id }), + ]), + facets: [], + nextPage: null, + total: 2, + }, + }); + }); + + it('should search by camera model', async () => { + const { status, body } = await request(app) + .post('/search/metadata') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ model: 'Canon EOS 7D' }); + expect(status).toBe(200); + expect(body).toEqual({ + albums, + assets: { + count: 1, + items: [expect.objectContaining({ id: assetDenali.id })], + facets: [], + nextPage: null, + total: 1, + }, + }); + }); + }); + + describe('POST /search/smart', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/search/smart'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('GET /search/explore', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/search/explore'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get explore data', async () => { + const { status, body } = await request(app) + .get('/search/explore') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual([ + { fieldName: 'exifInfo.city', items: [] }, + { fieldName: 'smartInfo.tags', items: [] }, + ]); + }); + }); + + describe('GET /search/places', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/search/places'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get places', async () => { + const { status, body } = await request(app) + .get('/search/places?name=Paris') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThan(10); + }); + }); + + describe('GET /search/suggestions', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/search/suggestions'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get suggestions for country', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=country') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual(['United States of America']); + expect(status).toBe(200); + }); + + it('should get suggestions for state', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=state') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual(['Douglas County, Nebraska', 'Mesa County, Colorado']); + expect(status).toBe(200); + }); + + it('should get suggestions for city', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=city') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual(['Palisade', 'Ralston']); + expect(status).toBe(200); + }); + + it('should get suggestions for camera make', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=camera-make') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([ + 'Apple', + 'Canon', + 'FUJIFILM', + 'NIKON CORPORATION', + 'PENTAX Corporation', + 'samsung', + 'SONY', + ]); + expect(status).toBe(200); + }); + + it('should get suggestions for camera model', async () => { + const { status, body } = await request(app) + .get('/search/suggestions?type=camera-model') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([ + 'Canon EOS 7D', + 'Canon EOS R5', + 'DSLR-A550', + 'FinePix S3Pro', + 'iPhone 7', + 'NIKON D700', + 'NIKON D750', + 'NIKON D80', + 'PENTAX K10D', + 'SM-F711N', + 'SM-S906U', + 'SM-T970', + ]); + expect(status).toBe(200); + }); + }); +}); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 49ac2b8122f55..d62497b8e4e70 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -173,6 +173,7 @@ export const utils = { }, waitForWebsocketEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise => { + console.log(`Waiting for ${event} [${assetId}]`); const set = events[event]; if (set.has(assetId)) { return; @@ -232,6 +233,10 @@ export const utils = { const assetData = dto?.assetData?.bytes || makeRandomImage(); const filename = dto?.assetData?.filename || 'example.png'; + if (dto?.assetData?.bytes) { + console.log(`Uploading ${filename}`); + } + const builder = request(app) .post(`/asset/upload`) .attach('assetData', assetData, filename) diff --git a/server/e2e/api/specs/search.e2e-spec.ts b/server/e2e/api/specs/search.e2e-spec.ts deleted file mode 100644 index 0e5cc428ccc21..0000000000000 --- a/server/e2e/api/specs/search.e2e-spec.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { - AssetResponseDto, - IAssetRepository, - ISearchRepository, - LibraryResponseDto, - LoginResponseDto, - mapAsset, -} from '@app/domain'; -import { SearchController } from '@app/immich'; -import { INestApplication } from '@nestjs/common'; -import { errorStub, searchStub } from '@test/fixtures'; -import request from 'supertest'; -import { api } from '../../client'; -import { generateAsset, testApp } from '../utils'; - -describe(`${SearchController.name}`, () => { - let app: INestApplication; - let server: any; - let loginResponse: LoginResponseDto; - let accessToken: string; - let libraries: LibraryResponseDto[]; - let assetRepository: IAssetRepository; - let smartInfoRepository: ISearchRepository; - let asset1: AssetResponseDto; - - beforeAll(async () => { - app = await testApp.create(); - server = app.getHttpServer(); - assetRepository = app.get(IAssetRepository); - smartInfoRepository = app.get(ISearchRepository); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - beforeEach(async () => { - await testApp.reset(); - await api.authApi.adminSignUp(server); - loginResponse = await api.authApi.adminLogin(server); - accessToken = loginResponse.accessToken; - libraries = await api.libraryApi.getAll(server, accessToken); - }); - - describe('GET /search (exif)', () => { - beforeEach(async () => { - const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries)); - await assetRepository.upsertExif({ assetId, ...searchStub.exif }); - - const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true }); - if (!assetWithMetadata) { - throw new Error('Asset not found'); - } - asset1 = mapAsset(assetWithMetadata); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).get('/search'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should return assets when searching by exif', async () => { - if (!asset1?.exifInfo?.make) { - throw new Error('Asset 1 does not have exif info'); - } - - const { status, body } = await request(server) - .get('/search') - .set('Authorization', `Bearer ${accessToken}`) - .query({ q: asset1.exifInfo.make }); - - expect(status).toBe(200); - expect(body).toMatchObject({ - albums: { - total: 0, - count: 0, - items: [], - facets: [], - }, - assets: { - total: 1, - count: 1, - items: [ - { - id: asset1.id, - exifInfo: { - make: asset1.exifInfo.make, - }, - }, - ], - facets: [], - }, - }); - }); - - it('should be case-insensitive for metadata search', async () => { - if (!asset1?.exifInfo?.make) { - throw new Error('Asset 1 does not have exif info'); - } - - const { status, body } = await request(server) - .get('/search') - .set('Authorization', `Bearer ${accessToken}`) - .query({ q: asset1.exifInfo.make.toLowerCase() }); - - expect(status).toBe(200); - expect(body).toMatchObject({ - albums: { - total: 0, - count: 0, - items: [], - facets: [], - }, - assets: { - total: 1, - count: 1, - items: [ - { - id: asset1.id, - exifInfo: { - make: asset1.exifInfo.make, - }, - }, - ], - facets: [], - }, - }); - }); - - it('should be whitespace-insensitive for metadata search', async () => { - if (!asset1?.exifInfo?.make) { - throw new Error('Asset 1 does not have exif info'); - } - - const { status, body } = await request(server) - .get('/search') - .set('Authorization', `Bearer ${accessToken}`) - .query({ q: ` ${asset1.exifInfo.make} ` }); - - expect(status).toBe(200); - expect(body).toMatchObject({ - albums: { - total: 0, - count: 0, - items: [], - facets: [], - }, - assets: { - total: 1, - count: 1, - items: [ - { - id: asset1.id, - exifInfo: { - make: asset1.exifInfo.make, - }, - }, - ], - facets: [], - }, - }); - }); - }); - - describe('GET /search (smart info)', () => { - beforeEach(async () => { - const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries)); - await assetRepository.upsertExif({ assetId, ...searchStub.exif }); - await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random)); - - const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true, smartInfo: true }); - if (!assetWithMetadata) { - throw new Error('Asset not found'); - } - asset1 = mapAsset(assetWithMetadata); - }); - - it('should return assets when searching by object', async () => { - if (!asset1?.smartInfo?.objects) { - throw new Error('Asset 1 does not have smart info'); - } - - const { status, body } = await request(server) - .get('/search') - .set('Authorization', `Bearer ${accessToken}`) - .query({ q: asset1.smartInfo.objects[0] }); - - expect(status).toBe(200); - expect(body).toMatchObject({ - albums: { - total: 0, - count: 0, - items: [], - facets: [], - }, - assets: { - total: 1, - count: 1, - items: [ - { - id: asset1.id, - smartInfo: { - objects: asset1.smartInfo.objects, - tags: asset1.smartInfo.tags, - }, - }, - ], - facets: [], - }, - }); - }); - }); - - describe('GET /search (file name)', () => { - beforeEach(async () => { - const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries)); - await assetRepository.upsertExif({ assetId, ...searchStub.exif }); - - const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true }); - if (!assetWithMetadata) { - throw new Error('Asset not found'); - } - asset1 = mapAsset(assetWithMetadata); - }); - - it('should return assets when searching by file name', async () => { - if (asset1?.originalFileName.length === 0) { - throw new Error('Asset 1 does not have an original file name'); - } - - const { status, body } = await request(server) - .get('/search') - .set('Authorization', `Bearer ${accessToken}`) - .query({ q: asset1.originalFileName }); - - expect(status).toBe(200); - expect(body).toMatchObject({ - albums: { - total: 0, - count: 0, - items: [], - facets: [], - }, - assets: { - total: 1, - count: 1, - items: [ - { - id: asset1.id, - originalFileName: asset1.originalFileName, - }, - ], - facets: [], - }, - }); - }); - - it('should return assets when searching by file name with extension', async () => { - if (asset1?.originalFileName.length === 0) { - throw new Error('Asset 1 does not have an original file name'); - } - - const { status, body } = await request(server) - .get('/search') - .set('Authorization', `Bearer ${accessToken}`) - .query({ q: asset1.originalFileName + '.jpg' }); - - expect(status).toBe(200); - expect(body).toMatchObject({ - albums: { - total: 0, - count: 0, - items: [], - facets: [], - }, - assets: { - total: 1, - count: 1, - items: [ - { - id: asset1.id, - originalFileName: asset1.originalFileName, - }, - ], - facets: [], - }, - }); - }); - }); -}); diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 70d8ee2884308..9fa7d8e8ba44b 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -123,7 +123,7 @@ class BaseSearchDto { @ValidateBoolean({ optional: true }) isNotInAlbum?: boolean; - @Optional() + @ValidateUUID({ each: true, optional: true }) personIds?: string[]; } diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 8dce8434c7e13..00c5e883ec319 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -1,5 +1,4 @@ import { AssetEntity } from '@app/infra/entities'; -import { ImmichLogger } from '@app/infra/logger'; import { Inject, Injectable } from '@nestjs/common'; import { AssetOrder, AssetResponseDto, mapAsset } from '../asset'; import { AuthDto } from '../auth'; @@ -30,7 +29,6 @@ import { SearchResponseDto } from './response-dto'; @Injectable() export class SearchService { - private logger = new ImmichLogger(SearchService.name); private configCore: SystemConfigCore; constructor( diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index b807da96651b8..df1bec7c636bf 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -12,7 +12,7 @@ import { SmartSearchDto, } from '@app/domain'; import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-suggestion.dto'; -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; import { UseValidation } from '../app.utils'; @@ -24,22 +24,24 @@ import { UseValidation } from '../app.utils'; export class SearchController { constructor(private service: SearchService) {} + @Get() + @ApiOperation({ deprecated: true }) + search(@Auth() auth: AuthDto, @Query() dto: SearchDto): Promise { + return this.service.search(auth, dto); + } + @Post('metadata') + @HttpCode(HttpStatus.OK) searchMetadata(@Auth() auth: AuthDto, @Body() dto: MetadataSearchDto): Promise { return this.service.searchMetadata(auth, dto); } @Post('smart') + @HttpCode(HttpStatus.OK) searchSmart(@Auth() auth: AuthDto, @Body() dto: SmartSearchDto): Promise { return this.service.searchSmart(auth, dto); } - @Get() - @ApiOperation({ deprecated: true }) - search(@Auth() auth: AuthDto, @Query() dto: SearchDto): Promise { - return this.service.search(auth, dto); - } - @Get('explore') getExploreData(@Auth() auth: AuthDto): Promise { return this.service.getExploreData(auth) as Promise; diff --git a/server/test/fixtures/index.ts b/server/test/fixtures/index.ts index 7a25c159a382d..2217c9b1ff9db 100644 --- a/server/test/fixtures/index.ts +++ b/server/test/fixtures/index.ts @@ -10,7 +10,6 @@ export * from './library.stub'; export * from './media.stub'; export * from './partner.stub'; export * from './person.stub'; -export * from './search.stub'; export * from './shared-link.stub'; export * from './system-config.stub'; export * from './tag.stub'; diff --git a/server/test/fixtures/search.stub.ts b/server/test/fixtures/search.stub.ts deleted file mode 100644 index fc197d94f4f55..0000000000000 --- a/server/test/fixtures/search.stub.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SearchResult } from '@app/domain'; -import { AssetEntity, ExifEntity, SmartInfoEntity } from '@app/infra/entities'; -import { assetStub } from '.'; - -export const searchStub = { - emptyResults: Object.freeze>({ - total: 0, - count: 0, - page: 1, - items: [], - facets: [], - distances: [], - }), - - withImage: Object.freeze>({ - total: 1, - count: 1, - page: 1, - items: [assetStub.image], - facets: [], - distances: [], - }), - - exif: Object.freeze>({ - latitude: 90, - longitude: 90, - city: 'Immich', - state: 'Nebraska', - country: 'United States', - make: 'Canon', - model: 'EOS Rebel T7', - lensModel: 'Fancy lens', - }), - - smartInfo: Object.freeze>({ objects: ['car', 'tree'], tags: ['accident'] }), -}; From fe8c6b17a6b3f847d9f157ee15995fb2a0918395 Mon Sep 17 00:00:00 2001 From: DeclanE <160616898+declan8010@users.noreply.github.com> Date: Fri, 8 Mar 2024 16:49:44 +0000 Subject: [PATCH 45/47] chore: rename "Library" to "External Library" in system settings (#7744) * Change "Library" > "External Library" under system settings This is intended to assist with any confusion regarding standard libraries * Changed key from "library" to "external-library" * Updated "Encode Clip" to "Smart Search" --- .../machine-learning-settings.svelte | 2 +- web/src/routes/admin/system-settings/+page.svelte | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index ba24f3aabd3fd..b95a41acf77a4 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -69,7 +69,7 @@ >

The name of a CLIP model listed here. Note that you - must re-run the 'Encode CLIP' job for all images upon changing a model. + must re-run the 'Smart Search' job for all images upon changing a model.

diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 9cfd23b8cfb77..d63f9544a36b3 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -72,9 +72,9 @@ }, { item: LibrarySettings, - title: 'Library', - subtitle: 'Manage library settings', - key: 'library', + title: 'External Library', + subtitle: 'Manage external library settings', + key: 'external-library', }, { item: LoggingSettings, From fa32c6660c85967648012149887214c6eabcf50f Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Fri, 8 Mar 2024 20:03:37 +0100 Subject: [PATCH 46/47] fix(web): album state after removing assets (#7745) * fix(web): album state after removing assets * refresh album on remove + simplify AlbumSummary --- .../album-page/album-summary.svelte | 32 ++++++++++++ .../components/album-page/album-viewer.svelte | 35 +------------ .../(user)/albums/[albumId]/+page.svelte | 50 +++++-------------- 3 files changed, 46 insertions(+), 71 deletions(-) create mode 100644 web/src/lib/components/album-page/album-summary.svelte diff --git a/web/src/lib/components/album-page/album-summary.svelte b/web/src/lib/components/album-page/album-summary.svelte new file mode 100644 index 0000000000000..4a49eeb4449ae --- /dev/null +++ b/web/src/lib/components/album-page/album-summary.svelte @@ -0,0 +1,32 @@ + + + +

{getDateRange(startDate, endDate)}

+

·

+

{album.assetCount} items

+
diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 0e5abc1fac95e..9897125bcdd2f 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -3,11 +3,9 @@ import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; - import { locale } from '$lib/stores/preferences.store'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; import { onDestroy, onMount } from 'svelte'; - import { dateFormats } from '../../constants'; import { createAssetInteractionStore } from '../../stores/asset-interaction.store'; import { AssetStore } from '../../stores/assets.store'; import { downloadArchive } from '../../utils/asset-utils'; @@ -21,6 +19,7 @@ import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js'; import { handlePromiseError } from '$lib/utils'; + import AlbumSummary from './album-summary.svelte'; export let sharedLink: SharedLinkResponseDto; export let user: UserResponseDto | undefined = undefined; @@ -40,31 +39,6 @@ } }); - const getDateRange = () => { - const { startDate, endDate } = album; - - let start = ''; - let end = ''; - - if (startDate) { - start = new Date(startDate).toLocaleDateString($locale, dateFormats.album); - } - - if (endDate) { - end = new Date(endDate).toLocaleDateString($locale, dateFormats.album); - } - - if (startDate && endDate && start !== end) { - return `${start} - ${end}`; - } - - if (start) { - return start; - } - - return ''; - }; - const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); onMount(() => { @@ -148,13 +122,8 @@ {album.albumName} - {#if album.assetCount > 0} - -

{getDateRange()}

-

·

-

{album.assetCount} items

-
+ {/if} diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index 279054d915691..05d94bf3e175d 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -31,7 +31,7 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; - import { AppRoute, dateFormats } from '$lib/constants'; + import { AppRoute } from '$lib/constants'; import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; @@ -74,6 +74,7 @@ import AlbumTitle from '$lib/components/album-page/album-title.svelte'; import AlbumDescription from '$lib/components/album-page/album-description.svelte'; import { handlePromiseError } from '$lib/utils'; + import AlbumSummary from '$lib/components/album-page/album-summary.svelte'; export let data: PageData; @@ -280,31 +281,6 @@ album = await getAlbumInfo({ id: album.id, withoutAssets: true }); }; - const getDateRange = () => { - const { startDate, endDate } = album; - - let start = ''; - let end = ''; - - if (startDate) { - start = new Date(startDate).toLocaleDateString($locale, dateFormats.album); - } - - if (endDate) { - end = new Date(endDate).toLocaleDateString($locale, dateFormats.album); - } - - if (startDate && endDate && start !== end) { - return `${start} - ${end}`; - } - - if (start) { - return start; - } - - return ''; - }; - const handleAddAssets = async () => { const assetIds = [...$timelineSelected].map((asset) => asset.id); @@ -389,6 +365,11 @@ } }; + const handleRemoveAssets = async (assetIds: string[]) => { + assetStore.removeAssets(assetIds); + await refreshAlbum(); + }; + const handleUpdateThumbnail = async (assetId: string) => { if (viewMode !== ViewMode.SELECT_THUMBNAIL) { return; @@ -429,10 +410,10 @@ {/if} {#if isOwned || isAllUserOwned} - assetStore.removeAssets(assetIds)} /> + {/if} {#if isAllUserOwned} - assetStore.removeAssets(assetIds)} /> + {/if} @@ -469,9 +450,7 @@ {#if viewMode === ViewMode.ALBUM_OPTIONS} - {#if album.assetCount !== 0} - - {/if} + (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" /> (viewMode = ViewMode.OPTIONS)} text="Options" /> @@ -485,7 +464,7 @@