fix face embedding insert
This commit is contained in:
mertalev 2024-03-17 15:30:52 -04:00
parent de0fd06f43
commit 1b091dbf2d
No known key found for this signature in database
GPG Key ID: 9181CD92C0A1C5E3
31 changed files with 2081 additions and 1229 deletions

View File

@ -954,8 +954,6 @@ describe('/asset', () => {
exifImageHeight: 1080,
exifImageWidth: 1617,
fileSizeInByte: 862_424,
latitude: null,
longitude: null,
},
},
},
@ -968,8 +966,6 @@ describe('/asset', () => {
dateTimeOriginal: '2012-08-05T11:39:59.000Z',
exifImageWidth: 512,
exifImageHeight: 341,
latitude: null,
longitude: null,
focalLength: 75,
iso: 200,
fNumber: 11,
@ -977,7 +973,6 @@ describe('/asset', () => {
fileSizeInByte: 53_493,
make: 'SONY',
model: 'DSLR-A550',
orientation: null,
description: 'SONY DSC',
},
},
@ -992,8 +987,6 @@ describe('/asset', () => {
exifImageHeight: 1080,
exifImageWidth: 1440,
fileSizeInByte: 1_780_777,
latitude: null,
longitude: null,
},
},
},
@ -1029,8 +1022,6 @@ describe('/asset', () => {
exifInfo: {
exifImageWidth: 800,
exifImageHeight: 800,
latitude: null,
longitude: null,
fileSizeInByte: 25_408,
},
},
@ -1050,8 +1041,6 @@ describe('/asset', () => {
iso: 100,
fileSizeInByte: 9_057_784,
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
@ -1071,8 +1060,6 @@ describe('/asset', () => {
iso: 200,
fileSizeInByte: 15_856_335,
dateTimeOriginal: '2016-09-22T22:10:29.060Z',
latitude: null,
longitude: null,
orientation: '1',
timeZone: 'UTC-5',
},
@ -1095,8 +1082,6 @@ describe('/asset', () => {
iso: 400,
fileSizeInByte: 19_587_072,
dateTimeOriginal: '2018-05-10T08:42:37.842Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
@ -1119,8 +1104,6 @@ describe('/asset', () => {
lensModel: 'E PZ 18-105mm F4 G OSS',
fileSizeInByte: 25_001_984,
dateTimeOriginal: '2016-09-27T10:51:44.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
@ -1143,8 +1126,6 @@ describe('/asset', () => {
lensModel: 'E 25mm F2',
fileSizeInByte: 49_512_448,
dateTimeOriginal: '2016-01-08T14:08:01.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},

View File

@ -475,7 +475,6 @@ describe('/search', () => {
expect(status).toBe(200);
expect(body).toEqual([
{ fieldName: 'exifInfo.city', items: [] },
{ fieldName: 'smartInfo.tags', items: [] },
]);
});
});

View File

@ -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([]);

View File

@ -8348,6 +8348,7 @@
"type": "string"
},
"AssetOrder": {
"default": "desc",
"enum": [
"asc",
"desc"

495
server/package-lock.json generated
View File

@ -43,10 +43,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",
@ -98,6 +101,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",
"prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^4.0.0",
@ -9923,6 +9927,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",
@ -10382,6 +10482,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",
@ -10805,6 +10914,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",
@ -10956,6 +11159,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",
@ -11119,11 +11335,11 @@
}
},
"node_modules/micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dependencies": {
"braces": "^3.0.2",
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
@ -11516,6 +11732,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",
@ -12413,6 +12640,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",
@ -13396,6 +13636,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",
@ -14060,6 +14312,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",
@ -22855,6 +23154,83 @@
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"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",
@ -23181,6 +23557,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",
@ -23484,6 +23866,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",
@ -23607,6 +24026,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",
@ -23733,11 +24158,11 @@
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="
},
"micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"requires": {
"braces": "^3.0.2",
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"dependencies": {
@ -24033,6 +24458,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",
@ -24632,6 +25063,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",
@ -25244,6 +25681,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",
@ -25735,6 +26181,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",

View File

@ -69,10 +69,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",
"prettier": "^3.0.2",
"prettier-plugin-organize-imports": "^4.0.0",

View File

@ -7,11 +7,12 @@ import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import _ from 'lodash';
import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
import { commands } from 'src/commands';
import { bullConfig, bullQueues, clsConfig, immichAppConfig } from 'src/config';
import { controllers } from 'src/controllers';
import { databaseConfig } from 'src/database.config';
import { databaseConfig, kyselyConfig } from 'src/database.config';
import { entities } from 'src/entities';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -56,6 +57,7 @@ const imports = [
},
}),
TypeOrmModule.forFeature(entities),
KyselyModule.forRoot(kyselyConfig),
];
@Module({

View File

@ -1,17 +1,17 @@
import { PostgresJSDialect } from 'kysely-postgres-js';
import postgres from 'postgres';
import { DatabaseExtension } from 'src/interfaces/database.interface';
import { DataSource } from 'typeorm';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
const url = process.env.DB_URL;
const urlOrParts = url
? { url }
: {
host: process.env.DB_HOSTNAME || 'database',
port: Number.parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_DATABASE_NAME || 'immich',
};
const parts = {
host: process.env.DB_HOSTNAME || 'database',
port: Number.parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_DATABASE_NAME || 'immich',
};
/* eslint unicorn/prefer-module: "off" -- We can fix this when migrating to ESM*/
export const databaseConfig: PostgresConnectionOptions = {
@ -23,9 +23,25 @@ export const databaseConfig: PostgresConnectionOptions = {
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...urlOrParts,
...(url ? { url } : parts),
};
const driverOptions = {
max: 20,
types: {
bigint: {
to: 20,
from: [20],
parse: (value: string) => Number.parseInt(value),
serialize: (value: number) => value.toString(),
},
},
};
const dialect = new PostgresJSDialect({
postgres: url ? postgres(url, driverOptions) : postgres({ ...parts, ...driverOptions }),
});
export const kyselyConfig = { dialect, log: ['query', 'error'] as const };
/**
* @deprecated - DO NOT USE THIS
*

437
server/src/db.d.ts vendored Normal file
View File

@ -0,0 +1,437 @@
/**
* This file was generated by kysely-codegen.
* Please do not edit it manually.
*/
import type { ColumnType } from "kysely";
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[]
? U[]
: ArrayTypeImpl<T>;
export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S[], I[], U[]>
: T[];
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
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 Timestamp = ColumnType<Date, Date | string, Date | string>;
export interface Activity {
albumId: string;
assetId: string | null;
comment: string | null;
createdAt: Generated<Timestamp>;
id: Generated<string>;
isLiked: Generated<boolean>;
updatedAt: Generated<Timestamp>;
userId: string;
}
export interface Albums {
albumName: Generated<string>;
/**
* Asset ID to be used as thumbnail
*/
albumThumbnailAssetId: string | null;
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
description: Generated<string>;
id: Generated<string>;
isActivityEnabled: Generated<boolean>;
order: Generated<string>;
ownerId: string;
updatedAt: Generated<Timestamp>;
}
export interface AlbumsAssetsAssets {
albumsId: string;
assetsId: string;
}
export interface AlbumsSharedUsersUsers {
albumsId: string;
role: Generated<string>;
usersId: string;
}
export interface ApiKeys {
createdAt: Generated<Timestamp>;
id: Generated<string>;
key: string;
name: string;
permissions: string[];
updatedAt: Generated<Timestamp>;
userId: string;
}
export interface AssetFaces {
assetId: string;
boundingBoxX1: Generated<number>;
boundingBoxX2: Generated<number>;
boundingBoxY1: Generated<number>;
boundingBoxY2: Generated<number>;
id: Generated<string>;
imageHeight: Generated<number>;
imageWidth: Generated<number>;
personId: string | null;
}
export interface AssetFiles {
assetId: string;
createdAt: Generated<Timestamp>;
id: Generated<string>;
path: string;
type: string;
updatedAt: Generated<Timestamp>;
}
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<Timestamp>;
deletedAt: Timestamp | null;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;
duration: string | null;
encodedVideoPath: Generated<string | null>;
fileCreatedAt: Timestamp;
fileModifiedAt: Timestamp;
id: Generated<string>;
isArchived: Generated<boolean>;
isExternal: Generated<boolean>;
isFavorite: Generated<boolean>;
isOffline: Generated<boolean>;
isVisible: Generated<boolean>;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Timestamp;
originalFileName: string;
originalPath: string;
ownerId: string;
sidecarPath: string | null;
stackId: string | null;
thumbhash: Buffer | null;
type: string;
updatedAt: Generated<Timestamp>;
}
export interface AssetStack {
id: Generated<string>;
ownerId: string;
primaryAssetId: string;
}
export interface Audit {
action: string;
createdAt: Generated<Timestamp>;
entityId: string;
entityType: string;
id: Generated<number>;
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<string>;
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<string | null>;
id: number;
latitude: number;
longitude: number;
modificationDate: Timestamp;
name: string;
}
export interface Libraries {
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
exclusionPatterns: string[];
id: Generated<string>;
importPaths: string[];
name: string;
ownerId: string;
refreshedAt: Timestamp | null;
updatedAt: Generated<Timestamp>;
}
export interface Memories {
createdAt: Generated<Timestamp>;
data: Json;
deletedAt: Timestamp | null;
id: Generated<string>;
isSaved: Generated<boolean>;
memoryAt: Timestamp;
ownerId: string;
seenAt: Timestamp | null;
type: string;
updatedAt: Generated<Timestamp>;
}
export interface MemoriesAssetsAssets {
assetsId: string;
memoriesId: string;
}
export interface Migrations {
id: Generated<number>;
name: string;
timestamp: Int8;
}
export interface MoveHistory {
entityId: string;
id: Generated<string>;
newPath: string;
oldPath: string;
pathType: string;
}
export interface NaturalearthCountries {
admin: string;
admin_a3: string;
coordinates: string;
id: Generated<number>;
type: string;
}
export interface Partners {
createdAt: Generated<Timestamp>;
inTimeline: Generated<boolean>;
sharedById: string;
sharedWithId: string;
updatedAt: Generated<Timestamp>;
}
export interface Person {
birthDate: Timestamp | null;
createdAt: Generated<Timestamp>;
faceAssetId: string | null;
id: Generated<string>;
isHidden: Generated<boolean>;
name: Generated<string>;
ownerId: string;
thumbnailPath: Generated<string>;
updatedAt: Generated<Timestamp>;
}
export interface Sessions {
createdAt: Generated<Timestamp>;
deviceOS: Generated<string>;
deviceType: Generated<string>;
id: Generated<string>;
token: string;
updatedAt: Generated<Timestamp>;
userId: string;
}
export interface SharedLinkAsset {
assetsId: string;
sharedLinksId: string;
}
export interface SharedLinks {
albumId: string | null;
allowDownload: Generated<boolean>;
allowUpload: Generated<boolean>;
createdAt: Generated<Timestamp>;
description: string | null;
expiresAt: Timestamp | null;
id: Generated<string>;
key: Buffer;
password: string | null;
showExif: Generated<boolean>;
type: string;
userId: string;
}
export interface SmartInfo {
assetId: string;
objects: string[] | null;
smartInfoTextSearchableColumn: Generated<string>;
tags: string[] | null;
}
export interface SmartSearch {
assetId: string;
embedding: string;
}
export interface SocketIoAttachments {
created_at: Generated<Timestamp | null>;
id: Generated<Int8>;
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<Timestamp>;
id: Generated<string>;
parentId: string | null;
updatedAt: Generated<Timestamp>;
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<Timestamp>;
deletedAt: Timestamp | null;
email: string;
id: Generated<string>;
isAdmin: Generated<boolean>;
name: Generated<string>;
oauthId: Generated<string>;
password: Generated<string>;
profileImagePath: Generated<string>;
quotaSizeInBytes: Int8 | null;
quotaUsageInBytes: Generated<Int8>;
shouldChangePassword: Generated<boolean>;
status: Generated<string>;
storageLabel: string | null;
updatedAt: Generated<Timestamp>;
}
export interface VectorsPgVectorIndexStat {
idx_growing: ArrayType<Int8> | null;
idx_indexing: boolean | null;
idx_options: string | null;
idx_sealed: ArrayType<Int8> | 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;
}

View File

@ -99,7 +99,7 @@ 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),
};
};

View File

@ -127,8 +127,8 @@ export class AssetStatsResponseDto {
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
return {
images: stats[AssetType.IMAGE],
videos: stats[AssetType.VIDEO],
total: Object.values(stats).reduce((total, value) => total + value, 0),
images: Number(stats[AssetType.IMAGE]),
videos: Number(stats[AssetType.VIDEO]),
total: Object.values(stats).map(Number).reduce((total, value) => total + value, 0),
};
};

View File

@ -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;
}

View File

@ -166,7 +166,7 @@ export class MetadataSearchDto extends BaseSearchDto {
@IsEnum(AssetOrder)
@Optional()
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder, default: AssetOrder.DESC })
order?: AssetOrder;
}

View File

@ -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[];
}

View File

@ -4,9 +4,8 @@ import { ExifEntity } from 'src/entities/exif.entity';
import { AssetFileType, AssetOrder, 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<AssetType, number>;
export type AssetStats = Record<AssetType, bigint>;
export interface AssetStatsOptions {
isFavorite?: boolean;
@ -69,22 +68,6 @@ export interface TimeBucketItem {
count: number;
}
export type AssetCreate = Pick<
AssetEntity,
| 'deviceAssetId'
| 'ownerId'
| 'libraryId'
| 'deviceId'
| 'type'
| 'originalPath'
| 'fileCreatedAt'
| 'localDateTime'
| 'fileModifiedAt'
| 'checksum'
| 'originalFileName'
> &
Partial<AssetEntity>;
export type AssetWithoutRelations = Omit<
AssetEntity,
| 'livePhotoVideo'
@ -100,10 +83,25 @@ export type AssetWithoutRelations = Omit<
| 'tags'
>;
type AssetUpdateWithoutRelations = Pick<AssetWithoutRelations, 'id'> & Partial<AssetWithoutRelations>;
type AssetUpdateWithLivePhotoRelation = Pick<AssetWithoutRelations, 'id'> & Pick<AssetEntity, 'livePhotoVideo'>;
export type AssetCreate = Pick<
AssetEntity,
| 'deviceAssetId'
| 'ownerId'
| 'libraryId'
| 'deviceId'
| 'type'
| 'originalPath'
| 'fileCreatedAt'
| 'localDateTime'
| 'fileModifiedAt'
| 'checksum'
| 'originalFileName'
> &
Partial<AssetWithoutRelations>;
export type AssetUpdateOptions = AssetUpdateWithoutRelations | AssetUpdateWithLivePhotoRelation;
type AssetUpdateWithoutRelations = Pick<AssetEntity, 'id'> & Partial<AssetWithoutRelations>;
export type AssetUpdateOptions = AssetUpdateWithoutRelations;
export type AssetUpdateAllOptions = Omit<Partial<AssetWithoutRelations>, 'id'>;
@ -142,32 +140,51 @@ export interface AssetUpdateDuplicateOptions {
duplicateIds: string[];
}
export interface AssetGetByChecksumOptions {
ownerId: string;
checksum: Buffer;
libraryId?: string;
}
export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
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 {
getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]>;
getUniqueOriginalPaths(userId: string): Promise<string[]>;
create(asset: AssetCreate): Promise<AssetEntity>;
getByIds(
ids: string[],
relations?: FindOptionsRelations<AssetEntity>,
select?: FindOptionsSelect<AssetEntity>,
): Promise<AssetEntity[]>;
getByIds(ids: string[], relations?: GetByIdsRelations): Promise<AssetEntity[]>;
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
getByChecksum(options: { ownerId: string; checksum: Buffer; libraryId?: string }): Promise<AssetEntity | null>;
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<DayOfYearAssets[]>;
getByChecksum(options: AssetGetByChecksumOptions): Promise<AssetEntity | undefined>;
getByChecksums(userId: string, checksums: Buffer[]): Promise<AssetEntity[]>;
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<string[]>;
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
getById(
id: string,
relations?: FindOptionsRelations<AssetEntity>,
order?: FindOptionsOrder<AssetEntity>,
): Promise<AssetEntity | null>;
getById(id: string, relations?: GetByIdsRelations): Promise<AssetEntity | undefined>;
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
getWith(
pagination: PaginationOptions,
@ -176,29 +193,28 @@ export interface IAssetRepository {
withDeleted?: boolean,
): Paginated<AssetEntity>;
getRandom(userIds: string[], count: number): Promise<AssetEntity[]>;
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | undefined>;
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | undefined>;
getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity>;
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | undefined>;
deleteAll(ownerId: string): Promise<void>;
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
getLivePhotoCount(motionId: string): Promise<number>;
updateAll(ids: string[], options: Partial<AssetUpdateAllOptions>): Promise<void>;
updateDuplicates(options: AssetUpdateDuplicateOptions): Promise<void>;
update(asset: AssetUpdateOptions): Promise<void>;
update(asset: AssetUpdateOptions): Promise<AssetEntity>;
remove(asset: AssetEntity): Promise<void>;
softDeleteAll(ids: string[]): Promise<void>;
restoreAll(ids: string[]): Promise<void>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | undefined>;
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
upsertJobStatus(...jobStatus: Partial<AssetJobStatusEntity>[]): Promise<void>;
getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
getDuplicates(options: AssetBuilderOptions): Promise<AssetEntity[]>;
getDuplicates(userId: string): Promise<DuplicateGroup[]>;
getAllForUserFullSync(options: AssetFullSyncOptions): Promise<AssetEntity[]>;
getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]>;
upsertFile(options: { assetId: string; type: AssetFileType; path: string }): Promise<void>;

View File

@ -114,7 +114,7 @@ export interface SearchPeopleOptions {
}
export interface SearchOrderOptions {
orderDirection?: 'ASC' | 'DESC';
orderDirection?: 'asc' | 'desc';
}
export interface SearchPaginationOptions {
@ -148,13 +148,13 @@ 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[];
}

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddTruncatedDate1713654632360 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN "truncatedDate" timestamptz
GENERATED ALWAYS AS (date_trunc('day', "localDateTime" at time zone 'UTC') at time zone 'UTC') STORED`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE assets DROP COLUMN "truncatedDate"`);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -90,7 +90,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
this.emitHandlers[event] = [];
}
this.emitHandlers[event].push(handler);
this.emitHandlers[event]!.push(handler);
}
async emit<T extends EmitEvent>(event: T, ...args: ArgsOf<T>): Promise<void> {

View File

@ -1,14 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { getVectorExtension } from 'src/database.config';
import { DeduplicateJoinsPlugin, Kysely, OrderByDirectionExpression, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { AssetType } from 'src/enum';
import { DatabaseExtension } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import {
AssetDuplicateResult,
@ -20,39 +15,21 @@ import {
SearchPaginationOptions,
SmartSearchOptions,
} from 'src/interfaces/search.interface';
import { asVector, searchAssetBuilder } from 'src/utils/database';
import { DB } from 'src/db';
import { anyUuid, asUuid, asVector, searchAssetBuilder } from 'src/utils/database';
import { Instrumentation } from 'src/utils/instrumentation';
import { Paginated, PaginationMode, PaginationResult, paginatedBuilder } from 'src/utils/pagination';
import { Paginated } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation';
import { Repository, SelectQueryBuilder } from 'typeorm';
@Instrumentation()
@Injectable()
export class SearchRepository implements ISearchRepository {
private faceColumns: string[];
private assetsByCityQuery: string;
joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
constructor(
@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
@InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository<GeodataPlacesEntity>,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@InjectKysely() private db: Kysely<DB>,
) {
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({
@ -69,23 +46,15 @@ export class SearchRepository implements ISearchRepository {
],
})
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
let builder = this.assetRepository.createQueryBuilder('asset');
builder = searchAssetBuilder(builder, options);
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
return paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.SKIP_TAKE,
skip: (pagination.page - 1) * pagination.size,
take: pagination.size,
});
}
private createPersonFilter(builder: SelectQueryBuilder<AssetFaceEntity>, personIds: string[]) {
return builder
.select(`${builder.alias}."assetId"`)
.where(`${builder.alias}."personId" IN (:...personIds)`, { personIds })
.groupBy(`${builder.alias}."assetId"`)
.having(`COUNT(DISTINCT ${builder.alias}."personId") = :personCount`, { personCount: personIds.length });
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({
@ -101,39 +70,24 @@ export class SearchRepository implements ISearchRepository {
},
],
})
async searchSmart(
pagination: SearchPaginationOptions,
{ embedding, userIds, personIds, ...options }: SmartSearchOptions,
): Paginated<AssetEntity> {
let results: PaginationResult<AssetEntity> = { items: [], hasNextPage: false };
async searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity> {
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');
let items: AssetEntity[] = [];
const builder = 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);
console.log(builder.compile());
if (personIds?.length) {
const assetFaceBuilder = manager.createQueryBuilder(AssetFaceEntity, 'asset_face');
const cte = this.createPersonFilter(assetFaceBuilder, personIds);
builder
.addCommonTableExpression(cte, 'asset_face_ids')
.innerJoin('asset_face_ids', 'a', 'a."assetId" = asset.id');
}
items = (await builder.execute()) as any as AssetEntity[];
builder = searchAssetBuilder(builder, options);
builder
.innerJoin('asset.smartSearch', 'search')
.andWhere('asset.ownerId IN (:...userIds )')
.orderBy('search.embedding <=> :embedding')
.setParameters({ userIds, embedding: asVector(embedding) });
await manager.query(this.getRuntimeConfig(pagination.size));
results = await paginatedBuilder<AssetEntity>(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({
@ -152,31 +106,29 @@ export class SearchRepository implements ISearchRepository {
type,
userIds,
}: AssetDuplicateSearch): Promise<AssetDuplicateResult[]> {
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<AssetDuplicateResult[]>;
const vector = asVector(embedding);
return this.db
.with('cte', (qb) =>
qb
.selectFrom('assets')
.select([
'assets.id as assetId',
'assets.duplicateId',
sql<number>`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({
@ -189,117 +141,136 @@ export class SearchRepository implements ISearchRepository {
},
],
})
async searchFaces({
searchFaces({
userIds,
embedding,
numResults,
maxDistance,
hasPerson,
}: FaceEmbeddingSearch): Promise<FaceSearchResult[]> {
if (!isValidInteger(numResults, { min: 1 })) {
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<AssetFaceEntity & { distance: number }> = [];
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([
(eb) => eb.fn.toJson(eb.table('asset_faces')).as('face'),
sql<number>`face_search.embedding <=> ${vector}`.as('distance'),
])
.innerJoin('assets', 'assets.id', 'asset_faces.assetId')
.innerJoin('face_search', 'asset_faces.id', 'face_search.faceId')
.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() as Promise<FaceSearchResult[]>;
}
@GenerateSql({ params: [DummyValue.STRING] })
async searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
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<GeodataPlacesEntity[]> {
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<GeodataPlacesEntity[]>;
}
@GenerateSql({ params: [[DummyValue.UUID]] })
async getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
const parameters = [userIds, true, false, AssetType.IMAGE];
const rawRes = await this.repository.query(this.assetsByCityQuery, parameters);
getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
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<string, any> } as Record<string, any>;
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<AssetEntity[]>;
}
async upsert(assetId: string, embedding: number[]): Promise<void> {
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<number> {
const res = await this.smartSearchRepository.manager.query(`
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'`);
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`);
}
@ -311,54 +282,14 @@ 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 smart_search`.execute(trx);
await sql`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${sql.lit(dimSize)})`.execute(trx);
await sql`REINDEX INDEX clip_index`.execute(trx);
});
}
async deleteAllSearchEmbeddings(): Promise<void> {
return this.smartSearchRepository.clear();
}
private getRuntimeConfig(numResults?: number): string {
if (getVectorExtension() === 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;
await sql`TRUNCATE ${sql.table('smart_search')}`.execute(this.db);
}
}
// 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
)
`;

View File

@ -359,7 +359,7 @@ export class AssetMediaService {
localDateTime: dto.fileCreatedAt,
duration: dto.duration || null,
livePhotoVideo: null,
livePhotoVideoId: null,
sidecarPath: sidecarPath || null,
});

View File

@ -219,14 +219,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' });
});

View File

@ -69,27 +69,11 @@ export class AssetService {
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<number, AssetEntity[]> = {};
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) {
@ -114,30 +98,12 @@ export class AssetService {
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [id] });
const asset = await this.assetRepository.getById(
id,
{
exifInfo: true,
tags: true,
sharedLinks: true,
smartInfo: 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 },
});
if (!asset) {
throw new BadRequestException('Asset not found');
@ -178,22 +144,12 @@ export class AssetService {
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,
smartInfo: true,
tags: true,
faces: {
person: true,
},
files: true,
});
if (!asset) {
throw new BadRequestException('Asset not found');
@ -242,9 +198,7 @@ export class AssetService {
const { id, deleteOnDisk } = job;
const asset = await this.assetRepository.getById(id, {
faces: {
person: true,
},
faces: { person: true },
library: true,
stack: { assets: true },
exifInfo: true,

View File

@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { SystemConfigCore } from 'src/cores/system-config.core';
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 { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@ -38,9 +38,11 @@ export class DuplicateService {
}
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] });
return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true })));
const duplicates = await this.assetRepository.getDuplicates(auth.user.id);
return duplicates.map(({ duplicateId, assets }) => ({
duplicateId,
assets: assets.map((asset) => mapAsset(asset, { auth })),
}));
}
async handleQueueSearchDuplicates({ force }: IBaseJob): Promise<JobStatus> {

View File

@ -300,7 +300,7 @@ export class PersonService {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination, {
orderDirection: 'DESC',
orderDirection: 'desc',
withFaces: true,
withArchived: true,
isVisible: true,
@ -323,13 +323,7 @@ export class PersonService {
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 || asset.faces?.length > 0) {

View File

@ -81,10 +81,6 @@ describe(SearchService.name, () => {
fieldName: 'exifInfo.city',
items: [{ value: 'Paris', data: assetStub.image.id }],
});
assetMock.getAssetIdByTag.mockResolvedValue({
fieldName: 'smartInfo.tags',
items: [{ value: 'train', data: assetStub.imageFrom2015.id }],
});
assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]);
const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'Paris', data: mapAsset(assetStub.image) }] },

View File

@ -56,18 +56,10 @@ export class SearchService {
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
const options = { maxFields: 12, minAssetsPerField: 5 };
const results = await Promise.all([
this.assetRepository.getAssetIdByCity(auth.user.id, options),
this.assetRepository.getAssetIdByTag(auth.user.id, options),
]);
const assetIds = new Set<string>(results.flatMap((field) => field.items.map((item) => item.data)));
const assets = await this.assetRepository.getByIdsWithAllRelations([...assetIds]);
const assetMap = new Map<string, AssetResponseDto>(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<SearchResponseDto> {
@ -81,14 +73,13 @@ export class SearchService {
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,
},
);

View File

@ -4,6 +4,7 @@ import { IPartnerRepository } from 'src/interfaces/partner.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, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
@ -52,6 +53,7 @@ describe(TimelineService.name, () => {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
albumId: 'album-id',
userIds: [authStub.admin.user.id],
});
});
@ -66,12 +68,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 return the assets for a library time bucket if user has library.read', async () => {
@ -84,11 +89,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 () => {

View File

@ -1,7 +1,9 @@
import _ from 'lodash';
import { AssetEntity } from 'src/entities/asset.entity';
import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, RawBuilder, SelectQueryBuilder, sql } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { DB } from 'src/db';
import { AssetFileType } from 'src/enum';
import { AssetSearchBuilderOptions } from 'src/interfaces/search.interface';
import { Between, IsNull, LessThanOrEqual, MoreThanOrEqual, Not, SelectQueryBuilder } from 'typeorm';
import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
/**
* Allows optional values unlike the regular Between and uses MoreThanOrEqual
@ -17,136 +19,256 @@ export function OptionalBetween<T>(from?: T, to?: T) {
}
}
export const asVector = (embedding: number[], quote = false) =>
quote ? `'[${embedding.join(',')}]'` : `[${embedding.join(',')}]`;
export const UPSERT_COLUMNS: Record<string, { [k: string]: RawBuilder<unknown> }> = {};
export function searchAssetBuilder(
builder: SelectQueryBuilder<AssetEntity>,
options: AssetSearchBuilderOptions,
): SelectQueryBuilder<AssetEntity> {
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,
),
export const getUpsertColumns = async (tableName: string, pk: string, db: Kysely<DB>) => {
if (!(tableName in UPSERT_COLUMNS)) {
const tables = await db.introspection.getTables();
const table = tables.find((table) => table.name === tableName)!;
UPSERT_COLUMNS[tableName] = Object.fromEntries(
table.columns
.map((column) => column.name)
.filter((column) => column !== pk)
.map((column) => [column, sql`excluded.${sql.ref(column)}`]),
);
}
return UPSERT_COLUMNS[tableName];
};
export const mapUpsertColumns = (columns: Record<string, any>, entry: Record<string, any>, pk: string) => {
const upsertColumns: Record<string, any> = {};
for (const column in entry) {
if (column !== pk) {
upsertColumns[column] = columns[column];
}
}
return upsertColumns;
};
export const withExif = <O>(qb: SelectQueryBuilder<DB, 'assets', O>, 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 = <O>(qb: SelectQueryBuilder<DB, 'assets', O>, 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<number[]>`smart_search.embedding`.as('embedding'));
};
export const withFaces = (eb: ExpressionBuilder<DB, 'assets'>) =>
jsonArrayFrom(eb.selectFrom('asset_faces').selectAll().whereRef('asset_faces.assetId', '=', 'assets.id')).as('faces');
export const withFiles = (eb: ExpressionBuilder<DB, 'assets'>, 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<DB, 'assets'>) =>
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<DB>, 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),
);
const exifInfo = _.omitBy(_.pick(options, ['city', 'country', 'lensModel', 'make', 'model', 'state']), _.isUndefined);
const hasExifQuery = Object.keys(exifInfo).length > 0;
export const hasPeople = (db: Kysely<DB>, personIds?: string[]) =>
personIds && personIds.length > 0
? hasPeopleCte(db, personIds).selectFrom('assets').innerJoin('has_people', 'has_people.assetId', 'assets.id')
: db.selectFrom('assets');
if (options.withExif && !hasExifQuery) {
builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo');
}
export const withOwner = (eb: ExpressionBuilder<DB, 'assets'>) =>
jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner');
if (hasExifQuery) {
if (options.withExif) {
builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo');
} else {
builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo');
}
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 id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']);
if (id.libraryId === null) {
id.libraryId = IsNull() as unknown as string;
}
builder.andWhere(_.omitBy(id, _.isUndefined));
if (options.userIds) {
builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds });
}
const path = _.pick(options, ['encodedVideoPath', 'originalPath']);
builder.andWhere(_.omitBy(path, _.isUndefined));
if (options.originalFileName) {
builder.andWhere(`f_unaccent(${builder.alias}.originalFileName) ILIKE f_unaccent(:originalFileName)`, {
originalFileName: `%${options.originalFileName}%`,
});
}
const status = _.pick(options, ['isFavorite', 'isOffline', 'isVisible', 'type']);
const {
isArchived,
isEncoded,
isMotion,
withArchived,
isNotInAlbum,
withFaces,
withPeople,
withSmartInfo,
personIds,
withExif,
withStacked,
trashedAfter,
trashedBefore,
} = options;
builder.andWhere(
_.omitBy(
{
...status,
isArchived: isArchived ?? (withArchived ? undefined : false),
encodedVideoPath: isEncoded ? Not(IsNull()) : undefined,
livePhotoVideoId: isMotion ? Not(IsNull()) : undefined,
},
_.isUndefined,
),
export const withLibrary = (eb: ExpressionBuilder<DB, 'assets'>) =>
jsonObjectFrom(eb.selectFrom('libraries').selectAll().whereRef('libraries.id', '=', 'assets.libraryId')).as(
'library',
);
if (isNotInAlbum) {
builder
.leftJoin(`${builder.alias}.albums`, 'albums')
.andWhere('albums.id IS NULL')
.andWhere(`${builder.alias}.isVisible = true`);
}
export const withStack = <O>(
qb: SelectQueryBuilder<DB, 'assets', O>,
{ assets, withDeleted }: { assets: boolean; withDeleted?: boolean },
) =>
qb
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset_stack')
.selectAll('asset_stack')
.whereRef('assets.stackId', '=', 'asset_stack.id')
.$if(!!assets, (qb) =>
qb
.innerJoinLateral(
(eb: ExpressionBuilder<DB, 'assets' | 'asset_stack'>) =>
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(!withDeleted, (qb) => qb.where('stacked.deletedAt', 'is', null))
.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'));
if (withFaces || withPeople) {
builder.leftJoinAndSelect(`${builder.alias}.faces`, 'faces');
}
export const withAlbums = <O>(qb: SelectQueryBuilder<DB, 'assets', O>, { 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!)),
),
),
);
};
if (withPeople) {
builder.leftJoinAndSelect(`${builder.alias}.person`, 'person');
}
export const asUuid = (id: string) => sql<string>`${id}::uuid`;
if (withSmartInfo) {
builder.leftJoinAndSelect(`${builder.alias}.smartInfo`, 'smartInfo');
}
// export const anyUuid = (ids: string[]) => sql<string>`any(${ids}::uuid[])`;
export const anyUuid = (ids: string[]) => sql<string>`any(${`{${ids}}`}::uuid[])`;
if (personIds && personIds.length > 0) {
builder
.leftJoin(`${builder.alias}.faces`, 'faces')
.andWhere('faces.personId IN (:...personIds)', { personIds })
.addGroupBy(`${builder.alias}.id`)
.having('COUNT(DISTINCT faces.personId) = :personCount', { personCount: personIds.length });
export const asVector = (embedding: number[]) => sql<number[]>`${`[${embedding}]`}::vector`;
if (withExif) {
builder.addGroupBy('exifInfo.assetId');
}
}
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
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;
export function searchAssetBuilder(kysely: Kysely<DB>, 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));
}

View File

@ -37,7 +37,10 @@ export async function* usePagination<T>(
}
}
function paginationHelper<Entity extends ObjectLiteral>(items: Entity[], take: number): PaginationResult<Entity> {
export function paginationHelper<Entity extends ObjectLiteral>(
items: Entity[],
take: number,
): PaginationResult<Entity> {
const hasNextPage = items.length > take;
items.splice(take);

View File

@ -19,7 +19,8 @@
"preserveWatchOutput": true,
"baseUrl": "./",
"jsx": "react",
"types": ["vitest/globals"]
"types": ["vitest/globals"],
"noErrorTruncation": true
},
"exclude": ["dist", "node_modules", "upload"]
}