mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy
This commit is contained in:
+12
-11
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@extism/extism": "2.0.0-rc13",
|
||||
"@immich/sql-tools": "^0.2.0",
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
@@ -45,14 +46,14 @@
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.211.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.211.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.59.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.57.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.63.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.212.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.212.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.60.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.58.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.64.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.211.0",
|
||||
"@opentelemetry/sdk-node": "^0.212.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.5.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
@@ -70,7 +71,7 @@
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cron": "4.4.0",
|
||||
"exiftool-vendored": "^34.3.0",
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"express": "^5.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
@@ -116,7 +117,7 @@
|
||||
"validator": "^13.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@nestjs/cli": "^11.0.2",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.4",
|
||||
@@ -146,11 +147,11 @@
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/validator": "^13.15.2",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"eslint-plugin-unicorn": "^63.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"node-gyp": "^12.0.0",
|
||||
"pngjs": "^7.0.0",
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
|
||||
|
||||
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, extname, join } from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import 'src/schema';
|
||||
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
|
||||
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
|
||||
import { getKyselyConfig } from 'src/utils/database';
|
||||
|
||||
const main = async () => {
|
||||
const command = process.argv[2];
|
||||
@@ -130,10 +129,9 @@ const create = (path: string, up: string[], down: string[]) => {
|
||||
const compare = async () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const { database } = configRepository.getEnv();
|
||||
const db = postgres(asPostgresConnectionConfig(database.config));
|
||||
|
||||
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
|
||||
const target = await schemaFromDatabase(db, {});
|
||||
const target = await schemaFromDatabase({ connection: database.config });
|
||||
|
||||
console.log(source.warnings.join('\n'));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { asHuman } from '@immich/sql-tools';
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { ErrorMessages } from 'src/constants';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
import { asHuman } from 'src/sql-tools/schema-diff';
|
||||
|
||||
@Command({
|
||||
name: 'schema-check',
|
||||
|
||||
@@ -369,6 +369,31 @@ describe(AssetController.name, () => {
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should check the action and parameters discriminator', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/edits`)
|
||||
.send({
|
||||
edits: [
|
||||
{
|
||||
action: 'rotate',
|
||||
parameters: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([expect.stringContaining('parameters.angle must be one of the following values')]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require at least one edit', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/edits`)
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
UpdateAssetDto,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||
import { AssetEditsCreateDto, AssetEditsResponseDto } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
@@ -235,7 +235,7 @@ export class AssetController {
|
||||
description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.',
|
||||
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
|
||||
})
|
||||
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsDto> {
|
||||
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsResponseDto> {
|
||||
return this.service.getAssetEdits(auth, id);
|
||||
}
|
||||
|
||||
@@ -249,8 +249,8 @@ export class AssetController {
|
||||
editAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: AssetEditActionListDto,
|
||||
): Promise<AssetEditsDto> {
|
||||
@Body() dto: AssetEditsCreateDto,
|
||||
): Promise<AssetEditsResponseDto> {
|
||||
return this.service.editAsset(auth, id, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,20 @@ describe(MemoryController.name, () => {
|
||||
errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept showAt and hideAt', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/memories')
|
||||
.send({
|
||||
type: 'on_this_day',
|
||||
data: { year: 2020 },
|
||||
memoryAt: new Date(2021).toISOString(),
|
||||
showAt: new Date(2022).toISOString(),
|
||||
hideAt: new Date(2023).toISOString(),
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /memories/statistics', () => {
|
||||
|
||||
@@ -352,6 +352,7 @@ export const columns = {
|
||||
'asset_file.type',
|
||||
'asset_file.isEdited',
|
||||
'asset_file.isProgressive',
|
||||
'asset_file.isTransparent',
|
||||
],
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { BeforeUpdateTrigger, Column, ColumnOptions } from '@immich/sql-tools';
|
||||
import { SetMetadata, applyDecorators } from '@nestjs/common';
|
||||
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
|
||||
import _ from 'lodash';
|
||||
import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
|
||||
import { EmitEvent } from 'src/repositories/event.repository';
|
||||
import { immich_uuid_v7, updated_at } from 'src/schema/functions';
|
||||
import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools';
|
||||
import { setUnion } from 'src/utils/set';
|
||||
|
||||
const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
|
||||
|
||||
@@ -25,7 +25,10 @@ export class SanitizedAssetResponseDto {
|
||||
id!: string;
|
||||
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', description: 'Asset type' })
|
||||
type!: AssetType;
|
||||
@ApiProperty({ description: 'Thumbhash for thumbnail generation' })
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.',
|
||||
})
|
||||
thumbhash!: string | null;
|
||||
@ApiPropertyOptional({ description: 'Original MIME type' })
|
||||
originalMimeType?: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
|
||||
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer';
|
||||
import { ApiProperty, getSchemaPath } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
|
||||
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation';
|
||||
import { ExtraModel } from 'src/dtos/sync.dto';
|
||||
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetEditAction {
|
||||
Crop = 'crop',
|
||||
@@ -14,6 +15,7 @@ export enum MirrorAxis {
|
||||
Vertical = 'vertical',
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class CropParameters {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@@ -36,48 +38,21 @@ export class CropParameters {
|
||||
height!: number;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class RotateParameters {
|
||||
@IsAxisAlignedRotation()
|
||||
@ApiProperty({ description: 'Rotation angle in degrees' })
|
||||
angle!: number;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class MirrorParameters {
|
||||
@IsEnum(MirrorAxis)
|
||||
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
|
||||
axis!: MirrorAxis;
|
||||
}
|
||||
|
||||
class AssetEditActionBase {
|
||||
@IsEnum(AssetEditAction)
|
||||
@ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction', description: 'Type of edit action to perform' })
|
||||
action!: AssetEditAction;
|
||||
}
|
||||
|
||||
export class AssetEditActionCrop extends AssetEditActionBase {
|
||||
@ValidateNested()
|
||||
@Type(() => CropParameters)
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
parameters!: CropParameters;
|
||||
}
|
||||
|
||||
export class AssetEditActionRotate extends AssetEditActionBase {
|
||||
@ValidateNested()
|
||||
@Type(() => RotateParameters)
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
parameters!: RotateParameters;
|
||||
}
|
||||
|
||||
export class AssetEditActionMirror extends AssetEditActionBase {
|
||||
@ValidateNested()
|
||||
@Type(() => MirrorParameters)
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
parameters!: MirrorParameters;
|
||||
}
|
||||
|
||||
export type AssetEditParameters = CropParameters | RotateParameters | MirrorParameters;
|
||||
export type AssetEditActionItem =
|
||||
| {
|
||||
action: AssetEditAction.Crop;
|
||||
@@ -92,47 +67,48 @@ export type AssetEditActionItem =
|
||||
parameters: MirrorParameters;
|
||||
};
|
||||
|
||||
export type AssetEditActionParameter = {
|
||||
[AssetEditAction.Crop]: CropParameters;
|
||||
[AssetEditAction.Rotate]: RotateParameters;
|
||||
[AssetEditAction.Mirror]: MirrorParameters;
|
||||
export class AssetEditActionItemDto {
|
||||
@ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' })
|
||||
action!: AssetEditAction;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of edit actions to apply (crop, rotate, or mirror)',
|
||||
anyOf: [CropParameters, RotateParameters, MirrorParameters].map((type) => ({
|
||||
$ref: getSchemaPath(type),
|
||||
})),
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type((options) => actionParameterMap[options?.object.action as keyof AssetEditActionParameter])
|
||||
parameters!: AssetEditActionItem['parameters'];
|
||||
}
|
||||
|
||||
export class AssetEditActionItemResponseDto extends AssetEditActionItemDto {
|
||||
@ValidateUUID()
|
||||
id!: string;
|
||||
}
|
||||
|
||||
export type AssetEditActionParameter = typeof actionParameterMap;
|
||||
const actionParameterMap = {
|
||||
[AssetEditAction.Crop]: CropParameters,
|
||||
[AssetEditAction.Rotate]: RotateParameters,
|
||||
[AssetEditAction.Mirror]: MirrorParameters,
|
||||
};
|
||||
|
||||
type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror;
|
||||
const actionToClass: Record<AssetEditAction, ClassConstructor<AssetEditActions>> = {
|
||||
[AssetEditAction.Crop]: AssetEditActionCrop,
|
||||
[AssetEditAction.Rotate]: AssetEditActionRotate,
|
||||
[AssetEditAction.Mirror]: AssetEditActionMirror,
|
||||
} as const;
|
||||
|
||||
const getActionClass = (item: { action: AssetEditAction }): ClassConstructor<AssetEditActions> =>
|
||||
actionToClass[item.action];
|
||||
|
||||
@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop)
|
||||
export class AssetEditActionListDto {
|
||||
/** list of edits */
|
||||
export class AssetEditsCreateDto {
|
||||
@ArrayMinSize(1)
|
||||
@IsUniqueEditActions()
|
||||
@ValidateNested({ each: true })
|
||||
@Transform(({ value: edits }) =>
|
||||
Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits,
|
||||
)
|
||||
@ApiProperty({
|
||||
items: {
|
||||
anyOf: Object.values(actionToClass).map((type) => ({ $ref: getSchemaPath(type) })),
|
||||
discriminator: {
|
||||
propertyName: 'action',
|
||||
mapping: Object.fromEntries(
|
||||
Object.entries(actionToClass).map(([action, type]) => [action, getSchemaPath(type)]),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: 'List of edit actions to apply (crop, rotate, or mirror)',
|
||||
})
|
||||
edits!: AssetEditActionItem[];
|
||||
@Type(() => AssetEditActionItemDto)
|
||||
@ApiProperty({ description: 'List of edit actions to apply (crop, rotate, or mirror)' })
|
||||
edits!: AssetEditActionItemDto[];
|
||||
}
|
||||
|
||||
export class AssetEditsDto extends AssetEditActionListDto {
|
||||
@ValidateUUID({ description: 'Asset ID to apply edits to' })
|
||||
export class AssetEditsResponseDto {
|
||||
@ValidateUUID({ description: 'Asset ID these edits belong to' })
|
||||
assetId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of edit actions applied to the asset',
|
||||
})
|
||||
edits!: AssetEditActionItemResponseDto[];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
|
||||
import { DatabaseSslMode, ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
|
||||
import { ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
|
||||
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
|
||||
|
||||
// TODO import from sql-tools once the swagger plugin supports external enums
|
||||
enum DatabaseSslMode {
|
||||
Disable = 'disable',
|
||||
Allow = 'allow',
|
||||
Prefer = 'prefer',
|
||||
Require = 'require',
|
||||
VerifyFull = 'verify-full',
|
||||
}
|
||||
|
||||
export class EnvDto {
|
||||
@IsInt()
|
||||
@Optional()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
|
||||
import { Memory } from 'src/database';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetOrderWithRandom, MemoryType } from 'src/enum';
|
||||
@@ -77,6 +78,20 @@ export class MemoryCreateDto extends MemoryBaseDto {
|
||||
@ValidateDate({ description: 'Memory date' })
|
||||
memoryAt!: Date;
|
||||
|
||||
@ValidateDate({
|
||||
optional: true,
|
||||
description: 'Date when memory should be shown',
|
||||
history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'),
|
||||
})
|
||||
showAt?: Date;
|
||||
|
||||
@ValidateDate({
|
||||
optional: true,
|
||||
description: 'Date when memory should be hidden',
|
||||
history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'),
|
||||
})
|
||||
hideAt?: Date;
|
||||
|
||||
@ValidateUUID({ optional: true, each: true, description: 'Asset IDs to associate with memory' })
|
||||
assetIds?: string[];
|
||||
}
|
||||
|
||||
@@ -422,6 +422,20 @@ export class SyncAssetFaceV1 {
|
||||
sourceType!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetFaceV2 extends SyncAssetFaceV1 {
|
||||
@ApiProperty({ description: 'Face deleted at' })
|
||||
deletedAt!: Date | null;
|
||||
@ApiProperty({ description: 'Is the face visible in the asset' })
|
||||
isVisible!: boolean;
|
||||
}
|
||||
|
||||
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
|
||||
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
|
||||
|
||||
return faceV1;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetFaceDeleteV1 {
|
||||
@ApiProperty({ description: 'Asset face ID' })
|
||||
@@ -497,6 +511,7 @@ export type SyncItem = {
|
||||
[SyncEntityType.PersonV1]: SyncPersonV1;
|
||||
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
|
||||
[SyncEntityType.AssetFaceV1]: SyncAssetFaceV1;
|
||||
[SyncEntityType.AssetFaceV2]: SyncAssetFaceV2;
|
||||
[SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1;
|
||||
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
|
||||
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
|
||||
|
||||
+2
-8
@@ -762,6 +762,7 @@ export enum SyncRequestType {
|
||||
UsersV1 = 'UsersV1',
|
||||
PeopleV1 = 'PeopleV1',
|
||||
AssetFacesV1 = 'AssetFacesV1',
|
||||
AssetFacesV2 = 'AssetFacesV2',
|
||||
UserMetadataV1 = 'UserMetadataV1',
|
||||
}
|
||||
|
||||
@@ -820,6 +821,7 @@ export enum SyncEntityType {
|
||||
PersonDeleteV1 = 'PersonDeleteV1',
|
||||
|
||||
AssetFaceV1 = 'AssetFaceV1',
|
||||
AssetFaceV2 = 'AssetFaceV2',
|
||||
AssetFaceDeleteV1 = 'AssetFaceDeleteV1',
|
||||
|
||||
UserMetadataV1 = 'UserMetadataV1',
|
||||
@@ -851,14 +853,6 @@ export enum OAuthTokenEndpointAuthMethod {
|
||||
ClientSecretBasic = 'client_secret_basic',
|
||||
}
|
||||
|
||||
export enum DatabaseSslMode {
|
||||
Disable = 'disable',
|
||||
Allow = 'allow',
|
||||
Prefer = 'prefer',
|
||||
Require = 'require',
|
||||
VerifyFull = 'verify-full',
|
||||
}
|
||||
|
||||
export enum AssetVisibility {
|
||||
Archive = 'archive',
|
||||
Timeline = 'timeline',
|
||||
|
||||
+2
-2
@@ -52,9 +52,9 @@ class Workers {
|
||||
try {
|
||||
const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
|
||||
return value?.isMaintenanceMode || false;
|
||||
} catch (error) {
|
||||
} catch (error: Error | any) {
|
||||
// Table doesn't exist (migrations haven't run yet)
|
||||
if (error instanceof PostgresError && error.code === '42P01') {
|
||||
if ((error as PostgresError).code === '42P01') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ rollback
|
||||
|
||||
-- AssetEditRepository.getAll
|
||||
select
|
||||
"id",
|
||||
"action",
|
||||
"parameters"
|
||||
from
|
||||
|
||||
@@ -216,7 +216,8 @@ select
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive"
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
|
||||
@@ -540,6 +540,8 @@ select
|
||||
"boundingBoxX2",
|
||||
"boundingBoxY2",
|
||||
"sourceType",
|
||||
"isVisible",
|
||||
"asset_face"."deletedAt",
|
||||
"asset_face"."updateId"
|
||||
from
|
||||
"asset_face" as "asset_face"
|
||||
|
||||
@@ -31,7 +31,7 @@ export class ApiKeyRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getKey(hashedToken: string) {
|
||||
getKey(hashedToken: Buffer) {
|
||||
return this.db
|
||||
.selectFrom('api_key')
|
||||
.select((eb) => [
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto';
|
||||
import { DB } from 'src/schema';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,7 +12,7 @@ export class AssetEditRepository {
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID],
|
||||
})
|
||||
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
|
||||
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItemResponseDto[]> {
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
|
||||
|
||||
@@ -20,8 +20,8 @@ export class AssetEditRepository {
|
||||
return trx
|
||||
.insertInto('asset_edit')
|
||||
.values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit })))
|
||||
.returning(['action', 'parameters'])
|
||||
.execute() as Promise<AssetEditActionItem[]>;
|
||||
.returning(['id', 'action', 'parameters'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -31,12 +31,12 @@ export class AssetEditRepository {
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID],
|
||||
})
|
||||
getAll(assetId: string): Promise<AssetEditActionItem[]> {
|
||||
getAll(assetId: string): Promise<AssetEditActionItemResponseDto[]> {
|
||||
return this.db
|
||||
.selectFrom('asset_edit')
|
||||
.select(['action', 'parameters'])
|
||||
.select(['id', 'action', 'parameters'])
|
||||
.where('assetId', '=', assetId)
|
||||
.orderBy('sequence', 'asc')
|
||||
.execute() as Promise<AssetEditActionItem[]>;
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,7 +903,10 @@ export class AssetRepository {
|
||||
}
|
||||
|
||||
async upsertFile(
|
||||
file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>,
|
||||
file: Pick<
|
||||
Insertable<AssetFileTable>,
|
||||
'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive' | 'isTransparent'
|
||||
>,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.insertInto('asset_file')
|
||||
@@ -917,7 +920,10 @@ export class AssetRepository {
|
||||
}
|
||||
|
||||
async upsertFiles(
|
||||
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>[],
|
||||
files: Pick<
|
||||
Insertable<AssetFileTable>,
|
||||
'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive' | 'isTransparent'
|
||||
>[],
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
@@ -930,6 +936,7 @@ export class AssetRepository {
|
||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||
path: eb.ref('excluded.path'),
|
||||
isProgressive: eb.ref('excluded.isProgressive'),
|
||||
isTransparent: eb.ref('excluded.isTransparent'),
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DatabaseConnectionParams } from '@immich/sql-tools';
|
||||
import { RegisterQueueOptions } from '@nestjs/bullmq';
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { QueueOptions } from 'bullmq';
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
LogLevel,
|
||||
QueueName,
|
||||
} from 'src/enum';
|
||||
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
|
||||
import { VectorExtension } from 'src/types';
|
||||
import { setDifference } from 'src/utils/set';
|
||||
|
||||
export interface EnvData {
|
||||
@@ -184,7 +185,7 @@ const getEnv = (): EnvData => {
|
||||
try {
|
||||
redisConfig = JSON.parse(Buffer.from(redisUrl.slice(10), 'base64').toString());
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to decode redis options: ${error}`);
|
||||
throw new Error('Failed to decode redis options', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export class CryptoRepository {
|
||||
}
|
||||
|
||||
hashSha256(value: string) {
|
||||
return createHash('sha256').update(value).digest('base64');
|
||||
return createHash('sha256').update(value).digest();
|
||||
}
|
||||
|
||||
verifySha256(value: string, encryptedValue: string, publicKey: string) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -21,7 +22,6 @@ import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
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 { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
|
||||
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
|
||||
import { vectorIndexQuery } from 'src/utils/database';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
@@ -289,7 +289,8 @@ export class DatabaseRepository {
|
||||
|
||||
async getSchemaDrift() {
|
||||
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
|
||||
const target = await schemaFromDatabase(this.db, {});
|
||||
const { database } = this.configRepository.getEnv();
|
||||
const target = await schemaFromDatabase({ connection: database.config });
|
||||
|
||||
const drift = schemaDiff(source, target, {
|
||||
tables: { ignoreExtra: true },
|
||||
|
||||
@@ -309,9 +309,9 @@ export class MediaRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getImageDimensions(input: string | Buffer): Promise<ImageDimensions> {
|
||||
const { width = 0, height = 0 } = await sharp(input).metadata();
|
||||
return { width, height };
|
||||
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
|
||||
const { width = 0, height = 0, hasAlpha = false } = await sharp(input).metadata();
|
||||
return { width, height, isTransparent: hasAlpha };
|
||||
}
|
||||
|
||||
private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) {
|
||||
|
||||
@@ -69,7 +69,7 @@ export class ServerInfoRepository {
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch GitHub release: ${error}`);
|
||||
throw new Error('Failed to fetch GitHub release', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export class SessionRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getByToken(token: string) {
|
||||
getByToken(token: Buffer) {
|
||||
return this.db
|
||||
.selectFrom('session')
|
||||
.select((eb) => [
|
||||
|
||||
@@ -479,6 +479,8 @@ class AssetFaceSync extends BaseSync {
|
||||
'boundingBoxX2',
|
||||
'boundingBoxY2',
|
||||
'sourceType',
|
||||
'isVisible',
|
||||
'asset_face.deletedAt',
|
||||
'asset_face.updateId',
|
||||
])
|
||||
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { registerEnum } from '@immich/sql-tools';
|
||||
import { AssetStatus, AssetVisibility, SourceType } from 'src/enum';
|
||||
import { registerEnum } from 'src/sql-tools';
|
||||
|
||||
export const assets_status_enum = registerEnum({
|
||||
name: 'assets_status_enum',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { registerFunction } from 'src/sql-tools';
|
||||
import { registerFunction } from '@immich/sql-tools';
|
||||
|
||||
export const immich_uuid_v7 = registerFunction({
|
||||
name: 'immich_uuid_v7',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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,
|
||||
@@ -73,7 +74,6 @@ 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 { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
import { Database, Extensions, Generated, Int8 } from 'src/sql-tools';
|
||||
|
||||
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
|
||||
@Database({ name: 'immich' })
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_file" ADD "isTransparent" boolean NOT NULL DEFAULT false;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset_file" DROP COLUMN "isTransparent";`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "api_key" ALTER COLUMN "key" TYPE bytea USING decode("key", 'base64');`.execute(db);
|
||||
await sql`ALTER TABLE "session" ALTER COLUMN "token" TYPE bytea USING decode("token", 'base64');`.execute(db);
|
||||
await sql`CREATE INDEX "api_key_key_idx" ON "api_key" ("key");`.execute(db);
|
||||
await sql`CREATE INDEX "session_token_idx" ON "session" ("token");`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP INDEX "api_key_key_idx";`.execute(db);
|
||||
await sql`DROP INDEX "session_token_idx";`.execute(db);
|
||||
await sql`ALTER TABLE "api_key" ALTER COLUMN "key" TYPE character varying USING encode("key", 'base64');`.execute(db);
|
||||
await sql`ALTER TABLE "session" ALTER COLUMN "token" TYPE character varying USING encode("token", 'base64');`.execute(db);
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
@@ -15,7 +10,12 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('activity')
|
||||
@UpdatedAtTrigger('activity_updatedAt')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('album_asset_audit')
|
||||
export class AlbumAssetAuditTable {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { album_asset_delete_audit } from 'src/schema/functions';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
CreateDateColumn,
|
||||
@@ -10,7 +6,11 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { album_asset_delete_audit } from 'src/schema/functions';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
|
||||
@Table({ name: 'album_asset' })
|
||||
@UpdatedAtTrigger('album_asset_updatedAt')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('album_audit')
|
||||
export class AlbumAuditTable {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('album_user_audit')
|
||||
export class AlbumUserAuditTable {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
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';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
AfterInsertTrigger,
|
||||
@@ -13,7 +8,12 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
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' })
|
||||
// Pre-existing indices from original album <--> user ManyToMany mapping
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
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';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
@@ -14,7 +9,12 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} 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')
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { Permission } from 'src/enum';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -10,7 +7,10 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { Permission } from 'src/enum';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('api_key')
|
||||
@UpdatedAtTrigger('api_key_updatedAt')
|
||||
@@ -21,8 +21,8 @@ export class ApiKeyTable {
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
@Column()
|
||||
key!: string;
|
||||
@Column({ type: 'bytea', index: true })
|
||||
key!: Buffer;
|
||||
|
||||
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
|
||||
userId!: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('asset_audit')
|
||||
export class AssetAuditTable {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto';
|
||||
import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
AfterInsertTrigger,
|
||||
@@ -10,7 +7,10 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Unique,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { AssetEditAction, AssetEditParameters } from 'src/dtos/editing.dto';
|
||||
import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
|
||||
@Table('asset_edit')
|
||||
@AfterInsertTrigger({ scope: 'statement', function: asset_edit_insert, referencingNewTableAs: 'inserted_edit' })
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
@Unique({ columns: ['assetId', 'sequence'] })
|
||||
export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
|
||||
export class AssetEditTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@@ -29,10 +29,10 @@ export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
|
||||
assetId!: string;
|
||||
|
||||
@Column()
|
||||
action!: T;
|
||||
action!: AssetEditAction;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
parameters!: AssetEditActionParameter[T];
|
||||
parameters!: AssetEditParameters;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
sequence!: number;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools';
|
||||
import { LockableProperty } from 'src/database';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools';
|
||||
|
||||
@Table('asset_exif')
|
||||
@UpdatedAtTrigger('asset_exif_updatedAt')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('asset_face_audit')
|
||||
export class AssetFaceAuditTable {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { SourceType } from 'src/enum';
|
||||
import { asset_face_source_type } from 'src/schema/enums';
|
||||
import { asset_face_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
@@ -15,7 +9,13 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { SourceType } from 'src/enum';
|
||||
import { asset_face_source_type } from 'src/schema/enums';
|
||||
import { asset_face_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
|
||||
@Table({ name: 'asset_face' })
|
||||
@UpdatedAtTrigger('asset_face_updatedAt')
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -11,7 +8,10 @@ import {
|
||||
Timestamp,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
|
||||
@Table('asset_file')
|
||||
@Unique({ columns: ['assetId', 'type', 'isEdited'] })
|
||||
@@ -43,4 +43,7 @@ export class AssetFileTable {
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isProgressive!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isTransparent!: Generated<boolean>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, ForeignKeyColumn, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { Column, ForeignKeyColumn, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('asset_job_status')
|
||||
export class AssetJobStatusTable {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('asset_metadata_audit')
|
||||
export class AssetMetadataAuditTable {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetMetadataKey } from 'src/enum';
|
||||
import { asset_metadata_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
@@ -11,7 +7,11 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetMetadataKey } from 'src/enum';
|
||||
import { asset_metadata_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
|
||||
@UpdatedAtTrigger('asset_metadata_updated_at')
|
||||
@Table('asset_metadata')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('asset_ocr')
|
||||
export class AssetOcrTable {
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
|
||||
import { asset_delete_audit } from 'src/schema/functions';
|
||||
import { LibraryTable } from 'src/schema/tables/library.table';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
@@ -17,7 +10,14 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
|
||||
import { asset_delete_audit } from 'src/schema/functions';
|
||||
import { LibraryTable } from 'src/schema/tables/library.table';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
|
||||
|
||||
@Table('asset')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Index, PrimaryColumn, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { DatabaseAction, EntityType } from 'src/enum';
|
||||
import { Column, CreateDateColumn, Generated, Index, PrimaryColumn, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('audit')
|
||||
@Index({ columns: ['ownerId', 'createdAt'] })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
|
||||
|
||||
@Table({ name: 'face_search' })
|
||||
@Index({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Column, Index, PrimaryColumn, Table, Timestamp } from 'src/sql-tools';
|
||||
import { Column, Index, PrimaryColumn, Table, Timestamp } from '@immich/sql-tools';
|
||||
|
||||
@Table({ name: 'geodata_places', primaryConstraintName: 'geodata_places_pkey' })
|
||||
@Index({
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -10,7 +8,9 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('library')
|
||||
@UpdatedAtTrigger('library_updatedAt')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('memory_asset_audit')
|
||||
export class MemoryAssetAuditTable {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { memory_asset_delete_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
CreateDateColumn,
|
||||
@@ -10,7 +6,11 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { memory_asset_delete_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
|
||||
@Table('memory_asset')
|
||||
@UpdatedAtTrigger('memory_asset_updatedAt')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('memory_audit')
|
||||
export class MemoryAuditTable {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { MemoryType } from 'src/enum';
|
||||
import { memory_delete_audit } from 'src/schema/functions';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
@@ -13,7 +9,11 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { MemoryType } from 'src/enum';
|
||||
import { memory_delete_audit } from 'src/schema/functions';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('memory')
|
||||
@UpdatedAtTrigger('memory_updatedAt')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, Generated, PrimaryGeneratedColumn, Table, Unique } from '@immich/sql-tools';
|
||||
import { PathType } from 'src/enum';
|
||||
import { Column, Generated, PrimaryGeneratedColumn, Table, Unique } from 'src/sql-tools';
|
||||
|
||||
@Table('move_history')
|
||||
// path lock (per entity)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Column, Generated, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
|
||||
import { Column, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
|
||||
|
||||
@Table({ name: 'naturalearth_countries', primaryConstraintName: 'naturalearth_countries_pkey' })
|
||||
export class NaturalEarthCountriesTable {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { NotificationLevel, NotificationType } from 'src/enum';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -11,7 +8,10 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { NotificationLevel, NotificationType } from 'src/enum';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('notification')
|
||||
@UpdatedAtTrigger('notification_updatedAt')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('ocr_search')
|
||||
@Index({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('partner_audit')
|
||||
export class PartnerAuditTable {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { partner_delete_audit } from 'src/schema/functions';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
@@ -10,7 +7,10 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { partner_delete_audit } from 'src/schema/functions';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('partner')
|
||||
@UpdatedAtTrigger('partner_updatedAt')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('person_audit')
|
||||
export class PersonAuditTable {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { person_delete_audit } from 'src/schema/functions';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Check,
|
||||
@@ -13,7 +9,11 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { person_delete_audit } from 'src/schema/functions';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('person')
|
||||
@UpdatedAtTrigger('person_updatedAt')
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PluginContext } from 'src/enum';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -9,7 +8,8 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { PluginContext } from 'src/enum';
|
||||
import type { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
|
||||
@Table('plugin')
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -9,7 +7,9 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table({ name: 'session' })
|
||||
@UpdatedAtTrigger('session_updatedAt')
|
||||
@@ -17,9 +17,8 @@ export class SessionTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
// TODO convert to byte[]
|
||||
@Column()
|
||||
token!: string;
|
||||
@Column({ type: 'bytea', index: true })
|
||||
token!: Buffer;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ForeignKeyColumn, Table } from '@immich/sql-tools';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
import { ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('shared_link_asset')
|
||||
export class SharedLinkAssetTable {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -9,7 +6,10 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('shared_link')
|
||||
export class SharedLinkTable {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
|
||||
|
||||
@Table({ name: 'smart_search' })
|
||||
@Index({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('stack_audit')
|
||||
export class StackAuditTable {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { stack_delete_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
CreateDateColumn,
|
||||
@@ -11,7 +7,11 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { stack_delete_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('stack')
|
||||
@UpdatedAtTrigger('stack_updatedAt')
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { SyncEntityType } from 'src/enum';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -10,7 +7,10 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { SyncEntityType } from 'src/enum';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
|
||||
@Table('session_sync_checkpoint')
|
||||
@UpdatedAtTrigger('session_sync_checkpoint_updatedAt')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, PrimaryColumn, Table } from '@immich/sql-tools';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { Column, PrimaryColumn, Table } from 'src/sql-tools';
|
||||
import { SystemMetadata } from 'src/types';
|
||||
|
||||
@Table('system_metadata')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ForeignKeyColumn, Index, Table } from '@immich/sql-tools';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { TagTable } from 'src/schema/tables/tag.table';
|
||||
import { ForeignKeyColumn, Index, Table } from 'src/sql-tools';
|
||||
|
||||
@Index({ columns: ['assetId', 'tagId'] })
|
||||
@Table('tag_asset')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ForeignKeyColumn, Table } from '@immich/sql-tools';
|
||||
import { TagTable } from 'src/schema/tables/tag.table';
|
||||
import { ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('tag_closure')
|
||||
export class TagClosureTable {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -10,7 +8,9 @@ import {
|
||||
Timestamp,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
|
||||
@Table('tag')
|
||||
@UpdatedAtTrigger('tag_updatedAt')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('user_audit')
|
||||
export class UserAuditTable {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { UserMetadataKey } from 'src/enum';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('user_metadata_audit')
|
||||
export class UserMetadataAuditTable {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserMetadataKey } from 'src/enum';
|
||||
import { user_metadata_audit } from 'src/schema/functions';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
@@ -11,7 +7,11 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserMetadataKey } from 'src/enum';
|
||||
import { user_metadata_audit } from 'src/schema/functions';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { UserMetadata, UserMetadataItem } from 'src/types';
|
||||
|
||||
@UpdatedAtTrigger('user_metadata_updated_at')
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { ColumnType } from 'kysely';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserAvatarColor, UserStatus } from 'src/enum';
|
||||
import { user_delete_audit } from 'src/schema/functions';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
@@ -13,7 +9,11 @@ import {
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { ColumnType } from 'kysely';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { UserAvatarColor, UserStatus } from 'src/enum';
|
||||
import { user_delete_audit } from 'src/schema/functions';
|
||||
|
||||
@Table('user')
|
||||
@UpdatedAtTrigger('user_updatedAt')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from 'src/sql-tools';
|
||||
import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from '@immich/sql-tools';
|
||||
|
||||
@Table('version_history')
|
||||
export class VersionHistoryTable {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { PluginTriggerType } from 'src/enum';
|
||||
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -10,7 +7,10 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
} from 'src/sql-tools';
|
||||
} from '@immich/sql-tools';
|
||||
import { PluginTriggerType } from 'src/enum';
|
||||
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
|
||||
|
||||
@Table('workflow')
|
||||
|
||||
@@ -24,7 +24,7 @@ describe(ApiKeyService.name, () => {
|
||||
await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions });
|
||||
|
||||
expect(mocks.apiKey.create).toHaveBeenCalledWith({
|
||||
key: 'super-secret (hashed)',
|
||||
key: Buffer.from('super-secret (hashed)'),
|
||||
name: apiKey.name,
|
||||
permissions: apiKey.permissions,
|
||||
userId: apiKey.userId,
|
||||
@@ -44,7 +44,7 @@ describe(ApiKeyService.name, () => {
|
||||
await sut.create(auth, { permissions: [Permission.All] });
|
||||
|
||||
expect(mocks.apiKey.create).toHaveBeenCalledWith({
|
||||
key: 'super-secret (hashed)',
|
||||
key: Buffer.from('super-secret (hashed)'),
|
||||
name: 'API Key',
|
||||
permissions: [Permission.All],
|
||||
userId: auth.user.id,
|
||||
|
||||
@@ -10,14 +10,14 @@ import { isGranted } from 'src/utils/access';
|
||||
export class ApiKeyService extends BaseService {
|
||||
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
|
||||
const token = this.cryptoRepository.randomBytesAsText(32);
|
||||
const tokenHashed = this.cryptoRepository.hashSha256(token);
|
||||
const hashed = this.cryptoRepository.hashSha256(token);
|
||||
|
||||
if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) {
|
||||
throw new BadRequestException('Cannot grant permissions you do not have');
|
||||
}
|
||||
|
||||
const entity = await this.apiKeyRepository.create({
|
||||
key: tokenHashed,
|
||||
key: hashed,
|
||||
name: dto.name || 'API Key',
|
||||
userId: auth.user.id,
|
||||
permissions: dto.permissions,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
mapStats,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditAction, AssetEditActionCrop, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||
import { AssetEditAction, AssetEditActionItem, AssetEditsCreateDto, AssetEditsResponseDto } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
@@ -543,7 +543,7 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async getAssetEdits(auth: AuthDto, id: string): Promise<AssetEditsDto> {
|
||||
async getAssetEdits(auth: AuthDto, id: string): Promise<AssetEditsResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
|
||||
const edits = await this.assetEditRepository.getAll(id);
|
||||
return {
|
||||
@@ -552,7 +552,7 @@ export class AssetService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise<AssetEditsDto> {
|
||||
async editAsset(auth: AuthDto, id: string, dto: AssetEditsCreateDto): Promise<AssetEditsResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] });
|
||||
|
||||
const asset = await this.assetRepository.getForEdit(id);
|
||||
@@ -587,12 +587,13 @@ export class AssetService extends BaseService {
|
||||
throw new BadRequestException('Asset dimensions are not available for editing');
|
||||
}
|
||||
|
||||
const cropIndex = dto.edits.findIndex((e) => e.action === AssetEditAction.Crop);
|
||||
if (cropIndex > 0) {
|
||||
throw new BadRequestException('Crop action must be the first edit action');
|
||||
}
|
||||
const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop);
|
||||
const edits = dto.edits as AssetEditActionItem[];
|
||||
const crop = edits.find((e) => e.action === AssetEditAction.Crop);
|
||||
if (crop) {
|
||||
if (edits[0].action !== AssetEditAction.Crop) {
|
||||
throw new BadRequestException('Crop action must be the first edit action');
|
||||
}
|
||||
|
||||
// check that crop parameters will not go out of bounds
|
||||
const { width: assetWidth, height: assetHeight } = getDimensions(asset);
|
||||
|
||||
@@ -606,7 +607,7 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
const newEdits = await this.assetEditRepository.replaceAll(id, dto.edits);
|
||||
const newEdits = await this.assetEditRepository.replaceAll(id, edits);
|
||||
await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
|
||||
|
||||
// Return the asset and its applied edits
|
||||
|
||||
@@ -513,7 +513,7 @@ describe(AuthService.name, () => {
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
expect(mocks.apiKey.getKey).toHaveBeenCalledWith(Buffer.from('auth_token (hashed)'));
|
||||
});
|
||||
|
||||
it('should throw an error if api key has insufficient permissions', async () => {
|
||||
@@ -574,7 +574,7 @@ describe(AuthService.name, () => {
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
|
||||
}),
|
||||
).resolves.toEqual({ user: authUser, apiKey: expect.objectContaining(authApiKey) });
|
||||
expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
expect(mocks.apiKey.getKey).toHaveBeenCalledWith(Buffer.from('auth_token (hashed)'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -456,8 +456,8 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
private async validateApiKey(key: string): Promise<AuthDto> {
|
||||
const hashedKey = this.cryptoRepository.hashSha256(key);
|
||||
const apiKey = await this.apiKeyRepository.getKey(hashedKey);
|
||||
const hashed = this.cryptoRepository.hashSha256(key);
|
||||
const apiKey = await this.apiKeyRepository.getKey(hashed);
|
||||
if (apiKey?.user) {
|
||||
return {
|
||||
user: apiKey.user,
|
||||
@@ -476,9 +476,9 @@ export class AuthService extends BaseService {
|
||||
return this.cryptoRepository.compareBcrypt(inputSecret, existingHash);
|
||||
}
|
||||
|
||||
private async validateSession(tokenValue: string, headers: IncomingHttpHeaders): Promise<AuthDto> {
|
||||
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
|
||||
const session = await this.sessionRepository.getByToken(hashedToken);
|
||||
private async validateSession(token: string, headers: IncomingHttpHeaders): Promise<AuthDto> {
|
||||
const hashed = this.cryptoRepository.hashSha256(token);
|
||||
const session = await this.sessionRepository.getByToken(hashed);
|
||||
if (session?.user) {
|
||||
const { appVersion, deviceOS, deviceType } = getUserAgentDetails(headers);
|
||||
const now = DateTime.now();
|
||||
@@ -543,10 +543,10 @@ export class AuthService extends BaseService {
|
||||
|
||||
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
|
||||
const token = this.cryptoRepository.randomBytesAsText(32);
|
||||
const tokenHashed = this.cryptoRepository.hashSha256(token);
|
||||
const hashed = this.cryptoRepository.hashSha256(token);
|
||||
|
||||
await this.sessionRepository.create({
|
||||
token: tokenHashed,
|
||||
token: hashed,
|
||||
deviceOS: loginDetails.deviceOS,
|
||||
deviceType: loginDetails.deviceType,
|
||||
appVersion: loginDetails.appVersion,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { schemaDiff } from '@immich/sql-tools';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
@@ -5,7 +6,6 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { schemaDiff } from 'src/sql-tools';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
|
||||
|
||||
@@ -348,6 +348,7 @@ describe(MediaService.name, () => {
|
||||
: { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted
|
||||
),
|
||||
);
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false });
|
||||
});
|
||||
|
||||
it('should skip thumbnail generation if asset not found', async () => {
|
||||
@@ -467,6 +468,7 @@ describe(MediaService.name, () => {
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
@@ -474,6 +476,7 @@ describe(MediaService.name, () => {
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, thumbhash: thumbhashBuffer });
|
||||
@@ -508,6 +511,7 @@ describe(MediaService.name, () => {
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
@@ -515,6 +519,7 @@ describe(MediaService.name, () => {
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -548,6 +553,7 @@ describe(MediaService.name, () => {
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
@@ -555,6 +561,7 @@ describe(MediaService.name, () => {
|
||||
path: expect.any(String),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -770,10 +777,12 @@ describe(MediaService.name, () => {
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
isProgressive: true,
|
||||
isTransparent: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Thumbnail,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -807,10 +816,12 @@ describe(MediaService.name, () => {
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Thumbnail,
|
||||
isProgressive: true,
|
||||
isTransparent: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -829,10 +840,12 @@ describe(MediaService.name, () => {
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Thumbnail,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -857,7 +870,7 @@ describe(MediaService.name, () => {
|
||||
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
|
||||
.build();
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
@@ -871,12 +884,39 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not check transparency metadata for raw files without extracted images', async () => {
|
||||
const asset = AssetFactory.from({ originalFileName: 'file.dng' })
|
||||
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
|
||||
.build();
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
expect(mocks.media.getImageMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not check transparency metadata for raw files with extracted images', async () => {
|
||||
const asset = AssetFactory.from({ originalFileName: 'file.dng' })
|
||||
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
|
||||
.build();
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
expect(mocks.media.getImageMetadata).toHaveBeenCalledOnce();
|
||||
expect(mocks.media.getImageMetadata).toHaveBeenCalledWith(extractedBuffer);
|
||||
});
|
||||
|
||||
it('should resize original image if embedded image is too small', async () => {
|
||||
const asset = AssetFactory.from({ originalFileName: 'file.dng' })
|
||||
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
|
||||
.build();
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
@@ -970,7 +1010,7 @@ describe(MediaService.name, () => {
|
||||
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
|
||||
});
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
@@ -1008,7 +1048,7 @@ describe(MediaService.name, () => {
|
||||
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
|
||||
});
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
@@ -1056,7 +1096,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } });
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
@@ -1100,7 +1140,7 @@ describe(MediaService.name, () => {
|
||||
it('should generate full-size preview from non-web-friendly images', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
|
||||
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
|
||||
.exif({
|
||||
@@ -1139,7 +1179,7 @@ describe(MediaService.name, () => {
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
@@ -1162,7 +1202,7 @@ describe(MediaService.name, () => {
|
||||
it('should always generate full-size preview from non-web-friendly panoramas', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.media.copyTagGroup.mockResolvedValue(true);
|
||||
|
||||
const asset = AssetFactory.from({ originalFileName: 'panorama.tif' })
|
||||
@@ -1208,7 +1248,7 @@ describe(MediaService.name, () => {
|
||||
image: { fullsize: { enabled: true, format: ImageFormat.Webp, quality: 90 } },
|
||||
});
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
|
||||
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
|
||||
.exif({
|
||||
@@ -1248,7 +1288,7 @@ describe(MediaService.name, () => {
|
||||
image: { fullsize: { enabled: true, format: ImageFormat.Jpeg, progressive: true } },
|
||||
});
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
|
||||
.exif({
|
||||
fileSizeInByte: 5000,
|
||||
@@ -1286,6 +1326,7 @@ describe(MediaService.name, () => {
|
||||
: { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted
|
||||
),
|
||||
);
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false });
|
||||
});
|
||||
|
||||
it('should skip videos', async () => {
|
||||
@@ -1719,7 +1760,7 @@ describe(MediaService.name, () => {
|
||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
mocks.media.getImageDimensions.mockResolvedValue(info);
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
@@ -1802,7 +1843,7 @@ describe(MediaService.name, () => {
|
||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageDimensions.mockResolvedValue(info);
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
@@ -3554,6 +3595,7 @@ describe(MediaService.name, () => {
|
||||
path: '/new/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
@@ -3561,6 +3603,7 @@ describe(MediaService.name, () => {
|
||||
path: '/new/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -3571,6 +3614,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
@@ -3578,6 +3622,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
@@ -3595,6 +3640,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3603,6 +3649,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -3614,6 +3661,7 @@ describe(MediaService.name, () => {
|
||||
path: '/new/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
@@ -3621,6 +3669,7 @@ describe(MediaService.name, () => {
|
||||
path: '/new/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -3631,6 +3680,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
@@ -3638,6 +3688,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
@@ -3658,6 +3709,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3666,6 +3718,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -3681,6 +3734,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3689,6 +3743,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3708,6 +3763,7 @@ describe(MediaService.name, () => {
|
||||
path: '/same/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3716,6 +3772,7 @@ describe(MediaService.name, () => {
|
||||
path: '/same/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -3727,6 +3784,7 @@ describe(MediaService.name, () => {
|
||||
path: '/same/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
@@ -3734,6 +3792,7 @@ describe(MediaService.name, () => {
|
||||
path: '/same/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -3753,6 +3812,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3761,6 +3821,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -3772,6 +3833,7 @@ describe(MediaService.name, () => {
|
||||
path: '/new/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
}, // replace
|
||||
{
|
||||
assetId: asset.id,
|
||||
@@ -3779,6 +3841,7 @@ describe(MediaService.name, () => {
|
||||
path: '/new/fullsize.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
}, // new
|
||||
]);
|
||||
|
||||
@@ -3789,6 +3852,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-id',
|
||||
@@ -3796,6 +3860,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.FullSize,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||
@@ -3806,6 +3871,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3838,6 +3904,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -3853,6 +3920,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
@@ -3872,6 +3940,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
@@ -3880,6 +3949,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -3891,6 +3961,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/preview.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: true,
|
||||
isTransparent: false,
|
||||
},
|
||||
{
|
||||
assetId: asset.id,
|
||||
@@ -3898,6 +3969,7 @@ describe(MediaService.name, () => {
|
||||
path: '/old/thumbnail.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -3908,6 +3980,7 @@ describe(MediaService.name, () => {
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
isProgressive: true,
|
||||
isTransparent: false,
|
||||
},
|
||||
]);
|
||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||
|
||||
@@ -52,6 +52,7 @@ interface UpsertFileOptions {
|
||||
path: string;
|
||||
isEdited: boolean;
|
||||
isProgressive: boolean;
|
||||
isTransparent: boolean;
|
||||
}
|
||||
|
||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||
@@ -280,14 +281,20 @@ export class MediaService extends BaseService {
|
||||
useEdits;
|
||||
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
|
||||
|
||||
const thumbSource = extracted ? extracted.buffer : asset.originalPath;
|
||||
const { data, info, colorspace } = await this.decodeImage(
|
||||
extracted ? extracted.buffer : asset.originalPath,
|
||||
thumbSource,
|
||||
// only specify orientation to extracted images which don't have EXIF orientation data
|
||||
// or it can double rotate the image
|
||||
extracted ? asset.exifInfo : { ...asset.exifInfo, orientation: null },
|
||||
convertFullsize ? undefined : image.preview.size,
|
||||
);
|
||||
|
||||
let isTransparent = false;
|
||||
if (!extracted && mimeTypes.canBeTransparent(asset.originalPath)) {
|
||||
({ isTransparent } = await this.mediaRepository.getImageMetadata(asset.originalPath));
|
||||
}
|
||||
|
||||
return {
|
||||
extracted,
|
||||
data,
|
||||
@@ -295,50 +302,64 @@ export class MediaService extends BaseService {
|
||||
colorspace,
|
||||
convertFullsize,
|
||||
generateFullsize,
|
||||
isTransparent,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) {
|
||||
// Handle embedded preview extraction for RAW files
|
||||
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
|
||||
const { info, data, colorspace, generateFullsize, convertFullsize, extracted, isTransparent } = extractedImage;
|
||||
|
||||
const previewFormat = image.preview.format;
|
||||
this.warnOnTransparencyLoss(isTransparent, previewFormat, asset.id);
|
||||
|
||||
const thumbnailFormat = image.thumbnail.format;
|
||||
this.warnOnTransparencyLoss(isTransparent, thumbnailFormat, asset.id);
|
||||
|
||||
const previewFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Preview,
|
||||
format: image.preview.format,
|
||||
format: previewFormat,
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp,
|
||||
isProgressive: !!image.preview.progressive && previewFormat !== ImageFormat.Webp,
|
||||
isTransparent,
|
||||
});
|
||||
const thumbnailFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
format: thumbnailFormat,
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp,
|
||||
isProgressive: !!image.thumbnail.progressive && thumbnailFormat !== ImageFormat.Webp,
|
||||
isTransparent,
|
||||
});
|
||||
this.storageCore.ensureFolders(previewFile.path);
|
||||
|
||||
// Handle embedded preview extraction for RAW files
|
||||
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
|
||||
const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage;
|
||||
|
||||
// generate final images
|
||||
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
|
||||
const baseOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
|
||||
const thumbnailOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat };
|
||||
const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat };
|
||||
const promises = [
|
||||
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
||||
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
|
||||
this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path),
|
||||
this.mediaRepository.generateThumbhash(data, baseOptions),
|
||||
this.mediaRepository.generateThumbnail(data, thumbnailOptions, thumbnailFile.path),
|
||||
this.mediaRepository.generateThumbnail(data, previewOptions, previewFile.path),
|
||||
];
|
||||
|
||||
let fullsizeFile: UpsertFileOptions | undefined;
|
||||
if (convertFullsize) {
|
||||
const fullsizeFormat = image.fullsize.format;
|
||||
this.warnOnTransparencyLoss(isTransparent, fullsizeFormat, asset.id);
|
||||
// convert a new fullsize image from the same source as the thumbnail
|
||||
fullsizeFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.FullSize,
|
||||
format: image.fullsize.format,
|
||||
format: fullsizeFormat,
|
||||
isEdited: useEdits,
|
||||
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
|
||||
isProgressive: !!image.fullsize.progressive && fullsizeFormat !== ImageFormat.Webp,
|
||||
isTransparent,
|
||||
});
|
||||
const fullsizeOptions = {
|
||||
format: image.fullsize.format,
|
||||
...baseOptions,
|
||||
format: fullsizeFormat,
|
||||
quality: image.fullsize.quality,
|
||||
progressive: image.fullsize.progressive,
|
||||
...thumbnailOptions,
|
||||
};
|
||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path));
|
||||
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
||||
@@ -347,6 +368,7 @@ export class MediaService extends BaseService {
|
||||
format: extracted.format,
|
||||
isEdited: false,
|
||||
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
|
||||
isTransparent,
|
||||
});
|
||||
this.storageCore.ensureFolders(fullsizeFile.path);
|
||||
|
||||
@@ -493,12 +515,14 @@ export class MediaService extends BaseService {
|
||||
format: image.preview.format,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
});
|
||||
const thumbnailFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
});
|
||||
this.storageCore.ensureFolders(previewFile.path);
|
||||
|
||||
@@ -758,7 +782,7 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) {
|
||||
const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer);
|
||||
const { width, height } = await this.mediaRepository.getImageMetadata(extractedPathOrBuffer);
|
||||
const extractedSize = Math.min(width, height);
|
||||
return extractedSize >= targetSize;
|
||||
}
|
||||
@@ -785,7 +809,10 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncFiles(oldFiles: (AssetFile & { isProgressive: boolean })[], newFiles: UpsertFileOptions[]) {
|
||||
private async syncFiles(
|
||||
oldFiles: (AssetFile & { isProgressive: boolean; isTransparent: boolean })[],
|
||||
newFiles: UpsertFileOptions[],
|
||||
) {
|
||||
const toUpsert: UpsertFileOptions[] = [];
|
||||
const pathsToDelete: string[] = [];
|
||||
const toDelete = new Set(oldFiles);
|
||||
@@ -797,7 +824,11 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
// upsert new file path
|
||||
if (existingFile?.path !== newFile.path || existingFile.isProgressive !== newFile.isProgressive) {
|
||||
if (
|
||||
existingFile?.path !== newFile.path ||
|
||||
existingFile.isProgressive !== newFile.isProgressive ||
|
||||
existingFile.isTransparent !== newFile.isTransparent
|
||||
) {
|
||||
toUpsert.push(newFile);
|
||||
|
||||
// delete old file from disk
|
||||
@@ -857,7 +888,18 @@ export class MediaService extends BaseService {
|
||||
return generated;
|
||||
}
|
||||
|
||||
private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) {
|
||||
private warnOnTransparencyLoss(isTransparent: boolean, format: ImageFormat, assetId: string) {
|
||||
if (isTransparent && format === ImageFormat.Jpeg) {
|
||||
this.logger.warn(
|
||||
`Asset ${assetId} has transparency but the configured format is ${format} which does not support it, consider using a format that does, such as ${ImageFormat.Webp}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getImageFile(
|
||||
asset: ThumbnailPathEntity,
|
||||
options: ImagePathOptions & { isProgressive: boolean; isTransparent: boolean },
|
||||
) {
|
||||
const path = StorageCore.getImagePath(asset, options);
|
||||
return {
|
||||
assetId: asset.id,
|
||||
@@ -865,6 +907,7 @@ export class MediaService extends BaseService {
|
||||
path,
|
||||
isEdited: options.isEdited,
|
||||
isProgressive: options.isProgressive,
|
||||
isTransparent: options.isTransparent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,8 @@ export class MemoryService extends BaseService {
|
||||
data: dto.data,
|
||||
isSaved: dto.isSaved,
|
||||
memoryAt: dto.memoryAt,
|
||||
showAt: dto.showAt,
|
||||
hideAt: dto.hideAt,
|
||||
seenAt: dto.seenAt,
|
||||
},
|
||||
allowedAssetIds,
|
||||
|
||||
@@ -169,7 +169,7 @@ export class MetadataService extends BaseService {
|
||||
this.logger.log(`Initialized local reverse geocoder`);
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack);
|
||||
throw new Error(`Metadata service init failed`);
|
||||
throw new Error('Metadata service init failed', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ export class MetadataService extends BaseService {
|
||||
orientation: validate(exifTags.Orientation)?.toString() ?? null,
|
||||
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
|
||||
bitsPerSample: this.getBitsPerSample(exifTags),
|
||||
colorspace: exifTags.ColorSpace ?? null,
|
||||
colorspace: exifTags.ColorSpace === undefined ? null : String(exifTags.ColorSpace),
|
||||
|
||||
// camera
|
||||
make: exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? null,
|
||||
|
||||
@@ -59,7 +59,7 @@ export class NotificationAdminService extends BaseService {
|
||||
async getTemplate(name: EmailTemplate, customTemplate: string) {
|
||||
const { server, templates } = await this.getConfig({ withCache: false });
|
||||
|
||||
let templateResponse = '';
|
||||
let templateResponse: string;
|
||||
|
||||
switch (name) {
|
||||
case EmailTemplate.WELCOME: {
|
||||
|
||||
@@ -134,7 +134,7 @@ export class NotificationService extends BaseService {
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Failed to validate SMTP configuration: ${error}`, error?.stack);
|
||||
throw new Error(`Invalid SMTP configuration: ${error}`);
|
||||
throw new Error('Invalid SMTP configuration', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,14 +33,14 @@ export class SessionService extends BaseService {
|
||||
}
|
||||
|
||||
const token = this.cryptoRepository.randomBytesAsText(32);
|
||||
const tokenHashed = this.cryptoRepository.hashSha256(token);
|
||||
const hashed = this.cryptoRepository.hashSha256(token);
|
||||
const session = await this.sessionRepository.create({
|
||||
parentId: auth.session.id,
|
||||
userId: auth.user.id,
|
||||
expiresAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null,
|
||||
deviceType: dto.deviceType,
|
||||
deviceOS: dto.deviceOS,
|
||||
token: tokenHashed,
|
||||
token: hashed,
|
||||
});
|
||||
|
||||
return { ...mapSession(session), token };
|
||||
|
||||
@@ -69,8 +69,9 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
it('should accept a valid shared link auth token', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' });
|
||||
mocks.crypto.hashSha256.mockReturnValue('hashed-auth-token');
|
||||
await expect(sut.getMine(authStub.adminSharedLink, ['hashed-auth-token'])).resolves.toBeDefined();
|
||||
const secret = Buffer.from('auth-token-123');
|
||||
mocks.crypto.hashSha256.mockReturnValue(secret);
|
||||
await expect(sut.getMine(authStub.adminSharedLink, [secret.toString('base64')])).resolves.toBeDefined();
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.user.id,
|
||||
authStub.adminSharedLink.sharedLink?.id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user