diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index a0c429a82e..a554a0f660 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -538,7 +538,7 @@ describe('/asset', () => { expect(body).toMatchObject({ id: user1Assets[0].id, exifInfo: expect.objectContaining({ - dateTimeOriginal: '2023-11-20T01:11:00.000Z', + dateTimeOriginal: '2023-11-20T01:11:00+00:00', }), }); expect(status).toEqual(200); @@ -608,7 +608,7 @@ describe('/asset', () => { await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); const assetInfo = await utils.getAssetInfo(user1.accessToken, id); - expect(assetInfo.exifInfo?.dateTimeOriginal).toBe('2024-07-11T10:32:52.000Z'); + expect(assetInfo.exifInfo?.dateTimeOriginal).toBe('2024-07-11T10:32:52+00:00'); const { status, body } = await request(app) .put(`/assets/${id}`) @@ -618,7 +618,7 @@ describe('/asset', () => { expect(body).toMatchObject({ id, exifInfo: expect.objectContaining({ - dateTimeOriginal: '2023-11-20T01:11:00.000Z', + dateTimeOriginal: '2023-11-20T01:11:00+00:00', }), }); expect(status).toEqual(200); @@ -953,8 +953,6 @@ describe('/asset', () => { exifImageHeight: 1080, exifImageWidth: 1617, fileSizeInByte: 862_424, - latitude: null, - longitude: null, }, }, }, @@ -964,11 +962,9 @@ describe('/asset', () => { type: AssetTypeEnum.Image, originalFileName: 'el_torcal_rocks.jpg', exifInfo: { - dateTimeOriginal: '2012-08-05T11:39:59.000Z', + dateTimeOriginal: '2012-08-05T11:39:59+00:00', exifImageWidth: 512, exifImageHeight: 341, - latitude: null, - longitude: null, focalLength: 75, iso: 200, fNumber: 11, @@ -976,7 +972,6 @@ describe('/asset', () => { fileSizeInByte: 53_493, make: 'SONY', model: 'DSLR-A550', - orientation: null, description: 'SONY DSC', }, }, @@ -991,8 +986,6 @@ describe('/asset', () => { exifImageHeight: 1080, exifImageWidth: 1440, fileSizeInByte: 1_780_777, - latitude: null, - longitude: null, }, }, }, @@ -1003,7 +996,7 @@ describe('/asset', () => { originalFileName: 'IMG_2682.heic', fileCreatedAt: '2019-03-21T16:04:22.348Z', exifInfo: { - dateTimeOriginal: '2019-03-21T16:04:22.348Z', + dateTimeOriginal: '2019-03-21T16:04:22.348+00:00', exifImageWidth: 4032, exifImageHeight: 3024, latitude: 41.2203, @@ -1028,8 +1021,6 @@ describe('/asset', () => { exifInfo: { exifImageWidth: 800, exifImageHeight: 800, - latitude: null, - longitude: null, fileSizeInByte: 25_408, }, }, @@ -1048,9 +1039,7 @@ describe('/asset', () => { focalLength: 18, iso: 100, fileSizeInByte: 9_057_784, - dateTimeOriginal: '2010-07-20T17:27:12.000Z', - latitude: null, - longitude: null, + dateTimeOriginal: '2010-07-20T17:27:12+00:00', orientation: '1', }, }, @@ -1069,9 +1058,7 @@ describe('/asset', () => { focalLength: 85, iso: 200, fileSizeInByte: 15_856_335, - dateTimeOriginal: '2016-09-22T21:10:29.060Z', - latitude: null, - longitude: null, + dateTimeOriginal: '2016-09-22T21:10:29.06+00:00', orientation: '1', timeZone: 'UTC-4', }, @@ -1093,9 +1080,7 @@ describe('/asset', () => { focalLength: 35, iso: 400, fileSizeInByte: 19_587_072, - dateTimeOriginal: '2018-05-10T08:42:37.842Z', - latitude: null, - longitude: null, + dateTimeOriginal: '2018-05-10T08:42:37.842+00:00', orientation: '1', }, }, @@ -1117,9 +1102,7 @@ describe('/asset', () => { iso: 100, lensModel: 'E PZ 18-105mm F4 G OSS', fileSizeInByte: 25_001_984, - dateTimeOriginal: '2016-09-27T10:51:44.000Z', - latitude: null, - longitude: null, + dateTimeOriginal: '2016-09-27T10:51:44+00:00', orientation: '1', }, }, @@ -1141,9 +1124,7 @@ describe('/asset', () => { iso: 100, lensModel: 'E 25mm F2', fileSizeInByte: 49_512_448, - dateTimeOriginal: '2016-01-08T14:08:01.000Z', - latitude: null, - longitude: null, + dateTimeOriginal: '2016-01-08T14:08:01+00:00', orientation: '1', }, }, diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts index 2a1f891583..e13cf144f5 100644 --- a/e2e/src/api/specs/timeline.e2e-spec.ts +++ b/e2e/src/api/specs/timeline.e2e-spec.ts @@ -66,8 +66,8 @@ describe('/timeline', () => { expect(status).toBe(200); expect(body).toEqual( expect.arrayContaining([ - { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + { count: 3, timeBucket: '1970-02-01' }, + { count: 1, timeBucket: '1970-01-01' }, ]), ); }); @@ -94,9 +94,9 @@ describe('/timeline', () => { expect(status).toBe(200); expect(body).toEqual([ - { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + { count: 2, timeBucket: '1970-02-11' }, + { count: 1, timeBucket: '1970-02-10' }, + { count: 1, timeBucket: '1970-01-01' }, ]); }); @@ -151,7 +151,7 @@ describe('/timeline', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/timeline/bucket').query({ size: TimeBucketSize.Month, - timeBucket: '1900-01-01T00:00:00.000Z', + timeBucket: '1900-01-01', }); expect(status).toBe(401); @@ -161,7 +161,7 @@ describe('/timeline', () => { it('should handle 5 digit years', async () => { const { status, body } = await request(app) .get('/timeline/bucket') - .query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' }) + .query({ size: TimeBucketSize.Month, timeBucket: '012345-01-01' }) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); expect(status).toBe(200); @@ -183,7 +183,7 @@ describe('/timeline', () => { const { status, body } = await request(app) .get('/timeline/bucket') .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' }); + .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10' }); expect(status).toBe(200); expect(body).toEqual([]); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2d3f2fa6c2..d1d3719c0a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8308,6 +8308,7 @@ "type": "string" }, "AssetOrder": { + "default": "desc", "enum": [ "asc", "desc" diff --git a/server/package-lock.json b/server/package-lock.json index 08ad8a066f..b9df7e1530 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -42,10 +42,13 @@ "ioredis": "^5.3.2", "joi": "^17.10.0", "js-yaml": "^4.1.0", + "kysely": "^0.27.3", + "kysely-postgres-js": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.4.2", "nest-commander": "^3.11.1", "nestjs-cls": "^4.3.0", + "nestjs-kysely": "^1.0.0", "nestjs-otel": "^6.0.0", "nodemailer": "^6.9.13", "openid-client": "^5.4.3", @@ -99,6 +102,7 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", "globals": "^15.9.0", + "kysely-codegen": "^0.16.3", "mock-fs": "^5.2.0", "pngjs": "^7.0.0", "prettier": "^3.0.2", @@ -8836,6 +8840,102 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/git-diff": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/git-diff/-/git-diff-2.0.6.tgz", + "integrity": "sha512-/Iu4prUrydE3Pb3lCBMbcSNIf81tgGt0W1ZwknnyF62t3tHmtiJTRj0f+1ZIhp3+Rh0ktz1pJVoa7ZXUCskivA==", + "dev": true, + "dependencies": { + "chalk": "^2.3.2", + "diff": "^3.5.0", + "loglevel": "^1.6.1", + "shelljs": "^0.8.1", + "shelljs.exec": "^1.1.7" + }, + "engines": { + "node": ">= 4.8.0" + } + }, + "node_modules/git-diff/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/git-diff/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/git-diff/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/git-diff/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/git-diff/node_modules/diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/git-diff/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/git-diff/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/git-diff/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/glob": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", @@ -9288,6 +9388,15 @@ "node": ">=12.0.0" } }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/ioredis": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", @@ -9706,6 +9815,100 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.4.tgz", + "integrity": "sha512-dyNKv2KRvYOQPLCAOCjjQuCk4YFd33BvGdf/o5bC7FiW+BB6snA81Zt+2wT9QDFzKqxKa5rrOmvlK/anehCcgA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/kysely-codegen": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/kysely-codegen/-/kysely-codegen-0.16.3.tgz", + "integrity": "sha512-SOOF3AhrsjREJuRewXmKl0nb6CkEzpP7VavHXzWdfIdIdfoJnlWlozuZhgMsYoIFmzL8aG4skvKGXF/dF3mbwg==", + "dev": true, + "dependencies": { + "chalk": "4.1.2", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "git-diff": "^2.0.6", + "micromatch": "^4.0.8", + "minimist": "^1.2.8", + "pluralize": "^8.0.0" + }, + "bin": { + "kysely-codegen": "dist/cli/bin.js" + }, + "peerDependencies": { + "@libsql/kysely-libsql": "^0.3.0", + "@tediousjs/connection-string": "^0.5.0", + "better-sqlite3": ">=7.6.2", + "kysely": "^0.27.0", + "kysely-bun-sqlite": "^0.3.2", + "kysely-bun-worker": "^0.5.3", + "mysql2": "^2.3.3 || ^3.0.0", + "pg": "^8.8.0", + "tarn": "^3.0.0", + "tedious": "^18.0.0" + }, + "peerDependenciesMeta": { + "@libsql/kysely-libsql": { + "optional": true + }, + "@tediousjs/connection-string": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "kysely": { + "optional": false + }, + "kysely-bun-sqlite": { + "optional": true + }, + "kysely-bun-worker": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "tarn": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/kysely-codegen/node_modules/dotenv-expand": { + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.6.tgz", + "integrity": "sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==", + "dev": true, + "dependencies": { + "dotenv": "^16.4.4" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/kysely-postgres-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kysely-postgres-js/-/kysely-postgres-js-2.0.0.tgz", + "integrity": "sha512-R1tWx6/x3tSatWvsmbHJxpBZYhNNxcnMw52QzZaHKg7ZOWtHib4iZyEaw4gb2hNKVctWQ3jfMxZT/ZaEMK6kBQ==", + "peerDependencies": { + "kysely": ">= 0.24.0 < 1", + "postgres": ">= 3.4.0 < 4" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -9859,6 +10062,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loglevel": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", @@ -10434,6 +10650,17 @@ "rxjs": ">= 7" } }, + "node_modules/nestjs-kysely": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nestjs-kysely/-/nestjs-kysely-1.0.0.tgz", + "integrity": "sha512-0XwXm3H/sGQ8DhOY1UOMuosdgii+qePJpbdlWzRPP7ueP/Z/JGnxnu2jUHurFGS+bMGPipGnk9ynIkJuM05HUw==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "kysely": "0.x", + "reflect-metadata": "^0.1.13 || ^0.2.2" + } + }, "node_modules/nestjs-otel": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/nestjs-otel/-/nestjs-otel-6.1.1.tgz", @@ -11324,6 +11551,19 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "peer": true }, + "node_modules/postgres": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.4.tgz", + "integrity": "sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -12332,6 +12572,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dev": true, + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -13000,6 +13252,53 @@ "node": ">=8" } }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "dev": true, + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/shelljs.exec": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/shelljs.exec/-/shelljs.exec-1.1.8.tgz", + "integrity": "sha512-vFILCw+lzUtiwBAHV8/Ex8JsFjelFMdhONIsgKNLgTzeRckp2AOYRQtHJE/9LhNvdMmE27AGtzWx0+DHpwIwSw==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/shelljs/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/shimmer": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", @@ -21061,6 +21360,83 @@ "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", "dev": true }, + "git-diff": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/git-diff/-/git-diff-2.0.6.tgz", + "integrity": "sha512-/Iu4prUrydE3Pb3lCBMbcSNIf81tgGt0W1ZwknnyF62t3tHmtiJTRj0f+1ZIhp3+Rh0ktz1pJVoa7ZXUCskivA==", + "dev": true, + "requires": { + "chalk": "^2.3.2", + "diff": "^3.5.0", + "loglevel": "^1.6.1", + "shelljs": "^0.8.1", + "shelljs.exec": "^1.1.7" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "glob": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", @@ -21381,6 +21757,12 @@ "wrap-ansi": "^6.0.1" } }, + "interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true + }, "ioredis": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", @@ -21678,6 +22060,43 @@ "json-buffer": "3.0.1" } }, + "kysely": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.4.tgz", + "integrity": "sha512-dyNKv2KRvYOQPLCAOCjjQuCk4YFd33BvGdf/o5bC7FiW+BB6snA81Zt+2wT9QDFzKqxKa5rrOmvlK/anehCcgA==" + }, + "kysely-codegen": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/kysely-codegen/-/kysely-codegen-0.16.3.tgz", + "integrity": "sha512-SOOF3AhrsjREJuRewXmKl0nb6CkEzpP7VavHXzWdfIdIdfoJnlWlozuZhgMsYoIFmzL8aG4skvKGXF/dF3mbwg==", + "dev": true, + "requires": { + "chalk": "4.1.2", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "git-diff": "^2.0.6", + "micromatch": "^4.0.8", + "minimist": "^1.2.8", + "pluralize": "^8.0.0" + }, + "dependencies": { + "dotenv-expand": { + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.6.tgz", + "integrity": "sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==", + "dev": true, + "requires": { + "dotenv": "^16.4.4" + } + } + } + }, + "kysely-postgres-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kysely-postgres-js/-/kysely-postgres-js-2.0.0.tgz", + "integrity": "sha512-R1tWx6/x3tSatWvsmbHJxpBZYhNNxcnMw52QzZaHKg7ZOWtHib4iZyEaw4gb2hNKVctWQ3jfMxZT/ZaEMK6kBQ==", + "requires": {} + }, "lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -21802,6 +22221,12 @@ "is-unicode-supported": "^0.1.0" } }, + "loglevel": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", + "integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==", + "dev": true + }, "long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", @@ -22236,6 +22661,12 @@ "integrity": "sha512-4yhldwm/cJ02lQ8ZAdM8KQ7gMfjAc1z3fo5QAQgXNyN4N6X5So9BCwv+BTLRugDCkELUo3qtzQHnKhGYL/ftPg==", "requires": {} }, + "nestjs-kysely": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nestjs-kysely/-/nestjs-kysely-1.0.0.tgz", + "integrity": "sha512-0XwXm3H/sGQ8DhOY1UOMuosdgii+qePJpbdlWzRPP7ueP/Z/JGnxnu2jUHurFGS+bMGPipGnk9ynIkJuM05HUw==", + "requires": {} + }, "nestjs-otel": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/nestjs-otel/-/nestjs-otel-6.1.1.tgz", @@ -22829,6 +23260,12 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "peer": true }, + "postgres": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.4.tgz", + "integrity": "sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==", + "peer": true + }, "postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -23454,6 +23891,15 @@ } } }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dev": true, + "requires": { + "resolve": "^1.1.6" + } + }, "redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -23945,6 +24391,39 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, + "shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "dev": true, + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } + } + }, + "shelljs.exec": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/shelljs.exec/-/shelljs.exec-1.1.8.tgz", + "integrity": "sha512-vFILCw+lzUtiwBAHV8/Ex8JsFjelFMdhONIsgKNLgTzeRckp2AOYRQtHJE/9LhNvdMmE27AGtzWx0+DHpwIwSw==", + "dev": true + }, "shimmer": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", diff --git a/server/package.json b/server/package.json index a54212052a..2cef5efa09 100644 --- a/server/package.json +++ b/server/package.json @@ -67,10 +67,13 @@ "ioredis": "^5.3.2", "joi": "^17.10.0", "js-yaml": "^4.1.0", + "kysely": "^0.27.3", + "kysely-postgres-js": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.4.2", "nest-commander": "^3.11.1", "nestjs-cls": "^4.3.0", + "nestjs-kysely": "^1.0.0", "nestjs-otel": "^6.0.0", "nodemailer": "^6.9.13", "openid-client": "^5.4.3", @@ -124,6 +127,7 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", "globals": "^15.9.0", + "kysely-codegen": "^0.16.3", "mock-fs": "^5.2.0", "pngjs": "^7.0.0", "prettier": "^3.0.2", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index da8fa55606..9d96a0499b 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -4,6 +4,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@ne import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ClsModule } from 'nestjs-cls'; +import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; import { commands } from 'src/commands'; import { IWorker } from 'src/constants'; @@ -48,7 +49,7 @@ const imports = [ inject: [ModuleRef], useFactory: (moduleRef: ModuleRef) => { return { - ...database.config, + ...database.config.typeorm, poolErrorHandler: (error) => { moduleRef.get(DatabaseService, { strict: false }).handleConnectionError(error); }, @@ -56,6 +57,7 @@ const imports = [ }, }), TypeOrmModule.forFeature(entities), + KyselyModule.forRoot(database.config.kysely), ]; class BaseModule implements OnModuleInit, OnModuleDestroy { diff --git a/server/src/bin/database.ts b/server/src/bin/database.ts index c861902b4e..7ea56e0fc0 100644 --- a/server/src/bin/database.ts +++ b/server/src/bin/database.ts @@ -8,4 +8,4 @@ const { database } = new ConfigRepository().getEnv(); * * this export is ONLY to be used for TypeORM commands in package.json#scripts */ -export const dataSource = new DataSource({ ...database.config, host: 'localhost' }); +export const dataSource = new DataSource({ ...database.config.typeorm, host: 'localhost' }); diff --git a/server/src/db.d.ts b/server/src/db.d.ts new file mode 100644 index 0000000000..454c5176de --- /dev/null +++ b/server/src/db.d.ts @@ -0,0 +1,439 @@ +/** + * This file was generated by kysely-codegen. + * Please do not edit it manually. + */ + +import type { ColumnType } from 'kysely'; + +export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; + +export type ArrayTypeImpl = T extends ColumnType ? ColumnType : T[]; + +export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed'; + +export type Generated = + T extends ColumnType ? ColumnType : ColumnType; + +export type Int8 = ColumnType; + +export type Json = JsonValue; + +export type JsonArray = JsonValue[]; + +export type JsonObject = { + [x: string]: JsonValue | undefined; +}; + +export type JsonPrimitive = boolean | number | string | null; + +export type JsonValue = JsonArray | JsonObject | JsonPrimitive; + +export type Sourcetype = 'exif' | 'machine-learning'; + +export type Timestamp = ColumnType; + +export interface Activity { + albumId: string; + assetId: string | null; + comment: string | null; + createdAt: Generated; + id: Generated; + isLiked: Generated; + updatedAt: Generated; + userId: string; +} + +export interface Albums { + albumName: Generated; + /** + * Asset ID to be used as thumbnail + */ + albumThumbnailAssetId: string | null; + createdAt: Generated; + deletedAt: Timestamp | null; + description: Generated; + id: Generated; + isActivityEnabled: Generated; + order: Generated; + ownerId: string; + updatedAt: Generated; +} + +export interface AlbumsAssetsAssets { + albumsId: string; + assetsId: string; +} + +export interface AlbumsSharedUsersUsers { + albumsId: string; + role: Generated; + usersId: string; +} + +export interface ApiKeys { + createdAt: Generated; + id: Generated; + key: string; + name: string; + permissions: string[]; + updatedAt: Generated; + userId: string; +} + +export interface AssetFaces { + assetId: string; + boundingBoxX1: Generated; + boundingBoxX2: Generated; + boundingBoxY1: Generated; + boundingBoxY2: Generated; + id: Generated; + imageHeight: Generated; + imageWidth: Generated; + personId: string | null; + sourceType: Generated; +} + +export interface AssetFiles { + assetId: string; + createdAt: Generated; + id: Generated; + path: string; + type: string; + updatedAt: Generated; +} + +export interface AssetJobStatus { + assetId: string; + duplicatesDetectedAt: Timestamp | null; + facesRecognizedAt: Timestamp | null; + metadataExtractedAt: Timestamp | null; + previewAt: Timestamp | null; + thumbnailAt: Timestamp | null; +} + +export interface Assets { + checksum: Buffer; + createdAt: Generated; + deletedAt: Timestamp | null; + deviceAssetId: string; + deviceId: string; + duplicateId: string | null; + duration: string | null; + encodedVideoPath: Generated; + fileCreatedAt: Timestamp; + fileModifiedAt: Timestamp; + id: Generated; + isArchived: Generated; + isExternal: Generated; + isFavorite: Generated; + isOffline: Generated; + isVisible: Generated; + libraryId: string | null; + livePhotoVideoId: string | null; + localDateTime: Timestamp; + originalFileName: string; + originalPath: string; + ownerId: string; + sidecarPath: string | null; + stackId: string | null; + status: Generated; + thumbhash: Buffer | null; + type: string; + updatedAt: Generated; +} + +export interface AssetStack { + id: Generated; + ownerId: string; + primaryAssetId: string; +} + +export interface Audit { + action: string; + createdAt: Generated; + entityId: string; + entityType: string; + id: Generated; + ownerId: string; +} + +export interface Exif { + assetId: string; + autoStackId: string | null; + bitsPerSample: number | null; + city: string | null; + colorspace: string | null; + country: string | null; + dateTimeOriginal: Timestamp | null; + description: Generated; + exifImageHeight: number | null; + exifImageWidth: number | null; + exposureTime: string | null; + fileSizeInByte: Int8 | null; + fNumber: number | null; + focalLength: number | null; + fps: number | null; + iso: number | null; + latitude: number | null; + lensModel: string | null; + livePhotoCID: string | null; + longitude: number | null; + make: string | null; + model: string | null; + modifyDate: Timestamp | null; + orientation: string | null; + profileDescription: string | null; + projectionType: string | null; + rating: number | null; + state: string | null; + timeZone: string | null; +} + +export interface FaceSearch { + embedding: string; + faceId: string; +} + +export interface GeodataPlaces { + admin1Code: string | null; + admin1Name: string | null; + admin2Code: string | null; + admin2Name: string | null; + alternateNames: string | null; + countryCode: string; + earthCoord: Generated; + id: number; + latitude: number; + longitude: number; + modificationDate: Timestamp; + name: string; +} + +export interface Libraries { + createdAt: Generated; + deletedAt: Timestamp | null; + exclusionPatterns: string[]; + id: Generated; + importPaths: string[]; + name: string; + ownerId: string; + refreshedAt: Timestamp | null; + updatedAt: Generated; +} + +export interface Memories { + createdAt: Generated; + data: Json; + deletedAt: Timestamp | null; + id: Generated; + isSaved: Generated; + memoryAt: Timestamp; + ownerId: string; + seenAt: Timestamp | null; + type: string; + updatedAt: Generated; +} + +export interface MemoriesAssetsAssets { + assetsId: string; + memoriesId: string; +} + +export interface Migrations { + id: Generated; + name: string; + timestamp: Int8; +} + +export interface MoveHistory { + entityId: string; + id: Generated; + newPath: string; + oldPath: string; + pathType: string; +} + +export interface NaturalearthCountries { + admin: string; + admin_a3: string; + coordinates: string; + id: Generated; + type: string; +} + +export interface Partners { + createdAt: Generated; + inTimeline: Generated; + sharedById: string; + sharedWithId: string; + updatedAt: Generated; +} + +export interface Person { + birthDate: Timestamp | null; + createdAt: Generated; + faceAssetId: string | null; + id: Generated; + isHidden: Generated; + name: Generated; + ownerId: string; + thumbnailPath: Generated; + updatedAt: Generated; +} + +export interface Sessions { + createdAt: Generated; + deviceOS: Generated; + deviceType: Generated; + id: Generated; + token: string; + updatedAt: Generated; + userId: string; +} + +export interface SharedLinkAsset { + assetsId: string; + sharedLinksId: string; +} + +export interface SharedLinks { + albumId: string | null; + allowDownload: Generated; + allowUpload: Generated; + createdAt: Generated; + description: string | null; + expiresAt: Timestamp | null; + id: Generated; + key: Buffer; + password: string | null; + showExif: Generated; + type: string; + userId: string; +} + +export interface SmartInfo { + assetId: string; + objects: string[] | null; + smartInfoTextSearchableColumn: Generated; + tags: string[] | null; +} + +export interface SmartSearch { + assetId: string; + embedding: string; +} + +export interface SocketIoAttachments { + created_at: Generated; + id: Generated; + payload: Buffer | null; +} + +export interface SystemConfig { + key: string; + value: string | null; +} + +export interface SystemMetadata { + key: string; + value: Json; +} + +export interface TagAsset { + assetsId: string; + tagsId: string; +} + +export interface Tags { + color: string | null; + createdAt: Generated; + id: Generated; + parentId: string | null; + updatedAt: Generated; + userId: string; + value: string; +} + +export interface TagsClosure { + id_ancestor: string; + id_descendant: string; +} + +export interface UserMetadata { + key: string; + userId: string; + value: Json; +} + +export interface Users { + createdAt: Generated; + deletedAt: Timestamp | null; + email: string; + id: Generated; + isAdmin: Generated; + name: Generated; + oauthId: Generated; + password: Generated; + profileChangedAt: Generated; + profileImagePath: Generated; + quotaSizeInBytes: Int8 | null; + quotaUsageInBytes: Generated; + shouldChangePassword: Generated; + status: Generated; + storageLabel: string | null; + updatedAt: Generated; +} + +export interface VectorsPgVectorIndexStat { + idx_growing: ArrayType | null; + idx_indexing: boolean | null; + idx_options: string | null; + idx_sealed: ArrayType | null; + idx_size: Int8 | null; + idx_status: string | null; + idx_tuples: Int8 | null; + idx_write: Int8 | null; + indexname: string | null; + indexrelid: number | null; + tablename: string | null; + tablerelid: number | null; +} + +export interface DB { + activity: Activity; + albums: Albums; + albums_assets_assets: AlbumsAssetsAssets; + albums_shared_users_users: AlbumsSharedUsersUsers; + api_keys: ApiKeys; + asset_faces: AssetFaces; + asset_files: AssetFiles; + asset_job_status: AssetJobStatus; + asset_stack: AssetStack; + assets: Assets; + audit: Audit; + exif: Exif; + face_search: FaceSearch; + geodata_places: GeodataPlaces; + libraries: Libraries; + memories: Memories; + memories_assets_assets: MemoriesAssetsAssets; + migrations: Migrations; + move_history: MoveHistory; + naturalearth_countries: NaturalearthCountries; + partners: Partners; + person: Person; + sessions: Sessions; + shared_link__asset: SharedLinkAsset; + shared_links: SharedLinks; + smart_info: SmartInfo; + smart_search: SmartSearch; + socket_io_attachments: SocketIoAttachments; + system_config: SystemConfig; + system_metadata: SystemMetadata; + tag_asset: TagAsset; + tags: Tags; + tags_closure: TagsClosure; + user_metadata: UserMetadata; + users: Users; + 'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat; +} diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index a255ac103b..0658567912 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -97,10 +97,19 @@ const mapStack = (entity: AssetEntity) => { return { id: entity.stack.id, primaryAssetId: entity.stack.primaryAssetId, - assetCount: entity.stack.assetCount ?? entity.stack.assets.length, + assetCount: entity.stack.assetCount ?? entity.stack.assets.length + 1, }; }; +// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings +const hexOrBufferToBase64 = (encoded: string | Buffer) => { + if (typeof encoded === 'string') { + return Buffer.from(encoded.slice(2), 'hex').toString('base64'); + } + + return encoded.toString('base64'); +}; + export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; @@ -129,7 +138,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As originalPath: entity.originalPath, originalFileName: entity.originalFileName, originalMimeType: mimeTypes.lookup(entity.originalFileName), - thumbhash: entity.thumbhash?.toString('base64') ?? null, + thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, fileCreatedAt: entity.fileCreatedAt, fileModifiedAt: entity.fileModifiedAt, localDateTime: entity.localDateTime, @@ -143,7 +152,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As tags: entity.tags?.map((tag) => mapTag(tag)), people: peopleWithFaces(entity.faces), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), - checksum: entity.checksum.toString('base64'), + checksum: hexOrBufferToBase64(entity.checksum), stack: withStack ? mapStack(entity) : undefined, isOffline: entity.isOffline, hasMetadata: true, diff --git a/server/src/dtos/duplicate.dto.ts b/server/src/dtos/duplicate.dto.ts index 09976b3213..b12580ef18 100644 --- a/server/src/dtos/duplicate.dto.ts +++ b/server/src/dtos/duplicate.dto.ts @@ -1,5 +1,4 @@ import { IsNotEmpty } from 'class-validator'; -import { groupBy, sortBy } from 'lodash'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { ValidateUUID } from 'src/validation'; @@ -13,16 +12,3 @@ export class ResolveDuplicatesDto { @ValidateUUID({ each: true }) assetIds!: string[]; } - -export function mapDuplicateResponse(assets: AssetResponseDto[]): DuplicateResponseDto[] { - const result = []; - - const grouped = groupBy(assets, (a) => a.duplicateId); - - for (const [duplicateId, unsortedAssets] of Object.entries(grouped)) { - const assets = sortBy(unsortedAssets, (asset) => asset.localDateTime); - result.push({ duplicateId, assets }); - } - - return result; -} diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 5c5dce1a11..f3f45af44d 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -162,7 +162,7 @@ export class MetadataSearchDto extends RandomSearchDto { @IsEnum(AssetOrder) @Optional() - @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) + @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder, default: AssetOrder.DESC }) order?: AssetOrder; @IsInt() diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index f9e5c5e981..a4d25c6987 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,3 +1,6 @@ +import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, Selectable, SelectQueryBuilder, sql } from 'kysely'; +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; +import { Assets, DB } from 'src/db'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; @@ -9,7 +12,9 @@ import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { StackEntity } from 'src/entities/stack.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { AssetStatus, AssetType } from 'src/enum'; +import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; +import { AssetSearchBuilderOptions } from 'src/interfaces/search.interface'; +import { anyUuid, asUuid } from 'src/utils/database'; import { Column, CreateDateColumn, @@ -173,3 +178,243 @@ export class AssetEntity { @Column({ type: 'uuid', nullable: true }) duplicateId!: string | null; } + +export const withExif = (qb: SelectQueryBuilder, options?: { inner: boolean }) => { + const join = options?.inner + ? qb.innerJoin('exif', 'assets.id', 'exif.assetId') + : qb.leftJoin('exif', 'assets.id', 'exif.assetId'); + return join.select((eb) => eb.fn('jsonb_strip_nulls', [eb.fn('to_jsonb', [eb.table('exif')])]).as('exifInfo')); +}; + +export const withSmartSearch = (qb: SelectQueryBuilder, options?: { inner: boolean }) => { + const join = options?.inner + ? qb.innerJoin('smart_search', 'assets.id', 'smart_search.assetId') + : qb.leftJoin('smart_search', 'assets.id', 'smart_search.assetId'); + return join.select(sql`smart_search.embedding`.as('embedding')); +}; + +export const withFaces = (eb: ExpressionBuilder) => + jsonArrayFrom(eb.selectFrom('asset_faces').selectAll().whereRef('asset_faces.assetId', '=', 'assets.id')).as('faces'); + +export const withFiles = (eb: ExpressionBuilder, type?: AssetFileType) => + jsonArrayFrom( + eb + .selectFrom('asset_files') + .selectAll() + .whereRef('asset_files.assetId', '=', 'assets.id') + .$if(!!type, (qb) => qb.where('type', '=', type!)), + ).as('files'); + +export const withFacesAndPeople = (eb: ExpressionBuilder) => + eb + .selectFrom('asset_faces') + .leftJoin('person', 'person.id', 'asset_faces.personId') + .whereRef('asset_faces.assetId', '=', 'assets.id') + .select((eb) => + eb + .fn('jsonb_agg', [ + eb + .case() + .when('person.id', 'is not', null) + .then( + eb.fn('jsonb_insert', [ + eb.fn('to_jsonb', [eb.table('asset_faces')]), + sql`'{person}'::text[]`, + eb.fn('to_jsonb', [eb.table('person')]), + ]), + ) + .else(eb.fn('to_jsonb', [eb.table('asset_faces')])) + .end(), + ]) + .as('faces'), + ) + .as('faces'); + +/** Adds a `has_people` CTE that can be inner joined on to filter out assets */ +export const hasPeopleCte = (db: Kysely, personIds: string[]) => + db.with('has_people', (qb) => + qb + .selectFrom('asset_faces') + .select('assetId') + .where('personId', '=', anyUuid(personIds!)) + .groupBy('assetId') + .having((eb) => eb.fn.count('personId'), '>=', personIds.length), + ); + +export const hasPeople = (db: Kysely, personIds?: string[]) => + personIds && personIds.length > 0 + ? hasPeopleCte(db, personIds).selectFrom('assets').innerJoin('has_people', 'has_people.assetId', 'assets.id') + : db.selectFrom('assets'); + +export const withOwner = (eb: ExpressionBuilder) => + jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner'); + +export const withLibrary = (eb: ExpressionBuilder) => + jsonObjectFrom(eb.selectFrom('libraries').selectAll().whereRef('libraries.id', '=', 'assets.libraryId')).as( + 'library', + ); + +type Stacked = SelectQueryBuilder< + DB & { stacked: Selectable }, + 'assets' | 'asset_stack' | 'stacked', + { assets: Selectable[] } +>; + +type StackExpression = (eb: Stacked) => Stacked; + +export const withStack = ( + qb: SelectQueryBuilder, + { assets }: { assets?: boolean | StackExpression }, +) => + qb + .leftJoinLateral( + (eb) => + eb + .selectFrom('asset_stack') + .selectAll('asset_stack') + .whereRef('assets.stackId', '=', 'asset_stack.id') + .$if(!!assets, (qb) => + qb + .innerJoinLateral( + (eb: ExpressionBuilder) => + eb + .selectFrom('assets as stacked') + .select((eb) => eb.fn[]>('array_agg', [eb.table('stacked')]).as('assets')) + .whereRef('asset_stack.id', '=', 'stacked.stackId') + .whereRef('asset_stack.primaryAssetId', '!=', 'stacked.id') + .$if(typeof assets === 'function', assets as StackExpression) + .as('s'), + (join) => + join.on((eb) => + eb.or([ + eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), + eb('assets.stackId', 'is', null), + ]), + ), + ) + .select('s.assets'), + ) + .as('stacked_assets'), + (join) => join.onTrue(), + ) + .select((eb) => eb.fn('jsonb_strip_nulls', [eb.fn('to_jsonb', [eb.table('stacked_assets')])]).as('stack')); + +export const withAlbums = (qb: SelectQueryBuilder, { albumId }: { albumId?: string }) => { + return qb + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('albums') + .selectAll() + .innerJoin('albums_assets_assets', (join) => + join + .onRef('albums.id', '=', 'albums_assets_assets.albumsId') + .onRef('assets.id', '=', 'albums_assets_assets.assetsId'), + ) + .whereRef('albums.id', '=', 'albums_assets_assets.albumsId') + .$if(!!albumId, (qb) => qb.where('albums.id', '=', asUuid(albumId!))), + ).as('albums'), + ) + .$if(!!albumId, (qb) => + qb.where((eb) => + eb.exists((eb) => + eb + .selectFrom('albums_assets_assets') + .whereRef('albums_assets_assets.assetsId', '=', 'assets.id') + .where('albums_assets_assets.albumsId', '=', asUuid(albumId!)), + ), + ), + ); +}; + +export const withTags = (eb: ExpressionBuilder) => + jsonArrayFrom( + eb + .selectFrom('tags') + .selectAll('tags') + .innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId') + .whereRef('assets.id', '=', 'tag_asset.assetsId'), + ).as('tags'); + +const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); + +export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { + options.isArchived ??= options.withArchived ? undefined : false; + options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore); + return hasPeople(kysely.withPlugin(joinDeduplicationPlugin), options.personIds) + .selectAll('assets') + .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) + .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) + .$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore!)) + .$if(!!options.updatedAfter, (qb) => qb.where('assets.updatedAt', '>=', options.updatedAfter!)) + .$if(!!options.trashedBefore, (qb) => qb.where('assets.deletedAt', '<=', options.trashedBefore!)) + .$if(!!options.trashedAfter, (qb) => qb.where('assets.deletedAt', '>=', options.trashedAfter!)) + .$if(!!options.takenBefore, (qb) => qb.where('assets.fileCreatedAt', '<=', options.takenBefore!)) + .$if(!!options.takenAfter, (qb) => qb.where('assets.fileCreatedAt', '>=', options.takenAfter!)) + .$if(options.city !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.city', options.city === null ? 'is' : '=', options.city!), + ) + .$if(options.state !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.state', options.state === null ? 'is' : '=', options.state!), + ) + .$if(options.country !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.country', options.country === null ? 'is' : '=', options.country!), + ) + .$if(options.make !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.make', options.make === null ? 'is' : '=', options.make!), + ) + .$if(options.model !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.model', options.model === null ? 'is' : '=', options.model!), + ) + .$if(options.lensModel !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!), + ) + .$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!)) + .$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!)) + .$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!)) + .$if(!!options.id, (qb) => qb.where('assets.id', '=', asUuid(options.id!))) + .$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', asUuid(options.libraryId!))) + .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) + .$if(!!options.encodedVideoPath, (qb) => qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath!)) + .$if(!!options.originalPath, (qb) => qb.where('assets.originalPath', '=', options.originalPath!)) + .$if(!!options.originalFileName, (qb) => + qb.where( + sql`f_unaccent(assets."originalFileName")`, + 'ilike', + sql`'%' || f_unaccent(${options.originalFileName}) || '%'`, + ), + ) + .$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!)) + .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) + .$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!)) + .$if(options.isVisible !== undefined, (qb) => qb.where('assets.isVisible', '=', options.isVisible!)) + .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) + .$if(options.isEncoded !== undefined, (qb) => + qb.where('assets.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null), + ) + .$if(options.isMotion !== undefined, (qb) => + qb.where('assets.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), + ) + .$if(!!options.isNotInAlbum, (qb) => + qb.where((eb) => + eb.not(eb.exists((eb) => eb.selectFrom('albums_assets_assets').whereRef('assetsId', '=', 'assets.id'))), + ), + ) + .$if(!!options.withExif, (qb) => withExif(qb, { inner: true })) + .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => + qb.select((eb) => withFacesAndPeople(eb)), + ) + .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)); +} diff --git a/server/src/entities/face-search.entity.ts b/server/src/entities/face-search.entity.ts index 3fd3c65f28..e353260fe0 100644 --- a/server/src/entities/face-search.entity.ts +++ b/server/src/entities/face-search.entity.ts @@ -1,5 +1,4 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { asVector } from 'src/utils/database'; import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; @Entity('face_search', { synchronize: false }) @@ -15,7 +14,7 @@ export class FaceSearchEntity { @Column({ type: 'float4', array: true, - transformer: { from: (v) => JSON.parse(v), to: (v) => asVector(v) }, + transformer: { from: (v) => JSON.parse(v), to: (v) => `[${v}]` }, }) embedding!: number[]; } diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 1b32c57d41..5abaf9af26 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -1,10 +1,9 @@ -import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; +import { Insertable, Updateable } from 'kysely'; +import { AssetFiles, AssetJobStatus, Assets, Exif } from 'src/db'; import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; -import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; export type AssetStats = Record; @@ -66,43 +65,6 @@ export interface TimeBucketItem { count: number; } -export type AssetCreate = Pick< - AssetEntity, - | 'deviceAssetId' - | 'ownerId' - | 'libraryId' - | 'deviceId' - | 'type' - | 'originalPath' - | 'fileCreatedAt' - | 'localDateTime' - | 'fileModifiedAt' - | 'checksum' - | 'originalFileName' -> & - Partial; - -export type AssetWithoutRelations = Omit< - AssetEntity, - | 'livePhotoVideo' - | 'stack' - | 'albums' - | 'faces' - | 'owner' - | 'library' - | 'exifInfo' - | 'sharedLinks' - | 'smartSearch' - | 'tags' ->; - -type AssetUpdateWithoutRelations = Pick & Partial; -type AssetUpdateWithLivePhotoRelation = Pick & Pick; - -export type AssetUpdateOptions = AssetUpdateWithoutRelations | AssetUpdateWithLivePhotoRelation; - -export type AssetUpdateAllOptions = Omit, 'id'>; - export interface MonthDay { day: number; month: number; @@ -113,12 +75,6 @@ export interface AssetExploreFieldOptions { minAssetsPerField: number; } -export interface AssetExploreOptions extends AssetExploreFieldOptions { - relation: keyof AssetEntity; - relatedField: string; - unnest?: boolean; -} - export interface AssetFullSyncOptions { ownerId: string; lastId?: string; @@ -144,52 +100,71 @@ export interface UpsertFileOptions { path: string; } +export interface AssetGetByChecksumOptions { + ownerId: string; + checksum: Buffer; + libraryId?: string; +} + export type AssetPathEntity = Pick; +export interface GetByIdsRelations { + exifInfo?: boolean; + faces?: { person?: boolean }; + files?: boolean; + library?: boolean; + owner?: boolean; + smartSearch?: boolean; + stack?: { assets?: boolean }; + tags?: boolean; +} + +export interface DuplicateGroup { + duplicateId: string; + assets: AssetEntity[]; +} + +export interface DayOfYearAssets { + yearsAgo: number; + assets: AssetEntity[]; +} + export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { - create(asset: AssetCreate): Promise; - getByIds( - ids: string[], - relations?: FindOptionsRelations, - select?: FindOptionsSelect, - ): Promise; + create(asset: Insertable): Promise; + getByIds(ids: string[], relations?: GetByIdsRelations): Promise; getByIdsWithAllRelations(ids: string[]): Promise; - getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; - getByChecksum(options: { ownerId: string; checksum: Buffer; libraryId?: string }): Promise; + getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; + getByChecksum(options: AssetGetByChecksumOptions): Promise; getByChecksums(userId: string, checksums: Buffer[]): Promise; getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise; getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated; - getById( - id: string, - relations?: FindOptionsRelations, - order?: FindOptionsOrder, - ): Promise; + getById(id: string, relations?: GetByIdsRelations): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getRandom(userIds: string[], count: number): Promise; - getLastUpdatedAssetForAlbumId(albumId: string): Promise; - getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; + getLastUpdatedAssetForAlbumId(albumId: string): Promise; + getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; getAllByDeviceId(userId: string, deviceId: string): Promise; getLivePhotoCount(motionId: string): Promise; - updateAll(ids: string[], options: Partial): Promise; + updateAll(ids: string[], options: Updateable): Promise; updateDuplicates(options: AssetUpdateDuplicateOptions): Promise; - update(asset: AssetUpdateOptions): Promise; + update(asset: Updateable & { id: string }): Promise; remove(asset: AssetEntity): Promise; - findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; + findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; getStatistics(ownerId: string, options: AssetStatsOptions): Promise; getTimeBuckets(options: TimeBucketOptions): Promise; getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; - upsertExif(exif: Partial): Promise; - upsertJobStatus(...jobStatus: Partial[]): Promise; + upsertExif(exif: Insertable): Promise; + upsertJobStatus(...jobStatus: Insertable[]): Promise; getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise>; - getDuplicates(options: AssetBuilderOptions): Promise; + getDuplicates(userId: string): Promise; getAllForUserFullSync(options: AssetFullSyncOptions): Promise; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; - upsertFile(file: UpsertFileOptions): Promise; - upsertFiles(files: UpsertFileOptions[]): Promise; + upsertFile(options: Insertable): Promise; + upsertFiles(options: Insertable[]): Promise; } diff --git a/server/src/interfaces/config.interface.ts b/server/src/interfaces/config.interface.ts index 300b55f27b..8b45078039 100644 --- a/server/src/interfaces/config.interface.ts +++ b/server/src/interfaces/config.interface.ts @@ -1,6 +1,7 @@ import { RegisterQueueOptions } from '@nestjs/bullmq'; import { QueueOptions } from 'bullmq'; import { RedisOptions } from 'ioredis'; +import { KyselyConfig } from 'kysely'; import { ClsModuleOptions } from 'nestjs-cls'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; import { ImmichEnvironment, ImmichTelemetry, ImmichWorker, LogLevel } from 'src/enum'; @@ -42,7 +43,7 @@ export interface EnvData { }; database: { - config: PostgresConnectionOptions & DatabaseConnectionParams; + config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: KyselyConfig }; skipMigrations: boolean; vectorExtension: VectorExtension; }; diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index 6a10a92f31..9aff2d1d46 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -1,3 +1,6 @@ +import { TableMetadata } from 'kysely'; +import { DB } from 'src/db'; + export enum DatabaseExtension { CUBE = 'cube', EARTH_DISTANCE = 'earthdistance', @@ -59,11 +62,13 @@ export interface VectorUpdateResult { export const IDatabaseRepository = 'IDatabaseRepository'; export interface IDatabaseRepository { + init(): Promise; reconnect(): Promise; getExtensionVersion(extension: DatabaseExtension): Promise; getExtensionVersionRange(extension: VectorExtension): string; getPostgresVersion(): Promise; getPostgresVersionRange(): string; + getTable(name: K): TableMetadata & { name: K }; createExtension(extension: DatabaseExtension): Promise; updateVectorExtension(extension: VectorExtension, version?: string): Promise; reindex(index: VectorIndex): Promise; diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 87bf1bc4b1..449f8101ca 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -1,4 +1,3 @@ -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { AssetStatus, AssetType } from 'src/enum'; @@ -114,7 +113,7 @@ export interface SearchPeopleOptions { } export interface SearchOrderOptions { - orderDirection?: 'ASC' | 'DESC'; + orderDirection?: 'asc' | 'desc'; } export interface SearchPaginationOptions { @@ -148,20 +147,21 @@ export type SmartSearchOptions = SearchDateOptions & export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { hasPerson?: boolean; numResults: number; - maxDistance?: number; + maxDistance: number; } export interface AssetDuplicateSearch { assetId: string; embedding: number[]; - maxDistance?: number; + maxDistance: number; type: AssetType; userIds: string[]; } export interface FaceSearchResult { distance: number; - face: AssetFaceEntity; + id: string; + personId: string | null; } export interface AssetDuplicateResult { @@ -170,6 +170,28 @@ export interface AssetDuplicateResult { distance: number; } +interface GetMetadataOptions { + includeNull?: boolean; +} + +export type GetCountriesOptions = GetMetadataOptions; + +export interface GetStatesOptions extends GetMetadataOptions { + country?: string; +} + +export interface GetCitiesOptions extends GetStatesOptions { + state?: string; +} + +export interface GetCameraModelsOptions extends GetMetadataOptions { + make?: string; +} + +export interface GetCameraMakesOptions extends GetMetadataOptions { + model?: string; +} + export interface ISearchRepository { searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated; searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; @@ -182,9 +204,9 @@ export interface ISearchRepository { deleteAllSearchEmbeddings(): Promise; getDimensionSize(): Promise; setDimensionSize(dimSize: number): Promise; - getCountries(userIds: string[]): Promise>; - getStates(userIds: string[], country?: string): Promise>; - getCities(userIds: string[], country?: string, state?: string): Promise>; - getCameraMakes(userIds: string[], model?: string): Promise>; - getCameraModels(userIds: string[], make?: string): Promise>; + getCountries(userIds: string[], options: GetCountriesOptions): Promise>; + getStates(userIds: string[], options: GetStatesOptions): Promise>; + getCities(userIds: string[], options: GetCitiesOptions): Promise>; + getCameraMakes(userIds: string[], options: GetCameraMakesOptions): Promise>; + getCameraModels(userIds: string[], options: GetCameraModelsOptions): Promise>; } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index ce7d257b40..4dd8c70e24 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,22 +1,37 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { Insertable, Kysely, Updateable, sql } from 'kysely'; +import { isEmpty, isUndefined, omitBy } from 'lodash'; +import { InjectKysely } from 'nestjs-kysely'; +import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileEntity } from 'src/entities/asset-files.entity'; -import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetFileType, AssetOrder, AssetStatus, AssetType, PaginationMode } from 'src/enum'; import { - AssetBuilderOptions, - AssetCreate, + AssetEntity, + hasPeople, + hasPeopleCte, + searchAssetBuilder, + withAlbums, + withExif, + withFaces, + withFacesAndPeople, + withFiles, + withLibrary, + withOwner, + withSmartSearch, + withStack, + withTags, +} from 'src/entities/asset.entity'; +import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; +import { AssetDeltaSyncOptions, AssetExploreFieldOptions, AssetFullSyncOptions, + AssetGetByChecksumOptions, AssetStats, AssetStatsOptions, - AssetUpdateAllOptions, AssetUpdateDuplicateOptions, - AssetUpdateOptions, + DayOfYearAssets, + DuplicateGroup, + GetByIdsRelations, IAssetRepository, LivePhotoSearchOptions, MonthDay, @@ -26,139 +41,153 @@ import { WithProperty, WithoutProperty, } from 'src/interfaces/asset.interface'; -import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; -import { searchAssetBuilder } from 'src/utils/database'; -import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; -import { - Brackets, - FindOptionsOrder, - FindOptionsRelations, - FindOptionsSelect, - FindOptionsWhere, - In, - IsNull, - MoreThan, - Not, - Repository, -} from 'typeorm'; - -const truncateMap: Record = { - [TimeBucketSize.DAY]: 'day', - [TimeBucketSize.MONTH]: 'month', -}; - -const dateTrunc = (options: TimeBucketOptions) => - `(date_trunc('${ - truncateMap[options.size] - }', (asset."localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`; +import { IDatabaseRepository } from 'src/interfaces/database.interface'; +import { MapMarker, MapMarkerSearchOptions } from 'src/interfaces/map.interface'; +import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/interfaces/search.interface'; +import { anyUuid, asUuid, upsertHelper } from 'src/utils/database'; +import { Paginated, PaginationOptions, paginationHelper } from 'src/utils/pagination'; @Injectable() export class AssetRepository implements IAssetRepository { constructor( - @InjectRepository(AssetEntity) private repository: Repository, - @InjectRepository(AssetFileEntity) private fileRepository: Repository, - @InjectRepository(ExifEntity) private exifRepository: Repository, - @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, + @InjectKysely() private db: Kysely, + @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, ) {} - async upsertExif(exif: Partial): Promise { - await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] }); + async upsertExif(exif: Insertable): Promise { + const values = { ...exif, assetId: asUuid(exif.assetId) }; + await upsertHelper(this.db, this.databaseRepository.getTable('exif'), values, ['assetId']).execute(); } - async upsertJobStatus(...jobStatus: Partial[]): Promise { - await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] }); + async upsertJobStatus(...jobStatus: Insertable[]): Promise { + if (jobStatus.length === 0) { + return; + } + + const values = jobStatus.map((row) => ({ ...row, assetId: asUuid(row.assetId) })); + await upsertHelper(this.db, this.databaseRepository.getTable('asset_job_status'), values, ['assetId']).execute(); } - create(asset: AssetCreate): Promise { - return this.repository.save(asset); + create(asset: Insertable): Promise { + return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirst() as any as Promise; } - @GenerateSql({ params: [[DummyValue.UUID], { day: 1, month: 1 }] }) - getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { - return this.repository - .createQueryBuilder('entity') - .where( - `entity.ownerId IN (:...ownerIds) - AND entity.isVisible = true - AND entity.isArchived = false - AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day - AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`, - { - ownerIds, - day, - month, - }, + @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) + getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { + // TODO: CREATE INDEX idx_local_date_time ON public.assets ((("localDateTime" at time zone 'UTC')::date)); + // TODO: drop IDX_day_of_month and IDX_month + return this.db + .with('res', (qb) => + qb + .with('today', (qb) => + qb + .selectFrom((eb) => + eb.fn('generate_series', [eb.val(1970), sql`extract(year from current_date) - 1`]).as('year'), + ) + .select((eb) => eb.fn('make_date', [sql`year::int`, sql`${month}::int`, sql`${day}::int`]).as('date')), + ) + .selectFrom('today') + .innerJoinLateral( + (qb) => + qb + .selectFrom('assets') + .selectAll('assets') + .innerJoin('asset_job_status', 'assets.id', 'asset_job_status.assetId') + .where('asset_job_status.previewAt', 'is not', null) + .where(sql`(assets."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`) + .where('assets.ownerId', '=', anyUuid(ownerIds)) + .where('assets.isVisible', '=', true) + .where('assets.isArchived', '=', false) + .where((eb) => + eb.exists((qb) => + qb + .selectFrom('asset_files') + .whereRef('assetId', '=', 'assets.id') + .where('asset_files.type', '=', AssetFileType.PREVIEW), + ), + ) + .where('assets.deletedAt', 'is', null) + .limit(50) + .as('a'), + (join) => join.onTrue(), + ) + .innerJoin('exif', 'a.id', 'exif.assetId') + .selectAll('a') + .select((eb) => eb.fn('jsonb_strip_nulls', [eb.fn('to_jsonb', [eb.table('exif')])]).as('exifInfo')), ) - .leftJoinAndSelect('entity.exifInfo', 'exifInfo') - .leftJoinAndSelect('entity.files', 'files') - .orderBy('entity.fileCreatedAt', 'ASC') - .getMany(); + .selectFrom('res') + .select( + sql`((now() at time zone 'UTC')::date - ("localDateTime" at time zone 'UTC')::date) / 365`.as( + 'yearsAgo', + ), + ) + .select((eb) => eb.fn('jsonb_agg', [eb.table('res')]).as('assets')) + .groupBy(sql`("localDateTime" at time zone 'UTC')::date`) + .orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc') + .limit(10) + .execute() as any as Promise; } @GenerateSql({ params: [[DummyValue.UUID]] }) @ChunkedArray() - getByIds( + async getByIds( ids: string[], - relations?: FindOptionsRelations, - select?: FindOptionsSelect, + { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}, ): Promise { - return this.repository.find({ - where: { id: In(ids) }, - relations, - select, - withDeleted: true, - }); + const res = await this.db + .selectFrom('assets') + .selectAll('assets') + .where('assets.id', '=', anyUuid(ids)) + .$if(!!exifInfo, (qb) => withExif(qb)) + .$if(!!faces, (qb) => qb.select((eb) => (faces?.person ? withFacesAndPeople(eb) : withFaces(eb)))) + .$if(!!files, (qb) => qb.select((eb) => withFiles(eb))) + .$if(!!library, (qb) => qb.select((eb) => withLibrary(eb))) + .$if(!!owner, (qb) => qb.select((eb) => withOwner(eb))) + .$if(!!smartSearch, (qb) => withSmartSearch(qb)) + .$if(!!stack, (qb) => withStack(qb, { assets: stack!.assets })) + .$if(!!tags, (qb) => qb.select((eb) => withTags(eb))) + .execute(); + + return res as any as AssetEntity[]; } @GenerateSql({ params: [[DummyValue.UUID]] }) @ChunkedArray() getByIdsWithAllRelations(ids: string[]): Promise { - return this.repository.find({ - where: { id: In(ids) }, - relations: { - exifInfo: true, - tags: true, - faces: { - person: true, - }, - stack: { - assets: true, - }, - files: true, - }, - withDeleted: true, - }); + return this.db + .selectFrom('assets') + .selectAll('assets') + .select((eb) => withFacesAndPeople(eb)) + .select((eb) => withTags(eb)) + .$call((qb) => withExif(qb)) + .$call((qb) => withStack(qb, { assets: true })) + .where('assets.id', '=', anyUuid(ids)) + .execute() as any as Promise; } @GenerateSql({ params: [DummyValue.UUID] }) async deleteAll(ownerId: string): Promise { - await this.repository.delete({ ownerId }); + await this.db.deleteFrom('assets').where('ownerId', '=', ownerId).execute(); } - getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated { - return paginate(this.repository, pagination, { - where: { - albums: { - id: albumId, - }, - }, - relations: { - albums: true, - exifInfo: true, - }, - }); + async getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated { + const items = await withAlbums(this.db.selectFrom('assets'), { albumId }) + .selectAll('assets') + .where('deletedAt', 'is', null) + .orderBy('fileCreatedAt', 'desc') + .execute(); + + return paginationHelper(items as any as AssetEntity[], pagination.take); } async getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise { - const assets = await this.repository.find({ - select: { deviceAssetId: true }, - where: { - deviceAssetId: In(deviceAssetIds), - deviceId, - ownerId, - }, - withDeleted: true, - }); + const assets = await this.db + .selectFrom('assets') + .select(['deviceAssetId']) + .where('deviceAssetId', 'in', deviceAssetIds) + .where('deviceId', '=', deviceId) + .where('ownerId', '=', asUuid(ownerId)) + .execute(); return assets.map((asset) => asset.deviceAssetId); } @@ -172,37 +201,27 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { - return this.repository.findOne({ - where: { library: { id: libraryId }, originalPath }, - withDeleted: true, - }); + getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { + return this.db + .selectFrom('assets') + .selectAll('assets') + .where('libraryId', '=', asUuid(libraryId)) + .where('originalPath', '=', originalPath) + .limit(1) + .executeTakeFirst() as any as Promise; } - @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); - } - - getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { - let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files'); - builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.createdAt', options.orderDirection ?? 'ASC'); - return paginatedBuilder(builder, { - mode: PaginationMode.SKIP_TAKE, - skip: pagination.skip, - take: pagination.take, - }); + async getAll( + pagination: PaginationOptions, + { orderDirection, ...options }: AssetSearchOptions = {}, + ): Paginated { + const builder = searchAssetBuilder(this.db, options) + .select((eb) => withFiles(eb)) + .orderBy('assets.createdAt', orderDirection ?? 'asc') + .limit(pagination.take + 1) + .offset(pagination.skip ?? 0); + const items = await builder.execute(); + return paginationHelper(items as any as AssetEntity[], pagination.take); } /** @@ -214,140 +233,138 @@ export class AssetRepository implements IAssetRepository { */ @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) async getAllByDeviceId(ownerId: string, deviceId: string): Promise { - const items = await this.repository.find({ - select: { deviceAssetId: true }, - where: { - ownerId, - deviceId, - isVisible: true, - }, - withDeleted: true, - }); + const items = await this.db + .selectFrom('assets') + .select(['deviceAssetId']) + .where('ownerId', '=', asUuid(ownerId)) + .where('deviceId', '=', deviceId) + .where('isVisible', '=', true) + .where('deletedAt', 'is', null) + .execute(); return items.map((asset) => asset.deviceAssetId); } @GenerateSql({ params: [DummyValue.UUID] }) - getLivePhotoCount(motionId: string): Promise { - return this.repository.count({ - where: { - livePhotoVideoId: motionId, - }, - withDeleted: true, - }); + async getLivePhotoCount(motionId: string): Promise { + const [{ count }] = await this.db + .selectFrom('assets') + .select((eb) => eb.fn.countAll().as('count')) + .where('livePhotoVideoId', '=', asUuid(motionId)) + .execute(); + return count as number; } @GenerateSql({ params: [DummyValue.UUID] }) getById( id: string, - relations: FindOptionsRelations, - order?: FindOptionsOrder, - ): Promise { - return this.repository.findOne({ - where: { id }, - relations, - // We are specifically asking for this asset. Return it even if it is soft deleted - withDeleted: true, - order, - }); + { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}, + ): Promise { + return this.db + .selectFrom('assets') + .selectAll('assets') + .where('assets.id', '=', asUuid(id)) + .$if(!!exifInfo, (qb) => withExif(qb)) + .$if(!!faces, (qb) => qb.select((eb) => (faces?.person ? withFacesAndPeople(eb) : withFaces(eb)))) + .$if(!!library, (qb) => qb.select((eb) => withLibrary(eb))) + .$if(!!owner, (qb) => qb.select((eb) => withOwner(eb))) + .$if(!!smartSearch, (qb) => withSmartSearch(qb)) + .$if(!!stack, (qb) => withStack(qb, { assets: stack!.assets })) + .$if(!!files, (qb) => qb.select((eb) => withFiles(eb))) + .$if(!!tags, (qb) => qb.select((eb) => withTags(eb))) + .executeTakeFirst() as any as Promise; } @GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] }) @Chunked() - async updateAll(ids: string[], options: AssetUpdateAllOptions): Promise { - await this.repository.update({ id: In(ids) }, options); + async updateAll(ids: string[], options: Updateable): Promise { + if (ids.length === 0) { + return; + } + await this.db.updateTable('assets').set(options).where('id', '=', anyUuid(ids)).execute(); } @GenerateSql({ params: [{ targetDuplicateId: DummyValue.UUID, duplicateIds: [DummyValue.UUID], assetIds: [DummyValue.UUID] }], }) async updateDuplicates(options: AssetUpdateDuplicateOptions): Promise { - await this.repository - .createQueryBuilder() - .update() + await this.db + .updateTable('assets') .set({ duplicateId: options.targetDuplicateId }) - .where({ - duplicateId: In(options.duplicateIds), - }) - .orWhere({ id: In(options.assetIds) }) + .where((eb) => + eb.or([eb('duplicateId', '=', anyUuid(options.duplicateIds)), eb('id', '=', anyUuid(options.assetIds))]), + ) .execute(); } - async update(asset: AssetUpdateOptions): Promise { - await this.repository.update(asset.id, asset); + async update(asset: Updateable & { id: string }): Promise { + const value = omitBy(asset, (v) => isUndefined(v)); + delete value.id; + if (!isEmpty(value)) { + return this.db + .with('assets', (qb) => qb.updateTable('assets').set(asset).where('id', '=', asUuid(asset.id)).returningAll()) + .selectFrom('assets') + .selectAll('assets') + .$call((qb) => withExif(qb)) + .$call((qb) => qb.select((eb) => withFacesAndPeople(eb))) + .executeTakeFirst() as Promise; + } + + return this.getById(asset.id, { exifInfo: true, faces: { person: true } }) as Promise; } async remove(asset: AssetEntity): Promise { - await this.repository.remove(asset); + await this.db.deleteFrom('assets').where('id', '=', asUuid(asset.id)).execute(); } @GenerateSql({ params: [{ ownerId: DummyValue.UUID, libraryId: DummyValue.UUID, checksum: DummyValue.BUFFER }] }) - getByChecksum({ - ownerId, - libraryId, - checksum, - }: { - ownerId: string; - checksum: Buffer; - libraryId?: string; - }): Promise { - return this.repository.findOne({ - where: { - ownerId, - libraryId: libraryId || IsNull(), - checksum, - }, - }); + getByChecksum({ ownerId, libraryId, checksum }: AssetGetByChecksumOptions): Promise { + return this.db + .selectFrom('assets') + .selectAll('assets') + .where('ownerId', '=', asUuid(ownerId)) + .where('checksum', '=', checksum) + .$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null))) + .limit(1) + .executeTakeFirst() as Promise; } @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) - getByChecksums(ownerId: string, checksums: Buffer[]): Promise { - return this.repository.find({ - select: { - id: true, - checksum: true, - deletedAt: true, - }, - where: { - ownerId, - checksum: In(checksums), - }, - withDeleted: true, - }); + getByChecksums(userId: string, checksums: Buffer[]): Promise { + return this.db + .selectFrom('assets') + .select(['id', 'checksum', 'deletedAt']) + .where('ownerId', '=', asUuid(userId)) + .where('checksum', 'in', checksums) + .execute() as any as Promise; } @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) async getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise { - const asset = await this.repository.findOne({ - select: { id: true }, - where: { - ownerId, - checksum, - library: IsNull(), - }, - withDeleted: true, - }); + const asset = await this.db + .selectFrom('assets') + .select('id') + .where('ownerId', '=', asUuid(ownerId)) + .where('checksum', '=', checksum) + .where('libraryId', 'is', null) + .limit(1) + .executeTakeFirst(); return asset?.id; } - findLivePhotoMatch(options: LivePhotoSearchOptions): Promise { - const { ownerId, libraryId, otherAssetId, livePhotoCID, type } = options; + findLivePhotoMatch(options: LivePhotoSearchOptions): Promise { + const { ownerId, otherAssetId, livePhotoCID, type } = options; - return this.repository.findOne({ - where: { - id: Not(otherAssetId), - ownerId, - libraryId: libraryId || IsNull(), - type, - exifInfo: { - livePhotoCID, - }, - }, - relations: { - exifInfo: true, - }, - }); + return this.db + .selectFrom('assets') + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('id', '!=', asUuid(otherAssetId)) + .where('ownerId', '=', asUuid(ownerId)) + .where('type', '=', type) + .where('exif.livePhotoCID', '=', livePhotoCID) + .limit(1) + .executeTakeFirst() as Promise; } @GenerateSql( @@ -356,194 +373,243 @@ export class AssetRepository implements IAssetRepository { params: [DummyValue.PAGINATION, property], })), ) - getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { - let relations: FindOptionsRelations = {}; - let where: FindOptionsWhere | FindOptionsWhere[] = {}; + async getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { + const items = await this.db + .selectFrom('assets') + .selectAll('assets') + .$if(property === WithoutProperty.DUPLICATE, (qb) => + qb + .innerJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId') + .where('job_status.duplicatesDetectedAt', 'is', null) + .where('job_status.previewAt', 'is not', null) + .where((eb) => eb.exists(eb.selectFrom('smart_search').where('assetId', '=', eb.ref('assets.id')))) + .where('assets.isVisible', '=', true), + ) + .$if(property === WithoutProperty.ENCODED_VIDEO, (qb) => + qb + .where('assets.type', '=', AssetType.VIDEO) + .where((eb) => eb.or([eb('assets.encodedVideoPath', 'is', null), eb('assets.encodedVideoPath', '=', '')])), + ) + .$if(property === WithoutProperty.EXIF, (qb) => + qb + .innerJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId') + .where('job_status.metadataExtractedAt', 'is', null) + .where('assets.isVisible', '=', true), + ) + .$if(property === WithoutProperty.FACES, (qb) => + qb + .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') + .where('job_status.previewAt', 'is not', null) + .where('job_status.facesRecognizedAt', 'is', null) + .where('assets.isVisible', '=', true), + ) + .$if(property === WithoutProperty.SIDECAR, (qb) => + qb + .where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)])) + .where('assets.isVisible', '=', true), + ) + .$if(property === WithoutProperty.SMART_SEARCH, (qb) => + qb + .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') + .where('job_status.previewAt', 'is not', null) + .where('assets.isVisible', '=', true) + .where((eb) => + eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))), + ), + ) + .$if(property === WithoutProperty.THUMBNAIL, (qb) => + qb + .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') + .select((eb) => withFiles(eb)) + .where('assets.isVisible', '=', true) + .where((eb) => + eb.or([ + eb('job_status.previewAt', 'is', null), + eb('job_status.thumbnailAt', 'is', null), + eb('assets.thumbhash', 'is', null), + ]), + ), + ) + .where('deletedAt', 'is', null) + .limit(pagination.take + 1) + .offset(pagination.skip ?? 0) + .orderBy('createdAt', 'asc') + .execute(); - switch (property) { - case WithoutProperty.THUMBNAIL: { - relations = { jobStatus: true, files: true }; - where = [ - { jobStatus: { previewAt: IsNull() }, isVisible: true }, - { jobStatus: { thumbnailAt: IsNull() }, isVisible: true }, - { thumbhash: IsNull(), isVisible: true }, - ]; - break; - } - - case WithoutProperty.ENCODED_VIDEO: { - where = [ - { type: AssetType.VIDEO, encodedVideoPath: IsNull() }, - { type: AssetType.VIDEO, encodedVideoPath: '' }, - ]; - break; - } - - case WithoutProperty.EXIF: { - relations = { - exifInfo: true, - jobStatus: true, - }; - where = { - isVisible: true, - jobStatus: { - metadataExtractedAt: IsNull(), - }, - }; - break; - } - - case WithoutProperty.SMART_SEARCH: { - relations = { - smartSearch: true, - }; - where = { - isVisible: true, - jobStatus: { previewAt: Not(IsNull()) }, - smartSearch: { - embedding: IsNull(), - }, - }; - break; - } - - case WithoutProperty.DUPLICATE: { - where = { - isVisible: true, - smartSearch: true, - jobStatus: { - previewAt: Not(IsNull()), - duplicatesDetectedAt: IsNull(), - }, - }; - break; - } - - case WithoutProperty.FACES: { - relations = { - faces: true, - jobStatus: true, - }; - where = { - isVisible: true, - faces: { - assetId: IsNull(), - personId: IsNull(), - }, - jobStatus: { - previewAt: Not(IsNull()), - facesRecognizedAt: IsNull(), - }, - }; - break; - } - - case WithoutProperty.SIDECAR: { - where = [ - { sidecarPath: IsNull(), isVisible: true }, - { sidecarPath: '', isVisible: true }, - ]; - break; - } - - default: { - throw new Error(`Invalid getWithout property: ${property}`); - } - } - - return paginate(this.repository, pagination, { - relations, - where, - order: { - // Ensures correct order when paginating - createdAt: 'ASC', - }, - }); + return paginationHelper(items as any as AssetEntity[], pagination.take); } - getLastUpdatedAssetForAlbumId(albumId: string): Promise { - return this.repository.findOne({ - where: { albums: { id: albumId } }, - order: { updatedAt: 'DESC' }, - }); + getLastUpdatedAssetForAlbumId(albumId: string): Promise { + return this.db + .selectFrom('assets') + .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') + .where('albums_assets_assets.albumsId', '=', asUuid(albumId)) + .orderBy('updatedAt', 'desc') + .limit(1) + .executeTakeFirst() as Promise; } - async getStatistics(ownerId: string, options: AssetStatsOptions): Promise { - const builder = this.repository - .createQueryBuilder('asset') - .select(`COUNT(asset.id)`, 'count') - .addSelect(`asset.type`, 'type') - .where('"ownerId" = :ownerId', { ownerId }) - .andWhere('asset.isVisible = true') - .groupBy('asset.type'); + getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { + const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; - const { isArchived, isFavorite, isTrashed } = options; - if (isArchived !== undefined) { - builder.andWhere(`asset.isArchived = :isArchived`, { isArchived }); - } - - if (isFavorite !== undefined) { - builder.andWhere(`asset.isFavorite = :isFavorite`, { isFavorite }); - } - - if (isTrashed !== undefined) { - builder - .withDeleted() - .andWhere(`asset.deletedAt is not null`) - .andWhere('asset.status = :status', { status: AssetStatus.TRASHED }); - } - - const items = await builder.getRawMany(); - - const result: AssetStats = { - [AssetType.AUDIO]: 0, - [AssetType.IMAGE]: 0, - [AssetType.VIDEO]: 0, - [AssetType.OTHER]: 0, - }; - - for (const item of items) { - result[item.type as AssetType] = Number(item.count) || 0; - } - - return result; + return this.db + .selectFrom('assets') + .leftJoin('exif', 'assets.id', 'exif.assetId') + .select(['id', 'latitude as lat', 'longitude as lon', 'city', 'state', 'country']) + .where('ownerId', '=', anyUuid(ownerIds)) + .where('latitude', 'is not', null) + .where('longitude', 'is not', null) + .where('isVisible', '=', true) + .where('deletedAt', 'is', null) + .$if(!!isArchived, (qb) => qb.where('isArchived', '=', isArchived!)) + .$if(!!isFavorite, (qb) => qb.where('isFavorite', '=', isFavorite!)) + .$if(!!fileCreatedAfter, (qb) => qb.where('fileCreatedAt', '>=', fileCreatedAfter!)) + .$if(!!fileCreatedBefore, (qb) => qb.where('fileCreatedAt', '<=', fileCreatedBefore!)) + .orderBy('fileCreatedAt', 'desc') + .execute() as Promise; } - @GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER] }) - getRandom(userIds: string[], count: number): Promise { - return this.getBuilder({ userIds, exifInfo: true }).orderBy('RANDOM()').limit(count).getMany(); + getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise { + return this.db + .selectFrom('assets') + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) + .where('ownerId', '=', asUuid(ownerId)) + .where('isVisible', '=', true) + .$if(isArchived !== undefined, (qb) => qb.where('isArchived', '=', isArchived!)) + .$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!)) + .$if(!!isTrashed, (qb) => qb.where('assets.status', '=', AssetStatus.TRASHED)) + .where('deletedAt', isTrashed ? 'is not' : 'is', null) + .executeTakeFirst() as Promise; + } + + getRandom(userIds: string[], take: number): Promise { + return this.db + .selectFrom('assets') + .selectAll('assets') + .$call((qb) => withExif(qb)) + .where('ownerId', '=', anyUuid(userIds)) + .where('isVisible', '=', true) + .where('deletedAt', 'is', null) + .orderBy((eb) => eb.fn('random')) + .limit(take) + .execute() as any as Promise; } @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) - getTimeBuckets(options: TimeBucketOptions): Promise { - const truncated = dateTrunc(options); - return this.getBuilder(options) - .select(`COUNT(asset.id)::int`, 'count') - .addSelect(truncated, 'timeBucket') - .groupBy(truncated) - .orderBy(truncated, options.order === AssetOrder.ASC ? 'ASC' : 'DESC') - .getRawMany(); + async getTimeBuckets(options: TimeBucketOptions): Promise { + return ((options.personId ? hasPeopleCte(this.db, [options.personId]) : this.db) as Kysely) + .with('assets', (qb) => + qb + .selectFrom('assets') + .select((eb) => + eb.fn('date_trunc', [eb.val(options.size), sql`"localDateTime" at time zone 'UTC'`]).as('timeBucket'), + ) + .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '=', AssetStatus.TRASHED)) + .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) + .where('assets.isVisible', '=', true) + .$if(!!options.albumId, (qb) => + qb + .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') + .where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)), + ) + .$if(!!options.personId, (qb) => + qb.innerJoin(sql.table('has_people').as('has_people'), (join) => + join.onRef(sql`has_people."assetId"`, '=', 'assets.id'), + ), + ) + .$if(!!options.withStacked, (qb) => + qb + .leftJoin('asset_stack', (join) => + join + .onRef('asset_stack.id', '=', 'assets.stackId') + .onRef('asset_stack.primaryAssetId', '=', 'assets.id'), + ) + .where((eb) => eb.or([eb('assets.stackId', 'is', null), eb(eb.table('asset_stack'), 'is not', null)])), + ) + .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) + .$if(!!options.isArchived, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) + .$if(!!options.isFavorite, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) + .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) + .$if(!!options.isDuplicate, (qb) => + qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), + ), + ) + .selectFrom('assets') + .select(sql`"timeBucket"::date::text`.as('timeBucket')) + .select((eb) => eb.fn.countAll().as('count')) + .groupBy('timeBucket') + .orderBy('timeBucket', 'desc') + .execute() as any as Promise; } @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH }] }) - getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { - const truncated = dateTrunc(options); - return ( - this.getBuilder(options) - .andWhere(`${truncated} = :timeBucket`, { timeBucket: timeBucket.replace(/^[+-]/, '') }) - // First sort by the day in localtime (put it in the right bucket) - .orderBy(truncated, 'DESC') - // and then sort by the actual time - .addOrderBy('asset.fileCreatedAt', options.order === AssetOrder.ASC ? 'ASC' : 'DESC') - .getMany() - ); + async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { + // TODO: CREATE INDEX idx_local_date_time_month ON public.assets (date_trunc('MONTH', "localDateTime" at time zone 'UTC')); + return hasPeople(this.db, options.personId ? [options.personId] : undefined) + .selectAll('assets') + .$call((qb) => withExif(qb)) + .$if(!!options.albumId, (qb) => withAlbums(qb, { albumId: options.albumId })) + .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) + .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) + .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) + .$if(!!options.withStacked, (qb) => withStack(qb, { assets: true })) // TODO: optimize this; it's a huge performance hit + .$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) + .$if(options.isDuplicate !== undefined, (qb) => + qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), + ) + .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '=', AssetStatus.TRASHED)) + .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) + .where('assets.isVisible', '=', true) + .where( + (eb) => eb.fn('date_trunc', [eb.val(options.size), sql`assets."localDateTime" at time zone 'UTC'`]), + '=', + timeBucket.replace(/^[+-]/, ''), + ) + .orderBy('assets.localDateTime', 'desc') + .execute() as any as Promise; } @GenerateSql({ params: [{ userIds: [DummyValue.UUID, DummyValue.UUID] }] }) - getDuplicates(options: AssetBuilderOptions): Promise { - return this.getBuilder({ ...options, isDuplicate: true }) - .orderBy('asset.duplicateId') - .getMany(); + getDuplicates(userId: string): Promise { + return ( + this.db + .with('duplicates', (qb) => + qb + .selectFrom('assets') + .select('duplicateId') + .select((eb) => + eb.fn('jsonb_strip_nulls', [eb.fn('jsonb_agg', [eb.table('assets')])]).as('assets'), + ) + .where('ownerId', '=', asUuid(userId)) + .where('duplicateId', 'is not', null) + .where('deletedAt', 'is', null) + .where('isVisible', '=', true) + .groupBy('duplicateId'), + ) + .with('unique', (qb) => + qb + .selectFrom('duplicates') + .select('duplicateId') + .where((eb) => eb(eb.fn('jsonb_array_length', ['assets']), '=', 1)), + ) + .with('removed_unique', (qb) => + qb + .updateTable('assets') + .set({ duplicateId: null }) + .from('unique') + .whereRef('assets.duplicateId', '=', 'unique.duplicateId'), + ) + .selectFrom('duplicates') + .selectAll() + // TODO: compare with filtering by jsonb_array_length > 1 + .where(({ not, exists }) => + not(exists((eb) => eb.selectFrom('unique').whereRef('unique.duplicateId', '=', 'duplicates.duplicateId'))), + ) + .execute() as any as Promise + ); } @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) @@ -551,111 +617,35 @@ export class AssetRepository implements IAssetRepository { ownerId: string, { minAssetsPerField, maxFields }: AssetExploreFieldOptions, ): Promise> { - const cte = this.exifRepository - .createQueryBuilder('e') - .select('city') - .groupBy('city') - .having('count(city) >= :minAssetsPerField', { minAssetsPerField }); - - const items = await this.getBuilder({ - userIds: [ownerId], - exifInfo: false, - assetType: AssetType.IMAGE, - isArchived: false, - }) - .select('c.city', 'value') - .addSelect('asset.id', 'data') - .distinctOn(['c.city']) - .innerJoin('exif', 'e', 'asset.id = e."assetId"') - .addCommonTableExpression(cte, 'cities') - .innerJoin('cities', 'c', 'c.city = e.city') + const items = await this.db + .with('cities', (qb) => + qb + .selectFrom('exif') + .select('city') + .where('city', 'is not', null) + .groupBy('city') + .having((eb) => eb.fn('count', [eb.ref('assetId')]), '>=', minAssetsPerField), + ) + .selectFrom('assets') + .innerJoin('exif', 'assets.id', 'exif.assetId') + .innerJoin('cities', 'exif.city', 'cities.city') + .distinctOn('exif.city') + .select(['assetId as data', 'exif.city as value']) + .where('ownerId', '=', asUuid(ownerId)) + .where('isVisible', '=', true) + .where('isArchived', '=', false) + .where('type', '=', AssetType.IMAGE) + .where('deletedAt', 'is', null) .limit(maxFields) - .getRawMany(); + .execute(); - return { fieldName: 'exifInfo.city', items }; - } - - private getBuilder(options: AssetBuilderOptions) { - const builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true'); - - if (options.assetType !== undefined) { - builder.andWhere('asset.type = :assetType', { assetType: options.assetType }); - } - - if (options.tagId) { - builder.innerJoin( - 'asset.tags', - 'asset_tags', - 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)', - { tagId: options.tagId }, - ); - } - - let stackJoined = false; - - if (options.exifInfo !== false) { - stackJoined = true; - builder - .leftJoinAndSelect('asset.exifInfo', 'exifInfo') - .leftJoinAndSelect('asset.stack', 'stack') - .leftJoinAndSelect('stack.assets', 'stackedAssets'); - } - - if (options.albumId) { - builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId: options.albumId }); - } - - if (options.userIds) { - builder.andWhere('asset.ownerId IN (:...userIds )', { userIds: options.userIds }); - } - - if (options.isArchived !== undefined) { - builder.andWhere('asset.isArchived = :isArchived', { isArchived: options.isArchived }); - } - - if (options.isFavorite !== undefined) { - builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite: options.isFavorite }); - } - - if (options.isTrashed !== undefined) { - builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); - - if (options.isTrashed) { - // TODO: Temporarily inverted to support showing offline assets in the trash queries. - // Once offline assets are handled in a separate screen, this should be set back to status = TRASHED - // and the offline screens should use a separate isOffline = true parameter in the timeline query. - builder.andWhere('asset.status != :status', { status: AssetStatus.DELETED }); - } - } - - if (options.isDuplicate !== undefined) { - builder.andWhere(`asset.duplicateId ${options.isDuplicate ? 'IS NOT NULL' : 'IS NULL'}`); - } - - if (options.personId !== undefined) { - builder - .innerJoin('asset.faces', 'faces') - .innerJoin('faces.person', 'person') - .andWhere('person.id = :personId', { personId: options.personId }); - } - - if (options.withStacked) { - if (!stackJoined) { - builder.leftJoinAndSelect('asset.stack', 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets'); - } - builder.andWhere( - new Brackets((qb) => qb.where('stack.primaryAssetId = asset.id').orWhere('asset.stackId IS NULL')), - ); - } - - return builder; + return { fieldName: 'exifInfo.city', items: items as SearchExploreItemSet }; } @GenerateSql({ params: [ { ownerId: DummyValue.UUID, - lastCreationDate: DummyValue.DATE, lastId: DummyValue.UUID, updatedUntil: DummyValue.DATE, limit: 10, @@ -664,49 +654,43 @@ export class AssetRepository implements IAssetRepository { }) getAllForUserFullSync(options: AssetFullSyncOptions): Promise { const { ownerId, lastId, updatedUntil, limit } = options; - const builder = this.getBuilder({ - userIds: [ownerId], - exifInfo: false, // need to do this manually because `exifInfo: true` also loads stacked assets messing with `limit` - withStacked: false, // return all assets individually as expected by the app - }) - .leftJoinAndSelect('asset.exifInfo', 'exifInfo') - .leftJoinAndSelect('asset.stack', 'stack') - .loadRelationCountAndMap('stack.assetCount', 'stack.assets', 'stackedAssetsCount'); - - if (lastId !== undefined) { - builder.andWhere('asset.id > :lastId', { lastId }); - } - builder - .andWhere('asset.updatedAt <= :updatedUntil', { updatedUntil }) - .orderBy('asset.id', 'ASC') - .limit(limit) // cannot use `take` for performance reasons - .withDeleted(); - return builder.getMany(); + return this.db + .selectFrom('assets') + .where('ownerId', '=', asUuid(ownerId)) + .where('isVisible', '=', true) + .where('updatedAt', '<=', updatedUntil) + .$if(!!lastId, (qb) => qb.where('id', '>', lastId!)) + .orderBy('id', 'asc') + .limit(limit) + .execute() as any as Promise; } @GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE }] }) - getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise { - const builder = this.getBuilder({ - userIds: options.userIds, - exifInfo: false, // need to do this manually because `exifInfo: true` also loads stacked assets messing with `limit` - withStacked: false, // return all assets individually as expected by the app - }) - .leftJoinAndSelect('asset.exifInfo', 'exifInfo') - .leftJoinAndSelect('asset.stack', 'stack') - .loadRelationCountAndMap('stack.assetCount', 'stack.assets', 'stackedAssetsCount') - .andWhere({ updatedAt: MoreThan(options.updatedAfter) }) - .limit(options.limit) // cannot use `take` for performance reasons - .withDeleted(); - return builder.getMany(); + async getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise { + return this.db + .selectFrom('assets') + .selectAll('assets') + .select((eb) => + eb + .selectFrom('asset_stack') + .select((eb) => eb.fn.countAll().as('stackedAssetsCount')) + .whereRef('asset_stack.id', '=', 'assets.stackId') + .as('stackedAssetsCount'), + ) + .where('ownerId', '=', anyUuid(options.userIds)) + .where('isVisible', '=', true) + .where('updatedAt', '>', options.updatedAfter) + .limit(options.limit) + .execute() as any as Promise; } @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) - async upsertFile(file: { assetId: string; type: AssetFileType; path: string }): Promise { - await this.fileRepository.upsert(file, { conflictPaths: ['assetId', 'type'] }); + async upsertFile(values: Pick, 'assetId' | 'path' | 'type'>): Promise { + await upsertHelper(this.db, this.databaseRepository.getTable('asset_files'), values, ['assetId', 'type']).execute(); } @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) - async upsertFiles(files: { assetId: string; type: AssetFileType; path: string }[]): Promise { - await this.fileRepository.upsert(files, { conflictPaths: ['assetId', 'type'] }); + async upsertFiles(values: Pick, 'assetId' | 'path' | 'type'>[]): Promise { + await upsertHelper(this.db, this.databaseRepository.getTable('asset_files'), values, ['assetId', 'type']).execute(); } } diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index a8a1c9972b..9883d71448 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -2,8 +2,10 @@ import { Inject, Injectable, Optional } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { Request, Response } from 'express'; +import { PostgresJSDialect } from 'kysely-postgres-js'; import { CLS_ID } from 'nestjs-cls'; import { join, resolve } from 'node:path'; +import postgres from 'postgres'; import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { Telemetry } from 'src/decorators'; import { EnvDto } from 'src/dtos/env.dto'; @@ -96,6 +98,33 @@ const getEnv = (): EnvData => { } } + const driverOptions = { + max: 10, + types: { + date: { + to: 1184, + from: [1082, 1114, 1184], + serialize: (x: Date | string) => (x instanceof Date ? x.toISOString() : x), + parse: (x: string) => new Date(x), + }, + bigint: { + to: 20, + from: [20], + parse: (value: string) => Number.parseInt(value), + serialize: (value: number) => value.toString(), + }, + }, + }; + + const parts = { + connectionType: 'parts', + host: dto.DB_HOSTNAME || 'database', + port: dto.DB_PORT || 5432, + username: dto.DB_USERNAME || 'postgres', + password: dto.DB_PASSWORD || 'postgres', + database: dto.DB_DATABASE_NAME || 'immich', + } as const; + return { host: dto.IMMICH_HOST, port: dto.IMMICH_PORT || 2283, @@ -150,24 +179,23 @@ const getEnv = (): EnvData => { database: { config: { - type: 'postgres', - entities: [`${folders.dist}/entities` + '/*.entity.{js,ts}'], - migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'], - subscribers: [`${folders.dist}/subscribers` + '/*.{js,ts}'], - migrationsRun: false, - synchronize: false, - connectTimeoutMS: 10_000, // 10 seconds - parseInt8: true, - ...(databaseUrl - ? { connectionType: 'url', url: databaseUrl } - : { - connectionType: 'parts', - host: dto.DB_HOSTNAME || 'database', - port: dto.DB_PORT || 5432, - username: dto.DB_USERNAME || 'postgres', - password: dto.DB_PASSWORD || 'postgres', - database: dto.DB_DATABASE_NAME || 'immich', - }), + typeorm: { + type: 'postgres', + entities: [`${folders.dist}/entities` + '/*.entity.{js,ts}'], + migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'], + subscribers: [`${folders.dist}/subscribers` + '/*.{js,ts}'], + migrationsRun: false, + synchronize: false, + connectTimeoutMS: 10_000, // 10 seconds + parseInt8: true, + ...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts), + }, + kysely: { + dialect: new PostgresJSDialect({ + postgres: databaseUrl ? postgres(databaseUrl, driverOptions) : postgres({ ...parts, ...driverOptions }), + }), + log: ['error'] as const, + }, }, skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false, diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index b5e2edfdea..da69cffb32 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -1,8 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import AsyncLock from 'async-lock'; +import { Kysely, TableMetadata } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; import semver from 'semver'; import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; +import { DB } from 'src/db'; import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension, @@ -18,13 +21,17 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { isValidInteger } from 'src/validation'; import { DataSource, EntityManager, QueryRunner } from 'typeorm'; +type TableMetadataMap = { [K in keyof DB]: TableMetadata & { name: K } }; + @Injectable() export class DatabaseRepository implements IDatabaseRepository { private vectorExtension: VectorExtension; - readonly asyncLock = new AsyncLock(); + private readonly asyncLock = new AsyncLock(); + private tables?: TableMetadataMap; constructor( @InjectDataSource() private dataSource: DataSource, + @InjectKysely() private db: Kysely, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IConfigRepository) configRepository: IConfigRepository, ) { @@ -32,6 +39,19 @@ export class DatabaseRepository implements IDatabaseRepository { this.logger.setContext(DatabaseRepository.name); } + async init() { + if (this.tables) { + return; + } + + await this.asyncLock.acquire('getTable', async () => { + if (!this.tables) { + const tables = await this.db.introspection.getTables(); + this.tables = Object.fromEntries(tables.map((table) => [table.name, table])) as TableMetadataMap; + } + }); + } + async reconnect() { try { if (this.dataSource.isInitialized) { @@ -68,6 +88,14 @@ export class DatabaseRepository implements IDatabaseRepository { return POSTGRES_VERSION_RANGE; } + getTable(name: K): TableMetadata & { name: K } { + const table = this.tables?.[name]; + if (!table) { + throw new Error(`Could not find table with name ${name}`); + } + return table; + } + async createExtension(extension: DatabaseExtension): Promise { await this.dataSource.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`); } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 01b7773076..d258e8e575 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,60 +1,36 @@ import { Inject, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { Kysely, OrderByDirectionExpression, sql } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; import { randomUUID } from 'node:crypto'; +import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; +import { AssetEntity, searchAssetBuilder } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; -import { SmartSearchEntity } from 'src/entities/smart-search.entity'; -import { AssetType, PaginationMode } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { DatabaseExtension, VectorExtension } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { - AssetDuplicateResult, AssetDuplicateSearch, AssetSearchOptions, FaceEmbeddingSearch, - FaceSearchResult, + GetCameraMakesOptions, + GetCameraModelsOptions, + GetCitiesOptions, + GetCountriesOptions, + GetStatesOptions, ISearchRepository, SearchPaginationOptions, SmartSearchOptions, } from 'src/interfaces/search.interface'; -import { asVector, searchAssetBuilder } from 'src/utils/database'; -import { Paginated, PaginationResult, paginatedBuilder } from 'src/utils/pagination'; +import { anyUuid, asUuid, asVector } from 'src/utils/database'; +import { Paginated } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; -import { Repository } from 'typeorm'; @Injectable() export class SearchRepository implements ISearchRepository { - private vectorExtension: VectorExtension; - private faceColumns: string[]; - private assetsByCityQuery: string; - constructor( - @InjectRepository(AssetEntity) private assetRepository: Repository, - @InjectRepository(ExifEntity) private exifRepository: Repository, - @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, - @InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository, - @InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository, @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, + @InjectKysely() private db: Kysely, ) { - this.vectorExtension = configRepository.getEnv().database.vectorExtension; this.logger.setContext(SearchRepository.name); - this.faceColumns = this.assetFaceRepository.manager.connection - .getMetadata(AssetFaceEntity) - .ownColumns.map((column) => column.propertyName) - .filter((propertyName) => propertyName !== 'embedding'); - this.assetsByCityQuery = - assetsByCityCte + - this.assetRepository - .createQueryBuilder('asset') - .innerJoinAndSelect('asset.exifInfo', 'exif') - .withDeleted() - .getQuery() + - ' INNER JOIN cte ON asset.id = cte."assetId" ORDER BY exif.city'; } @GenerateSql({ @@ -70,14 +46,15 @@ export class SearchRepository implements ISearchRepository { ], }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { - let builder = this.assetRepository.createQueryBuilder('asset'); - builder = searchAssetBuilder(builder, options).orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); - - return paginatedBuilder(builder, { - mode: PaginationMode.SKIP_TAKE, - skip: (pagination.page - 1) * pagination.size, - take: pagination.size, - }); + const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirectionExpression; + const items = await searchAssetBuilder(this.db, options) + .orderBy('assets.fileCreatedAt', orderDirection) + .limit(pagination.size + 1) + .offset((pagination.page - 1) * pagination.size) + .execute(); + const hasNextPage = items.length > pagination.size; + items.splice(pagination.size); + return { items: items as any as AssetEntity[], hasNextPage }; } @GenerateSql({ @@ -92,21 +69,15 @@ export class SearchRepository implements ISearchRepository { }, ], }) - async searchRandom(size: number, options: AssetSearchOptions): Promise { - const builder1 = searchAssetBuilder(this.assetRepository.createQueryBuilder('asset'), options); - const builder2 = builder1.clone(); - + searchRandom(size: number, options: AssetSearchOptions): Promise { const uuid = randomUUID(); - builder1.andWhere('asset.id > :uuid', { uuid }).orderBy('asset.id').take(size); - builder2.andWhere('asset.id < :uuid', { uuid }).orderBy('asset.id').take(size); - - const [assets1, assets2] = await Promise.all([builder1.getMany(), builder2.getMany()]); - const missingCount = size - assets1.length; - for (let i = 0; i < missingCount && i < assets2.length; i++) { - assets1.push(assets2[i]); - } - - return assets1; + const builder = searchAssetBuilder(this.db, options); + return builder + .where('assets.id', '>', uuid) + .orderBy('assets.id') + .limit(size) + .unionAll(() => builder.where('assets.id', '<', uuid).orderBy('assets.id').limit(size)) + .execute() as any as Promise; } @GenerateSql({ @@ -122,30 +93,21 @@ export class SearchRepository implements ISearchRepository { }, ], }) - async searchSmart( - pagination: SearchPaginationOptions, - { embedding, userIds, ...options }: SmartSearchOptions, - ): Paginated { - let results: PaginationResult = { items: [], hasNextPage: false }; + async searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated { + if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) { + throw new Error(`Invalid value for 'size': ${pagination.size}`); + } - await this.assetRepository.manager.transaction(async (manager) => { - let builder = manager.createQueryBuilder(AssetEntity, 'asset'); - builder = searchAssetBuilder(builder, options); - builder - .innerJoin('asset.smartSearch', 'search') - .andWhere('asset.ownerId IN (:...userIds )') - .orderBy('search.embedding <=> :embedding') - .setParameters({ userIds, embedding: asVector(embedding) }); + const items = (await searchAssetBuilder(this.db, options) + .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') + .orderBy(sql`smart_search.embedding <=> ${asVector(options.embedding)}`) + .limit(pagination.size + 1) + .offset((pagination.page - 1) * pagination.size) + .execute()) as any as AssetEntity[]; - await manager.query(this.getRuntimeConfig(pagination.size)); - results = await paginatedBuilder(builder, { - mode: PaginationMode.LIMIT_OFFSET, - skip: (pagination.page - 1) * pagination.size, - take: pagination.size, - }); - }); - - return results; + const hasNextPage = items.length > pagination.size; + items.splice(pagination.size); + return { items, hasNextPage }; } @GenerateSql({ @@ -157,38 +119,30 @@ export class SearchRepository implements ISearchRepository { }, ], }) - searchDuplicates({ - assetId, - embedding, - maxDistance, - type, - userIds, - }: AssetDuplicateSearch): Promise { - const cte = this.assetRepository.createQueryBuilder('asset'); - cte - .select('search.assetId', 'assetId') - .addSelect('asset.duplicateId', 'duplicateId') - .addSelect(`search.embedding <=> :embedding`, 'distance') - .innerJoin('asset.smartSearch', 'search') - .where('asset.ownerId IN (:...userIds )') - .andWhere('asset.id != :assetId') - .andWhere('asset.isVisible = :isVisible') - .andWhere('asset.type = :type') - .orderBy('search.embedding <=> :embedding') - .limit(64) - .setParameters({ assetId, embedding: asVector(embedding), isVisible: true, type, userIds }); - - const builder = this.assetRepository.manager - .createQueryBuilder() - .addCommonTableExpression(cte, 'cte') - .from('cte', 'res') - .select('res.*'); - - if (maxDistance) { - builder.where('res.distance <= :maxDistance', { maxDistance }); - } - - return builder.getRawMany() as Promise; + searchDuplicates({ assetId, embedding, maxDistance, type, userIds }: AssetDuplicateSearch) { + const vector = asVector(embedding); + return this.db + .with('cte', (qb) => + qb + .selectFrom('assets') + .select([ + 'assets.id as assetId', + 'assets.duplicateId', + sql`smart_search.embedding <=> ${vector}`.as('distance'), + ]) + .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') + .where('assets.ownerId', '=', anyUuid(userIds)) + .where('assets.deletedAt', 'is', null) + .where('assets.isVisible', '=', true) + .where('assets.type', '=', type) + .where('assets.id', '!=', assetId) + .orderBy(sql`smart_search.embedding <=> ${vector}`) + .limit(64), + ) + .selectFrom('cte') + .selectAll() + .where('cte.distance', '<=', maxDistance as number) + .execute(); } @GenerateSql({ @@ -201,117 +155,131 @@ export class SearchRepository implements ISearchRepository { }, ], }) - async searchFaces({ - userIds, - embedding, - numResults, - maxDistance, - hasPerson, - }: FaceEmbeddingSearch): Promise { - if (!isValidInteger(numResults, { min: 1 })) { + searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson }: FaceEmbeddingSearch) { + if (!isValidInteger(numResults, { min: 1, max: 1000 })) { throw new Error(`Invalid value for 'numResults': ${numResults}`); } - // setting this too low messes with prefilter recall - numResults = Math.max(numResults, 64); - - let results: Array = []; - await this.assetRepository.manager.transaction(async (manager) => { - const cte = manager - .createQueryBuilder(AssetFaceEntity, 'faces') - .select('search.embedding <=> :embedding', 'distance') - .innerJoin('faces.asset', 'asset') - .innerJoin('faces.faceSearch', 'search') - .where('asset.ownerId IN (:...userIds )') - .orderBy('search.embedding <=> :embedding') - .setParameters({ userIds, embedding: asVector(embedding) }); - - cte.limit(numResults); - - if (hasPerson) { - cte.andWhere('faces."personId" IS NOT NULL'); - } - - for (const col of this.faceColumns) { - cte.addSelect(`faces.${col}`, col); - } - - await manager.query(this.getRuntimeConfig(numResults)); - results = await manager - .createQueryBuilder() - .select('res.*') - .addCommonTableExpression(cte, 'cte') - .from('cte', 'res') - .where('res.distance <= :maxDistance', { maxDistance }) - .orderBy('res.distance') - .getRawMany(); - }); - return results.map((row) => ({ - face: this.assetFaceRepository.create(row), - distance: row.distance, - })); + const vector = asVector(embedding); + return this.db + .with('cte', (qb) => + qb + .selectFrom('asset_faces') + .select([ + 'asset_faces.id', + 'asset_faces.personId', + sql`face_search.embedding <=> ${vector}`.as('distance'), + ]) + .innerJoin('assets', 'assets.id', 'asset_faces.assetId') + .innerJoin('face_search', 'face_search.faceId', 'asset_faces.id') + .where('assets.ownerId', '=', anyUuid(userIds)) + .where('assets.deletedAt', 'is', null) + .$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null)) + .orderBy(sql`face_search.embedding <=> ${vector}`) + .limit(numResults), + ) + .selectFrom('cte') + .selectAll() + .where('cte.distance', '<=', maxDistance) + .execute(); } @GenerateSql({ params: [DummyValue.STRING] }) - async searchPlaces(placeName: string): Promise { - return await this.geodataPlacesRepository - .createQueryBuilder('geoplaces') - .where(`f_unaccent(name) %>> f_unaccent(:placeName)`) - .orWhere(`f_unaccent("admin2Name") %>> f_unaccent(:placeName)`) - .orWhere(`f_unaccent("admin1Name") %>> f_unaccent(:placeName)`) - .orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`) + searchPlaces(placeName: string): Promise { + return this.db + .selectFrom('geodata_places') + .selectAll() + .where( + () => + // kysely doesn't support trigram %>> or <->>> operators + sql` + f_unaccent(name) %>> f_unaccent(${placeName}) or + f_unaccent("admin2Name") %>> f_unaccent(${placeName}) or + f_unaccent("admin1Name") %>> f_unaccent(${placeName}) or + f_unaccent("alternateNames") %>> f_unaccent(${placeName}) + `, + ) .orderBy( - ` - COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0.1) + - COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0.1) + - COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0.1) + - COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0.1) + sql` + coalesce(f_unaccent(name) <->>> f_unaccent(${placeName}), 0.1) + + coalesce(f_unaccent("admin2Name") <->>> f_unaccent(${placeName}), 0.1) + + coalesce(f_unaccent("admin1Name") <->>> f_unaccent(${placeName}), 0.1) + + coalesce(f_unaccent("alternateNames") <->>> f_unaccent(${placeName}), 0.1) `, ) - .setParameters({ placeName }) .limit(20) - .getMany(); + .execute() as Promise; } @GenerateSql({ params: [[DummyValue.UUID]] }) - async getAssetsByCity(userIds: string[]): Promise { - const parameters = [userIds, true, false, AssetType.IMAGE]; - const rawRes = await this.assetRepository.query(this.assetsByCityQuery, parameters); + getAssetsByCity(userIds: string[]): Promise { + return this.db + .withRecursive('cte', (qb) => { + const base = qb + .selectFrom('exif') + .select(['city', 'assetId']) + .innerJoin('assets', 'assets.id', 'exif.assetId') + .where('assets.ownerId', '=', anyUuid(userIds)) + .where('assets.isVisible', '=', true) + .where('assets.isArchived', '=', false) + .where('assets.type', '=', 'IMAGE') + .where('assets.deletedAt', 'is', null) + .orderBy('city') + .limit(1); - const items: AssetEntity[] = []; - for (const res of rawRes) { - const item = { exifInfo: {} as Record } as Record; - for (const [key, value] of Object.entries(res)) { - if (key.startsWith('exif_')) { - item.exifInfo[key.replace('exif_', '')] = value; - } else { - item[key.replace('asset_', '')] = value; - } - } - items.push(item as AssetEntity); - } + const recursive = qb + .selectFrom('cte') + .select(['l.city', 'l.assetId']) + .innerJoinLateral( + (qb) => + qb + .selectFrom('exif') + .select(['city', 'assetId']) + .innerJoin('assets', 'assets.id', 'exif.assetId') + .where('assets.ownerId', '=', anyUuid(userIds)) + .where('assets.isVisible', '=', true) + .where('assets.isArchived', '=', false) + .where('assets.type', '=', 'IMAGE') + .where('assets.deletedAt', 'is', null) + .whereRef('exif.city', '>', 'cte.city') + .orderBy('city') + .limit(1) + .as('l'), + (join) => join.onTrue(), + ); - return items; + return sql<{ city: string; assetId: string }>`(${base} union all ${recursive})`; + }) + .selectFrom('assets') + .innerJoin('exif', 'assets.id', 'exif.assetId') + .innerJoin('cte', 'assets.id', 'cte.assetId') + .selectAll('assets') + .select((eb) => eb.fn('jsonb_strip_nulls', [eb.fn('to_jsonb', [eb.table('exif')])]).as('exifInfo')) + .orderBy('exif.city') + .execute() as any as Promise; } async upsert(assetId: string, embedding: number[]): Promise { - await this.smartSearchRepository.upsert( - { assetId, embedding: () => asVector(embedding, true) }, - { conflictPaths: ['assetId'] }, - ); + const vector = asVector(embedding); + await this.db + .insertInto('smart_search') + .values({ assetId: asUuid(assetId), embedding: vector } as any) + .onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding: vector } as any)) + .execute(); } async getDimensionSize(): Promise { - const res = await this.smartSearchRepository.manager.query(` - SELECT atttypmod as dimsize - FROM pg_attribute f - JOIN pg_class c ON c.oid = f.attrelid - WHERE c.relkind = 'r'::char - AND f.attnum > 0 - AND c.relname = 'smart_search' - AND f.attname = 'embedding'`); + const { rows } = await sql<{ dimsize: number }>` + select atttypmod as dimsize + from pg_attribute f + join pg_class c ON c.oid = f.attrelid + where c.relkind = 'r'::char + and f.attnum > 0 + and c.relname = 'smart_search' + and f.attname = 'embedding' + `.execute(this.db); - const dimSize = res[0]['dimsize']; + const dimSize = rows[0]['dimsize']; if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { throw new Error(`Could not retrieve CLIP dimension size`); } @@ -323,141 +291,79 @@ export class SearchRepository implements ISearchRepository { throw new Error(`Invalid CLIP dimension size: ${dimSize}`); } - return this.smartSearchRepository.manager.transaction(async (manager) => { - await manager.clear(SmartSearchEntity); - await manager.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`); - await manager.query(`REINDEX INDEX clip_index`); + return this.db.transaction().execute(async (trx) => { + await sql`truncate ${sql.table('smart_search')}`.execute(trx); + await trx.schema + .alterTable('smart_search') + .alterColumn('embedding', (col) => col.setDataType(sql.lit(`vector(${dimSize})`))) + .execute(); + await sql`reindex index clip_index`.execute(trx); }); } async deleteAllSearchEmbeddings(): Promise { - return this.smartSearchRepository.clear(); + await sql`truncate ${sql.table('smart_search')}`.execute(this.db); } - @GenerateSql({ params: [[DummyValue.UUID]] }) - async getCountries(userIds: string[]): Promise { - const results = await this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.country', 'country') - .distinctOn(['exif.country']) - .getRawMany<{ country: string }>(); + async getCountries(userIds: string[], { includeNull }: GetCountriesOptions): Promise { + const res = await this.getExifField('country', userIds, includeNull).execute(); - return results.map(({ country }) => country).filter((item) => item !== ''); + return res.map((row) => row['country']!); } @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getStates(userIds: string[], country: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.state', 'state') - .distinctOn(['exif.state']); + async getStates(userIds: string[], { country, includeNull }: GetStatesOptions): Promise { + const res = await this.getExifField('state', userIds, includeNull) + .$if(!!country, (qb) => qb.where('country', '=', country!)) + .execute(); - if (country) { - query.andWhere('exif.country = :country', { country }); - } - - const result = await query.getRawMany<{ state: string }>(); - - return result.map(({ state }) => state).filter((item) => item !== ''); + return res.map((row) => row['state']!); } @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) - async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.city', 'city') - .distinctOn(['exif.city']); + async getCities(userIds: string[], { country, state, includeNull }: GetCitiesOptions): Promise { + const res = await this.getExifField('city', userIds, includeNull) + .$if(!!country, (qb) => qb.where('country', '=', country!)) + .$if(!!state, (qb) => qb.where('state', '=', state!)) + .execute(); - if (country) { - query.andWhere('exif.country = :country', { country }); - } - - if (state) { - query.andWhere('exif.state = :state', { state }); - } - - const results = await query.getRawMany<{ city: string }>(); - - return results.map(({ city }) => city).filter((item) => item !== ''); + return res.map((row) => row['city']!); } @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getCameraMakes(userIds: string[], model: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.make', 'make') - .distinctOn(['exif.make']); + async getCameraMakes(userIds: string[], { model, includeNull }: GetCameraMakesOptions): Promise { + const res = await this.getExifField('make', userIds, includeNull) + .$if(!!model, (qb) => qb.where('model', '=', model!)) + .execute(); - if (model) { - query.andWhere('exif.model = :model', { model }); - } - - const results = await query.getRawMany<{ make: string }>(); - return results.map(({ make }) => make).filter((item) => item !== ''); + return res.map((row) => row['make']!); } @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getCameraModels(userIds: string[], make: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.model', 'model') - .distinctOn(['exif.model']); + async getCameraModels(userIds: string[], { make, includeNull }: GetCameraModelsOptions): Promise { + const res = await this.getExifField('model', userIds, includeNull) + .$if(!!make, (qb) => qb.where('make', '=', make!)) + .execute(); - if (make) { - query.andWhere('exif.make = :make', { make }); - } - - const results = await query.getRawMany<{ model: string }>(); - return results.map(({ model }) => model).filter((item) => item !== ''); + return res.map((row) => row['model']!); } - private getRuntimeConfig(numResults?: number): string { - if (this.vectorExtension === DatabaseExtension.VECTOR) { - return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall - } - - let runtimeConfig = 'SET LOCAL vectors.enable_prefilter=on; SET LOCAL vectors.search_mode=vbase;'; - if (numResults) { - runtimeConfig += ` SET LOCAL vectors.hnsw_ef_search = ${numResults};`; - } - - return runtimeConfig; + private getExifField( + field: K, + userIds: string[], + includeNull?: boolean, + ) { + return this.db + .selectFrom('exif') + .select(field) + .distinctOn(field) + .innerJoin('assets', 'assets.id', 'exif.assetId') + .where('ownerId', '=', anyUuid(userIds)) + .where('isVisible', '=', true) + .where('deletedAt', 'is', null) + .where((eb) => + // kysely gets confused by the generic type here + includeNull === false ? eb(field, '!=', '' as any) : eb(eb(field, '!=', '' as any), 'is not', false), + ); } } - -// the performance difference between this and the normal way is too huge to ignore, e.g. 3s vs 4ms -const assetsByCityCte = ` -WITH RECURSIVE cte AS ( - ( - SELECT city, "assetId" - FROM exif - INNER JOIN assets ON exif."assetId" = assets.id - WHERE "ownerId" = ANY($1::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4 - ORDER BY city - LIMIT 1 - ) - - UNION ALL - - SELECT l.city, l."assetId" - FROM cte c - , LATERAL ( - SELECT city, "assetId" - FROM exif - INNER JOIN assets ON exif."assetId" = assets.id - WHERE city > c.city AND "ownerId" = ANY($1::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4 - ORDER BY city - LIMIT 1 - ) l -) -`; diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index 3645e3638a..7f0c067bfd 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -1,48 +1,44 @@ -import { InjectRepository } from '@nestjs/typeorm'; -import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetEntity } from 'src/entities/asset.entity'; +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DB } from 'src/db'; +import { AssetEntity, withExif } from 'src/entities/asset.entity'; import { IViewRepository } from 'src/interfaces/view.interface'; -import { Brackets, Repository } from 'typeorm'; +import { asUuid } from 'src/utils/database'; export class ViewRepository implements IViewRepository { - constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} + constructor(@InjectKysely() private db: Kysely) {} async getUniqueOriginalPaths(userId: string): Promise { - const results = await this.assetRepository - .createQueryBuilder('asset') - .where({ - isVisible: true, - isArchived: false, - ownerId: userId, - }) - .select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath') - .getRawMany(); + const results = await this.db + .selectFrom('assets') + .select((eb) => eb.fn('substring', ['assets.originalPath', eb.val('^(.*/)[^/]*$')]).as('directoryPath')) + .distinct() + .where('ownerId', '=', asUuid(userId)) + .where('isVisible', '=', true) + .where('isArchived', '=', false) + .where('deletedAt', 'is', null) + .execute(); - return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); + return results.map((row) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) async getAssetsByOriginalPath(userId: string, partialPath: string): Promise { const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, ''); - const assets = await this.assetRepository - .createQueryBuilder('asset') - .where({ - isVisible: true, - isArchived: false, - ownerId: userId, - }) - .leftJoinAndSelect('asset.exifInfo', 'exifInfo') - .andWhere( - new Brackets((qb) => { - qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere( - 'asset.originalPath NOT LIKE :notLikePath', - { notLikePath: `%${normalizedPath}/%/%` }, - ); - }), - ) - .orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC') - .getMany(); - return assets; + return this.db + .selectFrom('assets') + .selectAll('assets') + .$call((qb) => withExif(qb)) + .where('ownerId', '=', asUuid(userId)) + .where('isVisible', '=', true) + .where('isArchived', '=', false) + .where('deletedAt', 'is', null) + .where('originalPath', 'like', `%${normalizedPath}/%`) + .where('originalPath', 'not like', `%${normalizedPath}/%/%`) + .orderBy( + (eb) => eb.fn('regexp_replace', ['assets.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]), + 'asc', + ) + .execute() as any as Promise; } } diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index c269739935..d3b62d6420 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -479,7 +479,6 @@ describe(AssetMediaService.name, () => { it('should throw an error if the asset is not found', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getById.mockResolvedValue(null); await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); @@ -666,8 +665,6 @@ describe(AssetMediaService.name, () => { describe('replaceAsset', () => { it('should error when update photo does not exist', async () => { - assetMock.getById.mockResolvedValueOnce(null); - await expect(sut.replaceAsset(authStub.user1, 'id', replaceDto, fileStub.photo)).rejects.toThrow( 'Not found or no asset.update access', ); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 70f4905de3..ae6f0a99da 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -29,7 +29,6 @@ import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; -import { QueryFailedError } from 'typeorm'; export interface UploadRequest { auth: AuthDto | null; fieldName: UploadFieldName; @@ -289,7 +288,7 @@ export class AssetMediaService extends BaseService { }); // handle duplicates with a success response - if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) { + if (error.constraint_name === ASSET_CHECKSUM_CONSTRAINT) { const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum); if (!duplicateId) { this.logger.error(`Error locating duplicate for checksum constraint`); @@ -330,7 +329,7 @@ export class AssetMediaService extends BaseService { localDateTime: dto.fileCreatedAt, duration: dto.duration || null, - livePhotoVideo: null, + livePhotoVideoId: null, sidecarPath: sidecarPath || null, }); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 9063df9dc2..7ba748ea2c 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -51,9 +51,7 @@ describe(AssetService.name, () => { }); const mockGetById = (assets: AssetEntity[]) => { - assetMock.getById.mockImplementation((assetId) => - Promise.resolve(assets.find((asset) => asset.id === assetId) ?? null), - ); + assetMock.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId))); }; beforeEach(() => { @@ -80,7 +78,20 @@ describe(AssetService.name, () => { const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) }; partnerMock.getAll.mockResolvedValue([]); - assetMock.getByDayOfYear.mockResolvedValue([image1, image2, image3, image4]); + assetMock.getByDayOfYear.mockResolvedValue([ + { + yearsAgo: 1, + assets: [image1, image2], + }, + { + yearsAgo: 9, + assets: [image3], + }, + { + yearsAgo: 15, + assets: [image4], + }, + ]); await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([ { yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] }, @@ -237,14 +248,20 @@ describe(AssetService.name, () => { it('should update the asset', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); assetMock.getById.mockResolvedValue(assetStub.image); + assetMock.update.mockResolvedValue(assetStub.image); + await sut.update(authStub.admin, 'asset-1', { isFavorite: true }); + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true }); }); it('should update the exif description', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); assetMock.getById.mockResolvedValue(assetStub.image); + assetMock.update.mockResolvedValue(assetStub.image); + await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); + expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' }); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 98d6ec00f6..b0a6a5f24c 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -44,27 +44,11 @@ export class AssetService extends BaseService { const userIds = [auth.user.id, ...partnerIds]; const assets = await this.assetRepository.getByDayOfYear(userIds, dto); - const assetsWithThumbnails = assets.filter(({ files }) => !!getAssetFiles(files).thumbnailFile); - const groups: Record = {}; - const currentYear = new Date().getFullYear(); - for (const asset of assetsWithThumbnails) { - const yearsAgo = currentYear - asset.localDateTime.getFullYear(); - if (!groups[yearsAgo]) { - groups[yearsAgo] = []; - } - groups[yearsAgo].push(asset); - } - - return Object.keys(groups) - .map(Number) - .sort((a, b) => a - b) - .filter((yearsAgo) => yearsAgo > 0) - .map((yearsAgo) => ({ - yearsAgo, - // TODO move this to clients - title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, - assets: groups[yearsAgo].map((asset) => mapAsset(asset, { auth })), - })); + return assets.map(({ yearsAgo, assets }) => ({ + yearsAgo, + title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, + assets: assets.map((a) => mapAsset(a, { auth })), + })); } async getStatistics(auth: AuthDto, dto: AssetStatsDto) { @@ -89,29 +73,13 @@ export class AssetService extends BaseService { async get(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [id] }); - const asset = await this.assetRepository.getById( - id, - { - exifInfo: true, - sharedLinks: true, - tags: true, - owner: true, - faces: { - person: true, - }, - stack: { - assets: { - exifInfo: true, - }, - }, - files: true, - }, - { - faces: { - boundingBoxX1: 'ASC', - }, - }, - ); + const asset = await this.assetRepository.getById(id, { + exifInfo: true, + owner: true, + faces: { person: true }, + stack: { assets: true }, + tags: true, + }); if (!asset) { throw new BadRequestException('Asset not found'); @@ -152,22 +120,12 @@ export class AssetService extends BaseService { await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); - await this.assetRepository.update({ id, ...rest }); + const asset = await this.assetRepository.update({ id, ...rest }); if (previousMotion) { await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id }); } - const asset = await this.assetRepository.getById(id, { - exifInfo: true, - owner: true, - tags: true, - faces: { - person: true, - }, - files: true, - }); - if (!asset) { throw new BadRequestException('Asset not found'); } @@ -217,9 +175,7 @@ export class AssetService extends BaseService { const { id, deleteOnDisk } = job; const asset = await this.assetRepository.getById(id, { - faces: { - person: true, - }, + faces: { person: true }, library: true, stack: { assets: true }, exifInfo: true, diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index daa7d180f1..5ad7aa5a77 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -75,10 +75,8 @@ export class BackupService extends BaseService { @OnJob({ name: JobName.BACKUP_DATABASE, queue: QueueName.BACKUP_DATABASE }) async handleBackupDatabase(): Promise { this.logger.debug(`Database Backup Started`); - - const { - database: { config }, - } = this.configRepository.getEnv(); + const { database } = this.configRepository.getEnv(); + const config = database.config.typeorm; const isUrlConnection = config.connectionType === 'url'; diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index b1a270abd8..9107a9499d 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -113,6 +113,7 @@ export class DatabaseService extends BaseService { if (!database.skipMigrations) { await this.databaseRepository.runMigrations(); } + await this.databaseRepository.init(); }); } diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 75af1ef6f1..c954d81a74 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -31,7 +31,12 @@ describe(SearchService.name, () => { describe('getDuplicates', () => { it('should get duplicates', async () => { - assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe, assetStub.hasDupe]); + assetMock.getDuplicates.mockResolvedValue([ + { + duplicateId: assetStub.hasDupe.duplicateId!, + assets: [assetStub.hasDupe, assetStub.hasDupe], + }, + ]); await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([ { duplicateId: assetStub.hasDupe.duplicateId, @@ -42,12 +47,6 @@ describe(SearchService.name, () => { }, ]); }); - - it('should update assets with duplicateId', async () => { - assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe]); - await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([]); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasDupe.id], { duplicateId: null }); - }); }); describe('handleQueueSearchDuplicates', () => { diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 0d91df5790..7e8ea49991 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { OnJob } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto'; +import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { WithoutProperty } from 'src/interfaces/asset.interface'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface'; @@ -15,25 +15,11 @@ import { usePagination } from 'src/utils/pagination'; @Injectable() export class DuplicateService extends BaseService { async getDuplicates(auth: AuthDto): Promise { - const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); - const uniqueAssetIds: string[] = []; - const duplicates = mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true }))).filter( - (duplicate) => { - if (duplicate.assets.length === 1) { - uniqueAssetIds.push(duplicate.assets[0].id); - return false; - } - return true; - }, - ); - if (uniqueAssetIds.length > 0) { - try { - await this.assetRepository.updateAll(uniqueAssetIds, { duplicateId: null }); - } catch (error: any) { - this.logger.error(`Failed to remove duplicateId from assets: ${error.message}`); - } - } - return duplicates; + const duplicates = await this.assetRepository.getDuplicates(auth.user.id); + return duplicates.map(({ duplicateId, assets }) => ({ + duplicateId, + assets: assets.map((asset) => mapAsset(asset, { auth })), + })); } @OnJob({ name: JobName.QUEUE_DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION }) diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index d8da095abf..b578f87c53 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1,16 +1,17 @@ import { Injectable } from '@nestjs/common'; import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; +import { Insertable } from 'kysely'; import _ from 'lodash'; import { Duration } from 'luxon'; import { constants } from 'node:fs/promises'; import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; +import { Exif } from 'src/db'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetType, ExifOrientation, ImmichWorker, SourceType } from 'src/enum'; import { WithoutProperty } from 'src/interfaces/asset.interface'; @@ -169,7 +170,7 @@ export class MetadataService extends BaseService { const { width, height } = this.getImageDimensions(exifTags); - const exifData: Partial = { + const exifData: Insertable = { assetId: asset.id, // dates diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index da4656be02..113e760ceb 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -728,11 +728,13 @@ describe(PersonService.name, () => { assetId: assetStub.image.id, facesRecognizedAt: expect.any(Date), }); - expect(assetMock.upsertJobStatus.mock.calls[0][0].facesRecognizedAt?.getTime()).toBeGreaterThan(start); + const facesRecognizedAt = assetMock.upsertJobStatus.mock.calls[0][0].facesRecognizedAt as Date; + expect(facesRecognizedAt.getTime()).toBeGreaterThan(start); }); it('should create a face with no person and queue recognition job', async () => { machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); + searchMock.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleDetectFaces({ id: assetStub.image.id }); @@ -840,10 +842,10 @@ describe(PersonService.name, () => { } const faces = [ - { face: faceStub.noPerson1, distance: 0 }, - { face: faceStub.primaryFace1, distance: 0.2 }, - { face: faceStub.noPerson2, distance: 0.3 }, - { face: faceStub.face1, distance: 0.4 }, + { ...faceStub.noPerson1, distance: 0 }, + { ...faceStub.primaryFace1, distance: 0.2 }, + { ...faceStub.noPerson2, distance: 0.3 }, + { ...faceStub.face1, distance: 0.4 }, ] as FaceSearchResult[]; systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); @@ -867,8 +869,8 @@ describe(PersonService.name, () => { it('should create a new person if the face is a core point with no person', async () => { const faces = [ - { face: faceStub.noPerson1, distance: 0 }, - { face: faceStub.noPerson2, distance: 0.3 }, + { ...faceStub.noPerson1, distance: 0 }, + { ...faceStub.noPerson2, distance: 0.3 }, ] as FaceSearchResult[]; systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); @@ -889,7 +891,7 @@ describe(PersonService.name, () => { }); it('should not queue face with no matches', async () => { - const faces = [{ face: faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; + const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); @@ -905,8 +907,8 @@ describe(PersonService.name, () => { it('should defer non-core faces to end of queue', async () => { const faces = [ - { face: faceStub.noPerson1, distance: 0 }, - { face: faceStub.noPerson2, distance: 0.4 }, + { ...faceStub.noPerson1, distance: 0 }, + { ...faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); @@ -927,8 +929,8 @@ describe(PersonService.name, () => { it('should not assign person to deferred non-core face with no matching person', async () => { const faces = [ - { face: faceStub.noPerson1, distance: 0 }, - { face: faceStub.noPerson2, distance: 0.4 }, + { ...faceStub.noPerson1, distance: 0 }, + { ...faceStub.noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 5b6e721eab..208b30b55c 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -249,14 +249,14 @@ export class PersonService extends BaseService { } const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { - return force === false - ? this.assetRepository.getWithout(pagination, WithoutProperty.FACES) - : this.assetRepository.getAll(pagination, { - orderDirection: 'DESC', + return force + ? this.assetRepository.getAll(pagination, { + orderDirection: 'desc', withFaces: true, withArchived: true, isVisible: true, - }); + }) + : this.assetRepository.getWithout(pagination, WithoutProperty.FACES); }); for await (const assets of assetPagination) { @@ -279,13 +279,7 @@ export class PersonService extends BaseService { return JobStatus.SKIPPED; } - const relations = { - exifInfo: true, - faces: { - person: false, - }, - files: true, - }; + const relations = { exifInfo: true, faces: { person: false }, files: true }; const [asset] = await this.assetRepository.getByIds([id], relations); const { previewFile } = getAssetFiles(asset.files); if (!asset || !previewFile) { @@ -482,7 +476,7 @@ export class PersonService extends BaseService { return JobStatus.SKIPPED; } - let personId = matches.find((match) => match.face.personId)?.face.personId; + let personId = matches.find((match) => match.personId)?.personId; if (!personId) { const matchWithPerson = await this.searchRepository.searchFaces({ userIds: [face.asset.ownerId], @@ -493,7 +487,7 @@ export class PersonService extends BaseService { }); if (matchWithPerson.length > 0) { - personId = matchWithPerson[0].face.personId; + personId = matchWithPerson[0].personId; } } diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 04d3addb63..7be09f3565 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -34,16 +34,10 @@ export class SearchService extends BaseService { async getExploreData(auth: AuthDto): Promise[]> { const options = { maxFields: 12, minAssetsPerField: 5 }; - const result = await this.assetRepository.getAssetIdByCity(auth.user.id, options); - const results = [result]; - const assetIds = new Set(results.flatMap((field) => field.items.map((item) => item.data))); - const assets = await this.assetRepository.getByIdsWithAllRelations([...assetIds]); - const assetMap = new Map(assets.map((asset) => [asset.id, mapAsset(asset)])); - - return results.map(({ fieldName, items }) => ({ - fieldName, - items: items.map(({ value, data }) => ({ value, data: assetMap.get(data) as AssetResponseDto })), - })); + const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options); + const assets = await this.assetRepository.getByIdsWithAllRelations(cities.items.map(({ data }) => data)); + const items = assets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) })); + return [{ fieldName: cities.fieldName, items }]; } async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise { @@ -57,14 +51,13 @@ export class SearchService extends BaseService { const page = dto.page ?? 1; const size = dto.size || 250; - const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; const { hasNextPage, items } = await this.searchRepository.searchMetadata( { page, size }, { ...dto, checksum, userIds, - orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC', + orderDirection: dto.order ?? AssetOrder.DESC, }, ); @@ -112,22 +105,25 @@ export class SearchService extends BaseService { return results.filter((result) => (dto.includeNull ? true : result !== null)); } - private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) { - switch (dto.type) { + private getSuggestions( + userIds: string[], + { type, country, state, model, make, includeNull }: SearchSuggestionRequestDto, + ) { + switch (type) { case SearchSuggestionType.COUNTRY: { - return this.searchRepository.getCountries(userIds); + return this.searchRepository.getCountries(userIds, { includeNull }); } case SearchSuggestionType.STATE: { - return this.searchRepository.getStates(userIds, dto.country); + return this.searchRepository.getStates(userIds, { country, includeNull }); } case SearchSuggestionType.CITY: { - return this.searchRepository.getCities(userIds, dto.country, dto.state); + return this.searchRepository.getCities(userIds, { country, state, includeNull }); } case SearchSuggestionType.CAMERA_MAKE: { - return this.searchRepository.getCameraMakes(userIds, dto.model); + return this.searchRepository.getCameraMakes(userIds, { model, includeNull }); } case SearchSuggestionType.CAMERA_MODEL: { - return this.searchRepository.getCameraModels(userIds, dto.make); + return this.searchRepository.getCameraModels(userIds, { make, includeNull }); } default: { return []; diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index db6890c27b..1265561a7d 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -3,6 +3,7 @@ import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface import { TimelineService } from 'src/services/timeline.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; +import { partnerStub } from 'test/fixtures/partner.stub'; import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; @@ -47,6 +48,7 @@ describe(TimelineService.name, () => { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id', + userIds: [authStub.admin.user.id], }); }); @@ -61,12 +63,15 @@ describe(TimelineService.name, () => { userId: authStub.admin.user.id, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - userIds: [authStub.admin.user.id], - }); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith( + 'bucket', + expect.objectContaining({ + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + userIds: [authStub.admin.user.id], + }), + ); }); it('should include partner shared assets', async () => { @@ -143,11 +148,14 @@ describe(TimelineService.name, () => { userId: authStub.admin.user.id, }), ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - userIds: [authStub.admin.user.id], - }); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith( + 'bucket', + expect.objectContaining({ + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userIds: [authStub.admin.user.id], + }), + ); }); it('should throw an error if withParners is true and isArchived true or undefined', async () => { diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index ad2198b38c..d0d7ad5967 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,8 +1,8 @@ -import _ from 'lodash'; -import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetSearchBuilderOptions } from 'src/interfaces/search.interface'; -import { Between, IsNull, LessThanOrEqual, MoreThanOrEqual, Not, SelectQueryBuilder } from 'typeorm'; +import { Expression, Kysely, RawBuilder, sql, TableMetadata, ValueExpression } from 'kysely'; +import { InsertObject } from 'node_modules/kysely/dist/cjs'; +import { InsertObjectOrList } from 'node_modules/kysely/dist/cjs/parser/insert-values-parser'; +import { DB } from 'src/db'; +import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; /** * Allows optional values unlike the regular Between and uses MoreThanOrEqual @@ -18,131 +18,62 @@ export function OptionalBetween(from?: T, to?: T) { } } -export const asVector = (embedding: number[], quote = false) => - quote ? `'[${embedding.join(',')}]'` : `[${embedding.join(',')}]`; +const UPSERT_COLUMNS = {} as { [K in keyof DB]?: Partial<{ [C in keyof DB[K]]: ValueExpression }> }; -export function searchAssetBuilder( - builder: SelectQueryBuilder, - options: AssetSearchBuilderOptions, -): SelectQueryBuilder { - builder.andWhere( - _.omitBy( - { - createdAt: OptionalBetween(options.createdAfter, options.createdBefore), - updatedAt: OptionalBetween(options.updatedAfter, options.updatedBefore), - deletedAt: OptionalBetween(options.trashedAfter, options.trashedBefore), - fileCreatedAt: OptionalBetween(options.takenAfter, options.takenBefore), - }, - _.isUndefined, - ), - ); - - const exifInfo = _.omitBy(_.pick(options, ['city', 'country', 'lensModel', 'make', 'model', 'state']), _.isUndefined); - const hasExifQuery = Object.keys(exifInfo).length > 0; - - if (options.withExif && !hasExifQuery) { - builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo'); +const getUpsertColumns = (table: TableMetadata & { name: K }) => { + if (!(table.name in UPSERT_COLUMNS)) { + UPSERT_COLUMNS[table.name] = Object.fromEntries( + table.columns.map((column) => [column.name, sql`excluded.${sql.ref(column.name)}`]), + ) as Partial<{ [C in keyof DB[K]]: RawBuilder }>; } - if (hasExifQuery) { - if (options.withExif) { - builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo'); - } else { - builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); - } + return UPSERT_COLUMNS[table.name]!; +}; - for (const [key, value] of Object.entries(exifInfo)) { - if (value === null) { - builder.andWhere(`exifInfo.${key} IS NULL`); - } else { - builder.andWhere(`exifInfo.${key} = :${key}`, { [key]: value }); - } +const mapUpsertColumns = ( + columns: Record>, + entries: InsertObjectOrList, + conflictKeys: (keyof DB[T])[], +) => { + const entry: InsertObject = Array.isArray(entries) ? entries[0] : entries; + const upsertColumns: Partial>> = {}; + for (const entryColumn in entry) { + if (!conflictKeys.includes(entryColumn as keyof DB[T])) { + upsertColumns[entryColumn as keyof typeof entry] = columns[entryColumn as keyof DB[T]]; } } - const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']); + return upsertColumns as Expand>>; +}; - if (id.libraryId === null) { - id.libraryId = IsNull() as unknown as string; - } +export const upsertHelper = ( + db: Kysely, + table: TableMetadata & { name: T }, + values: InsertObjectOrList, + conflictKeys: (string & keyof DB[T])[], +) => + db + .insertInto(table.name) + .values(values) + .onConflict((oc) => + oc.columns(conflictKeys).doUpdateSet(() => mapUpsertColumns(getUpsertColumns(table), values, conflictKeys)), + ); - builder.andWhere(_.omitBy(id, _.isUndefined)); +export const asUuid = (id: string | Expression) => sql`${id}::uuid`; - if (options.userIds) { - builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds }); - } +export const anyUuid = (ids: string[]) => sql`any(${`{${ids}}`}::uuid[])`; - const path = _.pick(options, ['encodedVideoPath', 'originalPath']); - builder.andWhere(_.omitBy(path, _.isUndefined)); +export const asVector = (embedding: number[]) => sql`${`[${embedding}]`}::vector`; - if (options.originalFileName) { - builder.andWhere(`f_unaccent(${builder.alias}.originalFileName) ILIKE f_unaccent(:originalFileName)`, { - originalFileName: `%${options.originalFileName}%`, - }); - } +/** + * Mainly for type debugging to make VS Code display a more useful tooltip. + * Source: https://stackoverflow.com/a/69288824 + */ +export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; - const status = _.pick(options, ['isFavorite', 'isVisible', 'type']); - const { - isArchived, - isEncoded, - isMotion, - withArchived, - isNotInAlbum, - withFaces, - withPeople, - personIds, - withStacked, - trashedAfter, - trashedBefore, - } = options; - builder.andWhere( - _.omitBy( - { - ...status, - isArchived: isArchived ?? (withArchived ? undefined : false), - encodedVideoPath: isEncoded ? Not(IsNull()) : undefined, - livePhotoVideoId: isMotion ? Not(IsNull()) : undefined, - }, - _.isUndefined, - ), - ); - - if (isNotInAlbum) { - builder - .leftJoin(`${builder.alias}.albums`, 'albums') - .andWhere('albums.id IS NULL') - .andWhere(`${builder.alias}.isVisible = true`); - } - - if (withFaces || withPeople) { - builder.leftJoinAndSelect(`${builder.alias}.faces`, 'faces'); - } - - if (withPeople) { - builder.leftJoinAndSelect('faces.person', 'person'); - } - - if (personIds && personIds.length > 0) { - const cte = builder - .createQueryBuilder() - .select('faces."assetId"') - .from(AssetFaceEntity, 'faces') - .where('faces."personId" IN (:...personIds)', { personIds }) - .groupBy(`faces."assetId"`) - .having(`COUNT(DISTINCT faces."personId") = :personCount`, { personCount: personIds.length }); - builder.addCommonTableExpression(cte, 'face_ids').innerJoin('face_ids', 'a', 'a."assetId" = asset.id'); - - builder.getQuery(); // typeorm mixes up parameters without this (੭ °ཀ°)੭ - } - - if (withStacked) { - builder.leftJoinAndSelect(`${builder.alias}.stack`, 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets'); - } - - const withDeleted = options.withDeleted ?? (trashedAfter !== undefined || trashedBefore !== undefined); - if (withDeleted) { - builder.withDeleted(); - } - - return builder; -} +/** Recursive version of {@link Expand} from the same source. */ +export type ExpandRecursively = T extends object + ? T extends infer O + ? { [K in keyof O]: ExpandRecursively } + : never + : T; diff --git a/server/src/utils/pagination.ts b/server/src/utils/pagination.ts index 4009f219c1..4f1bd1a7f8 100644 --- a/server/src/utils/pagination.ts +++ b/server/src/utils/pagination.ts @@ -33,7 +33,10 @@ export async function* usePagination( } } -function paginationHelper(items: Entity[], take: number): PaginationResult { +export function paginationHelper( + items: Entity[], + take: number, +): PaginationResult { const hasNextPage = items.length > take; items.splice(take); diff --git a/server/tsconfig.json b/server/tsconfig.json index 1ffc110e83..8d8d12c54e 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -19,7 +19,8 @@ "preserveWatchOutput": true, "baseUrl": "./", "jsx": "react", - "types": ["vitest/globals"] + "types": ["vitest/globals"], + "noErrorTruncation": true }, "exclude": ["dist", "node_modules", "upload"] }