mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
catchup main
This commit is contained in:
commit
a7032bb3d9
@ -5,7 +5,7 @@ import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const { name, email, password } = signupDto.admin;
|
||||
const { email, password } = signupDto.admin;
|
||||
|
||||
describe(`/auth/admin-sign-up`, () => {
|
||||
beforeEach(async () => {
|
||||
@ -13,33 +13,6 @@ describe(`/auth/admin-sign-up`, () => {
|
||||
});
|
||||
|
||||
describe('POST /auth/admin-sign-up', () => {
|
||||
const invalid = [
|
||||
{
|
||||
should: 'require an email address',
|
||||
data: { name, password },
|
||||
},
|
||||
{
|
||||
should: 'require a password',
|
||||
data: { name, email },
|
||||
},
|
||||
{
|
||||
should: 'require a name',
|
||||
data: { email, password },
|
||||
},
|
||||
{
|
||||
should: 'require a valid email',
|
||||
data: { name, email: 'immich', password },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { should, data } of invalid) {
|
||||
it(`should ${should}`, async () => {
|
||||
const { status, body } = await request(app).post('/auth/admin-sign-up').send(data);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it(`should sign up the admin`, async () => {
|
||||
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
|
||||
expect(status).toBe(201);
|
||||
@ -57,14 +30,6 @@ describe(`/auth/admin-sign-up`, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should transform email to lower case', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ ...signupDto.admin, email: 'aDmIn@IMMICH.cloud' });
|
||||
expect(status).toEqual(201);
|
||||
expect(body).toEqual(signupResponseDto.admin);
|
||||
});
|
||||
|
||||
it('should not allow a second admin to sign up', async () => {
|
||||
await signUpAdmin({ signUpDto: signupDto.admin });
|
||||
|
||||
|
@ -31,33 +31,7 @@ describe('/users', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('GET /users', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/users');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should get users', async () => {
|
||||
const { status, body } = await request(app).get('/users').set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ email: 'admin@immich.cloud' }),
|
||||
expect.objectContaining({ email: 'user2@immich.cloud' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/me', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get(`/users/me`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should not work for shared links', async () => {
|
||||
const album = await utils.createAlbum(admin.accessToken, { albumName: 'Album' });
|
||||
const sharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||
@ -99,24 +73,6 @@ describe('/users', () => {
|
||||
});
|
||||
|
||||
describe('PUT /users/me', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).put(`/users/me`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
for (const key of ['email', 'name']) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const dto = { [key]: null };
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should update first and last name', async () => {
|
||||
const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
|
||||
|
||||
@ -269,11 +225,6 @@ describe('/users', () => {
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(app).get(`/users/${admin.userId}`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
|
||||
it('should get the user', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/users/${admin.userId}`)
|
||||
@ -292,12 +243,6 @@ describe('/users', () => {
|
||||
});
|
||||
|
||||
describe('GET /server/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(app).get('/users/me/license');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should return the user license', async () => {
|
||||
await request(app)
|
||||
.put('/users/me/license')
|
||||
@ -315,11 +260,6 @@ describe('/users', () => {
|
||||
});
|
||||
|
||||
describe('PUT /users/me/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(app).put(`/users/me/license`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
|
||||
it('should set the user license', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.put(`/users/me/license`)
|
||||
|
@ -351,7 +351,6 @@ class BackupService {
|
||||
);
|
||||
|
||||
baseRequest.headers.addAll(ApiService.getRequestHeaders());
|
||||
baseRequest.headers["Transfer-Encoding"] = "chunked";
|
||||
baseRequest.fields['deviceAssetId'] = asset.localId!;
|
||||
baseRequest.fields['deviceId'] = deviceId;
|
||||
baseRequest.fields['fileCreatedAt'] =
|
||||
|
119
server/package-lock.json
generated
119
server/package-lock.json
generated
@ -114,6 +114,7 @@
|
||||
"rimraf": "^6.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sql-formatter": "^15.0.0",
|
||||
"supertest": "^7.1.0",
|
||||
"testcontainers": "^10.18.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3",
|
||||
@ -7060,6 +7061,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
@ -7999,6 +8007,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/compress-commons": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
|
||||
@ -8129,6 +8147,13 @@
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookiejar": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.41.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz",
|
||||
@ -8446,6 +8471,17 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/diacritics": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
|
||||
@ -9787,6 +9823,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz",
|
||||
"integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dezalgo": "^1.0.4",
|
||||
"hexoid": "^2.0.0",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@ -10325,6 +10376,16 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hexoid": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz",
|
||||
"integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
|
||||
@ -11511,6 +11572,16 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
@ -11536,6 +11607,19 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
@ -15158,6 +15242,41 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz",
|
||||
"integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.0",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.4",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.0",
|
||||
"formidable": "^3.5.1",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.0.tgz",
|
||||
"integrity": "sha512-5QeSO8hSrKghtcWEoPiO036fxH0Ii2wVQfFZSP0oqQhmjk8bOLhDFXr4JrvaFmPuEWUoq4znY3uSi8UzLKxGqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^9.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
|
@ -140,6 +140,7 @@
|
||||
"rimraf": "^6.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sql-formatter": "^15.0.0",
|
||||
"supertest": "^7.1.0",
|
||||
"testcontainers": "^10.18.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
@ -27,7 +27,7 @@ import { getKyselyConfig } from 'src/utils/database';
|
||||
|
||||
const common = [...repositories, ...services, GlobalExceptionFilter];
|
||||
|
||||
const middleware = [
|
||||
export const middleware = [
|
||||
FileUploadInterceptor,
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
|
||||
|
@ -28,7 +28,7 @@ export interface MoveRequest {
|
||||
export type GeneratedImageType = AssetPathType.PREVIEW | AssetPathType.THUMBNAIL | AssetPathType.FULLSIZE;
|
||||
export type GeneratedAssetType = GeneratedImageType | AssetPathType.ENCODED_VIDEO;
|
||||
|
||||
type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
|
||||
let instance: StorageCore | null;
|
||||
|
||||
|
@ -263,6 +263,24 @@ export type AssetJobStatus = Selectable<DatabaseAssetJobStatus> & {
|
||||
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
|
||||
|
||||
export const columns = {
|
||||
asset: [
|
||||
'assets.id',
|
||||
'assets.checksum',
|
||||
'assets.deviceAssetId',
|
||||
'assets.deviceId',
|
||||
'assets.fileCreatedAt',
|
||||
'assets.fileModifiedAt',
|
||||
'assets.isExternal',
|
||||
'assets.isVisible',
|
||||
'assets.libraryId',
|
||||
'assets.livePhotoVideoId',
|
||||
'assets.localDateTime',
|
||||
'assets.originalFileName',
|
||||
'assets.originalPath',
|
||||
'assets.ownerId',
|
||||
'assets.sidecarPath',
|
||||
'assets.type',
|
||||
],
|
||||
assetFiles: ['asset_files.id', 'asset_files.path', 'asset_files.type'],
|
||||
authUser: [
|
||||
'users.id',
|
||||
|
@ -11,7 +11,8 @@ import { setUnion } from 'src/utils/set';
|
||||
const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
|
||||
Column({ ...options, type: 'uuid', nullable: false, default: () => `${immich_uuid_v7.name}()` });
|
||||
|
||||
export const UpdateIdColumn = () => GeneratedUuidV7Column();
|
||||
export const UpdateIdColumn = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
|
||||
GeneratedUuidV7Column(options);
|
||||
|
||||
export const PrimaryGeneratedUuidV7Column = () => GeneratedUuidV7Column({ primary: true });
|
||||
|
||||
|
@ -2,7 +2,6 @@ import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder,
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { AssetFace, AssetFile, AssetJobStatus, columns, Exif, Stack, Tag, User } from 'src/database';
|
||||
import { DB } from 'src/db';
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
|
||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||
@ -45,7 +44,6 @@ export class AssetEntity {
|
||||
exifInfo?: Exif;
|
||||
tags?: Tag[];
|
||||
sharedLinks!: SharedLinkEntity[];
|
||||
albums?: AlbumEntity[];
|
||||
faces!: AssetFace[];
|
||||
stackId?: string | null;
|
||||
stack?: Stack | null;
|
||||
@ -56,13 +54,13 @@ export class AssetEntity {
|
||||
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
||||
return qb
|
||||
.leftJoin('exif', 'assets.id', 'exif.assetId')
|
||||
.select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif>().as('exifInfo'));
|
||||
.select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif | null>().as('exifInfo'));
|
||||
}
|
||||
|
||||
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
||||
return qb
|
||||
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
||||
.select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo'));
|
||||
.select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif>().as('exifInfo'));
|
||||
}
|
||||
|
||||
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
||||
@ -92,30 +90,19 @@ export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileT
|
||||
}
|
||||
|
||||
export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) {
|
||||
return eb
|
||||
.selectFrom('asset_faces')
|
||||
.leftJoin('person', 'person.id', 'asset_faces.personId')
|
||||
.whereRef('asset_faces.assetId', '=', 'assets.id')
|
||||
.$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null))
|
||||
.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');
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset_faces')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb.selectFrom('person').selectAll('person').whereRef('asset_faces.personId', '=', 'person.id').as('person'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.selectAll('asset_faces')
|
||||
.select((eb) => eb.table('person').as('person'))
|
||||
.whereRef('asset_faces.assetId', '=', 'assets.id')
|
||||
.$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)),
|
||||
).as('faces');
|
||||
}
|
||||
|
||||
export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds: string[]) {
|
||||
@ -158,34 +145,6 @@ export function withLibrary(eb: ExpressionBuilder<DB, 'assets'>) {
|
||||
);
|
||||
}
|
||||
|
||||
export function withAlbums<O>(qb: SelectQueryBuilder<DB, 'assets', O>, { albumId }: { albumId?: string }) {
|
||||
return qb
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('albums')
|
||||
.selectAll('albums')
|
||||
.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 function withTags(eb: ExpressionBuilder<DB, 'assets'>) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
|
51
server/src/migrations/1744900200559-AddForeignKeyIndexes.ts
Normal file
51
server/src/migrations/1744900200559-AddForeignKeyIndexes.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddForeignKeyIndexes1744900200559 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE INDEX "IDX_0f6fc2fb195f24d19b0fb0d57c" ON "libraries" ("ownerId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_91704e101438fd0653f582426d" ON "asset_stack" ("primaryAssetId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_c05079e542fd74de3b5ecb5c1c" ON "asset_stack" ("ownerId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_2c5ac0d6fb58b238fd2068de67" ON "assets" ("ownerId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_16294b83fa8c0149719a1f631e" ON "assets" ("livePhotoVideoId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_9977c3c1de01c3d848039a6b90" ON "assets" ("libraryId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f15d48fa3ea5e4bda05ca8ab20" ON "assets" ("stackId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_b22c53f35ef20c28c21637c85f" ON "albums" ("ownerId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_05895aa505a670300d4816debc" ON "albums" ("albumThumbnailAssetId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_1af8519996fbfb3684b58df280" ON "activity" ("albumId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_3571467bcbe021f66e2bdce96e" ON "activity" ("userId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_8091ea76b12338cb4428d33d78" ON "activity" ("assetId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_6c2e267ae764a9413b863a2934" ON "api_keys" ("userId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_5527cc99f530a547093f9e577b" ON "person" ("ownerId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_2bbabe31656b6778c6b87b6102" ON "person" ("faceAssetId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_575842846f0c28fa5da46c99b1" ON "memories" ("ownerId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_d7e875c6c60e661723dbf372fd" ON "partners" ("sharedWithId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_57de40bc620f456c7311aa3a1e" ON "sessions" ("userId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_66fe3837414c5a9f1c33ca4934" ON "shared_links" ("userId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_d8ddd9d687816cc490432b3d4b" ON "session_sync_checkpoints" ("sessionId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_9f9590cc11561f1f48ff034ef9" ON "tags" ("parentId")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_66fe3837414c5a9f1c33ca4934";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_91704e101438fd0653f582426d";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_c05079e542fd74de3b5ecb5c1c";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_5527cc99f530a547093f9e577b";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_2bbabe31656b6778c6b87b6102";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_0f6fc2fb195f24d19b0fb0d57c";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_9f9590cc11561f1f48ff034ef9";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_2c5ac0d6fb58b238fd2068de67";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_16294b83fa8c0149719a1f631e";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_9977c3c1de01c3d848039a6b90";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_f15d48fa3ea5e4bda05ca8ab20";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_b22c53f35ef20c28c21637c85f";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_05895aa505a670300d4816debc";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_57de40bc620f456c7311aa3a1e";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_d8ddd9d687816cc490432b3d4b";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_d7e875c6c60e661723dbf372fd";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_575842846f0c28fa5da46c99b1";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6c2e267ae764a9413b863a2934";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_1af8519996fbfb3684b58df280";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_3571467bcbe021f66e2bdce96e";`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_8091ea76b12338cb4428d33d78";`);
|
||||
}
|
||||
}
|
@ -115,6 +115,150 @@ from
|
||||
where
|
||||
"assets"."id" = $1
|
||||
|
||||
-- AssetJobRepository.getForGenerateThumbnailJob
|
||||
select
|
||||
"assets"."id",
|
||||
"assets"."isVisible",
|
||||
"assets"."originalFileName",
|
||||
"assets"."originalPath",
|
||||
"assets"."ownerId",
|
||||
"assets"."thumbhash",
|
||||
"assets"."type",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_files"."id",
|
||||
"asset_files"."path",
|
||||
"asset_files"."type"
|
||||
from
|
||||
"asset_files"
|
||||
where
|
||||
"asset_files"."assetId" = "assets"."id"
|
||||
) as agg
|
||||
) as "files",
|
||||
to_json("exif") as "exifInfo"
|
||||
from
|
||||
"assets"
|
||||
inner join "exif" on "assets"."id" = "exif"."assetId"
|
||||
where
|
||||
"assets"."id" = $1
|
||||
|
||||
-- AssetJobRepository.getForMetadataExtraction
|
||||
select
|
||||
"assets"."id",
|
||||
"assets"."checksum",
|
||||
"assets"."deviceAssetId",
|
||||
"assets"."deviceId",
|
||||
"assets"."fileCreatedAt",
|
||||
"assets"."fileModifiedAt",
|
||||
"assets"."isExternal",
|
||||
"assets"."isVisible",
|
||||
"assets"."libraryId",
|
||||
"assets"."livePhotoVideoId",
|
||||
"assets"."localDateTime",
|
||||
"assets"."originalFileName",
|
||||
"assets"."originalPath",
|
||||
"assets"."ownerId",
|
||||
"assets"."sidecarPath",
|
||||
"assets"."type",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_faces".*
|
||||
from
|
||||
"asset_faces"
|
||||
where
|
||||
"asset_faces"."assetId" = "assets"."id"
|
||||
and "asset_faces"."deletedAt" is null
|
||||
) as agg
|
||||
) as "faces"
|
||||
from
|
||||
"assets"
|
||||
where
|
||||
"assets"."id" = $1
|
||||
|
||||
-- AssetJobRepository.getAlbumThumbnailFiles
|
||||
select
|
||||
"asset_files"."id",
|
||||
"asset_files"."path",
|
||||
"asset_files"."type"
|
||||
from
|
||||
"asset_files"
|
||||
where
|
||||
"asset_files"."assetId" = $1
|
||||
and "asset_files"."type" = $2
|
||||
|
||||
-- AssetJobRepository.getForClipEncoding
|
||||
select
|
||||
"assets"."id",
|
||||
"assets"."isVisible",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_files"."id",
|
||||
"asset_files"."path",
|
||||
"asset_files"."type"
|
||||
from
|
||||
"asset_files"
|
||||
where
|
||||
"asset_files"."assetId" = "assets"."id"
|
||||
and "asset_files"."type" = $1
|
||||
) as agg
|
||||
) as "files"
|
||||
from
|
||||
"assets"
|
||||
where
|
||||
"assets"."id" = $2
|
||||
|
||||
-- AssetJobRepository.getForDetectFacesJob
|
||||
select
|
||||
"assets"."id",
|
||||
"assets"."isVisible",
|
||||
to_json("exif") as "exifInfo",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_faces".*
|
||||
from
|
||||
"asset_faces"
|
||||
where
|
||||
"asset_faces"."assetId" = "assets"."id"
|
||||
) as agg
|
||||
) as "faces",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_files"."id",
|
||||
"asset_files"."path",
|
||||
"asset_files"."type"
|
||||
from
|
||||
"asset_files"
|
||||
where
|
||||
"asset_files"."assetId" = "assets"."id"
|
||||
and "asset_files"."type" = $1
|
||||
) as agg
|
||||
) as "files"
|
||||
from
|
||||
"assets"
|
||||
inner join "exif" on "assets"."id" = "exif"."assetId"
|
||||
where
|
||||
"assets"."id" = $2
|
||||
|
||||
-- AssetJobRepository.getForStorageTemplateJob
|
||||
select
|
||||
"assets"."id",
|
||||
|
@ -87,22 +87,26 @@ select
|
||||
"assets".*,
|
||||
(
|
||||
select
|
||||
jsonb_agg(
|
||||
case
|
||||
when "person"."id" is not null then jsonb_insert(
|
||||
to_jsonb("asset_faces"),
|
||||
'{person}'::text[],
|
||||
to_jsonb("person")
|
||||
)
|
||||
else to_jsonb("asset_faces")
|
||||
end
|
||||
) as "faces"
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
"asset_faces"
|
||||
left join "person" on "person"."id" = "asset_faces"."personId"
|
||||
where
|
||||
"asset_faces"."assetId" = "assets"."id"
|
||||
and "asset_faces"."deletedAt" is null
|
||||
(
|
||||
select
|
||||
"asset_faces".*,
|
||||
"person" as "person"
|
||||
from
|
||||
"asset_faces"
|
||||
left join lateral (
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"asset_faces"."personId" = "person"."id"
|
||||
) as "person" on true
|
||||
where
|
||||
"asset_faces"."assetId" = "assets"."id"
|
||||
and "asset_faces"."deletedAt" is null
|
||||
) as agg
|
||||
) as "faces",
|
||||
(
|
||||
select
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DB } from 'src/db';
|
||||
@ -418,6 +419,7 @@ class TagAccess {
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccessRepository {
|
||||
activity: ActivityAccess;
|
||||
album: AlbumAccess;
|
||||
|
@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { DB } from 'src/db';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { withFiles } from 'src/entities/asset.entity';
|
||||
import { withExifInner, withFaces, withFiles } from 'src/entities/asset.entity';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { StorageAsset } from 'src/types';
|
||||
import { asUuid } from 'src/utils/database';
|
||||
@ -87,6 +88,67 @@ export class AssetJobRepository {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForGenerateThumbnailJob(id: string) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.select([
|
||||
'assets.id',
|
||||
'assets.isVisible',
|
||||
'assets.originalFileName',
|
||||
'assets.originalPath',
|
||||
'assets.ownerId',
|
||||
'assets.thumbhash',
|
||||
'assets.type',
|
||||
])
|
||||
.select(withFiles)
|
||||
.$call(withExifInner)
|
||||
.where('assets.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForMetadataExtraction(id: string) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.select(columns.asset)
|
||||
.select(withFaces)
|
||||
.where('assets.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.THUMBNAIL] })
|
||||
getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) {
|
||||
return this.db
|
||||
.selectFrom('asset_files')
|
||||
.select(columns.assetFiles)
|
||||
.where('asset_files.assetId', '=', id)
|
||||
.$if(!!fileType, (qb) => qb.where('asset_files.type', '=', fileType!))
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForClipEncoding(id: string) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.select(['assets.id', 'assets.isVisible'])
|
||||
.select((eb) => withFiles(eb, AssetFileType.PREVIEW))
|
||||
.where('assets.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForDetectFacesJob(id: string) {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
.select(['assets.id', 'assets.isVisible'])
|
||||
.$call(withExifInner)
|
||||
.select((eb) => withFaces(eb, true))
|
||||
.select((eb) => withFiles(eb, AssetFileType.PREVIEW))
|
||||
.where('assets.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
private storageTemplateAssetQuery() {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
|
@ -9,7 +9,6 @@ import {
|
||||
hasPeople,
|
||||
searchAssetBuilder,
|
||||
truncatedDate,
|
||||
withAlbums,
|
||||
withExif,
|
||||
withFaces,
|
||||
withFacesAndPeople,
|
||||
@ -381,16 +380,6 @@ export class AssetRepository {
|
||||
await this.db.deleteFrom('assets').where('ownerId', '=', ownerId).execute();
|
||||
}
|
||||
|
||||
async getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity> {
|
||||
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<string[]> {
|
||||
const assets = await this.db
|
||||
.selectFrom('assets')
|
||||
@ -760,7 +749,11 @@ export class AssetRepository {
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
.$call(withExif)
|
||||
.$if(!!options.albumId, (qb) => withAlbums(qb, { albumId: options.albumId }))
|
||||
.$if(!!options.albumId, (qb) =>
|
||||
qb
|
||||
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
|
||||
.where('albums_assets_assets.albumsId', '=', options.albumId!),
|
||||
)
|
||||
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
||||
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
|
||||
.$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
|
||||
|
@ -5,7 +5,6 @@ import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Index,
|
||||
@ -51,7 +50,6 @@ export class ActivityTable {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isLiked!: boolean;
|
||||
|
||||
@ColumnIndex('IDX_activity_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_activity_update_id' })
|
||||
updateId!: string;
|
||||
}
|
||||
|
@ -1,25 +1,13 @@
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { ColumnIndex, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
import { CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' })
|
||||
export class AlbumAssetTable {
|
||||
@ForeignKeyColumn(() => AlbumTable, {
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
nullable: false,
|
||||
primary: true,
|
||||
})
|
||||
@ColumnIndex()
|
||||
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
|
||||
albumsId!: string;
|
||||
|
||||
@ForeignKeyColumn(() => AssetTable, {
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
nullable: false,
|
||||
primary: true,
|
||||
})
|
||||
@ColumnIndex()
|
||||
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false, primary: true })
|
||||
assetsId!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
|
@ -4,7 +4,6 @@ import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
ForeignKeyColumn,
|
||||
@ -51,7 +50,6 @@ export class AlbumTable {
|
||||
@Column({ default: AssetOrder.DESC })
|
||||
order!: AssetOrder;
|
||||
|
||||
@ColumnIndex('IDX_albums_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_albums_update_id' })
|
||||
updateId?: string;
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { Permission } from 'src/enum';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
@ -35,7 +34,6 @@ export class APIKeyTable {
|
||||
@Column({ array: true, type: 'character varying' })
|
||||
permissions!: Permission[];
|
||||
|
||||
@ColumnIndex({ name: 'IDX_api_keys_update_id' })
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_api_keys_update_id' })
|
||||
updateId?: string;
|
||||
}
|
||||
|
@ -1,20 +1,17 @@
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools';
|
||||
import { Column, CreateDateColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('assets_audit')
|
||||
export class AssetAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: string;
|
||||
|
||||
@ColumnIndex('IDX_assets_audit_asset_id')
|
||||
@Column({ type: 'uuid' })
|
||||
@Column({ type: 'uuid', indexName: 'IDX_assets_audit_asset_id' })
|
||||
assetId!: string;
|
||||
|
||||
@ColumnIndex('IDX_assets_audit_owner_id')
|
||||
@Column({ type: 'uuid' })
|
||||
@Column({ type: 'uuid', indexName: 'IDX_assets_audit_owner_id' })
|
||||
ownerId!: string;
|
||||
|
||||
@ColumnIndex('IDX_assets_audit_deleted_at')
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()' })
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_assets_audit_deleted_at' })
|
||||
deletedAt!: Date;
|
||||
}
|
||||
|
@ -8,10 +8,21 @@ import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColu
|
||||
@Index({ name: 'IDX_asset_faces_assetId_personId', columns: ['assetId', 'personId'] })
|
||||
@Index({ columns: ['personId', 'assetId'] })
|
||||
export class AssetFaceTable {
|
||||
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
@ForeignKeyColumn(() => AssetTable, {
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
// [assetId, personId] is the PK constraint
|
||||
index: false,
|
||||
})
|
||||
assetId!: string;
|
||||
|
||||
@ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true })
|
||||
@ForeignKeyColumn(() => PersonTable, {
|
||||
onDelete: 'SET NULL',
|
||||
onUpdate: 'CASCADE',
|
||||
nullable: true,
|
||||
// [personId, assetId] makes this redundant
|
||||
index: false,
|
||||
})
|
||||
personId!: string | null;
|
||||
|
||||
@Column({ default: 0, type: 'integer' })
|
||||
|
@ -3,7 +3,6 @@ import { AssetFileType } from 'src/enum';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import {
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
@ -19,8 +18,11 @@ export class AssetFileTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: string;
|
||||
|
||||
@ColumnIndex('IDX_asset_files_assetId')
|
||||
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
@ForeignKeyColumn(() => AssetTable, {
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
indexName: 'IDX_asset_files_assetId',
|
||||
})
|
||||
assetId?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
@ -35,7 +37,6 @@ export class AssetFileTable {
|
||||
@Column()
|
||||
path!: string;
|
||||
|
||||
@ColumnIndex('IDX_asset_files_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_asset_files_update_id' })
|
||||
updateId?: string;
|
||||
}
|
||||
|
@ -9,7 +9,6 @@ import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
ForeignKeyColumn,
|
||||
@ -78,8 +77,7 @@ export class AssetTable {
|
||||
@Column()
|
||||
originalPath!: string;
|
||||
|
||||
@ColumnIndex('idx_asset_file_created_at')
|
||||
@Column({ type: 'timestamp with time zone' })
|
||||
@Column({ type: 'timestamp with time zone', indexName: 'idx_asset_file_created_at' })
|
||||
fileCreatedAt!: Date;
|
||||
|
||||
@Column({ type: 'timestamp with time zone' })
|
||||
@ -94,8 +92,7 @@ export class AssetTable {
|
||||
@Column({ type: 'character varying', nullable: true, default: '' })
|
||||
encodedVideoPath!: string | null;
|
||||
|
||||
@Column({ type: 'bytea' })
|
||||
@ColumnIndex()
|
||||
@Column({ type: 'bytea', index: true })
|
||||
checksum!: Buffer; // sha1 checksum
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
@ -113,8 +110,7 @@ export class AssetTable {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isArchived!: boolean;
|
||||
|
||||
@Column()
|
||||
@ColumnIndex()
|
||||
@Column({ index: true })
|
||||
originalFileName!: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@ -141,14 +137,12 @@ export class AssetTable {
|
||||
@ForeignKeyColumn(() => StackTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
|
||||
stackId?: string | null;
|
||||
|
||||
@ColumnIndex('IDX_assets_duplicateId')
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
@Column({ type: 'uuid', nullable: true, indexName: 'IDX_assets_duplicateId' })
|
||||
duplicateId!: string | null;
|
||||
|
||||
@Column({ enum: assets_status_enum, default: AssetStatus.ACTIVE })
|
||||
status!: AssetStatus;
|
||||
|
||||
@ColumnIndex('IDX_assets_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_assets_update_id' })
|
||||
updateId?: string;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools';
|
||||
import { Column, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools';
|
||||
|
||||
@Table('exif')
|
||||
@UpdatedAtTrigger('asset_exif_updated_at')
|
||||
@ -50,8 +50,7 @@ export class ExifTable {
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
longitude!: number | null;
|
||||
|
||||
@ColumnIndex('exif_city')
|
||||
@Column({ type: 'character varying', nullable: true })
|
||||
@Column({ type: 'character varying', nullable: true, indexName: 'exif_city' })
|
||||
city!: string | null;
|
||||
|
||||
@Column({ type: 'character varying', nullable: true })
|
||||
@ -69,8 +68,7 @@ export class ExifTable {
|
||||
@Column({ type: 'character varying', nullable: true })
|
||||
exposureTime!: string | null;
|
||||
|
||||
@ColumnIndex('IDX_live_photo_cid')
|
||||
@Column({ type: 'character varying', nullable: true })
|
||||
@Column({ type: 'character varying', nullable: true, indexName: 'IDX_live_photo_cid' })
|
||||
livePhotoCID!: string | null;
|
||||
|
||||
@Column({ type: 'character varying', nullable: true })
|
||||
@ -88,8 +86,7 @@ export class ExifTable {
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
bitsPerSample!: number | null;
|
||||
|
||||
@ColumnIndex('IDX_auto_stack_id')
|
||||
@Column({ type: 'character varying', nullable: true })
|
||||
@Column({ type: 'character varying', nullable: true, indexName: 'IDX_auto_stack_id' })
|
||||
autoStackId!: string | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
@ -98,7 +95,6 @@ export class ExifTable {
|
||||
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
|
||||
updatedAt?: Date;
|
||||
|
||||
@ColumnIndex('IDX_asset_exif_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_asset_exif_update_id' })
|
||||
updateId?: string;
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
ForeignKeyColumn,
|
||||
@ -41,7 +40,6 @@ export class LibraryTable {
|
||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||
refreshedAt!: Date | null;
|
||||
|
||||
@ColumnIndex('IDX_libraries_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_libraries_update_id' })
|
||||
updateId?: string;
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { MemoryType } from 'src/enum';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
ForeignKeyColumn,
|
||||
@ -55,7 +54,6 @@ export class MemoryTable<T extends MemoryType = MemoryType> {
|
||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||
hideAt?: Date;
|
||||
|
||||
@ColumnIndex('IDX_memories_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_memories_update_id' })
|
||||
updateId?: string;
|
||||
}
|
||||
|
@ -1,14 +1,12 @@
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
import { ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('memories_assets_assets')
|
||||
export class MemoryAssetTable {
|
||||
@ColumnIndex()
|
||||
@ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
|
||||
memoriesId!: string;
|
||||
|
||||
@ColumnIndex()
|
||||
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
|
||||
assetsId!: string;
|
||||
}
|
||||
|
@ -1,20 +1,17 @@
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools';
|
||||
import { Column, CreateDateColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('partners_audit')
|
||||
export class PartnerAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: string;
|
||||
|
||||
@ColumnIndex('IDX_partners_audit_shared_by_id')
|
||||
@Column({ type: 'uuid' })
|
||||
@Column({ type: 'uuid', indexName: 'IDX_partners_audit_shared_by_id' })
|
||||
sharedById!: string;
|
||||
|
||||
@ColumnIndex('IDX_partners_audit_shared_with_id')
|
||||
@Column({ type: 'uuid' })
|
||||
@Column({ type: 'uuid', indexName: 'IDX_partners_audit_shared_with_id' })
|
||||
sharedWithId!: string;
|
||||
|
||||
@ColumnIndex('IDX_partners_audit_deleted_at')
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()' })
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_partners_audit_deleted_at' })
|
||||
deletedAt!: Date;
|
||||
}
|
||||
|
@ -1,15 +1,7 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { partners_delete_audit } from 'src/schema/functions';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Table,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
import { AfterDeleteTrigger, Column, CreateDateColumn, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools';
|
||||
|
||||
@Table('partners')
|
||||
@UpdatedAtTrigger('partners_updated_at')
|
||||
@ -21,7 +13,12 @@ import {
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
export class PartnerTable {
|
||||
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true })
|
||||
@ForeignKeyColumn(() => UserTable, {
|
||||
onDelete: 'CASCADE',
|
||||
primary: true,
|
||||
// [sharedById, sharedWithId] is the PK constraint
|
||||
index: false,
|
||||
})
|
||||
sharedById!: string;
|
||||
|
||||
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true })
|
||||
@ -36,7 +33,6 @@ export class PartnerTable {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
inTimeline!: boolean;
|
||||
|
||||
@ColumnIndex('IDX_partners_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_partners_update_id' })
|
||||
updateId!: string;
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
@ -49,7 +48,6 @@ export class PersonTable {
|
||||
@Column({ type: 'character varying', nullable: true, default: null })
|
||||
color?: string | null;
|
||||
|
||||
@ColumnIndex('IDX_person_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_person_update_id' })
|
||||
updateId!: string;
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
@ -35,7 +34,6 @@ export class SessionTable {
|
||||
@Column({ default: '' })
|
||||
deviceOS!: string;
|
||||
|
||||
@ColumnIndex('IDX_sessions_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_sessions_update_id' })
|
||||
updateId!: string;
|
||||
}
|
||||
|
@ -1,14 +1,12 @@
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
import { ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('shared_link__asset')
|
||||
export class SharedLinkAssetTable {
|
||||
@ColumnIndex()
|
||||
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
|
||||
assetsId!: string;
|
||||
|
||||
@ColumnIndex()
|
||||
@ForeignKeyColumn(() => SharedLinkTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
|
||||
sharedLinksId!: string;
|
||||
}
|
||||
|
@ -1,15 +1,7 @@
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Unique,
|
||||
} from 'src/sql-tools';
|
||||
import { Column, CreateDateColumn, ForeignKeyColumn, PrimaryGeneratedColumn, Table, Unique } from 'src/sql-tools';
|
||||
|
||||
@Table('shared_links')
|
||||
@Unique({ name: 'UQ_sharedlink_key', columns: ['key'] })
|
||||
@ -23,8 +15,7 @@ export class SharedLinkTable {
|
||||
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
userId!: string;
|
||||
|
||||
@ColumnIndex('IDX_sharedlink_key')
|
||||
@Column({ type: 'bytea' })
|
||||
@Column({ type: 'bytea', indexName: 'IDX_sharedlink_key' })
|
||||
key!: Buffer; // use to access the inidividual asset
|
||||
|
||||
@Column()
|
||||
@ -39,8 +30,12 @@ export class SharedLinkTable {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
allowUpload!: boolean;
|
||||
|
||||
@ColumnIndex('IDX_sharedlink_albumId')
|
||||
@ForeignKeyColumn(() => AlbumTable, { nullable: true, onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
@ForeignKeyColumn(() => AlbumTable, {
|
||||
nullable: true,
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
indexName: 'IDX_sharedlink_albumId',
|
||||
})
|
||||
albumId!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
|
@ -1,15 +1,7 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { SyncEntityType } from 'src/enum';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
import {
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
PrimaryColumn,
|
||||
Table,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
import { Column, CreateDateColumn, ForeignKeyColumn, PrimaryColumn, Table, UpdateDateColumn } from 'src/sql-tools';
|
||||
|
||||
@Table('session_sync_checkpoints')
|
||||
@UpdatedAtTrigger('session_sync_checkpoints_updated_at')
|
||||
@ -29,7 +21,6 @@ export class SessionSyncCheckpointTable {
|
||||
@Column()
|
||||
ack!: string;
|
||||
|
||||
@ColumnIndex('IDX_session_sync_checkpoints_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_session_sync_checkpoints_update_id' })
|
||||
updateId!: string;
|
||||
}
|
||||
|
@ -1,15 +1,13 @@
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { TagTable } from 'src/schema/tables/tag.table';
|
||||
import { ColumnIndex, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
|
||||
import { ForeignKeyColumn, Index, Table } from 'src/sql-tools';
|
||||
|
||||
@Index({ name: 'IDX_tag_asset_assetsId_tagsId', columns: ['assetsId', 'tagsId'] })
|
||||
@Table('tag_asset')
|
||||
export class TagAssetTable {
|
||||
@ColumnIndex()
|
||||
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
|
||||
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true, index: true })
|
||||
assetsId!: string;
|
||||
|
||||
@ColumnIndex()
|
||||
@ForeignKeyColumn(() => TagTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
|
||||
@ForeignKeyColumn(() => TagTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true, index: true })
|
||||
tagsId!: string;
|
||||
}
|
||||
|
@ -1,13 +1,11 @@
|
||||
import { TagTable } from 'src/schema/tables/tag.table';
|
||||
import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
import { ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('tags_closure')
|
||||
export class TagClosureTable {
|
||||
@ColumnIndex()
|
||||
@ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION' })
|
||||
@ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION', index: true })
|
||||
id_ancestor!: string;
|
||||
|
||||
@ColumnIndex()
|
||||
@ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION' })
|
||||
@ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION', index: true })
|
||||
id_descendant!: string;
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
@ -18,7 +17,12 @@ export class TagTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: string;
|
||||
|
||||
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
|
||||
@ForeignKeyColumn(() => UserTable, {
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
// [userId, value] makes this redundant
|
||||
index: false,
|
||||
})
|
||||
userId!: string;
|
||||
|
||||
@Column()
|
||||
@ -36,7 +40,6 @@ export class TagTable {
|
||||
@ForeignKeyColumn(() => TagTable, { nullable: true, onDelete: 'CASCADE' })
|
||||
parentId?: string;
|
||||
|
||||
@ColumnIndex('IDX_tags_update_id')
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_tags_update_id' })
|
||||
updateId!: string;
|
||||
}
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools';
|
||||
import { Column, CreateDateColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('users_audit')
|
||||
export class UserAuditTable {
|
||||
@Column({ type: 'uuid' })
|
||||
userId!: string;
|
||||
|
||||
@ColumnIndex('IDX_users_audit_deleted_at')
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()' })
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_users_audit_deleted_at' })
|
||||
deletedAt!: Date;
|
||||
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
|
@ -5,7 +5,13 @@ import { UserMetadata, UserMetadataItem } from 'src/types';
|
||||
|
||||
@Table('user_metadata')
|
||||
export class UserMetadataTable<T extends keyof UserMetadata = UserMetadataKey> implements UserMetadataItem<T> {
|
||||
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
|
||||
@ForeignKeyColumn(() => UserTable, {
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
primary: true,
|
||||
// [userId, key] is the PK constraint
|
||||
index: false,
|
||||
})
|
||||
userId!: string;
|
||||
|
||||
@PrimaryColumn({ type: 'character varying' })
|
||||
|
@ -5,7 +5,6 @@ import { users_delete_audit } from 'src/schema/functions';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
ColumnIndex,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Index,
|
||||
@ -77,7 +76,6 @@ export class UserTable {
|
||||
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
|
||||
profileChangedAt!: Generated<Timestamp>;
|
||||
|
||||
@ColumnIndex({ name: 'IDX_users_update_id' })
|
||||
@UpdateIdColumn()
|
||||
@UpdateIdColumn({ indexName: 'IDX_users_update_id' })
|
||||
updateId!: Generated<string>;
|
||||
}
|
||||
|
@ -582,12 +582,7 @@ export class LibraryService extends BaseService {
|
||||
return AssetSyncResult.CHECK_OFFLINE;
|
||||
}
|
||||
|
||||
if (
|
||||
!asset.fileCreatedAt ||
|
||||
!asset.localDateTime ||
|
||||
!asset.fileModifiedAt ||
|
||||
stat.mtime.valueOf() !== asset.fileModifiedAt.valueOf()
|
||||
) {
|
||||
if (stat.mtime.valueOf() !== asset.fileModifiedAt.valueOf()) {
|
||||
this.logger.verbose(`Asset ${asset.originalPath} needs metadata extraction in library ${asset.libraryId}`);
|
||||
|
||||
return AssetSyncResult.UPDATE;
|
||||
|
@ -2,7 +2,6 @@ import { OutputInfo } from 'sharp';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { Exif } from 'src/database';
|
||||
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
@ -249,6 +248,7 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip thumbnail generation if asset not found', async () => {
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(void 0);
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
@ -256,7 +256,7 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip thumbnail generation if asset type is unknown', async () => {
|
||||
mocks.asset.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ ...assetStub.image, type: 'foo' as AssetType });
|
||||
|
||||
await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED);
|
||||
expect(mocks.media.probe).not.toHaveBeenCalled();
|
||||
@ -266,14 +266,14 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should skip video thumbnail generation if no video stream', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.video);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
|
||||
await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toThrowError();
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should skip invisible assets', async () => {
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
|
||||
expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
|
||||
|
||||
@ -283,7 +283,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should delete previous preview if different path', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -291,7 +291,7 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should generate P3 thumbnails for a wide gamut image', async () => {
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as Exif,
|
||||
});
|
||||
@ -359,7 +359,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should generate a thumbnail for a video', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.video);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||
@ -394,7 +394,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should tonemap thumbnail for hdr video', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.video);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||
@ -432,7 +432,7 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: { twoPass: true, maxBitrate: '5000k' },
|
||||
});
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.video);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
@ -453,7 +453,7 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
it('should not skip intra frames for MTS file', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.video);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
@ -471,7 +471,7 @@ describe(MediaService.name, () => {
|
||||
it('should use scaling divisible by 2 even when using quick sync', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.video);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
@ -487,7 +487,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
|
||||
@ -532,7 +532,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`;
|
||||
@ -577,7 +577,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should delete previous thumbnail if different path', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -588,7 +588,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageDng);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -605,7 +605,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageDng);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -621,7 +621,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should resize original image if embedded image not found', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageDng);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -636,7 +636,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should resize original image if embedded image extraction is not enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageDng);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -653,7 +653,7 @@ describe(MediaService.name, () => {
|
||||
it('should process invalid images if enabled', async () => {
|
||||
vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true');
|
||||
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageDng);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -689,7 +689,7 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: true } });
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageDng);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -719,7 +719,7 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } });
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageDng);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -760,7 +760,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageHif);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -788,7 +788,7 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
@ -814,7 +814,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.extract.mockResolvedValue(true);
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.imageHif);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { Exif } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
@ -136,7 +136,7 @@ export class MediaService extends BaseService {
|
||||
|
||||
@OnJob({ name: JobName.GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
|
||||
async handleGenerateThumbnails({ id }: JobOf<JobName.GENERATE_THUMBNAILS>): Promise<JobStatus> {
|
||||
const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true });
|
||||
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
||||
if (!asset) {
|
||||
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`);
|
||||
return JobStatus.FAILED;
|
||||
@ -213,7 +213,13 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private async generateImageThumbnails(asset: AssetEntity) {
|
||||
private async generateImageThumbnails(asset: {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
exifInfo: Exif;
|
||||
}) {
|
||||
const { image } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format);
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
||||
@ -286,7 +292,7 @@ export class MediaService extends BaseService {
|
||||
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer };
|
||||
}
|
||||
|
||||
private async generateVideoThumbnails(asset: AssetEntity) {
|
||||
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
|
||||
const { image, ffmpeg } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format);
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
||||
@ -515,8 +521,8 @@ export class MediaService extends BaseService {
|
||||
return name !== VideoContainer.MP4 && !ffmpegConfig.acceptedContainers.includes(name);
|
||||
}
|
||||
|
||||
isSRGB(asset: AssetEntity): boolean {
|
||||
const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {};
|
||||
isSRGB(asset: { exifInfo: Exif }): boolean {
|
||||
const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo;
|
||||
if (colorspace || profileDescription) {
|
||||
return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb'));
|
||||
} else if (bitsPerSample) {
|
||||
|
@ -3,7 +3,6 @@ import { randomBytes } from 'node:crypto';
|
||||
import { Stats } from 'node:fs';
|
||||
import { constants } from 'node:fs/promises';
|
||||
import { defaults } from 'src/config';
|
||||
import { Exif } from 'src/database';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
|
||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
||||
@ -144,9 +143,10 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle an asset that could not be found', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0);
|
||||
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
|
||||
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -154,11 +154,11 @@ describe(MetadataService.name, () => {
|
||||
it('should handle a date in a sidecar file', async () => {
|
||||
const originalDate = new Date('2023-11-21T16:13:17.517Z');
|
||||
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
|
||||
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.sidecar.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }));
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@ -173,7 +173,7 @@ describe(MetadataService.name, () => {
|
||||
it('should take the file modification date when missing exif and earlier than creation date', async () => {
|
||||
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
|
||||
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: fileModifiedAt,
|
||||
@ -183,7 +183,7 @@ describe(MetadataService.name, () => {
|
||||
mockReadTags();
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dateTimeOriginal: fileModifiedAt }),
|
||||
);
|
||||
@ -199,7 +199,7 @@ describe(MetadataService.name, () => {
|
||||
it('should take the file creation date when missing exif and earlier than modification date', async () => {
|
||||
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
|
||||
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: fileModifiedAt,
|
||||
@ -209,7 +209,7 @@ describe(MetadataService.name, () => {
|
||||
mockReadTags();
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt }));
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.image.id,
|
||||
@ -222,7 +222,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should account for the server being in a non-UTC timezone', async () => {
|
||||
process.env.TZ = 'America/Los_Angeles';
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
|
||||
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -240,7 +240,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle lists of numbers', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: assetStub.image.fileModifiedAt,
|
||||
@ -252,7 +252,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }));
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.image.id,
|
||||
@ -265,7 +265,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should not delete latituide and longitude without reverse geocode', async () => {
|
||||
// regression test for issue 17511
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: false } });
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
@ -279,7 +279,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ city: null, state: null, country: null }),
|
||||
);
|
||||
@ -293,7 +293,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should apply reverse geocoding', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
|
||||
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
@ -308,7 +308,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
|
||||
);
|
||||
@ -322,19 +322,19 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should discard latitude and longitude on null island', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
|
||||
mockReadTags({
|
||||
GPSLatitude: 0,
|
||||
GPSLongitude: 0,
|
||||
});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
|
||||
});
|
||||
|
||||
it('should extract tags from TagsList', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ TagsList: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@ -344,7 +344,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract hierarchy from TagsList', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@ -364,7 +364,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract tags from Keywords as a string', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ Keywords: 'Parent' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@ -374,7 +374,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract tags from Keywords as a list', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ Keywords: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@ -384,7 +384,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract tags from Keywords as a list with a number', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@ -395,7 +395,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract hierarchal tags from Keywords', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ Keywords: 'Parent/Child' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@ -414,7 +414,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should ignore Keywords when TagsList is present', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@ -433,7 +433,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@ -454,7 +454,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@ -465,7 +465,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
|
||||
@ -479,7 +479,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@ -498,7 +498,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should remove existing tags', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -507,13 +507,11 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should not apply motion photos if asset is video', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true });
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], {
|
||||
faces: { person: false },
|
||||
});
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
|
||||
expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
||||
@ -523,7 +521,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle an invalid Directory Item', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({
|
||||
MotionPhoto: 1,
|
||||
ContainerDirectory: [{ Foo: 100 }],
|
||||
@ -533,19 +531,24 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract the correct video orientation', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.video]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
mockReadTags({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.livePhotoWithOriginalFileName,
|
||||
livePhotoVideoId: null,
|
||||
libraryId: null,
|
||||
});
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]);
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
@ -573,9 +576,7 @@ describe(MetadataService.name, () => {
|
||||
assetStub.livePhotoWithOriginalFileName.originalPath,
|
||||
'MotionPhotoVideo',
|
||||
);
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], {
|
||||
faces: { person: false },
|
||||
});
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id);
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith({
|
||||
checksum: expect.any(Buffer),
|
||||
deviceAssetId: 'NONE',
|
||||
@ -607,7 +608,11 @@ describe(MetadataService.name, () => {
|
||||
mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(),
|
||||
birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(),
|
||||
} as Stats);
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.livePhotoWithOriginalFileName,
|
||||
livePhotoVideoId: null,
|
||||
libraryId: null,
|
||||
});
|
||||
mockReadTags({
|
||||
Directory: 'foo/bar/',
|
||||
EmbeddedVideoFile: new BinaryField(0, ''),
|
||||
@ -625,9 +630,7 @@ describe(MetadataService.name, () => {
|
||||
assetStub.livePhotoWithOriginalFileName.originalPath,
|
||||
'EmbeddedVideoFile',
|
||||
);
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], {
|
||||
faces: { person: false },
|
||||
});
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id);
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith({
|
||||
checksum: expect.any(Buffer),
|
||||
deviceAssetId: 'NONE',
|
||||
@ -653,7 +656,11 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract the motion photo video from the XMP directory entry ', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.livePhotoWithOriginalFileName,
|
||||
livePhotoVideoId: null,
|
||||
libraryId: null,
|
||||
});
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
|
||||
@ -673,9 +680,7 @@ describe(MetadataService.name, () => {
|
||||
mocks.storage.readFile.mockResolvedValue(video);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], {
|
||||
faces: { person: false },
|
||||
});
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id);
|
||||
expect(mocks.storage.readFile).toHaveBeenCalledWith(
|
||||
assetStub.livePhotoWithOriginalFileName.originalPath,
|
||||
expect.any(Object),
|
||||
@ -705,7 +710,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete old motion photo video assets if they do not match what is extracted', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoWithOriginalFileName]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoWithOriginalFileName);
|
||||
mockReadTags({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
@ -727,7 +732,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||
mockReadTags({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
@ -749,7 +754,10 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.livePhotoStillAsset,
|
||||
livePhotoVideoId: null,
|
||||
});
|
||||
mockReadTags({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
@ -774,9 +782,11 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should not update storage usage if motion photo is external', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([
|
||||
{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null, isExternal: true },
|
||||
]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.livePhotoStillAsset,
|
||||
livePhotoVideoId: null,
|
||||
isExternal: true,
|
||||
});
|
||||
mockReadTags({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
@ -818,11 +828,11 @@ describe(MetadataService.name, () => {
|
||||
tz: 'UTC-11:30',
|
||||
Rating: 3,
|
||||
};
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags(tags);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({
|
||||
assetId: assetStub.image.id,
|
||||
bitsPerSample: expect.any(Number),
|
||||
@ -878,11 +888,11 @@ describe(MetadataService.name, () => {
|
||||
DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'),
|
||||
tz: undefined,
|
||||
};
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags(tags);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeZone: 'UTC+0',
|
||||
@ -891,7 +901,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract duration', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
format: {
|
||||
@ -902,7 +912,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalled();
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@ -913,7 +923,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should only extract duration for videos', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image }]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
format: {
|
||||
@ -923,7 +933,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalled();
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@ -934,7 +944,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should omit duration of zero', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
format: {
|
||||
@ -945,7 +955,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalled();
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@ -956,7 +966,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should a handle duration of 1 week', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.video }]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
format: {
|
||||
@ -967,7 +977,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.video.id });
|
||||
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalled();
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@ -978,7 +988,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should ignore duration from exif data', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({}, { Duration: { Value: 123 } });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -986,7 +996,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should trim whitespace from description', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ Description: '\t \v \f \n \r' });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -1006,7 +1016,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle a numeric description', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ Description: 1000 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -1018,7 +1028,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip importing metadata when the feature is disabled', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } });
|
||||
mockReadTags(makeFaceTags({ Name: 'Person 1' }));
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -1026,7 +1036,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip importing metadata face for assets without tags.RegionInfo', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags();
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -1034,7 +1044,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip importing faces without name', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags());
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
@ -1046,7 +1056,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip importing faces with empty name', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: '' }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
@ -1058,14 +1068,14 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should apply metadata face tags creating new persons', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
|
||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
|
||||
expect(mocks.person.createAll).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ name: personStub.withName.name }),
|
||||
@ -1099,14 +1109,14 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should assign metadata face tags to existing persons', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
|
||||
mocks.person.createAll.mockResolvedValue([]);
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
|
||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
|
||||
expect(mocks.person.createAll).not.toHaveBeenCalled();
|
||||
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
||||
@ -1131,7 +1141,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle invalid modify date', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ ModifyDate: '00:00:00.000' });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -1143,7 +1153,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle invalid rating value', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ Rating: 6 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -1155,7 +1165,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle valid rating value', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ Rating: 5 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -1167,7 +1177,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle valid negative rating value', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ Rating: -1 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -1179,11 +1189,11 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle livePhotoCID not set', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
|
||||
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false }));
|
||||
expect(mocks.album.removeAsset).not.toHaveBeenCalled();
|
||||
@ -1191,20 +1201,19 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle not finding a match', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(
|
||||
JobStatus.SUCCESS,
|
||||
);
|
||||
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], {
|
||||
faces: { person: false },
|
||||
});
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
|
||||
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
||||
livePhotoCID: 'CID',
|
||||
ownerId: assetStub.livePhotoMotionAsset.ownerId,
|
||||
otherAssetId: assetStub.livePhotoMotionAsset.id,
|
||||
libraryId: null,
|
||||
type: AssetType.IMAGE,
|
||||
});
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false }));
|
||||
@ -1212,7 +1221,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should link photo and video', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoStillAsset);
|
||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
@ -1220,9 +1229,7 @@ describe(MetadataService.name, () => {
|
||||
JobStatus.SUCCESS,
|
||||
);
|
||||
|
||||
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], {
|
||||
faces: { person: false },
|
||||
});
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id);
|
||||
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
||||
livePhotoCID: 'CID',
|
||||
ownerId: assetStub.livePhotoStillAsset.ownerId,
|
||||
@ -1238,12 +1245,9 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should notify clients on live photo link', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([
|
||||
{
|
||||
...assetStub.livePhotoStillAsset,
|
||||
exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as Exif,
|
||||
},
|
||||
]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.livePhotoStillAsset,
|
||||
});
|
||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
@ -1258,12 +1262,11 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should search by libraryId', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([
|
||||
{
|
||||
...assetStub.livePhotoStillAsset,
|
||||
libraryId: 'library-id',
|
||||
},
|
||||
]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.livePhotoStillAsset,
|
||||
libraryId: 'library-id',
|
||||
});
|
||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
|
||||
@ -1296,7 +1299,7 @@ describe(MetadataService.name, () => {
|
||||
},
|
||||
{ exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } },
|
||||
])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags(exif);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@ -1318,7 +1321,7 @@ describe(MetadataService.name, () => {
|
||||
{ exif: { LensID: ' Unknown 6-30mm' }, expected: null },
|
||||
{ exif: { LensID: '' }, expected: null },
|
||||
])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags(exif);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
@ -9,9 +9,9 @@ import { constants } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset, AssetFace } from 'src/database';
|
||||
import { AssetFaces, Exif, Person } from 'src/db';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import {
|
||||
AssetType,
|
||||
DatabaseLock,
|
||||
@ -134,7 +134,10 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async linkLivePhotos(asset: AssetEntity, exifInfo: Insertable<Exif>): Promise<void> {
|
||||
private async linkLivePhotos(
|
||||
asset: { id: string; type: AssetType; ownerId: string; libraryId: string | null },
|
||||
exifInfo: Insertable<Exif>,
|
||||
): Promise<void> {
|
||||
if (!exifInfo.livePhotoCID) {
|
||||
return;
|
||||
}
|
||||
@ -182,9 +185,9 @@ export class MetadataService extends BaseService {
|
||||
|
||||
@OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
|
||||
async handleMetadataExtraction(data: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> {
|
||||
const [{ metadata, reverseGeocoding }, [asset]] = await Promise.all([
|
||||
const [{ metadata, reverseGeocoding }, asset] = await Promise.all([
|
||||
this.getConfig({ withCache: true }),
|
||||
this.assetRepository.getByIds([data.id], { faces: { person: false } }),
|
||||
this.assetJobRepository.getForMetadataExtraction(data.id),
|
||||
]);
|
||||
|
||||
if (!asset) {
|
||||
@ -268,7 +271,7 @@ export class MetadataService extends BaseService {
|
||||
];
|
||||
|
||||
if (this.isMotionPhoto(asset, exifTags)) {
|
||||
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
|
||||
promises.push(this.applyMotionPhotos(asset as unknown as Asset, exifTags, dates, stats));
|
||||
}
|
||||
|
||||
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
|
||||
@ -376,7 +379,11 @@ export class MetadataService extends BaseService {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
private getExifTags(asset: AssetEntity): Promise<ImmichTags> {
|
||||
private getExifTags(asset: {
|
||||
originalPath: string;
|
||||
sidecarPath: string | null;
|
||||
type: AssetType;
|
||||
}): Promise<ImmichTags> {
|
||||
if (!asset.sidecarPath && asset.type === AssetType.IMAGE) {
|
||||
return this.metadataRepository.readTags(asset.originalPath);
|
||||
}
|
||||
@ -384,7 +391,11 @@ export class MetadataService extends BaseService {
|
||||
return this.mergeExifTags(asset);
|
||||
}
|
||||
|
||||
private async mergeExifTags(asset: AssetEntity): Promise<ImmichTags> {
|
||||
private async mergeExifTags(asset: {
|
||||
originalPath: string;
|
||||
sidecarPath: string | null;
|
||||
type: AssetType;
|
||||
}): Promise<ImmichTags> {
|
||||
const [mediaTags, sidecarTags, videoTags] = await Promise.all([
|
||||
this.metadataRepository.readTags(asset.originalPath),
|
||||
asset.sidecarPath ? this.metadataRepository.readTags(asset.sidecarPath) : null,
|
||||
@ -434,7 +445,7 @@ export class MetadataService extends BaseService {
|
||||
return tags;
|
||||
}
|
||||
|
||||
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
|
||||
private async applyTagList(asset: { id: string; ownerId: string }, exifTags: ImmichTags) {
|
||||
const tags = this.getTagList(exifTags);
|
||||
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
|
||||
await this.tagRepository.replaceAssetTags(
|
||||
@ -443,11 +454,11 @@ export class MetadataService extends BaseService {
|
||||
);
|
||||
}
|
||||
|
||||
private isMotionPhoto(asset: AssetEntity, tags: ImmichTags): boolean {
|
||||
private isMotionPhoto(asset: { type: AssetType }, tags: ImmichTags): boolean {
|
||||
return asset.type === AssetType.IMAGE && !!(tags.MotionPhoto || tags.MicroVideo);
|
||||
}
|
||||
|
||||
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags, dates: Dates, stats: Stats) {
|
||||
private async applyMotionPhotos(asset: Asset, tags: ImmichTags, dates: Dates, stats: Stats) {
|
||||
const isMotionPhoto = tags.MotionPhoto;
|
||||
const isMicroVideo = tags.MicroVideo;
|
||||
const videoOffset = tags.MicroVideoOffset;
|
||||
@ -582,7 +593,10 @@ export class MetadataService extends BaseService {
|
||||
);
|
||||
}
|
||||
|
||||
private async applyTaggedFaces(asset: AssetEntity, tags: ImmichTags) {
|
||||
private async applyTaggedFaces(
|
||||
asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string },
|
||||
tags: ImmichTags,
|
||||
) {
|
||||
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
|
||||
return;
|
||||
}
|
||||
@ -649,7 +663,7 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private getDates(asset: AssetEntity, exifTags: ImmichTags, stats: Stats) {
|
||||
private getDates(asset: { id: string; originalPath: string }, exifTags: ImmichTags, stats: Stats) {
|
||||
const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS);
|
||||
this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`);
|
||||
|
||||
|
@ -412,11 +412,13 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
mocks.systemMetadata.get.mockResolvedValue({ server: {} });
|
||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
|
||||
expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, {
|
||||
files: true,
|
||||
});
|
||||
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
|
||||
albumStub.emptyWithValidThumbnail.albumThumbnailAssetId,
|
||||
AssetFileType.THUMBNAIL,
|
||||
);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SEND_EMAIL,
|
||||
data: expect.objectContaining({
|
||||
@ -439,15 +441,15 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
mocks.systemMetadata.get.mockResolvedValue({ server: {} });
|
||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.asset.getById.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
files: [{ id: '1', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' }],
|
||||
});
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([
|
||||
{ id: '1', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' },
|
||||
]);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
|
||||
expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, {
|
||||
files: true,
|
||||
});
|
||||
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
|
||||
albumStub.emptyWithValidThumbnail.albumThumbnailAssetId,
|
||||
AssetFileType.THUMBNAIL,
|
||||
);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SEND_EMAIL,
|
||||
data: expect.objectContaining({
|
||||
@ -470,12 +472,13 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
mocks.systemMetadata.get.mockResolvedValue({ server: {} });
|
||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]);
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
|
||||
expect(mocks.asset.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, {
|
||||
files: true,
|
||||
});
|
||||
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
|
||||
albumStub.emptyWithValidThumbnail.albumThumbnailAssetId,
|
||||
AssetFileType.THUMBNAIL,
|
||||
);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SEND_EMAIL,
|
||||
data: expect.objectContaining({
|
||||
@ -506,6 +509,7 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
mocks.user.get.mockResolvedValueOnce(userStub.user1);
|
||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
||||
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||
@ -527,6 +531,7 @@ describe(NotificationService.name, () => {
|
||||
],
|
||||
});
|
||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
||||
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||
@ -548,6 +553,7 @@ describe(NotificationService.name, () => {
|
||||
],
|
||||
});
|
||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
||||
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||
@ -561,6 +567,7 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||
mocks.notification.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
||||
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||
|
@ -1,13 +1,11 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
||||
import { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { EmailTemplate } from 'src/repositories/notification.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types';
|
||||
import { getAssetFile } from 'src/utils/asset.util';
|
||||
import { getFilenameExtension } from 'src/utils/file';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
import { isEqualObject } from 'src/utils/object';
|
||||
@ -392,24 +390,25 @@ export class NotificationService extends BaseService {
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private async getAlbumThumbnailAttachment(album: AlbumEntity): Promise<EmailImageAttachment | undefined> {
|
||||
private async getAlbumThumbnailAttachment(album: {
|
||||
albumThumbnailAssetId: string | null;
|
||||
}): Promise<EmailImageAttachment | undefined> {
|
||||
if (!album.albumThumbnailAssetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId, { files: true });
|
||||
if (!albumThumbnail) {
|
||||
return;
|
||||
}
|
||||
const albumThumbnailFiles = await this.assetJobRepository.getAlbumThumbnailFiles(
|
||||
album.albumThumbnailAssetId,
|
||||
AssetFileType.THUMBNAIL,
|
||||
);
|
||||
|
||||
const thumbnailFile = getAssetFile(albumThumbnail.files, AssetFileType.THUMBNAIL);
|
||||
if (!thumbnailFile) {
|
||||
if (albumThumbnailFiles.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
filename: `album-thumbnail${getFilenameExtension(thumbnailFile.path)}`,
|
||||
path: thumbnailFile.path,
|
||||
filename: `album-thumbnail${getFilenameExtension(albumThumbnailFiles[0].path)}`,
|
||||
path: albumThumbnailFiles[0].path,
|
||||
cid: 'album-thumbnail',
|
||||
};
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { AssetFace } from 'src/database';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
||||
import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
||||
@ -719,24 +718,7 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip when no resize path', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]);
|
||||
await sut.handleDetectFaces({ id: assetStub.noResizePath.id });
|
||||
expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip it the asset has already been processed', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([
|
||||
{
|
||||
...assetStub.noResizePath,
|
||||
faces: [
|
||||
{
|
||||
id: 'asset-face-1',
|
||||
assetId: assetStub.noResizePath.id,
|
||||
personId: faceStub.face1.personId,
|
||||
} as AssetFace,
|
||||
],
|
||||
},
|
||||
]);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.noResizePath, files: [] });
|
||||
await sut.handleDetectFaces({ id: assetStub.noResizePath.id });
|
||||
expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -745,7 +727,7 @@ describe(PersonService.name, () => {
|
||||
const start = Date.now();
|
||||
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] });
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] });
|
||||
await sut.handleDetectFaces({ id: assetStub.image.id });
|
||||
expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith(
|
||||
['http://immich-machine-learning:3003'],
|
||||
@ -766,7 +748,7 @@ describe(PersonService.name, () => {
|
||||
it('should create a face with no person and queue recognition job', async () => {
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock);
|
||||
mocks.search.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]);
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] });
|
||||
mocks.person.refreshFaces.mockResolvedValue();
|
||||
|
||||
await sut.handleDetectFaces({ id: assetStub.image.id });
|
||||
@ -782,7 +764,11 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should delete an existing face not among the new detected faces', async () => {
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 });
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
faces: [faceStub.primaryFace1],
|
||||
files: [assetStub.image.files[1]],
|
||||
});
|
||||
|
||||
await sut.handleDetectFaces({ id: assetStub.image.id });
|
||||
|
||||
@ -794,7 +780,11 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should add new face and delete an existing face not among the new detected faces', async () => {
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock);
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
faces: [faceStub.primaryFace1],
|
||||
files: [assetStub.image.files[1]],
|
||||
});
|
||||
mocks.person.refreshFaces.mockResolvedValue();
|
||||
|
||||
await sut.handleDetectFaces({ id: assetStub.image.id });
|
||||
@ -810,7 +800,11 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should add embedding to matching metadata face', async () => {
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock);
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif1] }]);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
faces: [faceStub.fromExif1],
|
||||
files: [assetStub.image.files[1]],
|
||||
});
|
||||
mocks.person.refreshFaces.mockResolvedValue();
|
||||
|
||||
await sut.handleDetectFaces({ id: assetStub.image.id });
|
||||
@ -827,7 +821,11 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should not add embedding to non-matching metadata face', async () => {
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock);
|
||||
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif2] }]);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
faces: [faceStub.fromExif2],
|
||||
files: [assetStub.image.files[1]],
|
||||
});
|
||||
|
||||
await sut.handleDetectFaces({ id: assetStub.image.id });
|
||||
|
||||
|
@ -24,7 +24,6 @@ import {
|
||||
PersonUpdateDto,
|
||||
} from 'src/dtos/person.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetType,
|
||||
CacheControl,
|
||||
ImageFormat,
|
||||
@ -41,7 +40,6 @@ import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||
import { UpdateFacesData } from 'src/repositories/person.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 'src/types';
|
||||
import { getAssetFile } from 'src/utils/asset.util';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
@ -297,10 +295,9 @@ export class PersonService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const relations = { exifInfo: true, faces: { person: false, withDeleted: true }, files: true };
|
||||
const [asset] = await this.assetRepository.getByIds([id], relations);
|
||||
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
|
||||
if (!asset || !previewFile) {
|
||||
const asset = await this.assetJobRepository.getForDetectFacesJob(id);
|
||||
const previewFile = asset?.files[0];
|
||||
if (!asset || asset.files.length !== 1 || !previewFile) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
|
@ -264,7 +264,7 @@ describe(SmartInfoService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip assets without a resize path', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.noResizePath]);
|
||||
mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.noResizePath, files: [] });
|
||||
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED);
|
||||
|
||||
@ -274,6 +274,7 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
it('should save the returned objects', async () => {
|
||||
mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
|
||||
mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] });
|
||||
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
|
||||
|
||||
@ -286,7 +287,10 @@ describe(SmartInfoService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip invisible assets', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
|
||||
mocks.assetJob.getForClipEncoding.mockResolvedValue({
|
||||
...assetStub.livePhotoMotionAsset,
|
||||
files: [assetStub.image.files[1]],
|
||||
});
|
||||
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
|
||||
|
||||
@ -295,7 +299,7 @@ describe(SmartInfoService.name, () => {
|
||||
});
|
||||
|
||||
it('should fail if asset could not be found', async () => {
|
||||
mocks.asset.getByIds.mockResolvedValue([]);
|
||||
mocks.assetJob.getForClipEncoding.mockResolvedValue(void 0);
|
||||
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.FAILED);
|
||||
|
||||
@ -306,6 +310,7 @@ describe(SmartInfoService.name, () => {
|
||||
it('should wait for database', async () => {
|
||||
mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
|
||||
mocks.database.isBusy.mockReturnValue(true);
|
||||
mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] });
|
||||
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
|
||||
|
||||
|
@ -2,12 +2,11 @@ import { Injectable } from '@nestjs/common';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetFileType, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobOf } from 'src/types';
|
||||
import { getAssetFile } from 'src/utils/asset.util';
|
||||
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@ -107,8 +106,8 @@ export class SmartInfoService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([id], { files: true });
|
||||
if (!asset) {
|
||||
const asset = await this.assetJobRepository.getForClipEncoding(id);
|
||||
if (!asset || asset.files.length !== 1) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
@ -116,14 +115,9 @@ export class SmartInfoService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
|
||||
if (!previewFile) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
const embedding = await this.machineLearningRepository.encodeImage(
|
||||
machineLearning.urls,
|
||||
previewFile.path,
|
||||
asset.files[0].path,
|
||||
machineLearning.clip,
|
||||
);
|
||||
|
||||
|
@ -1,16 +0,0 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type ColumnIndexOptions = {
|
||||
name?: string;
|
||||
unique?: boolean;
|
||||
expression?: string;
|
||||
using?: string;
|
||||
with?: string;
|
||||
where?: string;
|
||||
synchronize?: boolean;
|
||||
};
|
||||
export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => {
|
||||
return (object: object, propertyName: string | symbol) =>
|
||||
void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } });
|
||||
};
|
@ -15,13 +15,15 @@ export type ColumnBaseOptions = {
|
||||
synchronize?: boolean;
|
||||
storage?: ColumnStorage;
|
||||
identity?: boolean;
|
||||
index?: boolean;
|
||||
indexName?: string;
|
||||
unique?: boolean;
|
||||
uniqueConstraintName?: string;
|
||||
};
|
||||
|
||||
export type ColumnOptions = ColumnBaseOptions & {
|
||||
enum?: DatabaseEnum;
|
||||
array?: boolean;
|
||||
unique?: boolean;
|
||||
uniqueConstraintName?: string;
|
||||
};
|
||||
|
||||
export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => {
|
||||
|
@ -7,8 +7,6 @@ export type ForeignKeyColumnOptions = ColumnBaseOptions & {
|
||||
onUpdate?: Action;
|
||||
onDelete?: Action;
|
||||
constraintName?: string;
|
||||
unique?: boolean;
|
||||
uniqueConstraintName?: string;
|
||||
};
|
||||
|
||||
export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => {
|
||||
|
@ -1,8 +1,13 @@
|
||||
import { ColumnIndexOptions } from 'src/sql-tools/from-code/decorators/column-index.decorator';
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asOptions } from 'src/sql-tools/helpers';
|
||||
|
||||
export type IndexOptions = ColumnIndexOptions & {
|
||||
export type IndexOptions = {
|
||||
name?: string;
|
||||
unique?: boolean;
|
||||
expression?: string;
|
||||
using?: string;
|
||||
with?: string;
|
||||
where?: string;
|
||||
columns?: string[];
|
||||
synchronize?: boolean;
|
||||
};
|
||||
|
@ -1,6 +1,5 @@
|
||||
import 'reflect-metadata';
|
||||
import { processCheckConstraints } from 'src/sql-tools/from-code/processors/check-constraint.processor';
|
||||
import { processColumnIndexes } from 'src/sql-tools/from-code/processors/column-index.processor';
|
||||
import { processColumns } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { processConfigurationParameters } from 'src/sql-tools/from-code/processors/configuration-parameter.processor';
|
||||
import { processDatabases } from 'src/sql-tools/from-code/processors/database.processor';
|
||||
@ -36,14 +35,21 @@ const processors: Processor[] = [
|
||||
processUniqueConstraints,
|
||||
processCheckConstraints,
|
||||
processPrimaryKeyConstraints,
|
||||
processIndexes,
|
||||
processColumnIndexes,
|
||||
processForeignKeyConstraints,
|
||||
processIndexes,
|
||||
processTriggers,
|
||||
];
|
||||
|
||||
export const schemaFromCode = () => {
|
||||
export type SchemaFromCodeOptions = {
|
||||
/** automatically create indexes on foreign key columns */
|
||||
createForeignKeyIndexes?: boolean;
|
||||
};
|
||||
export const schemaFromCode = (options: SchemaFromCodeOptions = {}) => {
|
||||
if (!initialized) {
|
||||
const globalOptions = {
|
||||
createForeignKeyIndexes: options.createForeignKeyIndexes ?? true,
|
||||
};
|
||||
|
||||
const builder: SchemaBuilder = {
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
@ -58,7 +64,7 @@ export const schemaFromCode = () => {
|
||||
const items = getRegisteredItems();
|
||||
|
||||
for (const processor of processors) {
|
||||
processor(builder, items);
|
||||
processor(builder, items, globalOptions);
|
||||
}
|
||||
|
||||
schema = { ...builder, tables: builder.tables.map(({ metadata: _, ...table }) => table) };
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asCheckConstraintName } from 'src/sql-tools/helpers';
|
||||
import { asKey } from 'src/sql-tools/helpers';
|
||||
import { DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processCheckConstraints: Processor = (builder, items) => {
|
||||
@ -24,3 +24,5 @@ export const processCheckConstraints: Processor = (builder, items) => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const asCheckConstraintName = (table: string, expression: string) => asKey('CHK_', table, [expression]);
|
||||
|
@ -1,32 +0,0 @@
|
||||
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { onMissingTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asIndexName } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processColumnIndexes: Processor = (builder, items) => {
|
||||
for (const {
|
||||
item: { object, propertyName, options },
|
||||
} of items.filter((item) => item.type === 'columnIndex')) {
|
||||
const { table, column } = resolveColumn(builder, object, propertyName);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@ColumnIndex', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
onMissingColumn(builder, `@ColumnIndex`, object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
table.indexes.push({
|
||||
name: options.name || asIndexName(table.name, [column.name], options.where),
|
||||
tableName: table.name,
|
||||
unique: options.unique ?? false,
|
||||
expression: options.expression,
|
||||
using: options.using,
|
||||
where: options.where,
|
||||
columnNames: [column.name],
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
@ -1,8 +1,8 @@
|
||||
import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asMetadataKey, asUniqueConstraintName, fromColumnValue } from 'src/sql-tools/helpers';
|
||||
import { DatabaseColumn, DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
import { asMetadataKey, fromColumnValue } from 'src/sql-tools/helpers';
|
||||
import { DatabaseColumn } from 'src/sql-tools/types';
|
||||
|
||||
export const processColumns: Processor = (builder, items) => {
|
||||
for (const {
|
||||
@ -54,16 +54,6 @@ export const processColumns: Processor = (builder, items) => {
|
||||
writeMetadata(object, propertyName, { name: column.name, options });
|
||||
|
||||
table.columns.push(column);
|
||||
|
||||
if (type === 'column' && !options.primary && options.unique) {
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]),
|
||||
tableName: table.name,
|
||||
columnNames: [column.name],
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asForeignKeyConstraintName, asRelationKeyConstraintName } from 'src/sql-tools/helpers';
|
||||
import { asKey } from 'src/sql-tools/helpers';
|
||||
import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processForeignKeyConstraints: Processor = (builder, items) => {
|
||||
@ -46,7 +46,7 @@ export const processForeignKeyConstraints: Processor = (builder, items) => {
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
|
||||
if (options.unique) {
|
||||
if (options.unique || options.uniqueConstraintName) {
|
||||
table.constraints.push({
|
||||
name: options.uniqueConstraintName || asRelationKeyConstraintName(table.name, columnNames),
|
||||
tableName: table.name,
|
||||
@ -57,3 +57,6 @@ export const processForeignKeyConstraints: Processor = (builder, items) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns);
|
||||
const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns);
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asIndexName } from 'src/sql-tools/helpers';
|
||||
import { asKey } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processIndexes: Processor = (builder, items) => {
|
||||
export const processIndexes: Processor = (builder, items, config) => {
|
||||
for (const {
|
||||
item: { object, options },
|
||||
} of items.filter((item) => item.type === 'index')) {
|
||||
@ -24,4 +25,66 @@ export const processIndexes: Processor = (builder, items) => {
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// column indexes
|
||||
for (const {
|
||||
type,
|
||||
item: { object, propertyName, options },
|
||||
} of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) {
|
||||
const { table, column } = resolveColumn(builder, object, propertyName);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@Column', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
// should be impossible since they are created in `column.processor.ts`
|
||||
onMissingColumn(builder, '@Column', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.index === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isIndexRequested =
|
||||
options.indexName || options.index || (type === 'foreignKeyColumn' && config.createForeignKeyIndexes);
|
||||
if (!isIndexRequested) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const indexName = options.indexName || asIndexName(table.name, [column.name]);
|
||||
|
||||
const isIndexPresent = table.indexes.some((index) => index.name === indexName);
|
||||
if (isIndexPresent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isOnlyPrimaryColumn = options.primary && table.columns.filter(({ primary }) => primary === true).length === 1;
|
||||
if (isOnlyPrimaryColumn) {
|
||||
// will have an index created by the primary key constraint
|
||||
continue;
|
||||
}
|
||||
|
||||
table.indexes.push({
|
||||
name: indexName,
|
||||
tableName: table.name,
|
||||
unique: false,
|
||||
columnNames: [column.name],
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const asIndexName = (table: string, columns?: string[], where?: string) => {
|
||||
const items: string[] = [];
|
||||
for (const columnName of columns ?? []) {
|
||||
items.push(columnName);
|
||||
}
|
||||
|
||||
if (where) {
|
||||
items.push(where);
|
||||
}
|
||||
|
||||
return asKey('IDX_', table, items);
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asPrimaryKeyConstraintName } from 'src/sql-tools/helpers';
|
||||
import { asKey } from 'src/sql-tools/helpers';
|
||||
import { DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processPrimaryKeyConstraints: Processor = (builder) => {
|
||||
@ -22,3 +22,5 @@ export const processPrimaryKeyConstraints: Processor = (builder) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asPrimaryKeyConstraintName = (table: string, columns: string[]) => asKey('PK_', table, columns);
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator';
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asTriggerName } from 'src/sql-tools/helpers';
|
||||
import { asKey } from 'src/sql-tools/helpers';
|
||||
|
||||
export const processTriggers: Processor = (builder, items) => {
|
||||
for (const {
|
||||
@ -26,3 +27,6 @@ export const processTriggers: Processor = (builder, items) => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const asTriggerName = (table: string, trigger: TriggerOptions) =>
|
||||
asKey('TR_', table, [...trigger.actions, trigger.scope, trigger.timing, trigger.functionName]);
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { SchemaFromCodeOptions } from 'src/sql-tools/from-code';
|
||||
import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator';
|
||||
import { RegisterItem } from 'src/sql-tools/from-code/register-item';
|
||||
import { DatabaseSchema, DatabaseTable } from 'src/sql-tools/types';
|
||||
@ -6,4 +7,4 @@ import { DatabaseSchema, DatabaseTable } from 'src/sql-tools/types';
|
||||
export type TableWithMetadata = DatabaseTable & { metadata: { options: TableOptions; object: Function } };
|
||||
export type SchemaBuilder = Omit<DatabaseSchema, 'tables'> & { tables: TableWithMetadata[] };
|
||||
|
||||
export type Processor = (builder: SchemaBuilder, items: RegisterItem[]) => void;
|
||||
export type Processor = (builder: SchemaBuilder, items: RegisterItem[], options: SchemaFromCodeOptions) => void;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor';
|
||||
import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor';
|
||||
import { Processor } from 'src/sql-tools/from-code/processors/type';
|
||||
import { asUniqueConstraintName } from 'src/sql-tools/helpers';
|
||||
import { asKey } from 'src/sql-tools/helpers';
|
||||
import { DatabaseConstraintType } from 'src/sql-tools/types';
|
||||
|
||||
export const processUniqueConstraints: Processor = (builder, items) => {
|
||||
@ -24,4 +25,34 @@ export const processUniqueConstraints: Processor = (builder, items) => {
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// column level constraints
|
||||
for (const {
|
||||
type,
|
||||
item: { object, propertyName, options },
|
||||
} of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) {
|
||||
const { table, column } = resolveColumn(builder, object, propertyName);
|
||||
if (!table) {
|
||||
onMissingTable(builder, '@Column', object);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!column) {
|
||||
// should be impossible since they are created in `column.processor.ts`
|
||||
onMissingColumn(builder, '@Column', object, propertyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === 'column' && !options.primary && (options.unique || options.uniqueConstraintName)) {
|
||||
table.constraints.push({
|
||||
type: DatabaseConstraintType.UNIQUE,
|
||||
name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]),
|
||||
tableName: table.name,
|
||||
columnNames: [column.name],
|
||||
synchronize: options.synchronize ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const asUniqueConstraintName = (table: string, columns: string[]) => asKey('UQ_', table, columns);
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { register } from 'src/sql-tools/from-code/register';
|
||||
import { asFunctionExpression } from 'src/sql-tools/helpers';
|
||||
import { ColumnType, DatabaseFunction } from 'src/sql-tools/types';
|
||||
|
||||
export type FunctionOptions = {
|
||||
@ -27,3 +26,37 @@ export const registerFunction = (options: FunctionOptions) => {
|
||||
|
||||
return item;
|
||||
};
|
||||
|
||||
const asFunctionExpression = (options: FunctionOptions) => {
|
||||
const name = options.name;
|
||||
const sql: string[] = [
|
||||
`CREATE OR REPLACE FUNCTION ${name}(${(options.arguments || []).join(', ')})`,
|
||||
`RETURNS ${options.returnType}`,
|
||||
];
|
||||
|
||||
const flags = [
|
||||
options.parallel ? `PARALLEL ${options.parallel.toUpperCase()}` : undefined,
|
||||
options.strict ? 'STRICT' : undefined,
|
||||
options.behavior ? options.behavior.toUpperCase() : undefined,
|
||||
`LANGUAGE ${options.language ?? 'SQL'}`,
|
||||
].filter((x) => x !== undefined);
|
||||
|
||||
if (flags.length > 0) {
|
||||
sql.push(flags.join(' '));
|
||||
}
|
||||
|
||||
if ('return' in options) {
|
||||
sql.push(` RETURN ${options.return}`);
|
||||
}
|
||||
|
||||
if ('body' in options) {
|
||||
sql.push(
|
||||
//
|
||||
`AS $$`,
|
||||
' ' + options.body.trim(),
|
||||
`$$;`,
|
||||
);
|
||||
}
|
||||
|
||||
return sql.join('\n ').trim();
|
||||
};
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { CheckOptions } from 'src/sql-tools/from-code/decorators/check.decorator';
|
||||
import { ColumnIndexOptions } from 'src/sql-tools/from-code/decorators/column-index.decorator';
|
||||
import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { ConfigurationParameterOptions } from 'src/sql-tools/from-code/decorators/configuration-parameter.decorator';
|
||||
import { DatabaseOptions } from 'src/sql-tools/from-code/decorators/database.decorator';
|
||||
@ -21,7 +20,6 @@ export type RegisterItem =
|
||||
| { type: 'uniqueConstraint'; item: ClassBased<{ options: UniqueOptions }> }
|
||||
| { type: 'checkConstraint'; item: ClassBased<{ options: CheckOptions }> }
|
||||
| { type: 'column'; item: PropertyBased<{ options: ColumnOptions }> }
|
||||
| { type: 'columnIndex'; item: PropertyBased<{ options: ColumnIndexOptions }> }
|
||||
| { type: 'function'; item: DatabaseFunction }
|
||||
| { type: 'enum'; item: DatabaseEnum }
|
||||
| { type: 'trigger'; item: ClassBased<{ options: TriggerOptions }> }
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
import { TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator';
|
||||
import { FunctionOptions } from 'src/sql-tools/from-code/register-function';
|
||||
import {
|
||||
Comparer,
|
||||
DatabaseColumn,
|
||||
@ -18,25 +16,6 @@ export const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A
|
||||
// match TypeORM
|
||||
export const asKey = (prefix: string, tableName: string, values: string[]) =>
|
||||
(prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30);
|
||||
export const asPrimaryKeyConstraintName = (table: string, columns: string[]) => asKey('PK_', table, columns);
|
||||
export const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns);
|
||||
export const asTriggerName = (table: string, trigger: TriggerOptions) =>
|
||||
asKey('TR_', table, [...trigger.actions, trigger.scope, trigger.timing, trigger.functionName]);
|
||||
export const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns);
|
||||
export const asUniqueConstraintName = (table: string, columns: string[]) => asKey('UQ_', table, columns);
|
||||
export const asCheckConstraintName = (table: string, expression: string) => asKey('CHK_', table, [expression]);
|
||||
export const asIndexName = (table: string, columns: string[] | undefined, where: string | undefined) => {
|
||||
const items: string[] = [];
|
||||
for (const columnName of columns ?? []) {
|
||||
items.push(columnName);
|
||||
}
|
||||
|
||||
if (where) {
|
||||
items.push(where);
|
||||
}
|
||||
|
||||
return asKey('IDX_', table, items);
|
||||
};
|
||||
|
||||
export const asOptions = <T extends { name?: string }>(options: string | T): T => {
|
||||
if (typeof options === 'string') {
|
||||
@ -46,40 +25,6 @@ export const asOptions = <T extends { name?: string }>(options: string | T): T =
|
||||
return options;
|
||||
};
|
||||
|
||||
export const asFunctionExpression = (options: FunctionOptions) => {
|
||||
const name = options.name;
|
||||
const sql: string[] = [
|
||||
`CREATE OR REPLACE FUNCTION ${name}(${(options.arguments || []).join(', ')})`,
|
||||
`RETURNS ${options.returnType}`,
|
||||
];
|
||||
|
||||
const flags = [
|
||||
options.parallel ? `PARALLEL ${options.parallel.toUpperCase()}` : undefined,
|
||||
options.strict ? 'STRICT' : undefined,
|
||||
options.behavior ? options.behavior.toUpperCase() : undefined,
|
||||
`LANGUAGE ${options.language ?? 'SQL'}`,
|
||||
].filter((x) => x !== undefined);
|
||||
|
||||
if (flags.length > 0) {
|
||||
sql.push(flags.join(' '));
|
||||
}
|
||||
|
||||
if ('return' in options) {
|
||||
sql.push(` RETURN ${options.return}`);
|
||||
}
|
||||
|
||||
if ('body' in options) {
|
||||
sql.push(
|
||||
//
|
||||
`AS $$`,
|
||||
' ' + options.body.trim(),
|
||||
`$$;`,
|
||||
);
|
||||
}
|
||||
|
||||
return sql.join('\n ').trim();
|
||||
};
|
||||
|
||||
export const sha1 = (value: string) => createHash('sha1').update(value).digest('hex');
|
||||
export const hasMask = (input: number, mask: number) => (input & mask) === mask;
|
||||
|
||||
|
@ -3,7 +3,6 @@ export { schemaFromCode } from 'src/sql-tools/from-code';
|
||||
export * from 'src/sql-tools/from-code/decorators/after-delete.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/before-update.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/check.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/column-index.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/configuration-parameter.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/create-date-column.decorator';
|
||||
|
@ -16,14 +16,16 @@ export async function bootstrap() {
|
||||
|
||||
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
|
||||
const logger = await app.resolve(LoggingRepository);
|
||||
const configRepository = app.get(ConfigRepository);
|
||||
|
||||
const { environment, host } = configRepository.getEnv();
|
||||
|
||||
logger.setContext('Bootstrap');
|
||||
app.useLogger(logger);
|
||||
app.useWebSocketAdapter(new WebSocketAdapter(app));
|
||||
|
||||
await app.listen(0);
|
||||
await (host ? app.listen(0, host) : app.listen(0));
|
||||
|
||||
const configRepository = app.get(ConfigRepository);
|
||||
const { environment } = configRepository.getEnv();
|
||||
logger.log(`Immich Microservices is running [v${serverVersion}] [${environment}] `);
|
||||
}
|
||||
|
||||
|
30
server/test/fixtures/asset.stub.ts
vendored
30
server/test/fixtures/asset.stub.ts
vendored
@ -52,7 +52,7 @@ export const assetStub = {
|
||||
fileSizeInByte: 12_345,
|
||||
...asset,
|
||||
}),
|
||||
noResizePath: Object.freeze<AssetEntity>({
|
||||
noResizePath: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
originalFileName: 'IMG_123.jpg',
|
||||
@ -79,6 +79,7 @@ export const assetStub = {
|
||||
livePhotoVideoId: null,
|
||||
sharedLinks: [],
|
||||
faces: [],
|
||||
exifInfo: {} as Exif,
|
||||
sidecarPath: null,
|
||||
deletedAt: null,
|
||||
isExternal: false,
|
||||
@ -157,7 +158,7 @@ export const assetStub = {
|
||||
isOffline: false,
|
||||
}),
|
||||
|
||||
primaryImage: Object.freeze<AssetEntity>({
|
||||
primaryImage: Object.freeze({
|
||||
id: 'primary-asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -200,9 +201,10 @@ export const assetStub = {
|
||||
]),
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
}),
|
||||
|
||||
image: Object.freeze<AssetEntity>({
|
||||
image: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -239,6 +241,7 @@ export const assetStub = {
|
||||
} as Exif,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
}),
|
||||
|
||||
trashed: Object.freeze<AssetEntity>({
|
||||
@ -470,7 +473,7 @@ export const assetStub = {
|
||||
isOffline: false,
|
||||
}),
|
||||
|
||||
video: Object.freeze<AssetEntity>({
|
||||
video: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
originalFileName: 'asset-id.ext',
|
||||
@ -507,6 +510,7 @@ export const assetStub = {
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
}),
|
||||
|
||||
livePhotoMotionAsset: Object.freeze({
|
||||
@ -522,7 +526,8 @@ export const assetStub = {
|
||||
fileSizeInByte: 100_000,
|
||||
timeZone: `America/New_York`,
|
||||
},
|
||||
} as AssetEntity),
|
||||
libraryId: null,
|
||||
} as AssetEntity & { libraryId: string | null; files: AssetFile[]; exifInfo: Exif }),
|
||||
|
||||
livePhotoStillAsset: Object.freeze({
|
||||
id: 'live-photo-still-asset',
|
||||
@ -539,7 +544,7 @@ export const assetStub = {
|
||||
timeZone: `America/New_York`,
|
||||
},
|
||||
files,
|
||||
} as AssetEntity),
|
||||
} as AssetEntity & { libraryId: string | null }),
|
||||
|
||||
livePhotoWithOriginalFileName: Object.freeze({
|
||||
id: 'live-photo-still-asset',
|
||||
@ -556,9 +561,10 @@ export const assetStub = {
|
||||
fileSizeInByte: 25_000,
|
||||
timeZone: `America/New_York`,
|
||||
},
|
||||
} as AssetEntity),
|
||||
libraryId: null,
|
||||
} as AssetEntity & { libraryId: string | null }),
|
||||
|
||||
withLocation: Object.freeze<AssetEntity>({
|
||||
withLocation: Object.freeze({
|
||||
id: 'asset-with-favorite-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -598,9 +604,10 @@ export const assetStub = {
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
}),
|
||||
|
||||
sidecar: Object.freeze<AssetEntity>({
|
||||
sidecar: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -632,6 +639,7 @@ export const assetStub = {
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
}),
|
||||
|
||||
sidecarWithoutExt: Object.freeze<AssetEntity>({
|
||||
@ -743,7 +751,7 @@ export const assetStub = {
|
||||
isOffline: false,
|
||||
}),
|
||||
|
||||
imageDng: Object.freeze<AssetEntity>({
|
||||
imageDng: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
@ -782,7 +790,7 @@ export const assetStub = {
|
||||
isOffline: false,
|
||||
}),
|
||||
|
||||
imageHif: Object.freeze<AssetEntity>({
|
||||
imageHif: Object.freeze({
|
||||
id: 'asset-id',
|
||||
status: AssetStatus.ACTIVE,
|
||||
deviceAssetId: 'device-asset-id',
|
||||
|
2
server/test/fixtures/shared-link.stub.ts
vendored
2
server/test/fixtures/shared-link.stub.ts
vendored
@ -116,6 +116,8 @@ export const sharedLinkStub = {
|
||||
album: undefined,
|
||||
description: null,
|
||||
assets: [assetStub.image],
|
||||
password: 'password',
|
||||
albumId: null,
|
||||
} as SharedLinkEntity),
|
||||
valid: Object.freeze({
|
||||
id: '123',
|
||||
|
@ -34,7 +34,7 @@ import { Mocked } from 'vitest';
|
||||
const sha256 = (value: string) => createHash('sha256').update(value).digest('base64');
|
||||
|
||||
// type Repositories = Omit<ServiceOverrides, 'access' | 'telemetry'>;
|
||||
type Repositories = {
|
||||
type RepositoriesTypes = {
|
||||
activity: ActivityRepository;
|
||||
album: AlbumRepository;
|
||||
asset: AssetRepository;
|
||||
@ -54,22 +54,22 @@ type Repositories = {
|
||||
systemMetadata: SystemMetadataRepository;
|
||||
versionHistory: VersionHistoryRepository;
|
||||
};
|
||||
type RepositoryMocks = { [K in keyof Repositories]: Mocked<RepositoryInterface<Repositories[K]>> };
|
||||
type RepositoryOptions = Partial<{ [K in keyof Repositories]: 'mock' | 'real' }>;
|
||||
type RepositoryMocks = { [K in keyof RepositoriesTypes]: Mocked<RepositoryInterface<RepositoriesTypes[K]>> };
|
||||
type RepositoryOptions = Partial<{ [K in keyof RepositoriesTypes]: 'mock' | 'real' }>;
|
||||
|
||||
type ContextRepositoryMocks<R extends RepositoryOptions> = {
|
||||
[K in keyof Repositories as R[K] extends 'mock' ? K : never]: Mocked<RepositoryInterface<Repositories[K]>>;
|
||||
[K in keyof RepositoriesTypes as R[K] extends 'mock' ? K : never]: Mocked<RepositoryInterface<RepositoriesTypes[K]>>;
|
||||
};
|
||||
|
||||
type ContextRepositories<R extends RepositoryOptions> = {
|
||||
[K in keyof Repositories as R[K] extends 'real' ? K : never]: Repositories[K];
|
||||
[K in keyof RepositoriesTypes as R[K] extends 'real' ? K : never]: RepositoriesTypes[K];
|
||||
};
|
||||
|
||||
export type Context<R extends RepositoryOptions, S extends BaseService> = {
|
||||
sut: S;
|
||||
mocks: ContextRepositoryMocks<R>;
|
||||
repos: ContextRepositories<R>;
|
||||
getRepository<T extends keyof Repositories>(key: T): Repositories[T];
|
||||
getRepository<T extends keyof RepositoriesTypes>(key: T): RepositoriesTypes[T];
|
||||
};
|
||||
|
||||
export const newMediumService = <R extends RepositoryOptions, S extends BaseService>(
|
||||
@ -79,7 +79,7 @@ export const newMediumService = <R extends RepositoryOptions, S extends BaseServ
|
||||
repos: R;
|
||||
},
|
||||
): Context<R, S> => {
|
||||
const repos: Partial<Repositories> = {};
|
||||
const repos: Partial<RepositoriesTypes> = {};
|
||||
const mocks: Partial<RepositoryMocks> = {};
|
||||
|
||||
const loggerMock = getRepositoryMock('logger') as Mocked<LoggingRepository>;
|
||||
@ -88,7 +88,7 @@ export const newMediumService = <R extends RepositoryOptions, S extends BaseServ
|
||||
|
||||
for (const [_key, type] of Object.entries(options.repos)) {
|
||||
if (type === 'real') {
|
||||
const key = _key as keyof Repositories;
|
||||
const key = _key as keyof RepositoriesTypes;
|
||||
repos[key] = getRepository(key, options.database) as any;
|
||||
continue;
|
||||
}
|
||||
@ -100,7 +100,7 @@ export const newMediumService = <R extends RepositoryOptions, S extends BaseServ
|
||||
}
|
||||
}
|
||||
|
||||
const makeRepository = <K extends keyof Repositories>(key: K) => {
|
||||
const makeRepository = <K extends keyof RepositoriesTypes>(key: K) => {
|
||||
return repos[key] || getRepository(key, options.database);
|
||||
};
|
||||
|
||||
@ -115,7 +115,7 @@ export const newMediumService = <R extends RepositoryOptions, S extends BaseServ
|
||||
} as Context<R, S>;
|
||||
};
|
||||
|
||||
export const getRepository = <K extends keyof Repositories>(key: K, db: Kysely<DB>) => {
|
||||
export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kysely<DB>) => {
|
||||
switch (key) {
|
||||
case 'activity': {
|
||||
return new ActivityRepository(db);
|
||||
@ -189,10 +189,10 @@ export const getRepository = <K extends keyof Repositories>(key: K, db: Kysely<D
|
||||
}
|
||||
};
|
||||
|
||||
const getRepositoryMock = <K extends keyof Repositories>(key: K) => {
|
||||
const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
|
||||
switch (key) {
|
||||
case 'activity': {
|
||||
return automock(ActivityRepository);
|
||||
return automock(ActivityRepository) as Mocked<RepositoryInterface<ActivityRepository>>;
|
||||
}
|
||||
|
||||
case 'album': {
|
||||
|
121
server/test/medium/responses.ts
Normal file
121
server/test/medium/responses.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { expect } from 'vitest';
|
||||
|
||||
export const errorDto = {
|
||||
unauthorized: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Authentication required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
forbidden: {
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: expect.any(String),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
missingPermission: (permission: string) => ({
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: `Missing required permission: ${permission}`,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
wrongPassword: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'Wrong password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidToken: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid user token',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidShareKey: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid share key',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidSharePassword: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: message ?? expect.anything(),
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
noPermission: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
incorrectLogin: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Incorrect email or password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
alreadyHasAdmin: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'The server already has an admin',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidEmail: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: ['email must be an email'],
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
};
|
||||
|
||||
export const signupResponseDto = {
|
||||
admin: {
|
||||
avatarColor: expect.any(String),
|
||||
id: expect.any(String),
|
||||
name: 'Immich Admin',
|
||||
email: 'admin@immich.cloud',
|
||||
storageLabel: 'admin',
|
||||
profileImagePath: '',
|
||||
// why? lol
|
||||
shouldChangePassword: true,
|
||||
isAdmin: true,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
deletedAt: null,
|
||||
oauthId: '',
|
||||
quotaUsageInBytes: 0,
|
||||
quotaSizeInBytes: null,
|
||||
status: 'active',
|
||||
license: null,
|
||||
profileChangedAt: expect.any(String),
|
||||
},
|
||||
};
|
||||
|
||||
export const loginResponseDto = {
|
||||
admin: {
|
||||
accessToken: expect.any(String),
|
||||
name: 'Immich Admin',
|
||||
isAdmin: true,
|
||||
profileImagePath: '',
|
||||
shouldChangePassword: true,
|
||||
userEmail: 'admin@immich.cloud',
|
||||
userId: expect.any(String),
|
||||
},
|
||||
};
|
||||
export const deviceDto = {
|
||||
current: {
|
||||
id: expect.any(String),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
current: true,
|
||||
deviceOS: '',
|
||||
deviceType: '',
|
||||
},
|
||||
};
|
60
server/test/medium/specs/controllers/auth.controller.spec.ts
Normal file
60
server/test/medium/specs/controllers/auth.controller.spec.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { createControllerTestApp, TestControllerApp } from 'test/medium/utils';
|
||||
|
||||
describe(AuthController.name, () => {
|
||||
let app: TestControllerApp;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createControllerTestApp();
|
||||
});
|
||||
|
||||
describe('POST /auth/admin-sign-up', () => {
|
||||
const name = 'admin';
|
||||
const email = 'admin@immich.cloud';
|
||||
const password = 'password';
|
||||
|
||||
const invalid = [
|
||||
{
|
||||
should: 'require an email address',
|
||||
data: { name, password },
|
||||
},
|
||||
{
|
||||
should: 'require a password',
|
||||
data: { name, email },
|
||||
},
|
||||
{
|
||||
should: 'require a name',
|
||||
data: { email, password },
|
||||
},
|
||||
{
|
||||
should: 'require a valid email',
|
||||
data: { name, email: 'immich', password },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { should, data } of invalid) {
|
||||
it(`should ${should}`, async () => {
|
||||
const { status, body } = await request(app.getHttpServer()).post('/auth/admin-sign-up').send(data);
|
||||
expect(status).toEqual(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should transform email to lower case', async () => {
|
||||
const { status } = await request(app.getHttpServer())
|
||||
.post('/auth/admin-sign-up')
|
||||
.send({ name: 'admin', password: 'password', email: 'aDmIn@IMMICH.cloud' });
|
||||
expect(status).toEqual(201);
|
||||
expect(app.getMockedService(AuthService).adminSignUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'admin@immich.cloud' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
100
server/test/medium/specs/controllers/user.controller.spec.ts
Normal file
100
server/test/medium/specs/controllers/user.controller.spec.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { UserController } from 'src/controllers/user.controller';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { createControllerTestApp, TestControllerApp } from 'test/medium/utils';
|
||||
import { factory } from 'test/small.factory';
|
||||
|
||||
describe(UserController.name, () => {
|
||||
let realApp: TestControllerApp;
|
||||
let mockApp: TestControllerApp;
|
||||
|
||||
beforeAll(async () => {
|
||||
realApp = await createControllerTestApp({ authType: 'real' });
|
||||
mockApp = await createControllerTestApp({ authType: 'mock' });
|
||||
});
|
||||
|
||||
describe('GET /users', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer()).get('/users');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
it('should call the service with an auth dto', async () => {
|
||||
const user = factory.user();
|
||||
const authService = mockApp.getMockedService(AuthService);
|
||||
const auth = factory.auth({ user });
|
||||
authService.authenticate.mockResolvedValue(auth);
|
||||
|
||||
const userService = mockApp.getMockedService(UserService);
|
||||
const { status } = await request(mockApp.getHttpServer()).get('/users').set('Authorization', `Bearer token`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(userService.search).toHaveBeenCalledWith(auth);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/me', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer()).get(`/users/me`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer()).put(`/users/me`);
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
for (const key of ['email', 'name']) {
|
||||
it(`should not allow null ${key}`, async () => {
|
||||
const dto = { [key]: null };
|
||||
const { status, body } = await request(mockApp.getHttpServer())
|
||||
.put(`/users/me`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(realApp.getHttpServer()).get(`/users/${factory.uuid()}`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /server/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(realApp.getHttpServer()).get('/users/me/license');
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /users/me/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(realApp.getHttpServer()).put(`/users/me/license`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/me/license', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status } = await request(realApp.getHttpServer()).put(`/users/me/license`);
|
||||
expect(status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await realApp.close();
|
||||
await mockApp.close();
|
||||
});
|
||||
});
|
@ -2,7 +2,6 @@ import { Stats } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||
import { MetadataService } from 'src/services/metadata.service';
|
||||
@ -119,7 +118,7 @@ describe(MetadataService.name, () => {
|
||||
process.env.TZ = serverTimeZone ?? undefined;
|
||||
|
||||
const { filePath } = await createTestFile(exifData);
|
||||
mocks.asset.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as never);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: 'asset-1' });
|
||||
|
@ -60,6 +60,25 @@ describe(UserService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should get users', async () => {
|
||||
const { sut, repos } = createSut();
|
||||
const user1 = mediumFactory.userInsert();
|
||||
const user2 = mediumFactory.userInsert();
|
||||
|
||||
await Promise.all([repos.user.create(user1), repos.user.create(user2)]);
|
||||
|
||||
const auth = factory.auth({ user: user1 });
|
||||
|
||||
await expect(sut.search(auth)).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ email: user1.email }),
|
||||
expect.objectContaining({ email: user2.email }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should get a user', async () => {
|
||||
const { sut, repos } = createSut();
|
100
server/test/medium/utils.ts
Normal file
100
server/test/medium/utils.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { middleware } from 'src/app.module';
|
||||
import { controllers } from 'src/controllers';
|
||||
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { services } from 'src/services';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { automock } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
export const createControllerTestApp = async (options?: { authType?: 'mock' | 'real' }) => {
|
||||
const { authType = 'mock' } = options || {};
|
||||
|
||||
const configMock = { getEnv: () => ({ noColor: true }) };
|
||||
const clsMock = { getId: vitest.fn().mockReturnValue('cls-id') };
|
||||
const loggerMock = automock(LoggingRepository, { args: [clsMock, configMock], strict: false });
|
||||
loggerMock.setContext.mockReturnValue(void 0);
|
||||
loggerMock.error.mockImplementation((...args: any[]) => {
|
||||
console.log('Logger.error was called with', ...args);
|
||||
});
|
||||
|
||||
const mockBaseService = (service: ClassConstructor<BaseService>) => {
|
||||
return automock(service, { args: [loggerMock], strict: false });
|
||||
};
|
||||
|
||||
const clsServiceMock = clsMock;
|
||||
|
||||
const FAKE_MOCK = vitest.fn();
|
||||
|
||||
const providers: Provider[] = [
|
||||
...middleware,
|
||||
...services.map((Service) => {
|
||||
if ((authType === 'real' && Service === AuthService) || Service === ApiService) {
|
||||
return Service;
|
||||
}
|
||||
return { provide: Service, useValue: mockBaseService(Service as ClassConstructor<BaseService>) };
|
||||
}),
|
||||
GlobalExceptionFilter,
|
||||
{ provide: LoggingRepository, useValue: loggerMock },
|
||||
{ provide: ClsService, useValue: clsServiceMock },
|
||||
];
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [],
|
||||
controllers: [...controllers],
|
||||
providers,
|
||||
})
|
||||
.useMocker((token) => {
|
||||
if (token === LoggingRepository) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (token === SchedulerRegistry) {
|
||||
return FAKE_MOCK;
|
||||
}
|
||||
|
||||
if (typeof token === 'function' && token.name.endsWith('Repository')) {
|
||||
return FAKE_MOCK;
|
||||
}
|
||||
|
||||
if (typeof token === 'string' && token === 'KyselyModuleConnectionToken') {
|
||||
return FAKE_MOCK;
|
||||
}
|
||||
})
|
||||
|
||||
.compile();
|
||||
|
||||
const app = moduleRef.createNestApplication();
|
||||
|
||||
await app.init();
|
||||
|
||||
const getMockedRepository = <T>(token: ClassConstructor<T>) => {
|
||||
return app.get(token) as Mocked<T>;
|
||||
};
|
||||
|
||||
return {
|
||||
getHttpServer: () => app.getHttpServer(),
|
||||
getMockedService: <T>(token: ClassConstructor<T>) => {
|
||||
if (authType === 'real' && token === AuthService) {
|
||||
throw new Error('Auth type is real, cannot get mocked service');
|
||||
}
|
||||
return app.get(token) as Mocked<T>;
|
||||
},
|
||||
getMockedRepository,
|
||||
close: () => app.close(),
|
||||
};
|
||||
};
|
||||
|
||||
export type TestControllerApp = {
|
||||
getHttpServer: () => any;
|
||||
getMockedService: <T>(token: ClassConstructor<T>) => Mocked<T>;
|
||||
getMockedRepository: <T>(token: ClassConstructor<T>) => Mocked<T>;
|
||||
close: () => Promise<void>;
|
||||
};
|
@ -12,7 +12,6 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
getByDayOfYear: vitest.fn(),
|
||||
getByIds: vitest.fn().mockResolvedValue([]),
|
||||
getByIdsWithAllRelations: vitest.fn().mockResolvedValue([]),
|
||||
getByAlbumId: vitest.fn(),
|
||||
getByDeviceIds: vitest.fn(),
|
||||
getByUserId: vitest.fn(),
|
||||
getById: vitest.fn(),
|
||||
|
@ -310,4 +310,5 @@ export const factory = {
|
||||
jobAssets: {
|
||||
sidecarWrite: assetSidecarWriteFactory,
|
||||
},
|
||||
uuid: newUuid,
|
||||
};
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { Column, ColumnIndex, DatabaseSchema, Table } from 'src/sql-tools';
|
||||
import { Column, DatabaseSchema, Table } from 'src/sql-tools';
|
||||
|
||||
@Table()
|
||||
export class Table1 {
|
||||
@ColumnIndex()
|
||||
@Column()
|
||||
@Column({ index: true })
|
||||
column1!: string;
|
||||
}
|
||||
|
||||
|
46
server/test/sql-tools/column-index-name.ts
Normal file
46
server/test/sql-tools/column-index-name.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Column, DatabaseSchema, Table } from 'src/sql-tools';
|
||||
|
||||
@Table()
|
||||
export class Table1 {
|
||||
@Column({ indexName: 'IDX_test' })
|
||||
column1!: string;
|
||||
}
|
||||
|
||||
export const description = 'should create a column with an index if a name is provided';
|
||||
export const schema: DatabaseSchema = {
|
||||
name: 'postgres',
|
||||
schemaName: 'public',
|
||||
functions: [],
|
||||
enums: [],
|
||||
extensions: [],
|
||||
parameters: [],
|
||||
tables: [
|
||||
{
|
||||
name: 'table1',
|
||||
columns: [
|
||||
{
|
||||
name: 'column1',
|
||||
tableName: 'table1',
|
||||
type: 'character varying',
|
||||
nullable: false,
|
||||
isArray: false,
|
||||
primary: false,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{
|
||||
name: 'IDX_test',
|
||||
columnNames: ['column1'],
|
||||
tableName: 'table1',
|
||||
unique: false,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
constraints: [],
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
@ -60,7 +60,15 @@ export const schema: DatabaseSchema = {
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
indexes: [
|
||||
{
|
||||
name: 'IDX_3fcca5cc563abf256fc346e3ff',
|
||||
tableName: 'table2',
|
||||
columnNames: ['parentId'],
|
||||
unique: false,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
constraints: [
|
||||
{
|
||||
|
@ -60,7 +60,15 @@ export const schema: DatabaseSchema = {
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
indexes: [
|
||||
{
|
||||
name: 'IDX_3fcca5cc563abf256fc346e3ff',
|
||||
tableName: 'table2',
|
||||
columnNames: ['parentId'],
|
||||
unique: false,
|
||||
synchronize: true,
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
constraints: [
|
||||
{
|
||||
|
@ -93,13 +93,17 @@ export const automock = <T>(
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = `${Dependency.name}.${property}`;
|
||||
// console.log(`Automocking ${label}`);
|
||||
try {
|
||||
const label = `${Dependency.name}.${property}`;
|
||||
// console.log(`Automocking ${label}`);
|
||||
|
||||
const target = instance[property as keyof T];
|
||||
if (typeof target === 'function') {
|
||||
mock[property] = mockFn(label, { strict });
|
||||
continue;
|
||||
const target = instance[property as keyof T];
|
||||
if (typeof target === 'function') {
|
||||
mock[property] = mockFn(label, { strict });
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -130,18 +130,30 @@
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const clearLongPressTimer = () => clearTimeout(timer);
|
||||
|
||||
const preventContextMenu = (evt: Event) => evt.preventDefault();
|
||||
let disposeables: (() => void)[] = [];
|
||||
|
||||
const clearLongPressTimer = () => {
|
||||
clearTimeout(timer);
|
||||
for (const dispose of disposeables) {
|
||||
dispose();
|
||||
}
|
||||
disposeables = [];
|
||||
};
|
||||
|
||||
let startX: number = 0;
|
||||
let startY: number = 0;
|
||||
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
|
||||
let didPress = false;
|
||||
const start = (evt: TouchEvent) => {
|
||||
startX = evt.changedTouches[0].clientX;
|
||||
startY = evt.changedTouches[0].clientY;
|
||||
const start = (evt: PointerEvent) => {
|
||||
startX = evt.clientX;
|
||||
startY = evt.clientY;
|
||||
didPress = false;
|
||||
timer = setTimeout(() => {
|
||||
onLongPress();
|
||||
element.addEventListener('contextmenu', preventContextMenu, { once: true });
|
||||
disposeables.push(() => element.removeEventListener('contextmenu', preventContextMenu));
|
||||
didPress = true;
|
||||
}, 350);
|
||||
};
|
||||
@ -153,13 +165,13 @@
|
||||
e.preventDefault();
|
||||
};
|
||||
element.addEventListener('click', click);
|
||||
element.addEventListener('touchstart', start, true);
|
||||
element.addEventListener('touchend', clearLongPressTimer, true);
|
||||
element.addEventListener('pointerdown', start, true);
|
||||
element.addEventListener('pointerup', clearLongPressTimer, true);
|
||||
return {
|
||||
destroy: () => {
|
||||
element.removeEventListener('click', click);
|
||||
element.removeEventListener('touchstart', start, true);
|
||||
element.removeEventListener('touchend', clearLongPressTimer, true);
|
||||
element.removeEventListener('pointerdown', start, true);
|
||||
element.removeEventListener('pointerup', clearLongPressTimer, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user