Merge remote-tracking branch 'origin/main' into feat/yucca-integration

This commit is contained in:
izzy
2026-05-08 14:00:20 +01:00
948 changed files with 57491 additions and 11659 deletions
+3 -11
View File
@@ -1,4 +1,3 @@
import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
@@ -15,7 +14,6 @@ export const ErrorMessages = {
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <2';
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
@@ -25,15 +23,10 @@ export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
cube: 'cube',
earthdistance: 'earthdistance',
vector: 'pgvector',
vectors: 'pgvecto.rs',
vchord: 'VectorChord',
} as const;
export const VECTOR_EXTENSIONS = [
DatabaseExtension.VectorChord,
DatabaseExtension.Vectors,
DatabaseExtension.Vector,
] as const;
export const VECTOR_EXTENSIONS = [DatabaseExtension.VectorChord, DatabaseExtension.Vector] as const;
export const VECTOR_INDEX_TABLES = {
[VectorIndex.Clip]: 'smart_search',
@@ -43,6 +36,8 @@ export const VECTOR_INDEX_TABLES = {
export const VECTORCHORD_LIST_SLACK_FACTOR = 1.2;
export const SALT_ROUNDS = 10;
// Syntactically valid bcrypt hash used in login() preventing timing-based user enumeration.
export const LOGIN_DUMMY_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZabcde';
export const IWorker = 'IWorker';
@@ -52,9 +47,6 @@ const packageFile = join(basePath, '..', 'package.json');
const { version } = JSON.parse(readFileSync(packageFile, 'utf8'));
export const serverVersion = new SemVer(version);
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const citiesFile = 'cities500.txt';
export const reverseGeocodeMaxDistance = 25_000;
@@ -28,14 +28,16 @@ describe(ActivityController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).get('/activities');
expect(status).toEqual(400);
expect(body).toEqual(
factory.responses.badRequest(['[albumId] Invalid input: expected string, received undefined']),
factory.responses.validationError([
{ path: ['albumId'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
it('should reject an invalid albumId', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }]));
});
it('should reject an invalid assetId', async () => {
@@ -43,7 +45,7 @@ describe(ActivityController.name, () => {
.get('/activities')
.query({ albumId: factory.uuid(), assetId: '123' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }]));
});
});
@@ -58,7 +60,7 @@ describe(ActivityController.name, () => {
.post('/activities')
.send({ albumId: '123', type: 'like' });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }]));
});
it('should require a comment when type is comment', async () => {
@@ -66,7 +68,11 @@ describe(ActivityController.name, () => {
.post('/activities')
.send({ albumId: factory.uuid(), type: 'comment', comment: null });
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['[comment] Invalid input: expected string, received null']));
expect(body).toEqual(
factory.responses.validationError([
{ path: ['comment'], message: 'Invalid input: expected string, received null' },
]),
);
});
});
@@ -79,7 +85,7 @@ describe(ActivityController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
});
@@ -25,15 +25,19 @@ describe(AlbumController.name, () => {
});
it('should reject an invalid shared param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid');
const { status, body } = await request(ctx.getHttpServer()).get('/albums?isShared=invalid');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['[shared] Invalid option: expected one of "true"|"false"']));
expect(body).toEqual(
factory.responses.validationError([
{ path: ['isShared'], message: 'Invalid option: expected one of "true"|"false"' },
]),
);
});
it('should reject an invalid assetId param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }]));
});
});
@@ -49,7 +49,7 @@ describe(ApiKeyController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
@@ -64,7 +64,7 @@ describe(ApiKeyController.name, () => {
.put(`/api-keys/123`)
.send({ name: 'new name', permissions: [Permission.All] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
it('should allow updating just the name', async () => {
@@ -84,7 +84,7 @@ describe(ApiKeyController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
});
@@ -80,7 +80,9 @@ describe(AssetMediaController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['[metadata] Invalid input: expected JSON string, received string']),
factory.responses.validationError([
{ path: ['metadata'], message: 'Invalid input: expected JSON string, received string' },
]),
);
});
@@ -91,8 +93,8 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest([
'[fileCreatedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
factory.responses.validationError([
{ path: ['fileCreatedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
]),
);
});
@@ -104,8 +106,8 @@ describe(AssetMediaController.name, () => {
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest([
'[fileModifiedAt] Invalid input: expected ISO 8601 datetime string, received undefined',
factory.responses.validationError([
{ path: ['fileModifiedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' },
]),
);
});
@@ -117,7 +119,9 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['[isFavorite] Invalid option: expected one of "true"|"false"']),
factory.responses.validationError([
{ path: ['isFavorite'], message: 'Invalid option: expected one of "true"|"false"' },
]),
);
});
@@ -128,7 +132,9 @@ describe(AssetMediaController.name, () => {
.field({ ...makeUploadDto(), visibility: 'not-an-option' });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
factory.responses.validationError([
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
});
+87 -51
View File
@@ -31,7 +31,7 @@ describe(AssetController.name, () => {
.send({ ids: ['123'] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
});
it('should require duplicateId to be a string', async () => {
@@ -42,7 +42,9 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['[duplicateId] Invalid input: expected string, received boolean']),
factory.responses.validationError([
{ path: ['duplicateId'], message: 'Invalid input: expected string, received boolean' },
]),
);
});
@@ -70,7 +72,7 @@ describe(AssetController.name, () => {
.send({ ids: ['123'] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
});
});
@@ -83,7 +85,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
@@ -97,12 +99,10 @@ describe(AssetController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([
'[sourceId] Invalid input: expected string, received undefined',
'[targetId] Invalid input: expected string, received undefined',
]),
),
factory.responses.validationError([
{ path: ['sourceId'], message: 'Invalid input: expected string, received undefined' },
{ path: ['targetId'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
@@ -125,7 +125,9 @@ describe(AssetController.name, () => {
.put('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
expect(body).toEqual(
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
);
});
it('should require a key', async () => {
@@ -134,9 +136,9 @@ describe(AssetController.name, () => {
.send({ items: [{ assetId: factory.uuid(), value: {} }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
),
factory.responses.validationError([
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
@@ -159,7 +161,9 @@ describe(AssetController.name, () => {
.delete('/assets/metadata')
.send({ items: [{ assetId: '123', key: 'test' }] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID'])));
expect(body).toEqual(
factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]),
);
});
it('should require a key', async () => {
@@ -168,9 +172,9 @@ describe(AssetController.name, () => {
.send({ items: [{ assetId: factory.uuid() }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']),
),
factory.responses.validationError([
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
@@ -191,33 +195,56 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['Invalid input: expected object, received undefined']));
expect(body).toEqual(
factory.responses.validationError([
{ path: [], message: 'Invalid input: expected object, received undefined' },
]),
);
});
it('should reject invalid gps coordinates', async () => {
for (const test of [
{ latitude: 12 },
{ longitude: 12 },
{ latitude: 12, longitude: 'abc' },
{ latitude: 'abc', longitude: 12 },
{ latitude: null, longitude: 12 },
{ latitude: 12, longitude: null },
{ latitude: 91, longitude: 12 },
{ latitude: -91, longitude: 12 },
{ latitude: 12, longitude: -181 },
{ latitude: 12, longitude: 181 },
]) {
for (const [test, errors] of [
[{ latitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
[{ longitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]],
[
{ latitude: 12, longitude: 'abc' },
[{ path: ['longitude'], message: 'Invalid input: expected number, received string' }],
],
[
{ latitude: 'abc', longitude: 12 },
[{ path: ['latitude'], message: 'Invalid input: expected number, received string' }],
],
[
{ latitude: null, longitude: 12 },
[{ path: ['latitude'], message: 'Invalid input: expected number, received null' }],
],
[
{ latitude: 12, longitude: null },
[{ path: ['longitude'], message: 'Invalid input: expected number, received null' }],
],
[{ latitude: 91, longitude: 12 }, [{ path: ['latitude'], message: 'Too big: expected number to be <=90' }]],
[{ latitude: -91, longitude: 12 }, [{ path: ['latitude'], message: 'Too small: expected number to be >=-90' }]],
[
{ latitude: 12, longitude: -181 },
[{ path: ['longitude'], message: 'Too small: expected number to be >=-180' }],
],
[{ latitude: 12, longitude: 181 }, [{ path: ['longitude'], message: 'Too big: expected number to be <=180' }]],
] as const) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(factory.responses.validationError(errors));
}
});
it('should reject invalid rating', async () => {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) {
for (const [test, errors] of [
[{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]],
[{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]],
[{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]],
] as const) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(factory.responses.validationError(errors));
}
});
@@ -261,13 +288,17 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
it('should require items to be an array', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[items] Invalid input: expected array, received undefined']));
expect(body).toEqual(
factory.responses.validationError([
{ path: ['items'], message: 'Invalid input: expected array, received undefined' },
]),
);
});
it('should require each item to have a valid key', async () => {
@@ -276,7 +307,9 @@ describe(AssetController.name, () => {
.send({ items: [{ value: { some: 'value' } }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['[items.0.key] Invalid input: expected string, received undefined']),
factory.responses.validationError([
{ path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
@@ -286,9 +319,9 @@ describe(AssetController.name, () => {
.send({ items: [{ key: 'mobile-app', value: null }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining(['[items.0.value] Invalid input: expected record, received null']),
),
factory.responses.validationError([
{ path: ['items', 0, 'value'], message: 'Invalid input: expected record, received null' },
]),
);
});
@@ -326,7 +359,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
@@ -376,7 +409,7 @@ describe(AssetController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID'])));
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
it('should check the action and parameters discriminator', async () => {
@@ -398,13 +431,12 @@ describe(AssetController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([
expect.stringContaining(
"[edits.0.parameters] Invalid parameters for action 'rotate', expecting keys: angle",
),
]),
),
factory.responses.validationError([
{
path: ['edits', 0, 'parameters'],
message: expect.stringContaining("Invalid parameters for action 'rotate', expecting keys: angle"),
},
]),
);
});
@@ -413,7 +445,11 @@ describe(AssetController.name, () => {
.put(`/assets/${factory.uuid()}/edits`)
.send({ edits: [] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[edits] Too small: expected array to have >=1 items']));
expect(body).toEqual(
factory.responses.validationError([
{ path: ['edits'], message: 'Too small: expected array to have >=1 items' },
]),
);
});
});
@@ -426,7 +462,7 @@ describe(AssetController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
});
+41 -13
View File
@@ -28,19 +28,27 @@ describe(AuthController.name, () => {
it('should require an email address', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, password });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest());
expect(body).toEqual(
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received undefined' }]),
);
});
it('should require a password', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, email });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest());
expect(body).toEqual(
errorDto.validationError([
{ path: ['password'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ email, password });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest());
expect(body).toEqual(
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
);
});
it('should require a valid email', async () => {
@@ -48,7 +56,9 @@ describe(AuthController.name, () => {
.post('/auth/admin-sign-up')
.send({ name, email: 'immich', password });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest());
expect(body).toEqual(
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received string' }]),
);
});
it('should transform email to lower case', async () => {
@@ -73,9 +83,9 @@ describe(AuthController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/login').send({ name: 'admin' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'[email] Invalid input: expected email, received undefined',
'[password] Invalid input: expected string, received undefined',
errorDto.validationError([
{ path: ['email'], message: 'Invalid input: expected email, received undefined' },
{ path: ['password'], message: 'Invalid input: expected string, received undefined' },
]),
);
});
@@ -85,7 +95,9 @@ describe(AuthController.name, () => {
.post('/auth/login')
.send({ name: 'admin', email: null, password: 'password' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
expect(body).toEqual(
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]),
);
});
it(`should not allow null password`, async () => {
@@ -93,7 +105,9 @@ describe(AuthController.name, () => {
.post('/auth/login')
.send({ name: 'admin', email: 'admin@immich.cloud', password: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[password] Invalid input: expected string, received null']));
expect(body).toEqual(
errorDto.validationError([{ path: ['password'], message: 'Invalid input: expected string, received null' }]),
);
});
it('should reject an invalid email', async () => {
@@ -104,7 +118,9 @@ describe(AuthController.name, () => {
.send({ name: 'admin', email: [], password: 'password' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object']));
expect(body).toEqual(
errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]),
);
});
it('should transform the email to all lowercase', async () => {
@@ -195,19 +211,31 @@ describe(AuthController.name, () => {
it('should reject 5 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
expect(body).toEqual(
errorDto.validationError([
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
]),
);
});
it('should reject 7 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
expect(body).toEqual(
errorDto.validationError([
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
]),
);
});
it('should reject non-numbers', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`]));
expect(body).toEqual(
errorDto.validationError([
{ path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` },
]),
);
});
});
@@ -41,7 +41,7 @@ describe(DuplicateController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
});
@@ -41,8 +41,8 @@ export class DuplicateController {
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a duplicate',
description: 'Delete a single duplicate asset specified by its ID.',
summary: 'Dismiss a duplicate group',
description: 'Dismiss a duplicate group by its ID, unlinking all assets in the group without deleting them.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
@@ -31,7 +31,9 @@ describe(MaintenanceController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['[restoreBackupFilename] Backup filename is required when action is restore_database']),
errorDto.validationError([
{ path: ['restoreBackupFilename'], message: 'Backup filename is required when action is restore_database' },
]),
);
expect(ctx.authenticate).toHaveBeenCalled();
});
@@ -47,7 +47,11 @@ describe(MemoryController.name, () => {
});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[data.year] Invalid input: expected number, received undefined']));
expect(body).toEqual(
errorDto.validationError([
{ path: ['data', 'year'], message: 'Invalid input: expected number, received undefined' },
]),
);
});
it('should accept showAt and hideAt', async () => {
@@ -81,7 +85,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
@@ -94,13 +98,15 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
expect(body).toEqual(
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
);
});
it('should require at least one field', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['At least one field must be provided']));
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }]));
});
});
@@ -120,7 +126,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
it('should require a valid asset id', async () => {
@@ -128,7 +134,7 @@ describe(MemoryController.name, () => {
.put(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
});
});
@@ -141,7 +147,7 @@ describe(MemoryController.name, () => {
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
it('should require a valid asset id', async () => {
@@ -149,7 +155,7 @@ describe(MemoryController.name, () => {
.delete(`/memories/${factory.uuid()}/assets`)
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
});
});
});
@@ -31,7 +31,11 @@ describe(NotificationController.name, () => {
.query({ level: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[level] Invalid option: expected one of')]));
expect(body).toEqual(
errorDto.validationError([
{ path: ['level'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
});
});
@@ -45,7 +49,9 @@ describe(NotificationController.name, () => {
it('should require a list', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[ids] Invalid input: expected array, received boolean']));
expect(body).toEqual(
errorDto.validationError([{ path: ['ids'], message: 'Invalid input: expected array, received boolean' }]),
);
});
it('should require uuids', async () => {
@@ -53,7 +59,9 @@ describe(NotificationController.name, () => {
.put(`/notifications`)
.send({ ids: [true] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid input: expected string, received boolean']));
expect(body).toEqual(
errorDto.validationError([{ path: ['ids', 0], message: 'Invalid input: expected string, received boolean' }]),
);
});
it('should accept valid uuids', async () => {
@@ -75,7 +83,7 @@ describe(NotificationController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
@@ -33,7 +33,9 @@ describe(PartnerController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]),
errorDto.validationError([
{ path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
});
@@ -44,7 +46,9 @@ describe(PartnerController.name, () => {
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]),
errorDto.validationError([
{ path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
});
});
@@ -61,7 +65,7 @@ describe(PartnerController.name, () => {
.send({ sharedWithId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[sharedWithId] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['sharedWithId'], message: 'Invalid UUID' }]));
});
});
@@ -77,7 +81,7 @@ describe(PartnerController.name, () => {
.send({ inTimeline: true })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
@@ -92,7 +96,7 @@ describe(PartnerController.name, () => {
.delete(`/partners/invalid`)
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
});
@@ -35,7 +35,7 @@ describe(PersonController.name, () => {
.query({ closestPersonId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[closestPersonId] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['closestPersonId'], message: 'Invalid UUID' }]));
});
it(`should require closestAssetId to be a uuid`, async () => {
@@ -44,7 +44,7 @@ describe(PersonController.name, () => {
.query({ closestAssetId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[closestAssetId] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['closestAssetId'], message: 'Invalid UUID' }]));
});
});
@@ -76,7 +76,7 @@ describe(PersonController.name, () => {
.delete('/people')
.send({ ids: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }]));
});
it('should respond with 204', async () => {
@@ -104,7 +104,9 @@ describe(PersonController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
expect(body).toEqual(
errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]),
);
});
it(`should not allow a null name`, async () => {
@@ -113,7 +115,9 @@ describe(PersonController.name, () => {
.send({ name: null })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received null']));
expect(body).toEqual(
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received null' }]),
);
});
it(`should require featureFaceAssetId to be a uuid`, async () => {
@@ -122,7 +126,7 @@ describe(PersonController.name, () => {
.send({ featureFaceAssetId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[featureFaceAssetId] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['featureFaceAssetId'], message: 'Invalid UUID' }]));
});
it(`should require isFavorite to be a boolean`, async () => {
@@ -131,7 +135,11 @@ describe(PersonController.name, () => {
.send({ isFavorite: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string']));
expect(body).toEqual(
errorDto.validationError([
{ path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' },
]),
);
});
it(`should require isHidden to be a boolean`, async () => {
@@ -140,7 +148,9 @@ describe(PersonController.name, () => {
.send({ isHidden: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[isHidden] Invalid input: expected boolean, received string']));
expect(body).toEqual(
errorDto.validationError([{ path: ['isHidden'], message: 'Invalid input: expected boolean, received string' }]),
);
});
it('should map an empty birthDate to null', async () => {
@@ -154,7 +164,11 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`)
.send({ birthDate: false });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received boolean']));
expect(body).toEqual(
errorDto.validationError([
{ path: ['birthDate'], message: 'Invalid input: expected string, received boolean' },
]),
);
});
it('should not accept an invalid birth date (number)', async () => {
@@ -162,7 +176,9 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`)
.send({ birthDate: 123_456 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received number']));
expect(body).toEqual(
errorDto.validationError([{ path: ['birthDate'], message: 'Invalid input: expected string, received number' }]),
);
});
it('should not accept a birth date in the future)', async () => {
@@ -170,7 +186,9 @@ describe(PersonController.name, () => {
.put(`/people/${factory.uuid()}`)
.send({ birthDate: '9999-01-01' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[birthDate] Birth date cannot be in the future']));
expect(body).toEqual(
errorDto.validationError([{ path: ['birthDate'], message: 'Birth date cannot be in the future' }]),
);
});
});
@@ -183,7 +201,7 @@ describe(PersonController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
it('should respond with 204', async () => {
@@ -27,31 +27,41 @@ describe(SearchController.name, () => {
it('should reject page as a string', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[page] Invalid input: expected number, received string']));
expect(body).toEqual(
errorDto.validationError([{ path: ['page'], message: 'Invalid input: expected number, received string' }]),
);
});
it('should reject page as a negative number', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: -10 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1']));
expect(body).toEqual(
errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]),
);
});
it('should reject page as 0', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 0 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1']));
expect(body).toEqual(
errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]),
);
});
it('should reject size as a string', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: 'abc' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[size] Invalid input: expected number, received string']));
expect(body).toEqual(
errorDto.validationError([{ path: ['size'], message: 'Invalid input: expected number, received string' }]),
);
});
it('should reject an invalid size', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1.5 });
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
expect(body).toEqual(
errorDto.validationError([{ path: ['size'], message: 'Too small: expected number to be >=1' }]),
);
});
it('should reject an visibility as not an enum', async () => {
@@ -60,7 +70,9 @@ describe(SearchController.name, () => {
.send({ visibility: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]),
errorDto.validationError([
{ path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
});
@@ -69,7 +81,11 @@ describe(SearchController.name, () => {
.post('/search/metadata')
.send({ isFavorite: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string']));
expect(body).toEqual(
errorDto.validationError([
{ path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' },
]),
);
});
it('should reject an isEncoded as not a boolean', async () => {
@@ -77,7 +93,11 @@ describe(SearchController.name, () => {
.post('/search/metadata')
.send({ isEncoded: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[isEncoded] Invalid input: expected boolean, received string']));
expect(body).toEqual(
errorDto.validationError([
{ path: ['isEncoded'], message: 'Invalid input: expected boolean, received string' },
]),
);
});
it('should reject an isOffline as not a boolean', async () => {
@@ -85,13 +105,19 @@ describe(SearchController.name, () => {
.post('/search/metadata')
.send({ isOffline: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[isOffline] Invalid input: expected boolean, received string']));
expect(body).toEqual(
errorDto.validationError([
{ path: ['isOffline'], message: 'Invalid input: expected boolean, received string' },
]),
);
});
it('should reject an isMotion as not a boolean', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ isMotion: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[isMotion] Invalid input: expected boolean, received string']));
expect(body).toEqual(
errorDto.validationError([{ path: ['isMotion'], message: 'Invalid input: expected boolean, received string' }]),
);
});
describe('POST /search/random', () => {
@@ -105,7 +131,11 @@ describe(SearchController.name, () => {
.post('/search/random')
.send({ withStacked: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[withStacked] Invalid input: expected boolean, received string']));
expect(body).toEqual(
errorDto.validationError([
{ path: ['withStacked'], message: 'Invalid input: expected boolean, received string' },
]),
);
});
it('should reject if withPeople is not a boolean', async () => {
@@ -113,7 +143,11 @@ describe(SearchController.name, () => {
.post('/search/random')
.send({ withPeople: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[withPeople] Invalid input: expected boolean, received string']));
expect(body).toEqual(
errorDto.validationError([
{ path: ['withPeople'], message: 'Invalid input: expected boolean, received string' },
]),
);
});
});
@@ -140,7 +174,9 @@ describe(SearchController.name, () => {
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined']));
expect(body).toEqual(
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
);
});
});
@@ -153,7 +189,9 @@ describe(SearchController.name, () => {
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined']));
expect(body).toEqual(
errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]),
);
});
});
@@ -173,7 +211,11 @@ describe(SearchController.name, () => {
it('should require a type', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[type] Invalid option: expected one of')]));
expect(body).toEqual(
errorDto.validationError([
{ path: ['type'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
});
});
});
+13 -3
View File
@@ -35,7 +35,11 @@ describe(SyncController.name, () => {
.post('/sync/stream')
.send({ types: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')]));
expect(body).toEqual(
errorDto.validationError([
{ path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
@@ -57,7 +61,9 @@ describe(SyncController.name, () => {
const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`);
const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[acks] Too big: expected array to have <=1000 items']));
expect(body).toEqual(
errorDto.validationError([{ path: ['acks'], message: 'Too big: expected array to have <=1000 items' }]),
);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
@@ -73,7 +79,11 @@ describe(SyncController.name, () => {
.delete('/sync/ack')
.send({ types: ['invalid'] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')]));
expect(body).toEqual(
errorDto.validationError([
{ path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
@@ -67,8 +67,11 @@ describe(SystemConfigController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'[nightlyTasks.startTime] Invalid input: expected string in HH:mm format, received string',
errorDto.validationError([
{
path: ['nightlyTasks', 'startTime'],
message: 'Invalid input: expected string in HH:mm format, received string',
},
]),
);
});
@@ -86,7 +89,9 @@ describe(SystemConfigController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['[nightlyTasks.databaseCleanup] Invalid input: expected boolean, received string']),
errorDto.validationError([
{ path: ['nightlyTasks', 'databaseCleanup'], message: 'Invalid input: expected boolean, received string' },
]),
);
});
});
@@ -116,7 +121,12 @@ describe(SystemConfigController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['[image.thumbnail.progressive] Invalid input: expected boolean, received string']),
errorDto.validationError([
{
path: ['image', 'thumbnail', 'progressive'],
message: 'Invalid input: expected boolean, received string',
},
]),
);
});
});
@@ -54,7 +54,7 @@ describe(TagController.name, () => {
it('should require a valid uuid', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID']));
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
@@ -42,7 +42,9 @@ describe(TimelineController.name, () => {
const { status, body } = await request(ctx.getHttpServer()).get('/timeline/buckets').query({ bbox: '1,2,3' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['[bbox] bbox must have 4 comma-separated numbers: west,south,east,north'] as any),
errorDto.validationError([
{ path: ['bbox'], message: 'bbox must have 4 comma-separated numbers: west,south,east,north' },
]),
);
});
@@ -51,7 +53,7 @@ describe(TimelineController.name, () => {
.get('/timeline/buckets')
.query({ bbox: '1,2,3,invalid' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[bbox] bbox parts must be valid numbers'] as any));
expect(body).toEqual(errorDto.validationError([{ path: ['bbox'], message: 'bbox parts must be valid numbers' }]));
});
});
@@ -78,9 +78,9 @@ describe(UserAdminController.name, () => {
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
),
errorDto.validationError([
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
]),
);
});
@@ -98,9 +98,9 @@ describe(UserAdminController.name, () => {
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
),
errorDto.validationError([
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
]),
);
});
});
@@ -125,9 +125,9 @@ describe(UserAdminController.name, () => {
.send({ quotaSizeInBytes: 1.2 });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(
expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']),
),
errorDto.validationError([
{ path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' },
]),
);
});
@@ -43,15 +43,17 @@ describe(UserController.name, () => {
expect(ctx.authenticate).toHaveBeenCalled();
});
for (const key of ['email', 'name']) {
for (const [key, message] of [
['email', 'Invalid input: expected email, received object'],
['name', 'Invalid input: expected string, received null'],
] as const) {
it(`should not allow null ${key}`, async () => {
const dto = { [key]: null };
const { status, body } = await request(ctx.getHttpServer())
.put(`/users/me`)
.set('Authorization', `Bearer token`)
.send(dto);
.send({ [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
expect(body).toEqual(errorDto.validationError([{ path: [key], message }]));
});
}
+28
View File
@@ -18,6 +18,7 @@ import { MoveRepository } from 'src/repositories/move.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { VideoInterfaces } from 'src/types';
import { getAssetFile } from 'src/utils/asset.util';
import { getConfig } from 'src/utils/config';
@@ -299,6 +300,11 @@ export class StorageCore {
return this.storageRepository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
}
async getVideoInterfaces(): Promise<VideoInterfaces> {
const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
return { dri, mali };
}
private savePath(pathType: PathType, id: string, newPath: string) {
switch (pathType) {
case AssetPathType.Original: {
@@ -330,4 +336,26 @@ export class StorageCore {
static getTempPathInDir(dir: string): string {
return join(dir, `${randomUUID()}.tmp`);
}
private async getDevices() {
try {
return await this.storageRepository.readdir('/dev/dri');
} catch {
this.logger.debug('No devices found in /dev/dri.');
return [];
}
}
private async hasMaliOpenCL() {
try {
const [maliIcdStat, maliDeviceStat] = await Promise.all([
this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
this.storageRepository.stat('/dev/mali0'),
]);
return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
} catch {
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
return false;
}
}
}
+21 -4
View File
@@ -18,7 +18,7 @@ import {
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { UserMetadataItem } from 'src/types';
import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types';
@@ -195,7 +195,6 @@ export type SharedLink = {
};
export type Album = Selectable<AlbumTable> & {
owner: ShallowDehydrateObject<User>;
assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
};
@@ -277,8 +276,6 @@ export type AssetFace = {
isVisible: boolean;
};
export type Plugin = Selectable<PluginTable>;
export type PluginFilter = Selectable<PluginFilterTable> & {
methodName: string;
title: string;
@@ -398,6 +395,26 @@ export const columns = {
'asset.height',
'asset.isEdited',
],
syncPartnerAsset: [
'asset.id',
'asset.ownerId',
'asset.originalFileName',
'asset.thumbhash',
'asset.checksum',
'asset.fileCreatedAt',
'asset.fileModifiedAt',
'asset.localDateTime',
'asset.type',
'asset.deletedAt',
'asset.visibility',
'asset.duration',
'asset.livePhotoVideoId',
'asset.stackId',
'asset.libraryId',
'asset.width',
'asset.height',
'asset.isEdited',
],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId', 'profileImagePath', 'profileChangedAt'],
+1 -12
View File
@@ -1,6 +1,6 @@
import { BeforeUpdateTrigger, Column, ColumnOptions } from '@immich/sql-tools';
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiOperationOptions, ApiTags } from '@nestjs/swagger';
import _ from 'lodash';
import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { EmitEvent } from 'src/repositories/event.repository';
@@ -172,17 +172,6 @@ export const Endpoint = ({ history, ...options }: EndpointOptions) => {
return applyDecorators(...decorators);
};
export type PropertyOptions = ApiPropertyOptions & { history?: HistoryBuilder };
export const Property = ({ history, ...options }: PropertyOptions) => {
const extensions = history?.getExtensions() ?? {};
if (history?.isDeprecated()) {
options.deprecated = true;
}
return ApiProperty({ ...options, ...extensions });
};
type HistoryEntry = {
version: string;
state: ApiState | 'Added' | 'Updated';
+15 -16
View File
@@ -1,7 +1,6 @@
import { ShallowDehydrateObject } from 'kysely';
import _ from 'lodash';
import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { AlbumUser, AuthSharedLink } from 'src/database';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
@@ -66,10 +65,13 @@ const UpdateAlbumSchema = z
const GetAlbumsSchema = z
.object({
shared: stringToBool
isOwned: stringToBool
.optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = all owned albums'),
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores shared parameter)'),
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
isShared: stringToBool
.optional()
.describe('Filter by shared status: true = only shared, false = not shared, undefined = no filter'),
assetId: z.uuidv4().optional().describe('Filter albums containing this asset ID (ignores other parameters)'),
})
.meta({ id: 'GetAlbumsDto' });
@@ -104,7 +106,6 @@ const ContributorCountResponseSchema = z
export const AlbumResponseSchema = z
.object({
id: z.string().describe('Album ID'),
ownerId: z.string().describe('Owner user ID'),
albumName: z.string().describe('Album name'),
description: z.string().describe('Album description'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@@ -113,9 +114,13 @@ export const AlbumResponseSchema = z
updatedAt: z.string().meta({ format: 'date-time' }).describe('Last update date'),
albumThumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'),
shared: z.boolean().describe('Is shared album'),
albumUsers: z.array(AlbumUserResponseSchema),
albumUsers: z
.array(AlbumUserResponseSchema)
.min(1)
.describe(
'First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.',
),
hasSharedLink: z.boolean().describe('Has shared link'),
owner: UserResponseSchema,
assetCount: z.int().min(0).describe('Number of assets'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
lastModifiedAssetTimestamp: z
@@ -155,8 +160,6 @@ export type MapAlbumDto = {
createdAt: Date;
updatedAt: Date;
id: string;
ownerId: string;
owner: ShallowDehydrateObject<User>;
isActivityEnabled: boolean;
order: AssetOrder;
};
@@ -174,12 +177,10 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
}
}
const albumUsersSorted = _.orderBy(albumUsers, ['role', 'user.name']);
const assets = entity.assets || [];
const hasSharedLink = !!entity.sharedLinks && entity.sharedLinks.length > 0;
const hasSharedUser = albumUsers.length > 0;
const hasSharedUser = albumUsers.length > 1;
let startDate = assets.at(0)?.localDateTime;
let endDate = assets.at(-1)?.localDateTime;
@@ -195,9 +196,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
createdAt: asDateString(entity.createdAt),
updatedAt: asDateString(entity.updatedAt),
id: entity.id,
ownerId: entity.ownerId,
owner: mapUser(entity.owner),
albumUsers: albumUsersSorted,
albumUsers,
shared: hasSharedUser || hasSharedLink,
hasSharedLink,
startDate: asDateString(startDate),
@@ -3,7 +3,6 @@ import z from 'zod';
export enum AssetMediaStatus {
CREATED = 'created',
REPLACED = 'replaced',
DUPLICATE = 'duplicate',
}
+1 -1
View File
@@ -38,7 +38,7 @@ export enum UploadFieldName {
const AssetMediaBaseSchema = z.object({
fileCreatedAt: isoDatetimeToDate.describe('File creation date'),
fileModifiedAt: isoDatetimeToDate.describe('File modification date'),
duration: z.string().optional().describe('Duration (for videos)'),
duration: z.int32().min(0).optional().describe('Duration in milliseconds (for videos)'),
filename: z.string().optional().describe('Filename'),
/** The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled in the controller. */
[UploadFieldName.ASSET_DATA]: z.any().describe('Asset file data').meta({ type: 'string', format: 'binary' }),
+4 -4
View File
@@ -47,11 +47,11 @@ const SanitizedAssetResponseSchema = z
.describe(
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
),
duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'),
duration: z.int32().min(0).nullable().describe('Video/gif duration in milliseconds (null for static images)'),
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.number().min(0).nullable().describe('Asset width'),
height: z.number().min(0).nullable().describe('Asset height'),
width: z.int().min(0).nullable().describe('Asset width'),
height: z.int().min(0).nullable().describe('Asset height'),
})
.meta({ id: 'SanitizedAssetResponseDto' });
@@ -136,7 +136,7 @@ export type MapAsset = {
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
duplicateId: string | null;
duration: string | null;
duration: number | null;
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
faces?: ShallowDehydrateObject<AssetFace>[];
+1 -1
View File
@@ -40,7 +40,7 @@ const UpdateAssetBaseSchema = z
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
duplicateId: z.string().nullish().describe('Duplicate ID'),
dateTimeRelative: z.number().optional().describe('Relative time offset in seconds'),
dateTimeRelative: z.int().optional().describe('Relative time offset in seconds'),
timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
});
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const DatabaseBackupSchema = z
.object({
filename: z.string().describe('Backup filename'),
filesize: z.number().describe('Backup file size'),
filesize: z.int().describe('Backup file size'),
timezone: z.string().describe('Backup timezone'),
})
.meta({ id: 'DatabaseBackupDto' });
+4 -4
View File
@@ -21,10 +21,10 @@ const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mi
const CropParametersSchema = z
.object({
x: z.number().min(0).describe('Top-Left X coordinate of crop'),
y: z.number().min(0).describe('Top-Left Y coordinate of crop'),
width: z.number().min(1).describe('Width of the crop'),
height: z.number().min(1).describe('Height of the crop'),
x: z.int().min(0).describe('Top-Left X coordinate of crop'),
y: z.int().min(0).describe('Top-Left Y coordinate of crop'),
width: z.int().min(1).describe('Width of the crop'),
height: z.int().min(1).describe('Height of the crop'),
})
.meta({ id: 'CropParameters' });
+1 -1
View File
@@ -77,7 +77,7 @@ export const EnvSchema = z
DB_SSL_MODE: DatabaseSslModeSchema.optional(),
DB_URL: z.string().optional(),
DB_USERNAME: z.string().optional(),
DB_VECTOR_EXTENSION: z.enum(['pgvector', 'pgvecto.rs', 'vectorchord']).optional(),
DB_VECTOR_EXTENSION: z.enum(['pgvector', 'vectorchord']).optional(),
NO_COLOR: z.string().optional(),
REDIS_HOSTNAME: z.string().optional(),
REDIS_PORT: z.coerce.number().int().optional(),
+4 -4
View File
@@ -8,8 +8,8 @@ export const ExifResponseSchema = z
.object({
make: z.string().nullish().default(null).describe('Camera make'),
model: z.string().nullish().default(null).describe('Camera model'),
exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height in pixels'),
exifImageWidth: z.int().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.int().min(0).nullish().default(null).describe('Image height in pixels'),
fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'),
orientation: z.string().nullish().default(null).describe('Image orientation'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@@ -20,7 +20,7 @@ export const ExifResponseSchema = z
lensModel: z.string().nullish().default(null).describe('Lens model'),
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
focalLength: z.number().nullish().default(null).describe('Focal length in mm'),
iso: z.number().nullish().default(null).describe('ISO sensitivity'),
iso: z.int().nullish().default(null).describe('ISO sensitivity'),
exposureTime: z.string().nullish().default(null).describe('Exposure time'),
latitude: z.number().nullish().default(null).describe('GPS latitude'),
longitude: z.number().nullish().default(null).describe('GPS longitude'),
@@ -29,7 +29,7 @@ export const ExifResponseSchema = z
country: z.string().nullish().default(null).describe('Country name'),
description: z.string().nullish().default(null).describe('Image description'),
projectionType: z.string().nullish().default(null).describe('Projection type'),
rating: z.number().nullish().default(null).describe('Rating'),
rating: z.int().nullish().default(null).describe('Rating'),
})
.describe('EXIF response')
.meta({ id: 'ExifResponseDto' });
+2 -2
View File
@@ -29,7 +29,7 @@ const MaintenanceStatusResponseSchema = z
.object({
active: z.boolean(),
action: MaintenanceActionSchema,
progress: z.number().optional(),
progress: z.int().optional(),
task: z.string().optional(),
error: z.string().optional(),
})
@@ -40,7 +40,7 @@ const MaintenanceDetectInstallStorageFolderSchema = z
folder: StorageFolderSchema,
readable: z.boolean().describe('Whether the folder is readable'),
writable: z.boolean().describe('Whether the folder is writable'),
files: z.number().describe('Number of files in the folder'),
files: z.int().describe('Number of files in the folder'),
})
.meta({ id: 'MaintenanceDetectInstallStorageFolderDto' });
+2 -2
View File
@@ -51,8 +51,8 @@ const PersonSearchSchema = z
withHidden: stringToBool.optional().describe('Include hidden people'),
closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'),
closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'),
page: z.coerce.number().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().min(1).max(1000).default(500).describe('Number of items per page'),
page: z.coerce.number().int().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'),
})
.meta({ id: 'PersonSearchDto' });
+5 -5
View File
@@ -34,7 +34,7 @@ const BaseSearchSchema = z.object({
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
rating: z
.number()
.int()
.min(-1)
.max(5)
.nullish()
@@ -52,7 +52,7 @@ const BaseSearchSchema = z.object({
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
withDeleted: z.boolean().optional().describe('Include deleted assets'),
withExif: z.boolean().optional().describe('Include EXIF data in response'),
size: z.number().min(1).max(1000).optional().describe('Number of results to return'),
size: z.int().min(1).max(1000).optional().describe('Number of results to return'),
});
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
@@ -62,7 +62,7 @@ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
size: z.coerce.number().min(1).max(1000).optional().describe('Number of results to return'),
size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'),
}).meta({ id: 'LargeAssetSearchDto' });
const MetadataSearchSchema = RandomSearchSchema.extend({
@@ -75,7 +75,7 @@ const MetadataSearchSchema = RandomSearchSchema.extend({
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
page: z.number().min(1).optional().describe('Page number'),
page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = BaseSearchSchema.extend({
@@ -86,7 +86,7 @@ const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
query: z.string().trim().optional().describe('Natural language search query'),
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
language: z.string().optional().describe('Search language code'),
page: z.number().min(1).optional().describe('Page number'),
page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'SmartSearchDto' });
const SearchPlacesSchema = z
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const SessionCreateSchema = z
.object({
duration: z.number().min(1).optional().describe('Session duration in seconds'),
duration: z.int().min(1).optional().describe('Session duration in seconds'),
deviceType: z.string().optional().describe('Device type'),
deviceOS: z.string().optional().describe('Device OS'),
})
+58 -12
View File
@@ -2,6 +2,7 @@
import { createZodDto } from 'nestjs-zod';
import { AssetEditActionSchema } from 'src/dtos/editing.dto';
import {
AlbumUserRole,
AlbumUserRoleSchema,
AssetOrderSchema,
AssetTypeSchema,
@@ -89,6 +90,30 @@ const SyncAssetV1Schema = z
})
.meta({ id: 'SyncAssetV1' });
const SyncAssetV2Schema = z
.object({
id: z.string().describe('Asset ID'),
ownerId: z.string().describe('Owner ID'),
originalFileName: z.string().describe('Original file name'),
thumbhash: z.string().nullable().describe('Thumbhash'),
checksum: z.string().describe('Checksum'),
fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'),
fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'),
localDateTime: isoDatetimeToDate.nullable().describe('Local date time'),
duration: z.int32().min(0).nullable().describe('Duration'),
type: AssetTypeSchema,
deletedAt: isoDatetimeToDate.nullable().describe('Deleted at'),
isFavorite: z.boolean().describe('Is favorite'),
visibility: AssetVisibilitySchema,
livePhotoVideoId: z.string().nullable().describe('Live photo video ID'),
stackId: z.string().nullable().describe('Stack ID'),
libraryId: z.string().nullable().describe('Library ID'),
width: z.int().nullable().describe('Asset width'),
height: z.int().nullable().describe('Asset height'),
isEdited: z.boolean().describe('Is edited'),
})
.meta({ id: 'SyncAssetV2' });
@ExtraModel()
class SyncUserV1 extends createZodDto(SyncUserV1Schema) {}
@ExtraModel()
@@ -101,6 +126,8 @@ class SyncPartnerV1 extends createZodDto(SyncPartnerV1Schema) {}
class SyncPartnerDeleteV1 extends createZodDto(SyncPartnerDeleteV1Schema) {}
@ExtraModel()
export class SyncAssetV1 extends createZodDto(SyncAssetV1Schema) {}
@ExtraModel()
export class SyncAssetV2 extends createZodDto(SyncAssetV2Schema) {}
const SyncAssetDeleteV1Schema = z
.object({ assetId: z.string().describe('Asset ID') })
@@ -211,6 +238,19 @@ const SyncAlbumV1Schema = z
})
.meta({ id: 'SyncAlbumV1' });
const SyncAlbumV2Schema = z
.object({
id: z.string().describe('Album ID'),
name: z.string().describe('Album name'),
description: z.string().describe('Album description'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
thumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'),
isActivityEnabled: z.boolean().describe('Is activity enabled'),
order: AssetOrderSchema,
})
.meta({ id: 'SyncAlbumV2' });
const SyncAlbumToAssetV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
@@ -234,10 +274,21 @@ class SyncAlbumUserV1 extends createZodDto(SyncAlbumUserV1Schema) {}
@ExtraModel()
class SyncAlbumV1 extends createZodDto(SyncAlbumV1Schema) {}
@ExtraModel()
class SyncAlbumV2 extends createZodDto(SyncAlbumV2Schema) {}
@ExtraModel()
class SyncAlbumToAssetV1 extends createZodDto(SyncAlbumToAssetV1Schema) {}
@ExtraModel()
class SyncAlbumToAssetDeleteV1 extends createZodDto(SyncAlbumToAssetDeleteV1Schema) {}
export function syncAlbumV2ToV1(
albumV2: SyncAlbumV2,
albumUsers: { userId: string; role: AlbumUserRole }[],
): SyncAlbumV1 {
const owner = albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
return { ...albumV2, ownerId: owner.userId };
}
const SyncMemoryV1Schema = z
.object({
id: z.string().describe('Memory ID'),
@@ -369,12 +420,6 @@ class SyncPersonDeleteV1 extends createZodDto(SyncPersonDeleteV1Schema) {}
class SyncAssetFaceV1 extends createZodDto(SyncAssetFaceV1Schema) {}
@ExtraModel()
class SyncAssetFaceV2 extends createZodDto(SyncAssetFaceV2Schema) {}
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
return faceV1;
}
@ExtraModel()
class SyncAssetFaceDeleteV1 extends createZodDto(SyncAssetFaceDeleteV1Schema) {}
@ExtraModel()
@@ -394,26 +439,27 @@ export type SyncItem = {
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
[SyncEntityType.PartnerV1]: SyncPartnerV1;
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
[SyncEntityType.AssetV1]: SyncAssetV1;
[SyncEntityType.AssetV2]: SyncAssetV2;
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AssetEditV1]: SyncAssetEditV1;
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetV2]: SyncAssetV2;
[SyncEntityType.PartnerAssetBackfillV2]: SyncAssetV2;
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
[SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
[SyncEntityType.PartnerAssetExifBackfillV1]: SyncAssetExifV1;
[SyncEntityType.AlbumV1]: SyncAlbumV1;
[SyncEntityType.AlbumV2]: SyncAlbumV2;
[SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1;
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
[SyncEntityType.AlbumAssetCreateV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetUpdateV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetCreateV2]: SyncAssetV2;
[SyncEntityType.AlbumAssetUpdateV2]: SyncAssetV2;
[SyncEntityType.AlbumAssetBackfillV2]: SyncAssetV2;
[SyncEntityType.AlbumAssetExifCreateV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifUpdateV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1;
+5 -5
View File
@@ -51,7 +51,7 @@ const DatabaseBackupSchema = z
.object({
enabled: configBool.describe('Enabled'),
cronExpression: cronExpressionSchema,
keepLastAmount: z.number().min(1).describe('Keep last amount'),
keepLastAmount: z.int().min(1).describe('Keep last amount'),
})
.meta({ id: 'DatabaseBackupConfig' });
@@ -130,8 +130,8 @@ const SystemConfigLoggingSchema = z
const MachineLearningAvailabilityChecksSchema = z
.object({
enabled: configBool.describe('Enabled'),
timeout: z.number(),
interval: z.number(),
timeout: z.int(),
interval: z.int(),
})
.meta({ id: 'MachineLearningAvailabilityChecksDto' });
@@ -180,7 +180,7 @@ const SystemConfigOAuthSchema = z
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
timeout: z.int().min(1).describe('Timeout'),
allowInsecureRequests: configBool.describe('Allow insecure requests'),
defaultStorageQuota: z.number().min(0).nullable().describe('Default storage quota'),
defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'),
enabled: configBool.describe('Enabled'),
issuerUrl: z
.string()
@@ -254,7 +254,7 @@ const SystemConfigSmtpTransportSchema = z
.object({
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
host: z.string().describe('SMTP server hostname'),
port: z.number().min(0).max(65_535).describe('SMTP server port'),
port: z.int().min(0).max(65_535).describe('SMTP server port'),
secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
username: z.string().describe('SMTP username'),
password: z.string().describe('SMTP password'),
+2 -2
View File
@@ -89,8 +89,8 @@ const TimeBucketAssetResponseSchema = z
"Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.",
),
duration: z
.array(z.string().nullable())
.describe('Array of video/gif durations in hh:mm:ss.SSS format (null for static images)'),
.array(z.int32().min(0).nullable())
.describe('Array of video/gif durations in milliseconds (null for static images)'),
stack: z
.array(stackTupleSchema)
.optional()
+2 -2
View File
@@ -46,7 +46,7 @@ const WorkflowFilterResponseSchema = z
workflowId: z.string().describe('Workflow ID'),
pluginFilterId: z.string().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.nullable(),
order: z.number().describe('Filter order'),
order: z.int().describe('Filter order'),
})
.meta({ id: 'WorkflowFilterResponseDto' });
@@ -56,7 +56,7 @@ const WorkflowActionResponseSchema = z
workflowId: z.string().describe('Workflow ID'),
pluginActionId: z.string().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.nullable(),
order: z.number().describe('Action order'),
order: z.int().describe('Action order'),
})
.meta({ id: 'WorkflowActionResponseDto' });
+155 -95
View File
@@ -5,8 +5,6 @@ export enum AuthType {
OAuth = 'oauth',
}
export const AuthTypeSchema = z.enum(AuthType).describe('Auth type').meta({ id: 'AuthType' });
export enum ImmichCookie {
AccessToken = 'immich_access_token',
MaintenanceToken = 'immich_maintenance_token',
@@ -17,8 +15,6 @@ export enum ImmichCookie {
OAuthCodeVerifier = 'immich_oauth_code_verifier',
}
export const ImmichCookieSchema = z.enum(ImmichCookie).describe('Immich cookie').meta({ id: 'ImmichCookie' });
export enum ImmichHeader {
ApiKey = 'x-api-key',
UserToken = 'x-immich-user-token',
@@ -26,11 +22,9 @@ export enum ImmichHeader {
SharedLinkKey = 'x-immich-share-key',
SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid',
CorrelationId = 'X-Correlation-ID',
}
export const ImmichHeaderSchema = z.enum(ImmichHeader).describe('Immich header').meta({ id: 'ImmichHeader' });
export enum ImmichQuery {
SharedLinkKey = 'key',
SharedLinkSlug = 'slug',
@@ -38,8 +32,6 @@ export enum ImmichQuery {
SessionKey = 'sessionKey',
}
export const ImmichQuerySchema = z.enum(ImmichQuery).describe('Immich query').meta({ id: 'ImmichQuery' });
export enum AssetType {
Image = 'IMAGE',
Video = 'VIDEO',
@@ -56,11 +48,6 @@ export enum ChecksumAlgorithm {
sha1Path = 'sha1-path',
}
export const ChecksumAlgorithmSchema = z
.enum(ChecksumAlgorithm)
.describe('Checksum algorithm')
.meta({ id: 'ChecksumAlgorithmEnum' });
export enum AssetFileType {
/**
* An full/large-size image extracted/converted from RAW photos
@@ -72,10 +59,9 @@ export enum AssetFileType {
EncodedVideo = 'encoded_video',
}
export const AssetFileTypeSchema = z.enum(AssetFileType).describe('Asset file type').meta({ id: 'AssetFileType' });
export enum AlbumUserRole {
Editor = 'editor',
Owner = 'owner',
Viewer = 'viewer',
}
@@ -313,8 +299,6 @@ export enum Permission {
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}
export const PermissionSchema = z.enum(Permission).describe('Permission').meta({ id: 'Permission' });
export enum SharedLinkType {
Album = 'ALBUM',
@@ -351,11 +335,6 @@ export enum SystemMetadataKey {
License = 'license',
}
export const SystemMetadataKeySchema = z
.enum(SystemMetadataKey)
.describe('System metadata key')
.meta({ id: 'SystemMetadataKey' });
export enum UserMetadataKey {
Preferences = 'preferences',
License = 'license',
@@ -371,11 +350,6 @@ export enum AssetMetadataKey {
MobileApp = 'mobile-app',
}
export const AssetMetadataKeySchema = z
.enum(AssetMetadataKey)
.describe('Asset metadata key')
.meta({ id: 'AssetMetadataKey' });
export enum UserAvatarColor {
Primary = 'primary',
Pink = 'pink',
@@ -408,8 +382,6 @@ export enum AssetStatus {
Deleted = 'deleted',
}
export const AssetStatusSchema = z.enum(AssetStatus).describe('Asset status').meta({ id: 'AssetStatus' });
export enum SourceType {
MachineLearning = 'machine-learning',
Exif = 'exif',
@@ -434,20 +406,14 @@ export enum AssetPathType {
EncodedVideo = 'encoded_video',
}
export const AssetPathTypeSchema = z.enum(AssetPathType).describe('Asset path type').meta({ id: 'AssetPathType' });
export enum PersonPathType {
Face = 'face',
}
export const PersonPathTypeSchema = z.enum(PersonPathType).describe('Person path type').meta({ id: 'PersonPathType' });
export enum UserPathType {
Profile = 'profile',
}
export const UserPathTypeSchema = z.enum(UserPathType).describe('User path type').meta({ id: 'UserPathType' });
export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType;
export enum TranscodePolicy {
@@ -470,11 +436,6 @@ export enum TranscodeTarget {
All = 'ALL',
}
export const TranscodeTargetSchema = z
.enum(TranscodeTarget)
.describe('Transcode target')
.meta({ id: 'TranscodeTarget' });
export enum VideoCodec {
H264 = 'h264',
Hevc = 'hevc',
@@ -484,6 +445,12 @@ export enum VideoCodec {
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
export enum VideoSegmentCodec {
Av1 = 'av1',
Hevc = 'hevc',
H264 = 'h264',
}
export enum AudioCodec {
Mp3 = 'mp3',
Aac = 'aac',
@@ -556,11 +523,6 @@ export enum RawExtractedFormat {
Jxl = 'jxl',
}
export const RawExtractedFormatSchema = z
.enum(RawExtractedFormat)
.describe('Raw extracted format')
.meta({ id: 'RawExtractedFormat' });
export enum LogLevel {
Verbose = 'verbose',
Debug = 'debug',
@@ -586,38 +548,25 @@ export enum ApiCustomExtension {
State = 'x-immich-state',
}
export const ApiCustomExtensionSchema = z
.enum(ApiCustomExtension)
.describe('API custom extension')
.meta({ id: 'ApiCustomExtension' });
export enum MetadataKey {
AuthRoute = 'auth_route',
AdminRoute = 'admin_route',
SharedRoute = 'shared_route',
ApiKeySecurity = 'api_key',
EventConfig = 'event_config',
JobConfig = 'job_config',
TelemetryEnabled = 'telemetry_enabled',
}
export const MetadataKeySchema = z.enum(MetadataKey).describe('Metadata key').meta({ id: 'MetadataKey' });
export enum RouteKey {
Asset = 'assets',
User = 'users',
}
export const RouteKeySchema = z.enum(RouteKey).describe('Route key').meta({ id: 'RouteKey' });
export enum CacheControl {
PrivateWithCache = 'private_with_cache',
PrivateWithoutCache = 'private_without_cache',
None = 'none',
}
export const CacheControlSchema = z.enum(CacheControl).describe('Cache control').meta({ id: 'CacheControl' });
export enum ImmichEnvironment {
Development = 'development',
Testing = 'testing',
@@ -635,8 +584,6 @@ export enum ImmichWorker {
Microservices = 'microservices',
}
export const ImmichWorkerSchema = z.enum(ImmichWorker).describe('Immich worker').meta({ id: 'ImmichWorker' });
export enum ImmichTelemetry {
Host = 'host',
Api = 'api',
@@ -645,11 +592,6 @@ export enum ImmichTelemetry {
Job = 'job',
}
export const ImmichTelemetrySchema = z
.enum(ImmichTelemetry)
.describe('Immich telemetry')
.meta({ id: 'ImmichTelemetry' });
export enum ExifOrientation {
Horizontal = 1,
MirrorHorizontal = 2,
@@ -661,24 +603,140 @@ export enum ExifOrientation {
Rotate270CW = 8,
}
export const ExifOrientationSchema = z
.enum(ExifOrientation)
.describe('EXIF orientation')
.meta({ id: 'ExifOrientation' });
/** ITU-T H.273 colour primaries codes. */
export enum ColorPrimaries {
Reserved = 0,
Bt709 = 1,
Unknown = 2,
Bt470M = 4,
Bt470Bg = 5,
Smpte170M = 6,
Smpte240M = 7,
Film = 8,
Bt2020 = 9,
Smpte428 = 10,
Smpte431 = 11,
Smpte432 = 12,
Ebu3213 = 22,
}
/** ITU-T H.273 transfer characteristics codes. */
export enum ColorTransfer {
Reserved = 0,
Bt709 = 1,
Unknown = 2,
Bt470M = 4,
Bt470Bg = 5,
Smpte170M = 6,
Smpte240M = 7,
Linear = 8,
Log100 = 9,
Log316 = 10,
Iec6196624 = 11,
Bt1361E = 12,
Iec6196621 = 13,
Bt202010 = 14,
Bt202012 = 15,
Smpte2084 = 16,
Smpte428 = 17,
AribStdB67 = 18,
}
/** ITU-T H.273 matrix coefficients codes. */
export enum ColorMatrix {
Gbr = 0,
Bt709 = 1,
Unknown = 2,
Reserved = 3,
Fcc = 4,
Bt470Bg = 5,
Smpte170M = 6,
Smpte240M = 7,
Ycgco = 8,
Bt2020Nc = 9,
Bt2020C = 10,
Smpte2085 = 11,
ChromaDerivedNc = 12,
ChromaDerivedC = 13,
Ictcp = 14,
}
/** H.264 `profile_idc` values. */
// H.264 has a few profiles that have the same value but different names, included so lookup by name works
export enum H264Profile {
ConstrainedBaseline = 66,
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
Baseline = 66,
Main = 77,
Extended = 88,
ConstrainedHigh = 100,
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
ProgressiveHigh = 100,
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
High = 100,
High10 = 110,
High422 = 122,
High444Predictive = 244,
}
/** HEVC `profile_idc` values. */
export enum HevcProfile {
Main = 1,
Main10 = 2,
MainStillPicture = 3,
Rext = 4,
}
/** AV1 `seq_profile` values. */
export enum Av1Profile {
Main = 0,
High = 1,
Professional = 2,
}
/** MPEG-4 Audio Object Type values for AAC. */
export enum AacProfile {
Main = 1,
Lc = 2,
Ssr = 3,
Ltp = 4,
HeAac = 5,
Ld = 23,
HeAacv2 = 29,
Eld = 39,
XheAac = 42,
}
/** Dolby Vision bitstream profile numbers from the DOVI configuration record. */
export enum DvProfile {
Dvhe03 = 3,
Dvhe04 = 4,
Dvhe05 = 5,
Dvhe07 = 7,
Dvhe08 = 8,
Dvav09 = 9,
Dav110 = 10,
}
/**
* Dolby Vision base-layer signal-compatibility ID from the DOVI configuration record.
* Identifies what the base HEVC/AVC layer renders as on a non-DV decoder.
*/
export enum DvSignalCompatibility {
None = 0,
Hdr10 = 1,
Sdr709 = 2,
Hlg = 4,
Sdr2020 = 6,
}
export enum DatabaseExtension {
Cube = 'cube',
EarthDistance = 'earthdistance',
Vector = 'vector',
Vectors = 'vectors',
VectorChord = 'vchord',
}
export const DatabaseExtensionSchema = z
.enum(DatabaseExtension)
.describe('Database extension')
.meta({ id: 'DatabaseExtension' });
export enum BootstrapEventPriority {
// Database service should be initialized before anything else, most other services need database access
DatabaseService = -200,
@@ -690,11 +748,6 @@ export enum BootstrapEventPriority {
SystemConfig = 100,
}
export const BootstrapEventPrioritySchema = z
.enum(BootstrapEventPriority)
.describe('Bootstrap event priority')
.meta({ id: 'BootstrapEventPriority' });
export enum QueueName {
ThumbnailGeneration = 'thumbnailGeneration',
MetadataExtraction = 'metadataExtraction',
@@ -833,21 +886,15 @@ export enum JobStatus {
Skipped = 'skipped',
}
export const JobStatusSchema = z.enum(JobStatus).describe('Job status').meta({ id: 'JobStatus' });
export enum QueueCleanType {
Failed = 'failed',
}
export const QueueCleanTypeSchema = z.enum(QueueCleanType).describe('Queue clean type').meta({ id: 'QueueCleanType' });
export enum VectorIndex {
Clip = 'clip_index',
Face = 'face_index',
}
export const VectorIndexSchema = z.enum(VectorIndex).describe('Vector index').meta({ id: 'VectorIndex' });
export enum DatabaseLock {
GeodataImport = 100,
Migrations = 200,
@@ -866,8 +913,6 @@ export enum DatabaseLock {
YuccaModuleConfig = 900,
}
export const DatabaseLockSchema = z.enum(DatabaseLock).describe('Database lock').meta({ id: 'DatabaseLock' });
export enum MaintenanceAction {
Start = 'start',
End = 'end',
@@ -884,15 +929,18 @@ export enum ExitCode {
AppRestart = 7,
}
export const ExitCodeSchema = z.enum(ExitCode).describe('Exit code').meta({ id: 'ExitCode' });
export enum SyncRequestType {
AlbumsV1 = 'AlbumsV1',
AlbumsV2 = 'AlbumsV2',
AlbumUsersV1 = 'AlbumUsersV1',
AlbumToAssetsV1 = 'AlbumToAssetsV1',
/** @deprecated */
AlbumAssetsV1 = 'AlbumAssetsV1',
AlbumAssetsV2 = 'AlbumAssetsV2',
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
/** @deprecated */
AssetsV1 = 'AssetsV1',
AssetsV2 = 'AssetsV2',
AssetExifsV1 = 'AssetExifsV1',
AssetEditsV1 = 'AssetEditsV1',
AssetMetadataV1 = 'AssetMetadataV1',
@@ -900,12 +948,15 @@ export enum SyncRequestType {
MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1',
PartnersV1 = 'PartnersV1',
/** @deprecated */
PartnerAssetsV1 = 'PartnerAssetsV1',
PartnerAssetsV2 = 'PartnerAssetsV2',
PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
PartnerStacksV1 = 'PartnerStacksV1',
StacksV1 = 'StacksV1',
UsersV1 = 'UsersV1',
PeopleV1 = 'PeopleV1',
/** @deprecated */
AssetFacesV1 = 'AssetFacesV1',
AssetFacesV2 = 'AssetFacesV2',
UserMetadataV1 = 'UserMetadataV1',
@@ -922,7 +973,9 @@ export enum SyncEntityType {
UserV1 = 'UserV1',
UserDeleteV1 = 'UserDeleteV1',
/** @deprecated */
AssetV1 = 'AssetV1',
AssetV2 = 'AssetV2',
AssetDeleteV1 = 'AssetDeleteV1',
AssetExifV1 = 'AssetExifV1',
AssetEditV1 = 'AssetEditV1',
@@ -933,8 +986,12 @@ export enum SyncEntityType {
PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1',
/** @deprecated */
PartnerAssetV1 = 'PartnerAssetV1',
PartnerAssetV2 = 'PartnerAssetV2',
/** @deprecated */
PartnerAssetBackfillV1 = 'PartnerAssetBackfillV1',
PartnerAssetBackfillV2 = 'PartnerAssetBackfillV2',
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
PartnerAssetExifV1 = 'PartnerAssetExifV1',
PartnerAssetExifBackfillV1 = 'PartnerAssetExifBackfillV1',
@@ -943,15 +1000,22 @@ export enum SyncEntityType {
PartnerStackV1 = 'PartnerStackV1',
AlbumV1 = 'AlbumV1',
AlbumV2 = 'AlbumV2',
AlbumDeleteV1 = 'AlbumDeleteV1',
AlbumUserV1 = 'AlbumUserV1',
AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
/** @deprecated */
AlbumAssetCreateV1 = 'AlbumAssetCreateV1',
AlbumAssetCreateV2 = 'AlbumAssetCreateV2',
/** @deprecated */
AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1',
AlbumAssetUpdateV2 = 'AlbumAssetUpdateV2',
/** @deprecated */
AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1',
AlbumAssetBackfillV2 = 'AlbumAssetBackfillV2',
AlbumAssetExifCreateV1 = 'AlbumAssetExifCreateV1',
AlbumAssetExifUpdateV1 = 'AlbumAssetExifUpdateV1',
AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1',
@@ -1044,8 +1108,6 @@ export enum CronJob {
VersionCheck = 'VersionCheck',
}
export const CronJobSchema = z.enum(CronJob).describe('Cron job').meta({ id: 'CronJob' });
export enum ApiTag {
Activities = 'Activities',
Albums = 'Albums',
@@ -1086,8 +1148,6 @@ export enum ApiTag {
Workflows = 'Workflows',
}
export const ApiTagSchema = z.enum(ApiTag).describe('API tag').meta({ id: 'ApiTag' });
export enum PluginContext {
Asset = 'asset',
Album = 'album',
@@ -2,6 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/co
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { ImmichHeader } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger';
import { ZodError } from 'zod';
@@ -16,18 +17,13 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
}
catch(error: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { status, body } = this.fromError(error);
if (!response.headersSent) {
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
}
this.handleError(host.switchToHttp().getResponse<Response>(), error);
}
handleError(res: Response, error: Error) {
const { status, body } = this.fromError(error);
if (!res.headersSent) {
res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
res.header(ImmichHeader.CorrelationId, this.cls.getId()).status(status).json(body);
}
}
@@ -36,26 +32,24 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
if (error instanceof HttpException) {
const status = error.getStatus();
let body = error.getResponse();
// unclear what circumstances would return a string
if (typeof body === 'string') {
body = { message: body };
}
const response = error.getResponse();
const body: Record<string, unknown> =
typeof response === 'string' ? { message: response } : { ...(response as object) };
// handle both request and response validation errors
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) {
body = {
message: zodError.issues.map((issue) =>
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
),
error: 'Bad Request',
return {
status,
body: { message: 'Validation failed', errors: zodError.issues },
};
}
}
// remove fields injected by NestJS that duplicate the HTTP response line
delete body['error'];
delete body['statusCode'];
return { status, body };
}
+15 -17
View File
@@ -14,27 +14,26 @@ select
"activity"."id"
from
"activity"
left join "album" on "activity"."albumId" = "album"."id"
inner join "album" on "activity"."albumId" = "album"."id"
and "album"."deletedAt" is null
inner join "album_user" on "album"."id" = "album_user"."albumId"
and "album_user"."role" = 'owner'
and "album_user"."userId" = $1::uuid
where
"activity"."id" in ($1)
and "album"."ownerId" = $2::uuid
"activity"."id" in ($2)
-- AccessRepository.activity.checkCreateAccess
select
"album"."id"
from
"album"
left join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
left join "user" on "user"."id" = "albumUsers"."userId"
inner join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
inner join "user" on "user"."id" = "albumUsers"."userId"
and "user"."deletedAt" is null
where
"album"."id" in ($1)
and "album"."isActivityEnabled" = $2
and (
"album"."ownerId" = $3
or "user"."id" = $4
)
and "user"."id" = $3
and "album"."deletedAt" is null
-- AccessRepository.album.checkOwnerAccess
@@ -42,9 +41,11 @@ select
"album"."id"
from
"album"
inner join "album_user" on "album"."id" = "album_user"."albumId"
and "album_user"."role" = 'owner'
and "album_user"."userId" = $1
where
"album"."id" in ($1)
and "album"."ownerId" = $2
"album"."id" in ($2)
and "album"."deletedAt" is null
-- AccessRepository.album.checkSharedAlbumAccess
@@ -52,8 +53,8 @@ select
"album"."id"
from
"album"
left join "album_user" on "album_user"."albumId" = "album"."id"
left join "user" on "user"."id" = "album_user"."userId"
inner join "album_user" on "album_user"."albumId" = "album"."id"
inner join "user" on "user"."id" = "album_user"."userId"
and "user"."deletedAt" is null
where
"album"."id" in ($1)
@@ -93,10 +94,7 @@ where
"asset"."id" = any (target.ids)
or "asset"."livePhotoVideoId" = any (target.ids)
)
and (
"album"."ownerId" = $2
or "user"."id" = $3
)
and "user"."id" = $2
and "album"."deletedAt" is null
-- AccessRepository.asset.checkOwnerAccess
+194 -204
View File
@@ -1,26 +1,17 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AlbumRepository.getById
with
"album_user" as (
select
*
from
"album_user"
where
"album_user"."albumId" = $1
)
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -41,15 +32,21 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $2 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
@@ -88,30 +85,12 @@ select
from
"album"
where
"album"."id" = $1
"album"."id" = $3
and "album"."deletedAt" is null
-- AlbumRepository.getByAssetId
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -132,36 +111,38 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers"
from
"album"
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
where
(
"album"."ownerId" = $1
or exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
and "album_asset"."assetId" = $3
and "album"."deletedAt" is null
order by
"album"."createdAt" desc,
"album"."createdAt" desc
-- AlbumRepository.getByAssetIds
@@ -172,18 +153,15 @@ from
"album"
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
where
(
"album"."ownerId" = $1
or exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $1
)
and "album_asset"."assetId" in ($3)
and "album_asset"."assetId" in ($2)
and "album"."deletedAt" is null
-- AlbumRepository.getMetadataForIds
@@ -207,80 +185,7 @@ where
group by
"album_asset"."albumId"
-- AlbumRepository.getOwned
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
) as obj
) as "user"
from
"album_user"
where
"album_user"."albumId" = "album"."id"
) as agg
) as "albumUsers",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"shared_link".*
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
) as agg
) as "sharedLinks"
from
"album"
where
"album"."ownerId" = $1
and "album"."deletedAt" is null
order by
"album"."createdAt" desc
-- AlbumRepository.getShared
-- AlbumRepository.getAll
select
"album".*,
(
@@ -303,35 +208,23 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -347,18 +240,19 @@ select
) as "sharedLinks"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
where
(
"album"."deletedAt" is null
and "album_user"."role" = 'owner'
and (
exists (
select
from
"album_user"
"album_user" as "au"
where
"album_user"."albumId" = "album"."id"
and (
"album"."ownerId" = $1
or "album_user"."userId" = $2
)
"au"."albumId" = "album"."id"
and "au"."role" != 'owner'
)
or exists (
select
@@ -366,52 +260,37 @@ where
"shared_link"
where
"shared_link"."albumId" = "album"."id"
and "shared_link"."userId" = $3
)
)
and "album"."deletedAt" is null
order by
"album"."createdAt" desc
-- AlbumRepository.getNotShared
-- AlbumRepository.getAllIds
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner"
"album"."id"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $1
where
"album"."ownerId" = $1
and "album"."deletedAt" is null
and not exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
)
and not exists (
select
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
"album"."deletedAt" is null
and "album_user"."role" = 'owner'
and (
exists (
select
from
"album_user" as "au"
where
"au"."albumId" = "album"."id"
and "au"."role" != 'owner'
)
or exists (
select
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
)
)
order by
"album"."createdAt" desc
@@ -430,6 +309,117 @@ where
"album_asset"."albumId" = $1
and "album_asset"."assetId" in ($2)
-- AlbumRepository.addAssetIds
insert into
"album_asset"
select
$1::uuid as "albumId",
unnest($2::uuid[]) as "assetId"
from
(
select
1
) as "dummy"
on conflict do nothing
-- AlbumRepository.create
with
"album" as (
insert into
"album" ("albumName")
values
($1)
returning
*
),
"album_user" as (
insert into
"album_user"
select
"album"."id" as "albumId",
unnest($2::uuid[]) as "userId",
unnest($3::album_user_role_enum[]) as "role"
from
"album"
returning
"album_user"."albumId",
"album_user"."userId",
"album_user"."role"
),
"album_asset" as (
insert into
"album_asset"
select
"album"."id" as "albumId",
unnest($4::uuid[]) as "assetId"
from
"album"
on conflict do nothing
returning
"album_asset"."albumId",
"album_asset"."assetId"
)
select
"album".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"user"."name" asc
) as agg
) as "albumUsers",
(
select
json_agg("asset") as "assets"
from
(
select
"asset".*,
"asset_exif" as "exifInfo"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
where
"album_asset"."albumId" = "album"."id"
and "asset"."deletedAt" is null
and "asset"."visibility" in ('archive', 'timeline')
order by
"asset"."fileCreatedAt" desc
) as "asset"
) as "assets"
from
"album"
-- AlbumRepository.getContributorCounts
select
"asset"."ownerId" as "userId",
+140 -2
View File
@@ -163,6 +163,7 @@ where
'%.jp2',
'%.jpe',
'%.jxl',
'%.mpo',
'%.svg',
'%.tif',
'%.tiff'
@@ -238,10 +239,68 @@ select
"asset_edit"."assetId" = "asset"."id"
) as agg
) as "edits",
to_json("asset_exif") as "exifInfo"
to_json("asset_exif") as "exifInfo",
(
select
to_json(obj)
from
(
select
"asset_video"."index",
"asset_video"."codecName",
"asset_video"."profile",
"asset_video"."level",
"asset_video"."bitrate",
"asset_exif"."exifImageWidth" as "width",
"asset_exif"."exifImageHeight" as "height",
"asset_video"."pixelFormat",
"asset_video"."frameCount",
"asset_exif"."fps" as "frameRate",
"asset_video"."timeBase",
case
when "asset_exif"."orientation" = '6' then -90
when "asset_exif"."orientation" = '8' then 90
when "asset_exif"."orientation" = '3' then 180
else 0
end as "rotation",
"asset_video"."colorPrimaries",
"asset_video"."colorMatrix",
"asset_video"."colorTransfer",
"asset_video"."dvProfile",
"asset_video"."dvLevel",
"asset_video"."dvBlSignalCompatibilityId"
from
(
select
1
) as "dummy"
where
"asset_video"."assetId" is not null
) as obj
) as "videoStream",
(
select
to_json(obj)
from
(
select
"asset_video"."formatName",
"asset_video"."formatLongName",
"asset"."duration",
"asset_video"."bitrate"
from
(
select
1
) as "dummy"
where
"asset_video"."assetId" is not null
) as obj
) as "format"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
left join "asset_video" on "asset_video"."assetId" = "asset"."id"
where
"asset"."id" = $4
@@ -553,9 +612,88 @@ select
where
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files"
) as "files",
(
select
to_json(obj)
from
(
select
"asset_audio"."index",
"asset_audio"."codecName",
"asset_audio"."profile",
"asset_audio"."bitrate"
from
(
select
1
) as "dummy"
where
"asset_audio"."assetId" is not null
) as obj
) as "audioStream",
(
select
to_json(obj)
from
(
select
"asset_video"."index",
"asset_video"."codecName",
"asset_video"."profile",
"asset_video"."level",
"asset_video"."bitrate",
"asset_exif"."exifImageWidth" as "width",
"asset_exif"."exifImageHeight" as "height",
"asset_video"."pixelFormat",
"asset_video"."frameCount",
"asset_exif"."fps" as "frameRate",
"asset_video"."timeBase",
case
when "asset_exif"."orientation" = '6' then -90
when "asset_exif"."orientation" = '8' then 90
when "asset_exif"."orientation" = '3' then 180
else 0
end as "rotation",
"asset_video"."colorPrimaries",
"asset_video"."colorMatrix",
"asset_video"."colorTransfer",
"asset_video"."dvProfile",
"asset_video"."dvLevel",
"asset_video"."dvBlSignalCompatibilityId"
from
(
select
1
) as "dummy"
where
"asset_video"."assetId" is not null
) as obj
) as "videoStream",
(
select
to_json(obj)
from
(
select
"asset_video"."formatName",
"asset_video"."formatLongName",
"asset"."duration",
"asset_video"."bitrate"
from
(
select
1
) as "dummy"
where
"asset_video"."assetId" is not null
) as obj
) as "format"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "asset_video" on "asset_video"."assetId" = "asset"."id"
left join "asset_audio" on "asset_audio"."assetId" = "asset"."id"
where
"asset"."id" = $1
and "asset"."type" = 'VIDEO'
+24 -4
View File
@@ -42,6 +42,16 @@ select
"memory_asset"."memoriesId" = "memory"."id"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
and not exists (
select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."id" = "asset_face"."personId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
)
order by
"asset"."fileCreatedAt" asc
) as agg
@@ -51,7 +61,7 @@ from
"memory"
where
"deletedAt" is null
and "ownerId" = $1
and "ownerId" = $3
order by
"memoryAt" desc
@@ -71,6 +81,16 @@ select
"memory_asset"."memoriesId" = "memory"."id"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
and not exists (
select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."id" = "asset_face"."personId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
)
order by
"asset"."fileCreatedAt" asc
) as agg
@@ -81,14 +101,14 @@ from
where
(
"showAt" is null
or "showAt" <= $1
or "showAt" <= $3
)
and (
"hideAt" is null
or "hideAt" >= $2
or "hideAt" >= $4
)
and "deletedAt" is null
and "ownerId" = $3
and "ownerId" = $5
order by
"memoryAt" desc
+18 -2
View File
@@ -139,7 +139,15 @@ from
from
"user"
where
"user"."id" = "album"."ownerId"
exists (
select
from
"album_user"
where
"album_user"."role" = 'owner'
and "album_user"."albumId" = "album"."id"
and "album_user"."userId" = "user"."id"
)
and "user"."deletedAt" is null
) as "owner" on true
where
@@ -201,7 +209,15 @@ from
from
"user"
where
"user"."id" = "album"."ownerId"
exists (
select
from
"album_user"
where
"album_user"."role" = 'owner'
and "album_user"."albumId" = "album"."id"
and "album_user"."userId" = "user"."id"
)
and "user"."deletedAt" is null
) as "owner" on true
where
+37 -78
View File
@@ -29,7 +29,6 @@ order by
-- SyncRepository.album.getUpserts
select distinct
on ("album"."id", "album"."updateId") "album"."id",
"album"."ownerId",
"album"."albumName" as "name",
"album"."description",
"album"."createdAt",
@@ -44,13 +43,19 @@ from
where
"album"."updateId" < $1
and "album"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_users"."userId" = $4
)
and "album_users"."userId" = $3
order by
"album"."updateId" asc
-- SyncRepository.album.getAlbumUsers
select
"userId",
"role"
from
"album_user"
where
"albumId" = $1
-- SyncRepository.albumAsset.getBackfill
select
"asset"."id",
@@ -109,16 +114,12 @@ select
from
"asset" as "asset"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset"."updateId" < $1
and "asset"."updateId" > $2
and "album_asset"."updateId" <= $3
and (
"album"."ownerId" = $4
or "album_user"."userId" = $5
)
and "album_user"."userId" = $4
order by
"asset"."updateId" asc
@@ -147,15 +148,11 @@ select
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@@ -229,16 +226,12 @@ select
from
"asset_exif" as "asset_exif"
inner join "album_asset" on "album_asset"."assetId" = "asset_exif"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset_exif"."updateId" < $1
and "asset_exif"."updateId" > $2
and "album_asset"."updateId" <= $3
and (
"album"."ownerId" = $4
or "album_user"."userId" = $5
)
and "album_user"."userId" = $4
order by
"asset_exif"."updateId" asc
@@ -278,10 +271,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@@ -312,20 +302,11 @@ where
and "album_asset_audit"."id" > $2
and "albumId" in (
select
"id"
"album_user"."albumId" as "id"
from
"album"
"album_user"
where
"ownerId" = $3
union
(
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $4
)
"album_user"."userId" = $3
)
order by
"album_asset_audit"."id" asc
@@ -337,15 +318,11 @@ select
"album_asset"."updateId"
from
"album_asset" as "album_asset"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@@ -377,20 +354,11 @@ where
and "album_user_audit"."id" > $2
and "albumId" in (
select
"id"
"album_user"."albumId" as "id"
from
"album"
"album_user"
where
"ownerId" = $3
union
(
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $4
)
"album_user"."userId" = $3
)
order by
"album_user_audit"."id" asc
@@ -408,20 +376,11 @@ where
and "album_user"."updateId" > $2
and "album_user"."albumId" in (
select
"id"
"albumUsers"."albumId" as "id"
from
"album"
"album_user" as "albumUsers"
where
"ownerId" = $3
union
(
select
"albumUsers"."albumId" as "id"
from
"album_user" as "albumUsers"
where
"albumUsers"."userId" = $4
)
"albumUsers"."userId" = $3
)
order by
"album_user"."updateId" asc
@@ -780,7 +739,6 @@ select
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
"asset"."isFavorite",
"asset"."visibility",
"asset"."duration",
"asset"."livePhotoVideoId",
@@ -789,14 +747,15 @@ select
"asset"."width",
"asset"."height",
"asset"."isEdited",
$1 as "isFavorite",
"asset"."updateId"
from
"asset" as "asset"
where
"asset"."updateId" < $1
and "asset"."updateId" <= $2
and "asset"."updateId" >= $3
and "ownerId" = $4
"asset"."updateId" < $2
and "asset"."updateId" <= $3
and "asset"."updateId" >= $4
and "ownerId" = $5
order by
"asset"."updateId" asc
@@ -832,7 +791,6 @@ select
"asset"."localDateTime",
"asset"."type",
"asset"."deletedAt",
"asset"."isFavorite",
"asset"."visibility",
"asset"."duration",
"asset"."livePhotoVideoId",
@@ -841,19 +799,20 @@ select
"asset"."width",
"asset"."height",
"asset"."isEdited",
$1 as "isFavorite",
"asset"."updateId"
from
"asset" as "asset"
where
"asset"."updateId" < $1
and "asset"."updateId" > $2
"asset"."updateId" < $2
and "asset"."updateId" > $3
and "ownerId" in (
select
"sharedById"
from
"partner"
where
"sharedWithId" = $3
"sharedWithId" = $4
)
order by
"asset"."updateId" asc
@@ -0,0 +1,46 @@
-- NOTE: This file is auto generated by ./sql-generator
-- VideoStreamRepository.getSession
select
*
from
"video_stream_session"
where
"id" = $1
-- VideoStreamRepository.getVariant
select
*
from
"video_stream_variant"
where
"id" = $1
-- VideoStreamRepository.getSegment
select
*
from
"video_stream_segment"
where
"variantId" = $1
and "index" = $2
-- VideoStreamRepository.getExpiredSessions
select
"id"
from
"video_stream_session"
where
"expiresAt" <= $1
-- VideoStreamRepository.extendSession
update "video_stream_session"
set
"expiresAt" = $1
where
"id" = $2
-- VideoStreamRepository.deleteSession
delete from "video_stream_session"
where
"id" = $1
+19 -9
View File
@@ -35,9 +35,14 @@ class ActivityAccess {
return this.db
.selectFrom('activity')
.select('activity.id')
.leftJoin('album', (join) => join.onRef('activity.albumId', '=', 'album.id').on('album.deletedAt', 'is', null))
.innerJoin('album', (join) => join.onRef('activity.albumId', '=', 'album.id').on('album.deletedAt', 'is', null))
.innerJoin('album_user', (join) =>
join
.onRef('album.id', '=', 'album_user.albumId')
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.on('album_user.userId', '=', asUuid(userId)),
)
.where('activity.id', 'in', [...activityIds])
.whereRef('album.ownerId', '=', asUuid(userId))
.execute()
.then((activities) => new Set(activities.map((activity) => activity.id)));
}
@@ -52,11 +57,11 @@ class ActivityAccess {
return this.db
.selectFrom('album')
.select('album.id')
.leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
.innerJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
.innerJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
.where('album.id', 'in', [...albumIds])
.where('album.isActivityEnabled', '=', true)
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
.where((eb) => eb('user.id', '=', userId))
.where('album.deletedAt', 'is', null)
.execute()
.then((albums) => new Set(albums.map((album) => album.id)));
@@ -77,7 +82,12 @@ class AlbumAccess {
.selectFrom('album')
.select('album.id')
.where('album.id', 'in', [...albumIds])
.where('album.ownerId', '=', userId)
.innerJoin('album_user', (join) =>
join
.onRef('album.id', '=', 'album_user.albumId')
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.on('album_user.userId', '=', userId),
)
.where('album.deletedAt', 'is', null)
.execute()
.then((albums) => new Set(albums.map((album) => album.id)));
@@ -96,8 +106,8 @@ class AlbumAccess {
return this.db
.selectFrom('album')
.select('album.id')
.leftJoin('album_user', 'album_user.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
.innerJoin('album_user', 'album_user.albumId', 'album.id')
.innerJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
.where('album.id', 'in', [...albumIds])
.where('album.deletedAt', 'is', null)
.where('user.id', '=', userId)
@@ -152,7 +162,7 @@ class AssetAccess {
eb('asset.livePhotoVideoId', '=', sql<string>`any(target.ids)`),
]),
)
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
.where('user.id', '=', userId)
.where('album.deletedAt', 'is', null)
.execute()
.then((assets) => {
@@ -7,7 +7,7 @@ import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { asUuid } from 'src/utils/database';
import { asUuid, dummy } from 'src/utils/database';
export interface ActivitySearch {
albumId?: string;
@@ -31,11 +31,7 @@ export class ActivityRepository {
join.onRef('user2.id', '=', 'activity.userId').on('user2.deletedAt', 'is', null),
)
.innerJoinLateral(
(eb) =>
eb
.selectFrom(sql`(select 1)`.as('dummy'))
.select(columns.userWithPrefix)
.as('user'),
(eb) => eb.selectFrom(dummy).select(columns.userWithPrefix).as('user'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('user').as('user'))
+140 -144
View File
@@ -13,11 +13,12 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumUserCreateDto, MapAlbumDto } from 'src/dtos/album.dto';
import { AlbumUserRole } from 'src/enum';
import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { withDefaultVisibility } from 'src/utils/database';
import { asUuid, dummy, withDefaultVisibility } from 'src/utils/database';
export interface AlbumAssetCount {
albumId: string;
@@ -31,33 +32,25 @@ export interface AlbumInfoOptions {
withAssets: boolean;
}
const withOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album.ownerId'))
.$notNull()
.as('owner');
};
const withAlbumUsers = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonArrayFrom(
const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
jsonArrayFrom(
eb
.selectFrom('album_user')
.innerJoin('user', 'user.id', 'album_user.userId')
.whereRef('album_user.albumId', '=', 'album.id')
.select('album_user.role')
.select((eb) =>
jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.userId'))
.$notNull()
.as('user'),
)
.whereRef('album_user.albumId', '=', 'album.id'),
.select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user'))
.orderBy('album_user.role')
.$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc'))
.orderBy('user.name', 'asc'),
)
.$notNull()
.as('albumUsers');
};
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonArrayFrom(
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) =>
jsonArrayFrom(
eb.selectFrom('shared_link').selectAll('shared_link').whereRef('shared_link.albumId', '=', 'album.id'),
).as('sharedLinks');
};
const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
return eb
@@ -80,19 +73,28 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
.as('assets');
};
const isAlbumOwned = (ownerId: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.role', '=', AlbumUserRole.Owner)
.where('album_user.userId', '=', ownerId),
);
@Injectable()
export class AlbumRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
async getById(id: string, options: AlbumInfoOptions) {
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }, DummyValue.UUID] })
getById(id: string, options: AlbumInfoOptions, authUserId?: string) {
return this.db
.with('album_user', (qb) => qb.selectFrom('album_user').selectAll().where('album_user.albumId', '=', id))
.selectFrom('album')
.selectAll('album')
.where('album.id', '=', id)
.where('album.deletedAt', 'is', null)
.select(withOwner)
.select(withAlbumUsers)
.select(withAlbumUsers(authUserId))
.select(withSharedLink)
.$if(options.withAssets, (eb) => eb.select(withAssets))
.$narrowType<{ assets: NotNull }>()
@@ -100,27 +102,22 @@ export class AlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getByAssetId(ownerId: string, assetId: string) {
getByAssetId(ownerId: string, assetId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
.where((eb) =>
eb.or([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
]),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
)
.where('album_asset.assetId', '=', assetId)
.where('album.deletedAt', 'is', null)
.orderBy('album.createdAt', 'desc')
.select(withOwner)
.select(withAlbumUsers)
.select(withAlbumUsers(ownerId))
.orderBy('album.createdAt', 'desc')
.execute();
}
@@ -137,15 +134,12 @@ export class AlbumRepository {
.select('album.id')
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
.where((eb) =>
eb.or([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
]),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
)
.where('album_asset.assetId', 'in', assetIds)
.where('album.deletedAt', 'is', null)
@@ -189,79 +183,60 @@ export class AlbumRepository {
);
}
@GenerateSql({ params: [DummyValue.UUID] })
async getOwned(ownerId: string) {
private buildAlbumBaseQuery(ownerId: string, { isOwned, isShared }: { isOwned?: boolean; isShared?: boolean }) {
return this.db
.selectFrom('album')
.selectAll('album')
.select(withOwner)
.select(withAlbumUsers)
.select(withSharedLink)
.where('album.ownerId', '=', ownerId)
.where('album.deletedAt', 'is', null)
.orderBy('album.createdAt', 'desc')
.execute();
}
/**
* Get albums shared with and shared by owner.
*/
@GenerateSql({ params: [DummyValue.UUID] })
async getShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.where((eb) =>
eb.or([
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.userId', '=', ownerId)])),
),
eb.exists(
eb
.selectFrom('shared_link')
.whereRef('shared_link.albumId', '=', 'album.id')
.where('shared_link.userId', '=', ownerId),
),
]),
.innerJoin('album_user', (join) =>
join.onRef('album_user.albumId', '=', 'album.id').on('album_user.userId', '=', ownerId),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers)
.select(withOwner)
.$if(isOwned === true, (qb) => qb.where('album_user.role', '=', sql.lit(AlbumUserRole.Owner)))
.$if(isOwned === false, (qb) => qb.where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)))
.$if(isShared !== undefined, (qb) =>
qb.where((eb) => {
const isSharedAlbum = eb.or([
eb.exists(
eb
.selectFrom('album_user as au')
.whereRef('au.albumId', '=', 'album.id')
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
),
eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id')),
]);
return isShared ? isSharedAlbum : eb.not(isSharedAlbum);
}),
);
}
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
getAll(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<MapAlbumDto[]> {
return this.buildAlbumBaseQuery(ownerId, options)
.selectAll('album')
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
/**
* Get albums of owner that are _not_ shared
*/
@GenerateSql({ params: [DummyValue.UUID] })
async getNotShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.where('album.ownerId', '=', ownerId)
.where('album.deletedAt', 'is', null)
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id'))))
.where((eb) => eb.not(eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))))
.select(withOwner)
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
async getAllIds(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<string[]> {
const rows = await this.buildAlbumBaseQuery(ownerId, options)
.select('album.id')
.orderBy('album.createdAt', 'desc')
.execute();
return rows.map((r) => r.id);
}
async restoreAll(userId: string): Promise<void> {
await this.db.updateTable('album').set({ deletedAt: null }).where('ownerId', '=', userId).execute();
await this.db.updateTable('album').set({ deletedAt: null }).where(isAlbumOwned(userId)).execute();
}
async softDeleteAll(userId: string): Promise<void> {
await this.db.updateTable('album').set({ deletedAt: new Date() }).where('ownerId', '=', userId).execute();
await this.db.updateTable('album').set({ deletedAt: new Date() }).where(isAlbumOwned(userId)).execute();
}
async deleteAll(userId: string): Promise<void> {
await this.db.deleteFrom('album').where('ownerId', '=', userId).execute();
await this.db.deleteFrom('album').where(isAlbumOwned(userId)).execute();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@@ -306,52 +281,86 @@ export class AlbumRepository {
.then((results) => new Set(results.map(({ assetId }) => assetId)));
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
await this.addAssets(this.db, albumId, assetIds);
if (assetIds.length === 0) {
return;
}
await this.db
.insertInto('album_asset')
.expression((eb) =>
eb.selectFrom(dummy).select([asUuid(albumId).as('albumId'), sql`unnest(${assetIds}::uuid[])`.as('assetId')]),
)
.onConflict((oc) => oc.doNothing())
.execute();
}
create(album: Insertable<AlbumTable>, assetIds: string[], albumUsers: AlbumUserCreateDto[]) {
return this.db.transaction().execute(async (tx) => {
const newAlbum = await tx.insertInto('album').values(album).returning('album.id').executeTakeFirst();
@GenerateSql({
params: [
{ albumName: DummyValue.STRING },
[],
[{ userId: DummyValue.UUID, role: AlbumUserRole.Owner }, DummyValue.UUID],
],
})
async create(
album: Insertable<AlbumTable>,
assetIds: string[],
albumUsers: AlbumUserCreateDto[],
authUserId: string,
) {
if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) {
throw new Error('Album must have an owner');
}
if (!newAlbum) {
throw new Error('Failed to create album');
}
const userIds = albumUsers.map((u) => u.userId);
const roles = albumUsers.map((u) => u.role);
if (assetIds.length > 0) {
await this.addAssets(tx, newAlbum.id, assetIds);
}
if (albumUsers.length > 0) {
await tx
const result = await this.db
.with('album', (db) => db.insertInto('album').values(album).returningAll())
.with('album_user', (db) =>
db
.insertInto('album_user')
.values(
albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, role: albumUser.role })),
.expression((eb) =>
eb
.selectFrom('album')
.select(({ ref }) => [
ref('album.id').as('albumId'),
sql`unnest(${userIds}::uuid[])`.as('userId'),
sql`unnest(${roles}::album_user_role_enum[])`.as('role'),
]),
)
.execute();
}
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role']),
)
.with('album_asset', (db) =>
db
.insertInto('album_asset')
.expression((eb) =>
eb
.selectFrom('album')
.select(({ ref }) => [ref('album.id').as('albumId'), sql`unnest(${assetIds}::uuid[])`.as('assetId')]),
)
.onConflict((oc) => oc.doNothing())
.returning(['album_asset.albumId', 'album_asset.assetId']),
)
.selectFrom('album')
.selectAll('album')
.select(withAlbumUsers(authUserId))
.select(withAssets)
.$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
return tx
.selectFrom('album')
.selectAll('album')
.where('id', '=', newAlbum.id)
.select(withOwner)
.select(withAssets)
.select(withAlbumUsers)
.$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
});
return result;
}
update(id: string, album: Updateable<AlbumTable>) {
update(id: string, album: Updateable<AlbumTable>, authUserId: string) {
return this.db
.updateTable('album')
.set(album)
.where('id', '=', id)
.where('album.id', '=', id)
.returningAll('album')
.returning(withOwner)
.returning(withSharedLink)
.returning(withAlbumUsers)
.returning(withAlbumUsers(authUserId))
.executeTakeFirstOrThrow();
}
@@ -359,19 +368,6 @@ export class AlbumRepository {
await this.db.deleteFrom('album').where('id', '=', id).execute();
}
@Chunked({ paramIndex: 2, chunkSize: 30_000 })
private async addAssets(db: Kysely<DB>, albumId: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await db
.insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId })))
.onConflict((oc) => oc.doNothing())
.execute();
}
@Chunked({ chunkSize: 30_000 })
async addAssetIdsToAlbums(values: { albumId: string; assetId: string }[]): Promise<void> {
if (values.length === 0) {
@@ -402,7 +398,7 @@ export class AlbumRepository {
albumThumbnailAssetId: this.updateThumbnailBuilder(eb)
.select('album_asset.assetId')
.orderBy('asset.fileCreatedAt', 'desc')
.limit(1),
.limit(sql.lit(1)),
}))
.where((eb) =>
eb.or([
@@ -9,6 +9,7 @@ import { DB } from 'src/schema';
import {
anyUuid,
asUuid,
withAudioStream,
withDefaultVisibility,
withEdits,
withExif,
@@ -16,6 +17,8 @@ import {
withFaces,
withFilePath,
withFiles,
withVideoFormat,
withVideoStream,
} from 'src/utils/database';
import { mimeTypes } from 'src/utils/mime-types';
@@ -134,6 +137,9 @@ export class AssetJobRepository {
)
.select(withEdits)
.$call(withExifInner)
.leftJoin('asset_video', 'asset_video.assetId', 'asset.id')
.select((eb) => withVideoStream(eb).as('videoStream'))
.select((eb) => withVideoFormat(eb).as('format'))
.where('asset.id', '=', id)
.executeTakeFirst();
}
@@ -333,8 +339,14 @@ export class AssetJobRepository {
getForVideoConversion(id: string) {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.innerJoin('asset_video', 'asset_video.assetId', 'asset.id')
.leftJoin('asset_audio', 'asset_audio.assetId', 'asset.id')
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
.select(withFiles)
.select((eb) => withAudioStream(eb).as('audioStream'))
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
.select((eb) => withVideoFormat(eb).$notNull().as('format'))
.where('asset.id', '=', id)
.where('asset.type', '=', sql.lit(AssetType.Video))
.executeTakeFirst();
+77 -7
View File
@@ -19,6 +19,7 @@ import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
@@ -124,6 +125,14 @@ interface GetByIdsRelations {
edits?: boolean;
}
type UpsertExifOptions = {
exif: Insertable<AssetExifTable>;
audio?: Insertable<AssetAudioTable>;
video?: Insertable<AssetVideoTable>;
keyframes?: Insertable<AssetKeyframeTable>;
lockedPropertiesBehavior: 'override' | 'append' | 'skip';
};
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
@@ -161,15 +170,76 @@ export class AssetRepository {
@GenerateSql({
params: [
{ dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] },
{ lockedPropertiesBehavior: 'append' },
{
exif: { dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] },
lockedPropertiesBehavior: 'append',
},
],
})
async upsertExif(
exif: Insertable<AssetExifTable>,
{ lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'override' | 'append' | 'skip' },
): Promise<void> {
await this.db
async upsertExif({ exif, audio, video, keyframes, lockedPropertiesBehavior }: UpsertExifOptions): Promise<void> {
let query = this.db;
if (audio) {
(query as any) = this.db.with('audio', (qb) =>
qb
.insertInto('asset_audio')
.values(audio)
.onConflict((oc) =>
oc.column('assetId').doUpdateSet(({ ref }) => ({
bitrate: ref('asset_audio.bitrate'),
index: ref('asset_audio.index'),
profile: ref('asset_audio.profile'),
codecName: ref('asset_audio.codecName'),
})),
),
);
}
if (video) {
(query as any) = query.with('video', (qb) =>
qb
.insertInto('asset_video')
.values(video)
.onConflict((oc) =>
oc.column('assetId').doUpdateSet(({ ref }) => ({
bitrate: ref('asset_video.bitrate'),
timeBase: ref('asset_video.timeBase'),
index: ref('asset_video.index'),
profile: ref('asset_video.profile'),
level: ref('asset_video.level'),
colorPrimaries: ref('asset_video.colorPrimaries'),
colorTransfer: ref('asset_video.colorTransfer'),
colorMatrix: ref('asset_video.colorMatrix'),
dvProfile: ref('asset_video.dvProfile'),
dvLevel: ref('asset_video.dvLevel'),
dvBlSignalCompatibilityId: ref('asset_video.dvBlSignalCompatibilityId'),
codecName: ref('asset_video.codecName'),
formatName: ref('asset_video.formatName'),
formatLongName: ref('asset_video.formatLongName'),
pixelFormat: ref('asset_video.pixelFormat'),
})),
),
);
}
if (keyframes) {
(query as any) = query.with('keyframe', (qb) =>
qb
.insertInto('asset_keyframe')
.values(keyframes)
.onConflict((oc) =>
oc.column('assetId').doUpdateSet(({ ref }) => ({
pts: ref('asset_keyframe.pts'),
accDuration: ref('asset_keyframe.accDuration'),
ownDuration: ref('asset_keyframe.ownDuration'),
totalDuration: ref('asset_keyframe.totalDuration'),
packetCount: ref('asset_keyframe.packetCount'),
outputFrames: ref('asset_keyframe.outputFrames'),
})),
),
);
}
await query
.insertInto('asset_exif')
.values(exif)
.onConflict((oc) =>
+3 -13
View File
@@ -9,7 +9,7 @@ import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { citiesFile, excludePaths, IWorker } from 'src/constants';
import { citiesFile, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators';
import { EnvSchema } from 'src/dtos/env.dto';
import {
@@ -248,10 +248,6 @@ const getEnv = (): EnvData => {
vectorExtension = DatabaseExtension.Vector;
break;
}
case 'pgvecto.rs': {
vectorExtension = DatabaseExtension.Vectors;
break;
}
case 'vectorchord': {
vectorExtension = DatabaseExtension.VectorChord;
break;
@@ -301,11 +297,9 @@ const getEnv = (): EnvData => {
mount: true,
generateId: true,
setup: (cls, req: Request, res: Response) => {
const headerValues = req.headers[ImmichHeader.Cid];
const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues;
const cid = headerValue || cls.get(CLS_ID);
const cid = req.header(ImmichHeader.CorrelationId) || cls.get(CLS_ID);
cls.set(CLS_ID, cid);
res.header(ImmichHeader.Cid, cid);
res.header(ImmichHeader.CorrelationId, cid);
},
},
},
@@ -334,10 +328,6 @@ const getEnv = (): EnvData => {
otel: {
metrics: {
hostMetrics: telemetries.has(ImmichTelemetry.Host),
apiMetrics: {
enable: telemetries.has(ImmichTelemetry.Api),
ignoreRoutes: excludePaths,
},
},
},
+9 -40
View File
@@ -1,7 +1,7 @@
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
import { Injectable } from '@nestjs/common';
import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
import { FileMigrationProvider, Kysely, Migrator, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
@@ -14,7 +14,6 @@ import {
VECTOR_VERSION_RANGE,
VECTORCHORD_LIST_SLACK_FACTOR,
VECTORCHORD_VERSION_RANGE,
VECTORS_VERSION_RANGE,
} from 'src/constants';
import { GenerateSql } from 'src/decorators';
import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum';
@@ -23,7 +22,7 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode
import { DB } from 'src/schema';
import { immich_uuid_v7 } from 'src/schema/functions';
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
import { ExtensionVersion, VectorExtension } from 'src/types';
import { vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation';
@@ -73,7 +72,7 @@ export class DatabaseRepository {
return getVectorExtension(this.db);
}
@GenerateSql({ params: [[DatabaseExtension.Vectors]] })
@GenerateSql({ params: [[DatabaseExtension.Vector]] })
async getExtensionVersions(extensions: readonly DatabaseExtension[]): Promise<ExtensionVersion[]> {
const { rows } = await sql<ExtensionVersion>`
SELECT name, default_version as "availableVersion", installed_version as "installedVersion"
@@ -88,9 +87,6 @@ export class DatabaseRepository {
case DatabaseExtension.VectorChord: {
return VECTORCHORD_VERSION_RANGE;
}
case DatabaseExtension.Vectors: {
return VECTORS_VERSION_RANGE;
}
case DatabaseExtension.Vector: {
return VECTOR_VERSION_RANGE;
}
@@ -125,7 +121,7 @@ export class DatabaseRepository {
await sql`DROP EXTENSION IF EXISTS ${sql.raw(extension)}`.execute(this.db);
}
async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise<VectorUpdateResult> {
async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise<void> {
const [{ availableVersion, installedVersion }] = await this.getExtensionVersions([extension]);
if (!installedVersion) {
throw new Error(`${EXTENSION_NAMES[extension]} extension is not installed`);
@@ -136,10 +132,8 @@ export class DatabaseRepository {
}
targetVersion ??= availableVersion;
let restartRequired = false;
const diff = semver.diff(installedVersion, targetVersion);
if (!diff) {
return { restartRequired: false };
if (!semver.diff(installedVersion, targetVersion)) {
return;
}
await Promise.all([
@@ -147,22 +141,8 @@ export class DatabaseRepository {
this.db.schema.dropIndex(VectorIndex.Face).ifExists().execute(),
]);
await this.db.transaction().execute(async (tx) => {
await this.setSearchPath(tx);
await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx);
if (extension === DatabaseExtension.Vectors && (diff === 'major' || diff === 'minor')) {
await sql`SELECT pgvectors_upgrade()`.execute(tx);
restartRequired = true;
}
});
if (!restartRequired) {
await Promise.all([this.reindexVectors(VectorIndex.Clip), this.reindexVectors(VectorIndex.Face)]);
}
return { restartRequired };
await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(this.db);
await Promise.all([this.reindexVectors(VectorIndex.Clip), this.reindexVectors(VectorIndex.Face)]);
}
async prewarm(index: VectorIndex): Promise<void> {
@@ -198,12 +178,6 @@ export class DatabaseRepository {
}
break;
}
case DatabaseExtension.Vectors: {
if (!row.indexdef.toLowerCase().includes('using vectors')) {
promises.push(this.reindexVectors(indexName));
}
break;
}
case DatabaseExtension.VectorChord: {
const matches = row.indexdef.match(/(?<=lists = \[)\d+/g);
const lists = matches && matches.length > 0 ? Number(matches[0]) : 1;
@@ -260,11 +234,10 @@ export class DatabaseRepository {
await sql`ALTER TABLE ${sql.raw(table)} ADD COLUMN embedding real[] NOT NULL`.execute(tx);
}
await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx);
const schema = vectorExtension === DatabaseExtension.Vectors ? 'vectors.' : '';
await sql`
ALTER TABLE ${sql.raw(table)}
ALTER COLUMN embedding
SET DATA TYPE ${sql.raw(schema)}vector(${sql.raw(String(dimSize))})`.execute(tx);
SET DATA TYPE vector(${sql.raw(String(dimSize))})`.execute(tx);
await sql.raw(vectorIndexQuery({ vectorExtension, table, indexName, lists })).execute(tx);
});
try {
@@ -275,10 +248,6 @@ export class DatabaseRepository {
this.logger.log(`Reindexed ${indexName}`);
}
private async setSearchPath(tx: Transaction<DB>): Promise<void> {
await sql`SET search_path TO "$user", public, vectors`.execute(tx);
}
private async getDatabaseName(): Promise<string> {
const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(this.db);
return rows[0].db;
+1 -1
View File
@@ -40,7 +40,7 @@ type EventMap = {
// album events
AlbumUpdate: [{ id: string; recipientId: string }];
AlbumInvite: [{ id: string; userId: string }];
AlbumInvite: [{ id: string; userId: string; senderName: string }];
// asset events
AssetCreate: [{ asset: Asset }];
+2
View File
@@ -46,6 +46,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -100,6 +101,7 @@ export const repositories = [
UserRepository,
ViewRepository,
VersionHistoryRepository,
VideoStreamRepository,
WebsocketRepository,
WorkflowRepository,
];
@@ -33,12 +33,6 @@ export interface ReverseGeocodeResult {
city: string | null;
}
export interface MapMarker extends ReverseGeocodeResult {
id: string;
lat: number;
lon: number;
}
interface MapDB extends DB {
geodata_places_tmp: GeodataPlacesTable;
naturalearth_countries_tmp: NaturalEarthCountriesTable;
@@ -71,7 +71,7 @@ describe(MediaRepository.name, () => {
describe('applyEdits (single actions)', () => {
it('should apply crop edit correctly', async () => {
const result = await sut['applyEdits'](
const result = sut['applyEdits'](
sharp({
create: {
width: 1000,
@@ -98,7 +98,7 @@ describe(MediaRepository.name, () => {
expect(metadata.height).toBe(300);
});
it('should apply rotate edit correctly', async () => {
const result = await sut['applyEdits'](
const result = sut['applyEdits'](
sharp({
create: {
width: 500,
@@ -123,7 +123,7 @@ describe(MediaRepository.name, () => {
});
it('should apply mirror edit correctly', async () => {
const resultHorizontal = await sut['applyEdits'](sharp(await buildTestQuadImage()), [
const resultHorizontal = sut['applyEdits'](sharp(await buildTestQuadImage()), [
{
action: AssetEditAction.Mirror,
parameters: {
@@ -142,7 +142,7 @@ describe(MediaRepository.name, () => {
expect(await getPixelColor(bufferHorizontal, 10, 990)).toEqual({ r: 255, g: 255, b: 0 });
expect(await getPixelColor(bufferHorizontal, 990, 990)).toEqual({ r: 0, g: 0, b: 255 });
const resultVertical = await sut['applyEdits'](sharp(await buildTestQuadImage()), [
const resultVertical = sut['applyEdits'](sharp(await buildTestQuadImage()), [
{
action: AssetEditAction.Mirror,
parameters: {
@@ -170,7 +170,7 @@ describe(MediaRepository.name, () => {
describe('applyEdits (multiple sequential edits)', () => {
it('should apply horizontal mirror then vertical mirror (equivalent to 180° rotation)', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
]);
@@ -188,7 +188,7 @@ describe(MediaRepository.name, () => {
it('should apply rotate 90° then horizontal mirror', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
]);
@@ -206,7 +206,7 @@ describe(MediaRepository.name, () => {
it('should apply 180° rotation', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Rotate, parameters: { angle: 180 } },
]);
@@ -223,7 +223,7 @@ describe(MediaRepository.name, () => {
it('should apply 270° rotations', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Rotate, parameters: { angle: 270 } },
]);
@@ -240,7 +240,7 @@ describe(MediaRepository.name, () => {
it('should apply crop then rotate 90°', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 1000, height: 500 } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
]);
@@ -256,7 +256,7 @@ describe(MediaRepository.name, () => {
it('should apply rotate 90° then crop', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
]);
@@ -272,7 +272,7 @@ describe(MediaRepository.name, () => {
it('should apply vertical mirror then horizontal mirror then rotate 90°', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
@@ -291,7 +291,7 @@ describe(MediaRepository.name, () => {
it('should apply crop to single quadrant then mirror', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 500 } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
]);
@@ -309,7 +309,7 @@ describe(MediaRepository.name, () => {
it('should apply all operations: crop, rotate, mirror', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
const result = sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
+216 -78
View File
@@ -1,14 +1,30 @@
import { Injectable } from '@nestjs/common';
import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import ffmpeg, { FfprobeData, FfprobeStream } from 'fluent-ffmpeg';
import _ from 'lodash';
import { Duration } from 'luxon';
import { execFile as execFileCb } from 'node:child_process';
import fs from 'node:fs/promises';
import { Writable } from 'node:stream';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
import { Exif } from 'src/database';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
import {
AacProfile,
Av1Profile,
ColorMatrix,
ColorPrimaries,
Colorspace,
ColorTransfer,
DvProfile,
DvSignalCompatibility,
H264Profile,
HevcProfile,
LogLevel,
RawExtractedFormat,
} from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import {
DecodeToBufferOptions,
@@ -18,6 +34,7 @@ import {
ProbeOptions,
TranscodeCommand,
VideoInfo,
VideoPacketInfo,
} from 'src/types';
import { handlePromiseError } from 'src/utils/misc';
import { createAffineMatrix } from 'src/utils/transform';
@@ -26,9 +43,14 @@ const probe = (input: string, options: string[]): Promise<FfprobeData> =>
new Promise((resolve, reject) =>
ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))),
);
const execFile = promisify(execFileCb);
sharp.concurrency(0);
sharp.cache({ files: 0 });
const pascalCase = (str: string) => _.upperFirst(_.camelCase(str.toLowerCase()));
type ProgressEvent = {
frames: number;
currentFps: number;
@@ -55,34 +77,20 @@ export class MediaRepository {
* @returns ExtractResult if succeeded, or null if failed
*/
async extract(input: string): Promise<ExtractResult | null> {
try {
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
return { buffer, format: RawExtractedFormat.Jpeg };
} catch (error: any) {
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
}
try {
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
return { buffer, format: RawExtractedFormat.Jpeg };
} catch (error: any) {
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
}
try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
return { buffer, format: RawExtractedFormat.Jxl };
} catch (error: any) {
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
}
try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
return { buffer, format: RawExtractedFormat.Jpeg };
} catch (error: any) {
this.logger.debug(`Could not extract preview buffer from image: ${error}`);
return null;
for (const { tag, format } of [
{ tag: 'JpgFromRaw2', format: RawExtractedFormat.Jpeg },
{ tag: 'JpgFromRaw', format: RawExtractedFormat.Jpeg },
{ tag: 'PreviewJXL', format: RawExtractedFormat.Jxl },
{ tag: 'PreviewImage', format: RawExtractedFormat.Jpeg },
]) {
try {
const buffer = await exiftool.extractBinaryTagToBuffer(tag, input);
return { buffer, format };
} catch (error: any) {
this.logger.debug(`Could not extract ${tag} buffer from image: ${error}`);
}
}
return null;
}
async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
@@ -140,49 +148,45 @@ export class MediaRepository {
}
}
async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
const pipeline = await this.getImageDecodingPipeline(input, options);
return pipeline.raw().toBuffer({ resolveWithObject: true });
decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
}
private async applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): Promise<sharp.Sharp> {
const affineEditOperations = edits.filter((edit) => edit.action !== 'crop');
const matrix = createAffineMatrix(affineEditOperations);
private applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): sharp.Sharp {
const crop = edits.find((edit) => edit.action === 'crop');
const dimensions = await pipeline.metadata();
if (crop) {
pipeline = pipeline.extract({
left: crop ? Math.round(crop.parameters.x) : 0,
top: crop ? Math.round(crop.parameters.y) : 0,
width: crop ? Math.round(crop.parameters.width) : dimensions.width || 0,
height: crop ? Math.round(crop.parameters.height) : dimensions.height || 0,
left: Math.round(crop.parameters.x),
top: Math.round(crop.parameters.y),
width: Math.round(crop.parameters.width),
height: Math.round(crop.parameters.height),
});
}
const { a, b, c, d } = matrix;
pipeline = pipeline.affine([
[a, b],
[c, d],
]);
const affineEditOperations = edits.filter((edit) => edit.action !== 'crop');
if (affineEditOperations.length > 0) {
const { a, b, c, d } = createAffineMatrix(affineEditOperations);
pipeline = pipeline.affine([
[a, b],
[c, d],
]);
}
return pipeline;
}
async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
const pipeline = await this.getImageDecodingPipeline(input, options);
const decoded = pipeline.toFormat(options.format, {
quality: options.quality,
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
progressive: options.progressive,
});
await decoded.toFile(output);
await this.getImageDecodingPipeline(input, options)
.toFormat(options.format, {
quality: options.quality,
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
progressive: options.progressive,
})
.toFile(output);
}
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
let pipeline = sharp(input, {
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
failOn: options.processInvalidImages ? 'none' : 'error',
@@ -206,7 +210,7 @@ export class MediaRepository {
}
if (options.edits && options.edits.length > 0) {
pipeline = await this.applyEdits(pipeline, options.edits);
pipeline = this.applyEdits(pipeline, options.edits);
}
if (options.size !== undefined) {
@@ -216,19 +220,18 @@ export class MediaRepository {
}
async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise<Buffer> {
const [{ rgbaToThumbHash }, decodingPipeline] = await Promise.all([
import('thumbhash'),
this.getImageDecodingPipeline(input, {
colorspace: options.colorspace,
processInvalidImages: options.processInvalidImages,
raw: options.raw,
edits: options.edits,
}),
]);
const { rgbaToThumbHash } = await import('thumbhash');
const pipeline = decodingPipeline.resize(100, 100, { fit: 'inside', withoutEnlargement: true }).raw().ensureAlpha();
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
const { data, info } = await this.getImageDecodingPipeline(input, {
colorspace: options.colorspace,
processInvalidImages: options.processInvalidImages,
raw: options.raw,
edits: options.edits,
})
.resize(100, 100, { fit: 'inside', withoutEnlargement: true })
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
return Buffer.from(rgbaToThumbHash(info.width, info.height, data));
}
@@ -244,6 +247,7 @@ export class MediaRepository {
},
videoStreams: results.streams
.filter((stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic)
.sort((a, b) => this.compareStreams(a, b))
.map((stream) => {
const height = this.parseInt(stream.height);
const dar = this.getDar(stream.display_aspect_ratio);
@@ -251,29 +255,99 @@ export class MediaRepository {
index: stream.index,
height,
width: dar ? Math.round(height * dar) : this.parseInt(stream.width),
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
codecType: stream.codec_type,
codecName: stream.codec_name === 'h265' ? 'hevc' : (stream.codec_name ?? null),
profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined) ?? null,
level: this.parseOptionalInt(stream.level),
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
frameRate: this.parseFrameRate(stream.avg_frame_rate ?? stream.r_frame_rate),
timeBase: this.parseRational(stream.time_base)?.den ?? null,
rotation: this.parseInt(stream.rotation),
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
bitrate: this.parseInt(stream.bit_rate),
pixelFormat: stream.pix_fmt || 'yuv420p',
colorPrimaries: stream.color_primaries,
colorSpace: stream.color_space,
colorTransfer: stream.color_transfer,
colorPrimaries: this.parseEnum(ColorPrimaries, stream.color_primaries) ?? ColorPrimaries.Unknown,
colorMatrix: this.parseEnum(ColorMatrix, stream.color_space) ?? ColorMatrix.Unknown,
colorTransfer: this.parseEnum(ColorTransfer, stream.color_transfer) ?? ColorTransfer.Unknown,
dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | null,
dvLevel: this.parseOptionalInt(stream.dv_level),
dvBlSignalCompatibilityId: this.parseOptionalInt(
stream.dv_bl_signal_compatibility_id,
) as DvSignalCompatibility | null,
};
}),
audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio')
.sort((a, b) => this.compareStreams(a, b))
.map((stream) => ({
index: stream.index,
codecType: stream.codec_type,
codecName: stream.codec_name,
codecName: stream.codec_name ?? null,
profile:
stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : null,
bitrate: this.parseInt(stream.bit_rate),
})),
};
}
/**
* Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved.
* Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video.
*/
async probePackets(input: string, streamIndex: number): Promise<VideoPacketInfo | null> {
const { stdout } = await execFile('ffprobe', [
'-v',
'error',
'-select_streams',
String(streamIndex),
'-show_entries',
'packet=pts,duration,flags',
'-of',
'csv=p=0',
input,
]);
let totalDuration = 0;
const keyframePts: number[] = [];
const keyframeAccDuration: number[] = [];
const keyframeOwnDuration: number[] = [];
const postDiscard: { pts: number; duration: number }[] = [];
for (const line of stdout.split('\n')) {
if (!line) {
continue;
}
const [ptsStr, durationStr, flags] = line.split(',');
const pts = Number.parseInt(ptsStr);
const duration = Number.parseInt(durationStr);
if (Number.isNaN(pts) || Number.isNaN(duration)) {
continue;
}
// Discarded packets don't contribute to packet count, but still contribute to video duration
totalDuration += duration;
if (flags[1] !== 'D') {
postDiscard.push({ pts, duration });
}
if (flags[0] === 'K') {
keyframePts.push(pts);
keyframeAccDuration.push(totalDuration);
// VFR content can have variable duration keyframes,
// so we need to track their duration separately for accurate segment boundaries.
// Non-keyframes are accounted for in totalDuration.
keyframeOwnDuration.push(duration);
}
}
if (postDiscard.length === 0) {
return null;
}
return {
totalDuration,
packetCount: postDiscard.length,
outputFrames: this.cfrOutputFrames(postDiscard, postDiscard.length / totalDuration),
keyframePts,
keyframeAccDuration,
keyframeOwnDuration,
};
}
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
if (!options.twoPass) {
return new Promise((resolve, reject) => {
@@ -356,6 +430,31 @@ export class MediaRepository {
return Number.parseFloat(value as string) || 0;
}
private parseOptionalInt(value: string | number | undefined): number | null {
const parsed = Number.parseInt(value as string);
return Number.isNaN(parsed) ? null : parsed;
}
private parseEnum<E extends Record<string, number | string>>(enumObj: E, value?: string) {
return value ? ((enumObj[pascalCase(value)] as Extract<E[keyof E], number> | undefined) ?? null) : null;
}
/** Parse a rational like "60000/1001" or "1/600" into `{ num, den }`. */
private parseRational(value: string | undefined): { num: number; den: number } | null {
if (value) {
const [num, den = 1] = value.split('/').map(Number);
if (num && den) {
return { num, den };
}
}
return null;
}
private parseFrameRate(value: string | undefined): number | null {
const r = this.parseRational(value);
return r ? r.num / r.den : null;
}
private getDar(dar: string | undefined): number {
if (dar) {
const [darW, darH] = dar.split(':').map(Number);
@@ -366,4 +465,43 @@ export class MediaRepository {
return 0;
}
private parseVideoProfile(codec?: string, profile?: string) {
switch (codec) {
case 'h264': {
return this.parseEnum(H264Profile, profile);
}
case 'h265':
case 'hevc': {
return this.parseEnum(HevcProfile, profile);
}
case 'av1': {
return this.parseEnum(Av1Profile, profile);
}
}
return null;
}
private compareStreams(a: FfprobeStream, b: FfprobeStream): number {
const d = (b.disposition?.default ?? 0) - (a.disposition?.default ?? 0);
if (d !== 0) {
return d;
}
return this.parseInt(b.bit_rate) - this.parseInt(a.bit_rate);
}
private cfrOutputFrames(packets: { pts: number; duration: number }[], slotsPerTick: number) {
// Packets may be out of PTS order due to B-frames
packets.sort((a, b) => a.pts - b.pts);
const firstPts = packets[0].pts;
let outputFrames = 0;
let nextPts = 0;
for (const pkt of packets) {
const delta = (pkt.pts - firstPts) * slotsPerTick - nextPts + pkt.duration * slotsPerTick;
const nb = delta < -1.1 ? 0 : delta > 1.1 ? Math.round(delta) : 1;
outputFrames += nb;
nextPts += nb;
}
return outputFrames;
}
}
+14 -2
View File
@@ -66,9 +66,21 @@ export class MemoryRepository implements IBulkAsset {
.selectAll('asset')
.innerJoin('memory_asset', 'asset.id', 'memory_asset.assetId')
.whereRef('memory_asset.memoriesId', '=', 'memory.id')
.orderBy('asset.fileCreatedAt', 'asc')
.where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.where('asset.deletedAt', 'is', null),
.where('asset.deletedAt', 'is', null)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('asset_face')
.innerJoin('person', 'person.id', 'asset_face.personId')
.select((eb) => eb.val(1).as('one'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('person.isHidden', '=', true),
),
),
)
.orderBy('asset.fileCreatedAt', 'asc'),
).as('assets'),
)
.selectAll('memory')
+2 -2
View File
@@ -9,7 +9,7 @@ import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { removeUndefinedKeys, withFilePath } from 'src/utils/database';
import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions {
@@ -418,7 +418,7 @@ export class PersonRepository {
(query as any) = query.with('added_embeddings', (db) => db.insertInto('face_search').values(embeddingsToAdd));
}
await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
await query.selectFrom(dummy).execute();
}
async update(person: Updateable<PersonTable> & { id: string }) {
@@ -5,7 +5,7 @@ import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkType } from 'src/enum';
import { AlbumUserRole, SharedLinkType } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
@@ -39,7 +39,15 @@ const withAlbumOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
return eb
.selectFrom('user')
.select(columns.user)
.whereRef('user.id', '=', 'album.ownerId')
.where((eb) =>
eb.exists(
eb
.selectFrom('album_user')
.where('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.whereRef('album_user.albumId', '=', 'album.id')
.whereRef('album_user.userId', '=', 'user.id'),
),
)
.where('user.deletedAt', 'is', null)
.as('owner');
};
+24 -52
View File
@@ -171,10 +171,9 @@ class AlbumSync extends BaseSync {
return this.upsertQuery('album', options)
.distinctOn(['album.id', 'album.updateId'])
.leftJoin('album_user as album_users', 'album.id', 'album_users.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_users.userId', '=', userId)]))
.where('album_users.userId', '=', userId)
.select([
'album.id',
'album.ownerId',
'album.albumName as name',
'album.description',
'album.createdAt',
@@ -186,6 +185,11 @@ class AlbumSync extends BaseSync {
])
.stream();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getAlbumUsers(albumId: string) {
return this.db.selectFrom('album_user').select(['userId', 'role']).where('albumId', '=', albumId).execute();
}
}
class AlbumAssetSync extends BaseSync {
@@ -207,9 +211,8 @@ class AlbumAssetSync extends BaseSync {
.select(columns.syncAsset)
.select('asset.updateId')
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
@@ -220,9 +223,8 @@ class AlbumAssetSync extends BaseSync {
.select('album_asset.updateId')
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
.select(columns.syncAsset)
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
}
@@ -246,9 +248,8 @@ class AlbumAssetExifSync extends BaseSync {
.select(columns.syncAssetExif)
.select('asset_exif.updateId')
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send exif updates for assets that the client already knows about
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
@@ -261,7 +262,7 @@ class AlbumAssetExifSync extends BaseSync {
.select(columns.syncAssetExif)
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.where('album_user.userId', '=', userId)
.stream();
}
}
@@ -284,18 +285,7 @@ class AlbumToAssetSync extends BaseSync {
eb(
'albumId',
'in',
eb
.selectFrom('album')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('album_user')
.select(['album_user.albumId as id'])
.where('album_user.userId', '=', userId),
),
),
eb.selectFrom('album_user').select(['album_user.albumId as id']).where('album_user.userId', '=', userId),
),
)
.stream();
@@ -310,9 +300,8 @@ class AlbumToAssetSync extends BaseSync {
const userId = options.userId;
return this.upsertQuery('album_asset', options)
.select(['album_asset.assetId as assetId', 'album_asset.albumId as albumId', 'album_asset.updateId'])
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
}
@@ -336,18 +325,7 @@ class AlbumUserSync extends BaseSync {
eb(
'albumId',
'in',
eb
.selectFrom('album')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('album_user')
.select(['album_user.albumId as id'])
.where('album_user.userId', '=', userId),
),
),
eb.selectFrom('album_user').select(['album_user.albumId as id']).where('album_user.userId', '=', userId),
),
)
.stream();
@@ -368,17 +346,9 @@ class AlbumUserSync extends BaseSync {
'album_user.albumId',
'in',
eb
.selectFrom('album')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('album_user as albumUsers')
.select(['albumUsers.albumId as id'])
.where('albumUsers.userId', '=', userId),
),
),
.selectFrom('album_user as albumUsers')
.select(['albumUsers.albumId as id'])
.where('albumUsers.userId', '=', userId),
),
)
.stream();
@@ -625,7 +595,8 @@ class PartnerAssetsSync extends BaseSync {
@GenerateSql({ params: [dummyBackfillOptions, DummyValue.UUID], stream: true })
getBackfill(options: SyncBackfillOptions, partnerId: string) {
return this.backfillQuery('asset', options)
.select(columns.syncAsset)
.select(columns.syncPartnerAsset)
.select(sql.val(false).as('isFavorite'))
.select('asset.updateId')
.where('ownerId', '=', partnerId)
.stream();
@@ -644,7 +615,8 @@ class PartnerAssetsSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('asset', options)
.select(columns.syncAsset)
.select(columns.syncPartnerAsset)
.select(sql.val(false).as('isFavorite'))
.select('asset.updateId')
.where('ownerId', 'in', (eb) =>
eb.selectFrom('partner').select(['sharedById']).where('sharedWithId', '=', options.userId),
@@ -14,7 +14,7 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic
import { snakeCase, startCase } from 'lodash';
import { MetricService } from 'nestjs-otel';
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
import { serverVersion } from 'src/constants';
import { excludePaths, serverVersion } from 'src/constants';
import { ImmichTelemetry, MetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -60,6 +60,9 @@ export const bootstrapTelemetry = (port: number) => {
if (instance) {
throw new Error('OpenTelemetry SDK already started');
}
const { telemetry } = new ConfigRepository().getEnv();
instance = new NodeSDK({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: `immich`,
@@ -68,7 +71,10 @@ export const bootstrapTelemetry = (port: number) => {
metricReader: new PrometheusExporter({ port }),
contextManager: new AsyncLocalStorageContextManager(),
instrumentations: [
new HttpInstrumentation(),
new HttpInstrumentation({
enabled: telemetry.metrics.has(ImmichTelemetry.Api),
ignoreIncomingRequestHook: (request) => excludePaths.some((item) => request.url?.startsWith(item)),
}),
new IORedisInstrumentation(),
new NestInstrumentation(),
new PgInstrumentation(),
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DB } from 'src/schema';
import {
VideoStreamSegmentTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
@Injectable()
export class VideoStreamRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
createSession(session: Insertable<VideoStreamSessionTable>) {
return this.db.insertInto('video_stream_session').values(session).returning(['id']).executeTakeFirstOrThrow();
}
createVariant(variant: Insertable<VideoStreamVariantTable>) {
return this.db.insertInto('video_stream_variant').values(variant).returning(['id']).executeTakeFirstOrThrow();
}
async createSegment(segment: Insertable<VideoStreamSegmentTable>) {
await this.db.insertInto('video_stream_segment').values(segment).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getSession(id: string) {
return this.db.selectFrom('video_stream_session').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getVariant(id: string) {
return this.db.selectFrom('video_stream_variant').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
getSegment(variantId: string, index: number) {
return this.db
.selectFrom('video_stream_segment')
.selectAll()
.where('variantId', '=', variantId)
.where('index', '=', index)
.executeTakeFirst();
}
@GenerateSql()
getExpiredSessions() {
return this.db.selectFrom('video_stream_session').select(['id']).where('expiresAt', '<=', new Date()).execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
async extendSession(id: string, expiresAt: Date) {
await this.db.updateTable('video_stream_session').set({ expiresAt }).where('id', '=', id).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteSession(id: string) {
await this.db.deleteFrom('video_stream_session').where('id', '=', id).execute();
}
}
@@ -11,7 +11,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto';
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { handlePromiseError } from 'src/utils/misc';
@@ -35,9 +35,9 @@ export interface ClientEventMap {
on_notification: [NotificationDto];
on_session_delete: [string];
AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }];
AssetUploadReadyV2: [{ asset: SyncAssetV2; exif: SyncAssetExifV1 }];
AppRestartV1: [AppRestartEvent];
AssetEditReadyV1: [{ asset: SyncAssetV1; edit: SyncAssetEditV1[] }];
AssetEditReadyV2: [{ asset: SyncAssetV2; edit: SyncAssetEditV1[] }];
}
export type AuthFn = (client: Socket) => Promise<AuthDto>;
+18 -1
View File
@@ -1,5 +1,17 @@
import { registerEnum } from '@immich/sql-tools';
import { AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
import {
AlbumUserRole,
AssetStatus,
AssetVisibility,
ChecksumAlgorithm,
SourceType,
VideoSegmentCodec,
} from 'src/enum';
export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum',
values: [AlbumUserRole.Owner, AlbumUserRole.Editor, AlbumUserRole.Viewer],
});
export const assets_status_enum = registerEnum({
name: 'assets_status_enum',
@@ -20,3 +32,8 @@ export const asset_checksum_algorithm_enum = registerEnum({
name: 'asset_checksum_algorithm_enum',
values: Object.values(ChecksumAlgorithm),
});
export const video_stream_variant_codec_enum = registerEnum({
name: 'video_stream_variant_codec_enum',
values: Object.values(VideoSegmentCodec),
});
+2 -14
View File
@@ -29,7 +29,8 @@ export const album_user_after_insert = registerFunction({
body: `
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows);
WHERE "id" IN (SELECT "albumId" FROM inserted_rows)
AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = 'owner');
RETURN NULL;
END`,
});
@@ -119,19 +120,6 @@ export const asset_delete_audit = registerFunction({
END`,
});
export const album_delete_audit = registerFunction({
name: 'album_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
});
export const album_asset_delete_audit = registerFunction({
name: 'album_asset_delete_audit',
returnType: 'TRIGGER',
+23 -4
View File
@@ -1,7 +1,11 @@
import { Database, Extensions, Generated, Int8 } from '@immich/sql-tools';
import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import {
album_delete_audit,
album_user_role_enum,
asset_face_source_type,
asset_visibility_enum,
assets_status_enum,
} from 'src/schema/enums';
import {
album_user_after_insert,
album_user_delete_audit,
asset_delete_audit,
@@ -29,6 +33,7 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
import { AssetAuditTable } from 'src/schema/tables/asset-audit.table';
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
import { AssetEditAuditTable } from 'src/schema/tables/asset-edit-audit.table';
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -72,6 +77,11 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import {
VideoStreamSegmentTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@@ -129,6 +139,9 @@ export class ImmichDatabase {
UserMetadataAuditTable,
UserTable,
VersionHistoryTable,
VideoStreamSessionTable,
VideoStreamVariantTable,
VideoStreamSegmentTable,
PluginTable,
PluginFilterTable,
PluginActionTable,
@@ -146,7 +159,6 @@ export class ImmichDatabase {
user_delete_audit,
partner_delete_audit,
asset_delete_audit,
album_delete_audit,
album_user_after_insert,
album_user_delete_audit,
memory_delete_audit,
@@ -158,7 +170,7 @@ export class ImmichDatabase {
asset_face_audit,
];
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
}
export interface Migrations {
@@ -193,6 +205,9 @@ export interface DB {
asset_metadata_audit: AssetMetadataAuditTable;
asset_job_status: AssetJobStatusTable;
asset_ocr: AssetOcrTable;
asset_audio: AssetAudioTable;
asset_video: AssetVideoTable;
asset_keyframe: AssetKeyframeTable;
ocr_search: OcrSearchTable;
face_search: FaceSearchTable;
@@ -244,6 +259,10 @@ export interface DB {
version_history: VersionHistoryTable;
video_stream_session: VideoStreamSessionTable;
video_stream_variant: VideoStreamVariantTable;
video_stream_segment: VideoStreamSegmentTable;
plugin: PluginTable;
plugin_filter: PluginFilterTable;
plugin_action: PluginActionTable;
@@ -1,6 +1,5 @@
import { Kysely, sql } from 'kysely';
import { ErrorMessages } from 'src/constants';
import { DatabaseExtension } from 'src/enum';
import { getVectorExtension } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { vectorIndexQuery } from 'src/utils/database';
@@ -107,9 +106,6 @@ export async function up(db: Kysely<any>): Promise<void> {
RETURN NULL;
END;
$$;`.execute(db);
if (vectorExtension === DatabaseExtension.Vectors) {
await sql`SET search_path TO "$user", public, vectors`.execute(db);
}
await sql`CREATE TYPE "assets_status_enum" AS ENUM ('active','trashed','deleted');`.execute(db);
await sql`CREATE TYPE "sourcetype" AS ENUM ('machine-learning','exif','manual');`.execute(db);
await sql`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying NOT NULL, "password" character varying NOT NULL DEFAULT '', "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "profileImagePath" character varying NOT NULL DEFAULT '', "isAdmin" boolean NOT NULL DEFAULT false, "shouldChangePassword" boolean NOT NULL DEFAULT true, "deletedAt" timestamp with time zone, "oauthId" character varying NOT NULL DEFAULT '', "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "storageLabel" character varying, "name" character varying NOT NULL DEFAULT '', "quotaSizeInBytes" bigint, "quotaUsageInBytes" bigint NOT NULL DEFAULT 0, "status" character varying NOT NULL DEFAULT 'active', "profileChangedAt" timestamp with time zone NOT NULL DEFAULT now(), "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(
@@ -0,0 +1,17 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"asset_id_timeline_notDeleted_idx","sql":"CREATE INDEX \\"asset_id_timeline_notDeleted_idx\\" ON \\"asset\\" (\\"id\\") WHERE (visibility = ''timeline'' AND \\"deletedAt\\" IS NULL);"}'::jsonb WHERE "name" = 'index_asset_id_timeline_notDeleted_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"asset_localDateTime_month_idx","sql":"CREATE INDEX \\"asset_localDateTime_month_idx\\" ON \\"asset\\" (date_trunc(''MONTH''::text, (\\"localDateTime\\" AT TIME ZONE ''UTC''::text)) AT TIME ZONE ''UTC''::text);"}'::jsonb WHERE "name" = 'index_asset_localDateTime_month_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"asset_localDateTime_idx","sql":"CREATE INDEX \\"asset_localDateTime_idx\\" ON \\"asset\\" ((\\"localDateTime\\" at time zone ''UTC'')::date);"}'::jsonb WHERE "name" = 'index_asset_localDateTime_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"activity_like_idx","sql":"CREATE UNIQUE INDEX \\"activity_like_idx\\" ON \\"activity\\" (\\"assetId\\", \\"userId\\", \\"albumId\\") WHERE ((\\"isLiked\\" = true));"}'::jsonb WHERE "name" = 'index_activity_like_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"asset_localDateTime_month_idx\\" ON \\"asset\\" ((date_trunc(''MONTH''::text, (\\"localDateTime\\" AT TIME ZONE ''UTC''::text)) AT TIME ZONE ''UTC''::text));","name":"asset_localDateTime_month_idx","type":"index"}'::jsonb WHERE "name" = 'index_asset_localDateTime_month_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"asset_localDateTime_idx\\" ON \\"asset\\" (((\\"localDateTime\\" at time zone ''UTC'')::date));","name":"asset_localDateTime_idx","type":"index"}'::jsonb WHERE "name" = 'index_asset_localDateTime_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE UNIQUE INDEX \\"activity_like_idx\\" ON \\"activity\\" (\\"assetId\\", \\"userId\\", \\"albumId\\") WHERE (\\"isLiked\\" = true);","name":"activity_like_idx","type":"index"}'::jsonb WHERE "name" = 'index_activity_like_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"asset_id_timeline_notDeleted_idx\\" ON \\"asset\\" (\\"id\\") WHERE visibility = ''timeline'' AND \\"deletedAt\\" IS NULL;","name":"asset_id_timeline_notDeleted_idx","type":"index"}'::jsonb WHERE "name" = 'index_asset_id_timeline_notDeleted_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE \\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE;","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","type":"index"}'::jsonb WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
@@ -0,0 +1,92 @@
import { Kysely, sql } from 'kysely';
import { AlbumUserRole } from 'src/enum';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION album_user_after_insert()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT "albumId" FROM inserted_rows)
AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = 'owner');
RETURN NULL;
END
$$;`.execute(db);
await sql`DROP TRIGGER "album_delete_audit" ON "album";`.execute(db);
await sql`DROP FUNCTION album_delete_audit;`.execute(db);
await sql`CREATE TYPE "album_user_role_enum" AS ENUM ('owner','editor','viewer');`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" DROP DEFAULT;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" TYPE album_user_role_enum USING "role"::album_user_role_enum;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" SET DEFAULT 'editor'::album_user_role_enum;`.execute(db);
await db
.insertInto('album_user')
.expression((eb) =>
eb
.selectFrom('album')
.select(['album.id as albumId', 'album.ownerId as userId', eb.val(AlbumUserRole.Owner).as('role')]),
)
.execute();
await sql`ALTER TABLE "album" DROP CONSTRAINT "album_ownerId_fkey";`.execute(db);
await sql`ALTER TABLE "album" DROP COLUMN "ownerId";`.execute(db);
await sql`CREATE UNIQUE INDEX "album_user_unique_owner" ON "album_user" ("albumId") WHERE (role = 'owner');`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"album_user_after_insert","sql":"CREATE OR REPLACE FUNCTION album_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE album SET \\"updatedAt\\" = clock_timestamp(), \\"updateId\\" = immich_uuid_v7(clock_timestamp())\\n WHERE \\"id\\" IN (SELECT \\"albumId\\" FROM inserted_rows)\\n AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = ''owner'');\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_album_user_after_insert';`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_album_user_unique_owner', '{"type":"index","name":"album_user_unique_owner","sql":"CREATE UNIQUE INDEX \\"album_user_unique_owner\\" ON \\"album_user\\" (\\"albumId\\") WHERE (role = ''owner'');"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_delete_audit';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION public.album_user_after_insert()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows);
RETURN NULL;
END
$function$
`.execute(db);
await sql`CREATE OR REPLACE FUNCTION public.album_delete_audit()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END
$function$
`.execute(db);
await sql`ALTER TABLE "album" ADD "ownerId" uuid NOT NULL;`.execute(db);
await db
.updateTable('album')
.set((eb) =>
({
id: eb.ref('album_user.albumId'),
ownerId: eb.ref('album_user.userId')
})
)
.from('album_user')
.where('album_user.role', '=', AlbumUserRole.Owner)
.execute();
await sql`DROP INDEX "album_user_unique_owner";`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" DROP DEFAULT;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" TYPE character varying USING "role"::text;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" SET DEFAULT 'editor';`.execute(db);
await sql`DROP TYPE "album_user_role_enum";`.execute(db);
await sql`CREATE INDEX "album_ownerId_idx" ON "album" ("ownerId");`.execute(db);
await sql`ALTER TABLE "album" ADD CONSTRAINT "album_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_delete_audit"
AFTER DELETE ON "album"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN ((pg_trigger_depth() = 0))
EXECUTE FUNCTION album_delete_audit();`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION album_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE album SET \\"updatedAt\\" = clock_timestamp(), \\"updateId\\" = immich_uuid_v7(clock_timestamp())\\n WHERE \\"id\\" IN (SELECT DISTINCT \\"albumId\\" FROM inserted_rows);\\n RETURN NULL;\\n END\\n $$;","name":"album_user_after_insert","type":"function"}'::jsonb WHERE "name" = 'function_album_user_after_insert';`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_delete_audit', '{"sql":"CREATE OR REPLACE FUNCTION album_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;","name":"album_delete_audit","type":"function"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_delete_audit', '{"sql":"CREATE OR REPLACE TRIGGER \\"album_delete_audit\\"\\n AFTER DELETE ON \\"album\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION album_delete_audit();","name":"album_delete_audit","type":"trigger"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_album_user_unique_owner';`.execute(db);
}
@@ -0,0 +1,40 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "video_stream_variant_codec_enum" AS ENUM ('av1','hevc','h264');`.execute(db);
await sql`CREATE TABLE "video_stream_session" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"assetId" uuid NOT NULL,
"expiresAt" timestamp with time zone NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "video_stream_session_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_session_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "video_stream_session_assetId_idx" ON "video_stream_session" ("assetId");`.execute(db);
await sql`CREATE INDEX "video_stream_session_expiresAt_idx" ON "video_stream_session" ("expiresAt");`.execute(db);
await sql`CREATE TABLE "video_stream_variant" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"sessionId" uuid NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"bitrate" integer NOT NULL,
"codec" video_stream_variant_codec_enum NOT NULL,
"resolution" smallint NOT NULL,
CONSTRAINT "video_stream_variant_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "video_stream_session" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_variant_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE UNIQUE INDEX "video_stream_variant_sessionId_bitrate_resolution_codec_idx" ON "video_stream_variant" ("sessionId", "bitrate", "resolution", "codec");`.execute(db);
await sql`CREATE TABLE "video_stream_segment" (
"variantId" uuid NOT NULL,
"index" integer NOT NULL,
"durationUs" integer NOT NULL,
CONSTRAINT "video_stream_segment_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "video_stream_variant" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "video_stream_segment_pkey" PRIMARY KEY ("variantId", "index")
);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "video_stream_segment";`.execute(db);
await sql`DROP TABLE "video_stream_variant";`.execute(db);
await sql`DROP TABLE "video_stream_session";`.execute(db);
await sql`DROP TYPE "asset_checksum_algorithm_enum";`.execute(db);
}
@@ -0,0 +1,51 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "asset_audio" (
"assetId" uuid NOT NULL,
"bitrate" integer NOT NULL,
"index" smallint NOT NULL,
"profile" smallint,
"codecName" text NOT NULL,
CONSTRAINT "asset_audio_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "asset_audio_pkey" PRIMARY KEY ("assetId")
);`.execute(db);
await sql`CREATE TABLE "asset_video" (
"assetId" uuid NOT NULL,
"bitrate" integer NOT NULL,
"frameCount" integer NOT NULL,
"timeBase" integer NOT NULL,
"index" smallint NOT NULL,
"profile" smallint,
"level" smallint,
"colorPrimaries" smallint NOT NULL,
"colorTransfer" smallint NOT NULL,
"colorMatrix" smallint NOT NULL,
"dvProfile" smallint,
"dvLevel" smallint,
"dvBlSignalCompatibilityId" smallint,
"codecName" text NOT NULL,
"formatName" text NOT NULL,
"formatLongName" text NOT NULL,
"pixelFormat" text NOT NULL,
CONSTRAINT "asset_video_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "asset_video_pkey" PRIMARY KEY ("assetId")
);`.execute(db);
await sql`CREATE TABLE "asset_keyframe" (
"assetId" uuid NOT NULL,
"pts" integer[] NOT NULL,
"accDuration" integer[] NOT NULL,
"ownDuration" integer[] NOT NULL,
"totalDuration" integer NOT NULL,
"packetCount" integer NOT NULL,
"outputFrames" integer NOT NULL,
CONSTRAINT "asset_keyframe_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "asset_keyframe_pkey" PRIMARY KEY ("assetId")
);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "asset_audio";`.execute(db);
await sql`DROP TABLE "asset_video";`.execute(db);
await sql`DROP TABLE "asset_keyframe";`.execute(db);
}
@@ -0,0 +1,31 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
ALTER TABLE asset
ALTER COLUMN duration TYPE integer
USING (
CASE
WHEN duration ~ '^\\d{2}:\\d{2}:\\d{2}\\.\\d{3}$'
THEN substr(duration, 1, 2)::int * 3600000
+ substr(duration, 4, 2)::int * 60000
+ substr(duration, 7, 2)::int * 1000
+ substr(duration, 10, 3)::int
END
);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`
ALTER TABLE asset
ALTER COLUMN duration TYPE varchar
USING (
CASE
WHEN duration IS NULL THEN NULL
ELSE lpad((duration / 3600000)::text, 2, '0')
|| ':' || lpad(((duration / 60000) % 60)::text, 2, '0')
|| ':' || lpad(((duration / 1000) % 60)::text, 2, '0')
|| '.' || lpad((duration % 1000)::text, 3, '0')
END
);`.execute(db);
}
@@ -0,0 +1,10 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// isFavorite was incorrectly included in partner asset sync on server <=2.X.X
await sql`DELETE FROM session_sync_checkpoint WHERE type in ('PartnerAssetV1', 'PartnerAssetBackfillV1')`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented
}
+9 -1
View File
@@ -5,17 +5,25 @@ import {
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_user_role_enum } from 'src/schema/enums';
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table({ name: 'album_user' })
@Index({
name: 'album_user_unique_owner',
columns: ['albumId'],
unique: true,
where: `role = 'owner'`,
})
// Pre-existing indices from original album <--> user ManyToMany mapping
@UpdatedAtTrigger('album_user_updatedAt')
@AfterInsertTrigger({
@@ -47,7 +55,7 @@ export class AlbumUserTable {
})
userId!: string;
@Column({ type: 'character varying', default: AlbumUserRole.Editor })
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>;
@CreateIdColumn({ index: true })
-12
View File
@@ -1,5 +1,4 @@
import {
AfterDeleteTrigger,
Column,
CreateDateColumn,
DeleteDateColumn,
@@ -12,25 +11,14 @@ import {
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetOrder } from 'src/enum';
import { album_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table({ name: 'album' })
@UpdatedAtTrigger('album_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
function: album_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class AlbumTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: 'Untitled Album' })
albumName!: Generated<string>;
@@ -0,0 +1,98 @@
import { Column, ForeignKeyColumn, Table } from '@immich/sql-tools';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('asset_audio')
export class AssetAudioTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true })
assetId!: string;
@Column({ type: 'integer' })
bitrate!: number;
@Column({ type: 'smallint' })
index!: number;
@Column({ type: 'smallint', nullable: true })
profile!: number | null;
@Column({ type: 'text' })
codecName!: string;
}
@Table('asset_video')
export class AssetVideoTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true })
assetId!: string;
@Column({ type: 'integer' })
bitrate!: number;
@Column({ type: 'integer' })
frameCount!: number;
@Column({ type: 'integer' })
timeBase!: number;
@Column({ type: 'smallint' })
index!: number;
@Column({ type: 'smallint', nullable: true })
profile!: number | null;
@Column({ type: 'smallint', nullable: true })
level!: number | null;
@Column({ type: 'smallint' })
colorPrimaries!: number;
@Column({ type: 'smallint' })
colorTransfer!: number;
@Column({ type: 'smallint' })
colorMatrix!: number;
@Column({ type: 'smallint', nullable: true })
dvProfile!: number | null;
@Column({ type: 'smallint', nullable: true })
dvLevel!: number | null;
@Column({ type: 'smallint', nullable: true })
dvBlSignalCompatibilityId!: number | null;
@Column({ type: 'text' })
codecName!: string;
@Column({ type: 'text' })
formatName!: string;
@Column({ type: 'text' })
formatLongName!: string;
@Column({ type: 'text' })
pixelFormat!: string;
}
@Table('asset_keyframe')
export class AssetKeyframeTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true })
assetId!: string;
@Column({ type: 'integer', array: true })
pts!: number[];
@Column({ type: 'integer', array: true })
accDuration!: number[];
@Column({ type: 'integer', array: true })
ownDuration!: number[];
@Column({ type: 'integer' })
totalDuration!: number;
@Column({ type: 'integer' })
packetCount!: number;
@Column({ type: 'integer' })
outputFrames!: number;
}
+6 -6
View File
@@ -33,20 +33,20 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
name: ASSET_CHECKSUM_CONSTRAINT,
columns: ['ownerId', 'checksum'],
unique: true,
where: '("libraryId" IS NULL)',
where: '"libraryId" IS NULL',
})
@Index({
columns: ['ownerId', 'libraryId', 'checksum'],
unique: true,
where: '("libraryId" IS NOT NULL)',
where: '"libraryId" IS NOT NULL',
})
@Index({
name: 'asset_localDateTime_idx',
expression: `(("localDateTime" at time zone 'UTC')::date)`,
expression: `("localDateTime" at time zone 'UTC')::date`,
})
@Index({
name: 'asset_localDateTime_month_idx',
expression: `(date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text)`,
expression: `date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text`,
})
@Index({ columns: ['originalPath', 'libraryId'] })
@Index({ columns: ['id', 'stackId'] })
@@ -83,8 +83,8 @@ export class AssetTable {
@Column({ type: 'boolean', default: false })
isFavorite!: Generated<boolean>;
@Column({ type: 'character varying', nullable: true })
duration!: string | null;
@Column({ type: 'integer', nullable: true })
duration!: number | null;
@Column({ type: 'bytea', index: true })
checksum!: Buffer; // sha1 checksum
@@ -0,0 +1,63 @@
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryColumn,
PrimaryGeneratedColumn,
Table,
Timestamp,
} from '@immich/sql-tools';
import { VideoSegmentCodec } from 'src/enum';
import { video_stream_variant_codec_enum } from 'src/schema/enums';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('video_stream_session')
export class VideoStreamSessionTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE' })
assetId!: string;
@Column({ type: 'timestamp with time zone', index: true })
expiresAt!: Timestamp;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
}
@Index({ columns: ['sessionId', 'bitrate', 'resolution', 'codec'], unique: true })
@Table('video_stream_variant')
export class VideoStreamVariantTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => VideoStreamSessionTable, { onDelete: 'CASCADE', index: false })
sessionId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ type: 'integer' })
bitrate!: number;
@Column({ enum: video_stream_variant_codec_enum })
codec!: VideoSegmentCodec;
@Column({ type: 'smallint' })
resolution!: number;
}
@Table('video_stream_segment')
export class VideoStreamSegmentTable {
@ForeignKeyColumn(() => VideoStreamVariantTable, { onDelete: 'CASCADE', primary: true, index: false })
variantId!: string;
@PrimaryColumn({ type: 'integer' })
index!: number;
@Column({ type: 'integer' })
durationUs!: number;
}
File diff suppressed because it is too large Load Diff
+72 -60
View File
@@ -8,14 +8,13 @@ import {
CreateAlbumDto,
GetAlbumsDto,
mapAlbum,
MapAlbumDto,
UpdateAlbumDto,
UpdateAlbumUserDto,
} from 'src/dtos/album.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { Permission } from 'src/enum';
import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@@ -26,9 +25,9 @@ import { getPreferences } from 'src/utils/preferences';
export class AlbumService extends BaseService {
async getStatistics(auth: AuthDto): Promise<AlbumStatisticsResponseDto> {
const [owned, shared, notShared] = await Promise.all([
this.albumRepository.getOwned(auth.user.id),
this.albumRepository.getShared(auth.user.id),
this.albumRepository.getNotShared(auth.user.id),
this.albumRepository.getAll(auth.user.id, { isOwned: true }),
this.albumRepository.getAll(auth.user.id, { isShared: true }),
this.albumRepository.getAll(auth.user.id, { isOwned: true, isShared: false }),
]);
return {
@@ -38,18 +37,18 @@ export class AlbumService extends BaseService {
};
}
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, isOwned, isShared }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails();
let albums: MapAlbumDto[];
if (assetId) {
albums = await this.albumRepository.getByAssetId(ownerId, assetId);
} else if (shared === true) {
albums = await this.albumRepository.getShared(ownerId);
} else if (shared === false) {
albums = await this.albumRepository.getNotShared(ownerId);
} else {
albums = await this.albumRepository.getOwned(ownerId);
const albums = assetId
? await this.albumRepository.getByAssetId(ownerId, assetId)
: await this.albumRepository.getAll(ownerId, { isOwned, isShared });
if (albums.length === 0) {
return [];
}
// Get asset count for each album. Then map the result to an object:
@@ -74,10 +73,10 @@ export class AlbumService extends BaseService {
async get(auth: AuthDto, id: string): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
await this.albumRepository.updateThumbnails();
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0;
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 1;
const hasSharedLink = album.sharedLinks && album.sharedLinks.length > 0;
const isShared = hasSharedUsers || hasSharedLink;
@@ -107,7 +106,8 @@ export class AlbumService extends BaseService {
for (const { userId } of albumUsers) {
const exists = await this.userRepository.get(userId, {});
if (!exists) {
throw new BadRequestException('User not found');
this.logger.debug('Album creation failed: user not found');
throw new BadRequestException('Invalid user');
}
if (userId == auth.user.id) {
@@ -126,18 +126,18 @@ export class AlbumService extends BaseService {
const album = await this.albumRepository.create(
{
ownerId: auth.user.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: assetIds[0] || null,
order: getPreferences(userMetadata).albums.defaultAssetOrder,
},
assetIds,
albumUsers,
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
auth.user.id,
);
for (const { userId } of albumUsers) {
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId });
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
}
return mapAlbum(album);
@@ -146,7 +146,7 @@ export class AlbumService extends BaseService {
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumUpdate, ids: [id] });
const album = await this.findOrFail(id, { withAssets: true });
const album = await this.findOrFail(id, auth.user.id, { withAssets: true });
if (dto.albumThumbnailAssetId) {
const results = await this.albumRepository.getAssetIds(id, [dto.albumThumbnailAssetId]);
@@ -154,14 +154,18 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid album thumbnail');
}
}
const updatedAlbum = await this.albumRepository.update(album.id, {
id: album.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
});
const updatedAlbum = await this.albumRepository.update(
album.id,
{
id: album.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
},
auth.user.id,
);
return mapAlbum({ ...updatedAlbum, assets: album.assets });
}
@@ -172,7 +176,7 @@ export class AlbumService extends BaseService {
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
const results = await addAssets(
@@ -183,16 +187,18 @@ export class AlbumService extends BaseService {
const { id: firstNewAssetId } = results.find(({ success }) => success) || {};
if (firstNewAssetId) {
await this.albumRepository.update(id, {
await this.albumRepository.update(
id,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
});
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
{
id,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
},
auth.user.id,
);
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
for (const recipientId of allUsersExceptUs) {
await this.eventRepository.emit('AlbumUpdate', { id, recipientId });
}
@@ -231,21 +237,23 @@ export class AlbumService extends BaseService {
if (notPresentAssetIds.length === 0) {
continue;
}
const album = await this.findOrFail(albumId, { withAssets: false });
const album = await this.findOrFail(albumId, auth.user.id, { withAssets: false });
results.error = undefined;
results.success = true;
for (const assetId of notPresentAssetIds) {
albumAssetValues.push({ albumId, assetId });
}
await this.albumRepository.update(albumId, {
id: albumId,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
});
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
await this.albumRepository.update(
albumId,
{
id: albumId,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
},
auth.user.id,
);
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
events.push({ id: albumId, recipients: allUsersExceptUs });
}
@@ -262,7 +270,7 @@ export class AlbumService extends BaseService {
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const results = await removeAssets(
auth,
{ access: this.accessRepository, bulk: this.albumRepository },
@@ -280,11 +288,11 @@ export class AlbumService extends BaseService {
async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
for (const { userId, role } of albumUsers) {
if (album.ownerId === userId) {
throw new BadRequestException('Cannot be shared with owner');
if (role === AlbumUserRole.Owner) {
throw new BadRequestException('Cannot add another owner');
}
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
@@ -294,14 +302,15 @@ export class AlbumService extends BaseService {
const user = await this.userRepository.get(userId, {});
if (!user) {
throw new BadRequestException('User not found');
this.logger.debug('Adding user to album failed: user not found');
throw new BadRequestException('Invalid user');
}
await this.albumUserRepository.create({ userId, albumId: id, role });
await this.eventRepository.emit('AlbumInvite', { id, userId });
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
return this.findOrFail(id, { withAssets: true }).then(mapAlbum);
return this.findOrFail(id, auth.user.id, { withAssets: true }).then(mapAlbum);
}
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@@ -309,17 +318,20 @@ export class AlbumService extends BaseService {
userId = auth.user.id;
}
const album = await this.findOrFail(id, { withAssets: false });
if (album.ownerId === userId) {
throw new BadRequestException('Cannot remove album owner');
}
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
if (!exists) {
throw new BadRequestException('Album not shared with user');
}
if (
exists.role === AlbumUserRole.Owner &&
album.albumUsers.filter(({ role }) => role === AlbumUserRole.Owner).length === 1
) {
throw new BadRequestException('Cannot remove the last album owner');
}
// non-admin can remove themselves
if (auth.user.id !== userId) {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
@@ -333,8 +345,8 @@ export class AlbumService extends BaseService {
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}
private async findOrFail(id: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options);
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options, authUserId);
if (!album) {
throw new BadRequestException('Album not found');
}
@@ -80,6 +80,7 @@ const validImages = [
'.jxl',
'.k25',
'.kdc',
'.mpo',
'.mrw',
'.nef',
'.orf',
+4 -4
View File
@@ -351,10 +351,10 @@ export class AssetMediaService extends BaseService {
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif(
{ assetId: asset.id, fileSizeInByte: file.size },
{ lockedPropertiesBehavior: 'override' },
);
await this.assetRepository.upsertExif({
exif: { assetId: asset.id, fileSizeInByte: file.size },
lockedPropertiesBehavior: 'override',
});
await this.eventRepository.emit('AssetCreate', { asset });
+12 -8
View File
@@ -187,8 +187,10 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, asset.id, { description: 'Test description' });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: asset.id, description: 'Test description', lockedProperties: ['description'] },
{ lockedPropertiesBehavior: 'append' },
expect.objectContaining({
exif: { assetId: asset.id, description: 'Test description', lockedProperties: ['description'] },
lockedPropertiesBehavior: 'append',
}),
);
});
@@ -201,12 +203,14 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, asset.id, { rating: 3 });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{
assetId: asset.id,
rating: 3,
lockedProperties: ['rating'],
},
{ lockedPropertiesBehavior: 'append' },
expect.objectContaining({
exif: {
assetId: asset.id,
rating: 3,
lockedProperties: ['rating'],
},
lockedPropertiesBehavior: 'append',
}),
);
});
+4 -4
View File
@@ -517,13 +517,13 @@ export class AssetService extends BaseService {
);
if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif(
updateLockedColumns({
await this.assetRepository.upsertExif({
exif: updateLockedColumns({
assetId: id,
...writes,
}),
{ lockedPropertiesBehavior: 'append' },
);
lockedPropertiesBehavior: 'append',
});
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } });
}
}
+10 -12
View File
@@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
import { parse } from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import {
AuthDto,
@@ -62,15 +62,12 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Password login has been disabled');
}
let user = await this.userRepository.getByEmail(dto.email, { withPassword: true });
if (user) {
const isAuthenticated = this.validateSecret(dto.password, user.password);
if (!isAuthenticated) {
user = undefined;
}
}
const user = await this.userRepository.getByEmail(dto.email, { withPassword: true });
// Always run bcrypt so response time is constant regardless of whether the email
// is registered, preventing timing-based user enumeration.
const authenticated = this.cryptoRepository.compareBcrypt(dto.password, user?.password ?? LOGIN_DUMMY_HASH);
if (!user) {
if (!user || !user.password || !authenticated) {
this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`);
throw new UnauthorizedException('Incorrect email or password');
}
@@ -325,7 +322,8 @@ export class AuthService extends BaseService {
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
if (emailUser) {
if (emailUser.oauthId) {
throw new BadRequestException('User already exists, but is linked to another account.');
this.logger.debug('OAuth login conflict: email already linked to different account');
throw new BadRequestException('OAuth authentication failed');
}
user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
}
@@ -335,9 +333,9 @@ export class AuthService extends BaseService {
if (!user) {
if (!autoRegister) {
this.logger.warn(
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`,
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
throw new BadRequestException('OAuth authentication failed');
}
if (!normalizedEmail) {
+5 -1
View File
@@ -53,6 +53,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
@@ -109,6 +110,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
TrashRepository,
UserRepository,
VersionHistoryRepository,
VideoStreamRepository,
ViewRepository,
WebsocketRepository,
WorkflowRepository,
@@ -167,6 +169,7 @@ export class BaseService {
protected trashRepository: TrashRepository,
protected userRepository: UserRepository,
protected versionRepository: VersionHistoryRepository,
protected videoStreamRepository: VideoStreamRepository,
protected viewRepository: ViewRepository,
protected websocketRepository: WebsocketRepository,
protected workflowRepository: WorkflowRepository,
@@ -215,7 +218,8 @@ export class BaseService {
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
const exists = await this.userRepository.getByEmail(dto.email);
if (exists) {
throw new BadRequestException('User exists');
this.logger.debug('User creation rejected: user already exists');
throw new BadRequestException('Email is not available');
}
if (!dto.isAdmin) {
+18 -80
View File
@@ -2,7 +2,7 @@ import { EXTENSION_NAMES } from 'src/constants';
import { DatabaseExtension, VectorIndex } from 'src/enum';
import { DatabaseService } from 'src/services/database.service';
import { VectorExtension } from 'src/types';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { envData, mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService, ServiceMocks } from 'test/utils';
describe(DatabaseService.name, () => {
@@ -55,7 +55,6 @@ describe(DatabaseService.name, () => {
describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[
{ extension: DatabaseExtension.Vector, extensionName: EXTENSION_NAMES[DatabaseExtension.Vector] },
{ extension: DatabaseExtension.Vectors, extensionName: EXTENSION_NAMES[DatabaseExtension.Vectors] },
{ extension: DatabaseExtension.VectorChord, extensionName: EXTENSION_NAMES[DatabaseExtension.VectorChord] },
])('should work with $extensionName', ({ extension, extensionName }) => {
beforeEach(() => {
@@ -68,20 +67,7 @@ describe(DatabaseService.name, () => {
]);
mocks.database.getVectorExtension.mockResolvedValue(extension);
mocks.config.getEnv.mockReturnValue(
mockEnvData({
database: {
config: {
connectionType: 'parts',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
skipMigrations: false,
vectorExtension: extension,
},
}),
mockEnvData({ database: { ...envData.database, vectorExtension: extension } }),
);
});
@@ -157,7 +143,6 @@ describe(DatabaseService.name, () => {
installedVersion: minVersionInRange,
},
]);
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false });
await expect(sut.onBootstrap()).resolves.toBeUndefined();
@@ -278,27 +263,6 @@ describe(DatabaseService.name, () => {
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
});
it(`should warn if ${extension} extension update requires restart`, async () => {
mocks.database.getExtensionVersions.mockResolvedValue([
{
name: extension,
availableVersion: updateInRange,
installedVersion: minVersionInRange,
},
]);
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: true });
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.logger.warn.mock.calls).toEqual(
expect.arrayContaining([expect.arrayContaining([expect.stringContaining(extensionName)])]),
);
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal).not.toHaveBeenCalled();
});
it(`should reindex ${extension} indices if needed`, async () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined();
@@ -329,22 +293,7 @@ describe(DatabaseService.name, () => {
});
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
mocks.config.getEnv.mockReturnValue(
mockEnvData({
database: {
config: {
connectionType: 'parts',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
skipMigrations: true,
vectorExtension: DatabaseExtension.Vectors,
},
}),
);
mocks.config.getEnv.mockReturnValue(mockEnvData({ database: { ...envData.database, skipMigrations: true } }));
await expect(sut.onBootstrap()).resolves.toBeUndefined();
@@ -352,7 +301,6 @@ describe(DatabaseService.name, () => {
});
it(`should throw error if extension could not be created`, async () => {
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false });
mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension'));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
@@ -365,35 +313,42 @@ describe(DatabaseService.name, () => {
});
it(`should drop unused extension`, async () => {
mocks.config.getEnv.mockReturnValue(
mockEnvData({ database: { ...envData.database, vectorExtension: DatabaseExtension.Vector } }),
);
mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.Vector);
mocks.database.getExtensionVersions.mockResolvedValue([
{
name: DatabaseExtension.Vectors,
name: DatabaseExtension.Vector,
installedVersion: minVersionInRange,
availableVersion: minVersionInRange,
},
{
name: DatabaseExtension.VectorChord,
installedVersion: null,
installedVersion: minVersionInRange,
availableVersion: minVersionInRange,
},
]);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.database.createExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VectorChord);
expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.Vectors);
expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VectorChord);
});
it(`should warn if unused extension could not be dropped`, async () => {
mocks.config.getEnv.mockReturnValue(
mockEnvData({ database: { ...envData.database, vectorExtension: DatabaseExtension.Vector } }),
);
mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.Vector);
mocks.database.getExtensionVersions.mockResolvedValue([
{
name: DatabaseExtension.Vectors,
name: DatabaseExtension.Vector,
installedVersion: minVersionInRange,
availableVersion: minVersionInRange,
},
{
name: DatabaseExtension.VectorChord,
installedVersion: null,
installedVersion: minVersionInRange,
availableVersion: minVersionInRange,
},
]);
@@ -401,10 +356,9 @@ describe(DatabaseService.name, () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.database.createExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VectorChord);
expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.Vectors);
expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VectorChord);
expect(mocks.logger.warn).toHaveBeenCalledTimes(1);
expect(mocks.logger.warn.mock.calls[0][0]).toContain('DROP EXTENSION vectors');
expect(mocks.logger.warn.mock.calls[0][0]).toContain('DROP EXTENSION vchord');
});
it(`should not try to drop pgvector when using vectorchord`, async () => {
@@ -426,21 +380,5 @@ describe(DatabaseService.name, () => {
expect(mocks.database.dropExtension).not.toHaveBeenCalled();
});
it(`should warn if using pgvecto.rs`, async () => {
mocks.database.getExtensionVersions.mockResolvedValue([
{
name: DatabaseExtension.Vectors,
installedVersion: minVersionInRange,
availableVersion: minVersionInRange,
},
]);
mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.Vectors);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.logger.warn).toHaveBeenCalledTimes(1);
expect(mocks.logger.warn.mock.calls[0][0]).toContain('DEPRECATION WARNING');
});
});
});
+1 -14
View File
@@ -9,7 +9,6 @@ import { VectorExtension } from 'src/types';
type CreateFailedArgs = { name: string; extension: string };
type UpdateFailedArgs = { name: string; extension: string; availableVersion: string };
type DropFailedArgs = { name: string; extension: string };
type RestartRequiredArgs = { name: string; availableVersion: string };
type NightlyVersionArgs = { name: string; extension: string; version: string };
type OutOfRangeArgs = { name: string; extension: string; version: string; range: string };
type InvalidDowngradeArgs = { name: string; extension: string; installedVersion: string; availableVersion: string };
@@ -46,16 +45,10 @@ const messages = {
Please run 'DROP EXTENSION ${extension};' manually as a superuser.
See https://docs.immich.app/guides/database-queries for how to query the database.`,
restartRequired: ({ name, availableVersion }: RestartRequiredArgs) =>
`The ${name} extension has been updated to ${availableVersion}.
Please restart the Postgres instance to complete the update.`,
invalidDowngrade: ({ name, installedVersion, availableVersion }: InvalidDowngradeArgs) =>
`The database currently has ${name} ${installedVersion} activated, but the Postgres instance only has ${availableVersion} available.
This most likely means the extension was downgraded.
If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`,
deprecatedExtension: (name: string) =>
`DEPRECATION WARNING: The ${name} extension is deprecated and support for it will be removed very soon.
See https://docs.immich.app/install/upgrading#migrating-to-vectorchord in order to switch to the VectorChord extension instead.`,
};
@Injectable()
@@ -74,9 +67,6 @@ export class DatabaseService extends BaseService {
await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => {
const extension = await this.databaseRepository.getVectorExtension();
const name = EXTENSION_NAMES[extension];
if (extension === DatabaseExtension.Vectors) {
this.logger.warn(messages.deprecatedExtension(name));
}
const extensionRange = this.databaseRepository.getExtensionVersionRange(extension);
const extensionVersions = await this.databaseRepository.getExtensionVersions(VECTOR_EXTENSIONS);
@@ -156,10 +146,7 @@ export class DatabaseService extends BaseService {
private async updateExtension(extension: VectorExtension, availableVersion: string) {
this.logger.log(`Updating ${EXTENSION_NAMES[extension]} extension to ${availableVersion}`);
try {
const { restartRequired } = await this.databaseRepository.updateVectorExtension(extension, availableVersion);
if (restartRequired) {
this.logger.warn(messages.restartRequired({ name: EXTENSION_NAMES[extension], availableVersion }));
}
await this.databaseRepository.updateVectorExtension(extension, availableVersion);
} catch (error) {
this.logger.warn(messages.updateFailed({ name: EXTENSION_NAMES[extension], extension, availableVersion }));
throw error;
@@ -1,3 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
@@ -149,6 +150,36 @@ describe(DuplicateService.name, () => {
});
});
describe('delete', () => {
it('should throw for an unknown or unauthorized group id', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.delete(authStub.admin, 'group-1')).rejects.toThrow(BadRequestException);
expect(mocks.duplicateRepository.delete).not.toHaveBeenCalled();
});
it('should dismiss the duplicate group', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
mocks.duplicateRepository.delete.mockResolvedValue();
await expect(sut.delete(authStub.admin, 'group-1')).resolves.toBeUndefined();
expect(mocks.duplicateRepository.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'group-1');
});
});
describe('deleteAll', () => {
it('should throw if any group id is unknown or unauthorized', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1']));
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).rejects.toThrow(BadRequestException);
expect(mocks.duplicateRepository.deleteAll).not.toHaveBeenCalled();
});
it('should dismiss all duplicate groups', async () => {
mocks.access.duplicate.checkOwnerAccess.mockResolvedValue(new Set(['group-1', 'group-2']));
mocks.duplicateRepository.deleteAll.mockResolvedValue();
await expect(sut.deleteAll(authStub.admin, { ids: ['group-1', 'group-2'] })).resolves.toBeUndefined();
expect(mocks.duplicateRepository.deleteAll).toHaveBeenCalledWith(authStub.admin.user.id, ['group-1', 'group-2']);
});
});
describe('resolve', () => {
it('should handle mixed success and failure', async () => {
const asset = AssetFactory.create();
+2
View File
@@ -82,10 +82,12 @@ export class DuplicateService extends BaseService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: [id] });
await this.duplicateRepository.delete(auth.user.id, id);
}
async deleteAll(auth: AuthDto, dto: BulkIdsDto) {
await this.requireAccess({ auth, permission: Permission.DuplicateDelete, ids: dto.ids });
await this.duplicateRepository.deleteAll(auth.user.id, dto.ids);
}
+2 -2
View File
@@ -101,7 +101,7 @@ export class JobService extends BaseService {
const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id);
if (asset) {
this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, {
this.websocketRepository.clientSend('AssetEditReadyV2', asset.ownerId, {
asset: {
id: asset.id,
ownerId: asset.ownerId,
@@ -156,7 +156,7 @@ export class JobService extends BaseService {
this.websocketRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset));
if (asset.exifInfo) {
const exif = asset.exifInfo;
this.websocketRepository.clientSend('AssetUploadReadyV1', asset.ownerId, {
this.websocketRepository.clientSend('AssetUploadReadyV2', asset.ownerId, {
// TODO remove `on_upload_success` and then modify the query to select only the required fields)
asset: {
id: asset.id,
+5 -5
View File
@@ -4,7 +4,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { PartnerFactory } from 'test/factories/partner.factory';
import { userStub } from 'test/fixtures/user.stub';
import { getForAlbum, getForPartner } from 'test/mappers';
import { getForPartner } from 'test/mappers';
import { newTestService, ServiceMocks } from 'test/utils';
describe(MapService.name, () => {
@@ -82,15 +82,15 @@ describe(MapService.name, () => {
};
mocks.partner.getAll.mockResolvedValue([]);
mocks.map.getMapMarkers.mockResolvedValue([marker]);
mocks.album.getOwned.mockResolvedValue([getForAlbum(AlbumFactory.create())]);
mocks.album.getShared.mockResolvedValue([
getForAlbum(AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()),
]);
const album1 = AlbumFactory.create();
const album2 = AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build();
mocks.album.getAllIds.mockResolvedValue([album1.id, album2.id]);
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });
expect(markers).toHaveLength(1);
expect(markers[0]).toEqual(marker);
expect(mocks.album.getAllIds).toHaveBeenCalledWith(auth.user.id);
});
});
+1 -9
View File
@@ -13,15 +13,7 @@ export class MapService extends BaseService {
userIds.push(...partnerIds);
}
// TODO convert to SQL join
const albumIds: string[] = [];
if (options.withSharedAlbums) {
const [ownedAlbums, sharedAlbums] = await Promise.all([
this.albumRepository.getOwned(auth.user.id),
this.albumRepository.getShared(auth.user.id),
]);
albumIds.push(...ownedAlbums.map((album) => album.id), ...sharedAlbums.map((album) => album.id));
}
const albumIds = options.withSharedAlbums ? await this.albumRepository.getAllIds(auth.user.id) : [];
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More