Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Rasmussen
2248413280 feat: disable admin setup 2025-12-16 15:35:57 -05:00
27 changed files with 262 additions and 425 deletions

View File

@@ -43,6 +43,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api | | `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices | | `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. \*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.

View File

@@ -240,7 +240,7 @@ export type Session = {
isPendingSyncReset: boolean; isPendingSyncReset: boolean;
}; };
export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId' | 'lockedProperties'>; export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId'>;
export type Person = { export type Person = {
createdAt: Date; createdAt: Date;
@@ -465,13 +465,3 @@ export const columns = {
'plugin.updatedAt as updatedAt', 'plugin.updatedAt as updatedAt',
], ],
} as const; } as const;
export type LockableProperty = (typeof lockableProperties)[number];
export const lockableProperties = [
'description',
'dateTimeOriginal',
'latitude',
'longitude',
'rating',
'timeZone',
] as const;

View File

@@ -58,7 +58,7 @@ export class EnvDto {
IMMICH_MICROSERVICES_METRICS_PORT?: number; IMMICH_MICROSERVICES_METRICS_PORT?: number;
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
IMMICH_PLUGINS_ENABLED?: boolean; IMMICH_ALLOW_EXTERNAL_PLUGINS?: boolean;
@Optional() @Optional()
@Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' }) @Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' })
@@ -113,6 +113,9 @@ export class EnvDto {
@Optional() @Optional()
IMMICH_THIRD_PARTY_SUPPORT_URL?: string; IMMICH_THIRD_PARTY_SUPPORT_URL?: string;
@ValidateBoolean({ optional: true })
IMMICH_ALLOW_SETUP?: boolean;
@IsIPRange({ requireCIDR: false }, { each: true }) @IsIPRange({ requireCIDR: false }, { each: true })
@Transform(({ value }) => @Transform(({ value }) =>
value && typeof value === 'string' value && typeof value === 'string'

View File

@@ -50,11 +50,9 @@ select
where where
"asset"."id" = "tag_asset"."assetId" "asset"."id" = "tag_asset"."assetId"
) as agg ) as agg
) as "tags", ) as "tags"
to_json("asset_exif") as "exifInfo"
from from
"asset" "asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."id" = $2::uuid "asset"."id" = $2::uuid
limit limit
@@ -226,14 +224,6 @@ from
where where
"asset"."id" = $2 "asset"."id" = $2
-- AssetJobRepository.getLockedPropertiesForMetadataExtraction
select
"asset_exif"."lockedProperties"
from
"asset_exif"
where
"asset_exif"."assetId" = $1
-- AssetJobRepository.getAlbumThumbnailFiles -- AssetJobRepository.getAlbumThumbnailFiles
select select
"asset_file"."id", "asset_file"."id",

View File

@@ -1,49 +1,19 @@
-- NOTE: This file is auto generated by ./sql-generator -- NOTE: This file is auto generated by ./sql-generator
-- AssetRepository.upsertExif
insert into
"asset_exif" ("dateTimeOriginal", "lockedProperties")
values
($1, $2)
on conflict ("assetId") do update
set
"dateTimeOriginal" = "excluded"."dateTimeOriginal",
"lockedProperties" = nullif(
array(
select distinct
unnest("asset_exif"."lockedProperties" || $3)
),
'{}'
)
-- AssetRepository.updateAllExif -- AssetRepository.updateAllExif
update "asset_exif" update "asset_exif"
set set
"model" = $1, "model" = $1
"lockedProperties" = nullif(
array(
select distinct
unnest("asset_exif"."lockedProperties" || $2)
),
'{}'
)
where where
"assetId" in ($3) "assetId" in ($2)
-- AssetRepository.updateDateTimeOriginal -- AssetRepository.updateDateTimeOriginal
update "asset_exif" update "asset_exif"
set set
"dateTimeOriginal" = "dateTimeOriginal" + $1::interval, "dateTimeOriginal" = "dateTimeOriginal" + $1::interval,
"timeZone" = $2, "timeZone" = $2
"lockedProperties" = nullif(
array(
select distinct
unnest("asset_exif"."lockedProperties" || $3)
),
'{}'
)
where where
"assetId" in ($4) "assetId" in ($3)
returning returning
"assetId", "assetId",
"dateTimeOriginal", "dateTimeOriginal",

View File

@@ -50,7 +50,6 @@ export class AssetJobRepository {
.whereRef('asset.id', '=', 'tag_asset.assetId'), .whereRef('asset.id', '=', 'tag_asset.assetId'),
).as('tags'), ).as('tags'),
) )
.$call(withExifInner)
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
} }
@@ -129,16 +128,6 @@ export class AssetJobRepository {
.executeTakeFirst(); .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.UUID] })
async getLockedPropertiesForMetadataExtraction(assetId: string) {
return this.db
.selectFrom('asset_exif')
.select('asset_exif.lockedProperties')
.where('asset_exif.assetId', '=', assetId)
.executeTakeFirst()
.then((row) => row?.lockedProperties ?? []);
}
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Thumbnail] }) @GenerateSql({ params: [DummyValue.UUID, AssetFileType.Thumbnail] })
getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) { getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) {
return this.db return this.db

View File

@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely'; import { Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
import { isEmpty, isUndefined, omitBy } from 'lodash'; import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database'; import { Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
@@ -113,77 +113,51 @@ interface GetByIdsRelations {
tags?: boolean; tags?: boolean;
} }
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
@Injectable() @Injectable()
export class AssetRepository { export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ async upsertExif(exif: Insertable<AssetExifTable>): Promise<void> {
params: [ const value = { ...exif, assetId: asUuid(exif.assetId) };
{ dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] },
{ lockedPropertiesBehavior: 'append' },
],
})
async upsertExif(
exif: Insertable<AssetExifTable>,
{ lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'override' | 'append' | 'skip' },
): Promise<void> {
await this.db await this.db
.insertInto('asset_exif') .insertInto('asset_exif')
.values(exif) .values(value)
.onConflict((oc) => .onConflict((oc) =>
oc.column('assetId').doUpdateSet((eb) => { oc.column('assetId').doUpdateSet((eb) =>
const updateLocked = <T extends keyof AssetExifTable>(col: T) => eb.ref(`excluded.${col}`); removeUndefinedKeys(
const skipLocked = <T extends keyof AssetExifTable>(col: T) =>
eb
.case()
.when(sql`${col}`, '=', eb.fn.any('asset_exif.lockedProperties'))
.then(eb.ref(`asset_exif.${col}`))
.else(eb.ref(`excluded.${col}`))
.end();
const ref = lockedPropertiesBehavior === 'skip' ? skipLocked : updateLocked;
return {
...removeUndefinedKeys(
{ {
description: ref('description'), description: eb.ref('excluded.description'),
exifImageWidth: ref('exifImageWidth'), exifImageWidth: eb.ref('excluded.exifImageWidth'),
exifImageHeight: ref('exifImageHeight'), exifImageHeight: eb.ref('excluded.exifImageHeight'),
fileSizeInByte: ref('fileSizeInByte'), fileSizeInByte: eb.ref('excluded.fileSizeInByte'),
orientation: ref('orientation'), orientation: eb.ref('excluded.orientation'),
dateTimeOriginal: ref('dateTimeOriginal'), dateTimeOriginal: eb.ref('excluded.dateTimeOriginal'),
modifyDate: ref('modifyDate'), modifyDate: eb.ref('excluded.modifyDate'),
timeZone: ref('timeZone'), timeZone: eb.ref('excluded.timeZone'),
latitude: ref('latitude'), latitude: eb.ref('excluded.latitude'),
longitude: ref('longitude'), longitude: eb.ref('excluded.longitude'),
projectionType: ref('projectionType'), projectionType: eb.ref('excluded.projectionType'),
city: ref('city'), city: eb.ref('excluded.city'),
livePhotoCID: ref('livePhotoCID'), livePhotoCID: eb.ref('excluded.livePhotoCID'),
autoStackId: ref('autoStackId'), autoStackId: eb.ref('excluded.autoStackId'),
state: ref('state'), state: eb.ref('excluded.state'),
country: ref('country'), country: eb.ref('excluded.country'),
make: ref('make'), make: eb.ref('excluded.make'),
model: ref('model'), model: eb.ref('excluded.model'),
lensModel: ref('lensModel'), lensModel: eb.ref('excluded.lensModel'),
fNumber: ref('fNumber'), fNumber: eb.ref('excluded.fNumber'),
focalLength: ref('focalLength'), focalLength: eb.ref('excluded.focalLength'),
iso: ref('iso'), iso: eb.ref('excluded.iso'),
exposureTime: ref('exposureTime'), exposureTime: eb.ref('excluded.exposureTime'),
profileDescription: ref('profileDescription'), profileDescription: eb.ref('excluded.profileDescription'),
colorspace: ref('colorspace'), colorspace: eb.ref('excluded.colorspace'),
bitsPerSample: ref('bitsPerSample'), bitsPerSample: eb.ref('excluded.bitsPerSample'),
rating: ref('rating'), rating: eb.ref('excluded.rating'),
fps: ref('fps'), fps: eb.ref('excluded.fps'),
lockedProperties:
lockedPropertiesBehavior === 'append'
? distinctLocked(eb, exif.lockedProperties ?? null)
: ref('lockedProperties'),
}, },
exif, value,
),
), ),
};
}),
) )
.execute(); .execute();
} }
@@ -195,26 +169,19 @@ export class AssetRepository {
return; return;
} }
await this.db await this.db.updateTable('asset_exif').set(options).where('assetId', 'in', ids).execute();
.updateTable('asset_exif')
.set((eb) => ({
...options,
lockedProperties: distinctLocked(eb, Object.keys(options) as LockableProperty[]),
}))
.where('assetId', 'in', ids)
.execute();
} }
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] }) @GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] })
@Chunked() @Chunked()
updateDateTimeOriginal(ids: string[], delta?: number, timeZone?: string) { async updateDateTimeOriginal(
return this.db ids: string[],
delta?: number,
timeZone?: string,
): Promise<{ assetId: string; dateTimeOriginal: Date | null; timeZone: string | null }[]> {
return await this.db
.updateTable('asset_exif') .updateTable('asset_exif')
.set((eb) => ({ .set({ dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`, timeZone })
dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`,
timeZone,
lockedProperties: distinctLocked(eb, ['dateTimeOriginal', 'timeZone']),
}))
.where('assetId', 'in', ids) .where('assetId', 'in', ids)
.returning(['assetId', 'dateTimeOriginal', 'timeZone']) .returning(['assetId', 'dateTimeOriginal', 'timeZone'])
.execute(); .execute();

View File

@@ -8,6 +8,8 @@ const getEnv = () => {
const resetEnv = () => { const resetEnv = () => {
for (const env of [ for (const env of [
'IMMICH_ALLOW_EXTERNAL_PLUGINS',
'IMMICH_ALLOW_SETUP',
'IMMICH_ENV', 'IMMICH_ENV',
'IMMICH_WORKERS_INCLUDE', 'IMMICH_WORKERS_INCLUDE',
'IMMICH_WORKERS_EXCLUDE', 'IMMICH_WORKERS_EXCLUDE',
@@ -75,6 +77,9 @@ describe('getEnv', () => {
configFile: undefined, configFile: undefined,
logLevel: undefined, logLevel: undefined,
}); });
expect(config.plugins.external).toEqual({ allow: false });
expect(config.setup).toEqual({ allow: true });
}); });
describe('IMMICH_MEDIA_LOCATION', () => { describe('IMMICH_MEDIA_LOCATION', () => {
@@ -84,6 +89,32 @@ describe('getEnv', () => {
}); });
}); });
describe('IMMICH_ALLOW_EXTERNAL_PLUGINS', () => {
it('should disable plugins', () => {
process.env.IMMICH_ALLOW_EXTERNAL_PLUGINS = 'false';
const config = getEnv();
expect(config.plugins.external).toEqual({ allow: false });
});
it('should throw an error for invalid value', () => {
process.env.IMMICH_ALLOW_EXTERNAL_PLUGINS = 'invalid';
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_EXTERNAL_PLUGINS must be a boolean value');
});
});
describe('IMMICH_ALLOW_SETUP', () => {
it('should disable setup', () => {
process.env.IMMICH_ALLOW_SETUP = 'false';
const { setup } = getEnv();
expect(setup).toEqual({ allow: false });
});
it('should throw an error for invalid value', () => {
process.env.IMMICH_ALLOW_SETUP = 'invalid';
expect(() => getEnv()).toThrowError('IMMICH_ALLOW_SETUP must be a boolean value');
});
});
describe('database', () => { describe('database', () => {
it('should use defaults', () => { it('should use defaults', () => {
const { database } = getEnv(); const { database } = getEnv();

View File

@@ -90,6 +90,10 @@ export interface EnvData {
redis: RedisOptions; redis: RedisOptions;
setup: {
allow: boolean;
};
telemetry: { telemetry: {
apiPort: number; apiPort: number;
microservicesPort: number; microservicesPort: number;
@@ -104,9 +108,11 @@ export interface EnvData {
workers: ImmichWorker[]; workers: ImmichWorker[];
plugins: { plugins: {
enabled: boolean; external: {
allow: boolean;
installFolder?: string; installFolder?: string;
}; };
};
noColor: boolean; noColor: boolean;
nodeVersion?: string; nodeVersion?: string;
@@ -313,6 +319,10 @@ const getEnv = (): EnvData => {
corePlugin: join(buildFolder, 'corePlugin'), corePlugin: join(buildFolder, 'corePlugin'),
}, },
setup: {
allow: dto.IMMICH_ALLOW_SETUP ?? true,
},
storage: { storage: {
ignoreMountCheckErrors: !!dto.IMMICH_IGNORE_MOUNT_CHECK_ERRORS, ignoreMountCheckErrors: !!dto.IMMICH_IGNORE_MOUNT_CHECK_ERRORS,
mediaLocation: dto.IMMICH_MEDIA_LOCATION, mediaLocation: dto.IMMICH_MEDIA_LOCATION,
@@ -327,9 +337,11 @@ const getEnv = (): EnvData => {
workers, workers,
plugins: { plugins: {
enabled: !!dto.IMMICH_PLUGINS_ENABLED, external: {
allow: dto.IMMICH_ALLOW_EXTERNAL_PLUGINS ?? false,
installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER, installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER,
}, },
},
noColor: !!dto.NO_COLOR, noColor: !!dto.NO_COLOR,
}; };

View File

@@ -1,9 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_exif" ADD "lockedProperties" character varying[];`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_exif" DROP COLUMN "lockedProperties";`.execute(db);
}

View File

@@ -1,4 +1,3 @@
import { LockableProperty } from 'src/database';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools'; import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools';
@@ -98,7 +97,4 @@ export class AssetExifTable {
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
@Column({ type: 'character varying', array: true, nullable: true })
lockedProperties!: Array<LockableProperty> | null;
} }

View File

@@ -370,10 +370,7 @@ export class AssetMediaService extends BaseService {
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar })); : this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif( await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
{ assetId, fileSizeInByte: file.size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({ await this.jobRepository.queue({
name: JobName.AssetExtractMetadata, name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' }, data: { id: assetId, source: 'upload' },
@@ -402,10 +399,7 @@ export class AssetMediaService extends BaseService {
}); });
const { size } = await this.storageRepository.stat(created.originalPath); const { size } = await this.storageRepository.stat(created.originalPath);
await this.assetRepository.upsertExif( await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size });
{ assetId: created.id, fileSizeInByte: size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } }); await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
return created; return created;
} }
@@ -446,10 +440,7 @@ export class AssetMediaService extends BaseService {
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
} }
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif( await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
{ assetId: asset.id, fileSizeInByte: file.size },
{ lockedPropertiesBehavior: 'override' },
);
await this.eventRepository.emit('AssetCreate', { asset }); await this.eventRepository.emit('AssetCreate', { asset });

View File

@@ -225,10 +225,7 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' }); await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
{ assetId: 'asset-1', description: 'Test description', lockedProperties: ['description'] },
{ lockedPropertiesBehavior: 'append' },
);
}); });
it('should update the exif rating', async () => { it('should update the exif rating', async () => {
@@ -238,14 +235,7 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, 'asset-1', { rating: 3 }); await sut.update(authStub.admin, 'asset-1', { rating: 3 });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 });
{
assetId: 'asset-1',
rating: 3,
lockedProperties: ['rating'],
},
{ lockedPropertiesBehavior: 'append' },
);
}); });
it('should fail linking a live video if the motion part could not be found', async () => { it('should fail linking a live video if the motion part could not be found', async () => {
@@ -437,7 +427,9 @@ describe(AssetService.name, () => {
}); });
expect(mocks.asset.updateAll).toHaveBeenCalled(); expect(mocks.asset.updateAll).toHaveBeenCalled();
expect(mocks.asset.updateAllExif).toHaveBeenCalledWith(['asset-1'], { latitude: 0, longitude: 0 }); expect(mocks.asset.updateAllExif).toHaveBeenCalledWith(['asset-1'], { latitude: 0, longitude: 0 });
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: 'asset-1' } }]); expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.SidecarWrite, data: { id: 'asset-1', latitude: 0, longitude: 0 } },
]);
}); });
it('should update exif table if latitude field is provided', async () => { it('should update exif table if latitude field is provided', async () => {
@@ -458,7 +450,9 @@ describe(AssetService.name, () => {
latitude: 30, latitude: 30,
longitude: 50, longitude: 50,
}); });
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: 'asset-1' } }]); expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.SidecarWrite, data: { id: 'asset-1', dateTimeOriginal, latitude: 30, longitude: 50 } },
]);
}); });
it('should update Assets table if duplicateId is provided as null', async () => { it('should update Assets table if duplicateId is provided as null', async () => {
@@ -488,7 +482,18 @@ describe(AssetService.name, () => {
timeZone, timeZone,
}); });
expect(mocks.asset.updateDateTimeOriginal).toHaveBeenCalledWith(['asset-1'], dateTimeRelative, timeZone); expect(mocks.asset.updateDateTimeOriginal).toHaveBeenCalledWith(['asset-1'], dateTimeRelative, timeZone);
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: 'asset-1' } }]); expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.SidecarWrite,
data: {
id: 'asset-1',
dateTimeOriginal: '2020-02-25T06:41:00.000+02:00',
description: undefined,
latitude: undefined,
longitude: undefined,
},
},
]);
}); });
}); });

View File

@@ -30,10 +30,9 @@ import {
QueueName, QueueName,
} from 'src/enum'; } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types'; import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access'; import { requireElevatedPermission } from 'src/utils/access';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
import { updateLockedColumns } from 'src/utils/database';
@Injectable() @Injectable()
export class AssetService extends BaseService { export class AssetService extends BaseService {
@@ -143,26 +142,56 @@ export class AssetService extends BaseService {
} = dto; } = dto;
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids }); await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
const assetDto = _.omitBy({ isFavorite, visibility, duplicateId }, _.isUndefined); const assetDto = { isFavorite, visibility, duplicateId };
const exifDto = _.omitBy({ latitude, longitude, rating, description, dateTimeOriginal }, _.isUndefined); const exifDto = { latitude, longitude, rating, description, dateTimeOriginal };
if (Object.keys(exifDto).length > 0) { const isExifChanged = Object.values(exifDto).some((v) => v !== undefined);
if (isExifChanged) {
await this.assetRepository.updateAllExif(ids, exifDto); await this.assetRepository.updateAllExif(ids, exifDto);
} }
if ((dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined) { const assets =
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone); (dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined
? await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone)
: undefined;
const dateTimesWithTimezone = assets
? assets.map((asset) => {
const isoString = asset.dateTimeOriginal?.toISOString();
let dateTime = isoString ? DateTime.fromISO(isoString) : null;
if (dateTime && asset.timeZone) {
dateTime = dateTime.setZone(asset.timeZone);
} }
if (Object.keys(assetDto).length > 0) { return {
await this.assetRepository.updateAll(ids, assetDto); assetId: asset.assetId,
dateTimeOriginal: dateTime?.toISO() ?? null,
};
})
: ids.map((id) => ({ assetId: id, dateTimeOriginal }));
if (dateTimesWithTimezone.length > 0) {
await this.jobRepository.queueAll(
dateTimesWithTimezone.map(({ assetId: id, dateTimeOriginal }) => ({
name: JobName.SidecarWrite,
data: {
...exifDto,
id,
dateTimeOriginal: dateTimeOriginal ?? undefined,
},
})),
);
} }
const isAssetChanged = Object.values(assetDto).some((v) => v !== undefined);
if (isAssetChanged) {
await this.assetRepository.updateAll(ids, assetDto);
if (visibility === AssetVisibility.Locked) { if (visibility === AssetVisibility.Locked) {
await this.albumRepository.removeAssetsFromAll(ids); await this.albumRepository.removeAssetsFromAll(ids);
} }
}
await this.jobRepository.queueAll(ids.map((id) => ({ name: JobName.SidecarWrite, data: { id } })));
} }
async copy( async copy(
@@ -427,25 +456,12 @@ export class AssetService extends BaseService {
return asset; return asset;
} }
private async updateExif(dto: { private async updateExif(dto: ISidecarWriteJob) {
id: string;
description?: string;
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
rating?: number;
}) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
if (Object.keys(writes).length > 0) { if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif( await this.assetRepository.upsertExif({ assetId: id, ...writes });
updateLockedColumns({ await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id, ...writes } });
assetId: id,
...writes,
}),
{ lockedPropertiesBehavior: 'append' },
);
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } });
} }
} }
} }

View File

@@ -165,6 +165,11 @@ export class AuthService extends BaseService {
} }
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> { async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
const { setup } = this.configRepository.getEnv();
if (!setup.allow) {
throw new BadRequestException('Admin setup is disabled');
}
const adminUser = await this.userRepository.getAdmin(); const adminUser = await this.userRepository.getAdmin();
if (adminUser) { if (adminUser) {
throw new BadRequestException('The server already has an admin'); throw new BadRequestException('The server already has an admin');

View File

@@ -187,9 +187,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.sidecar.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.sidecar.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }), { expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }));
lockedPropertiesBehavior: 'skip',
});
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: assetStub.image.id, id: assetStub.image.id,
@@ -216,7 +214,6 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), expect.objectContaining({ dateTimeOriginal: fileModifiedAt }),
{ lockedPropertiesBehavior: 'skip' },
); );
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
@@ -241,10 +238,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt }));
expect.objectContaining({ dateTimeOriginal: fileCreatedAt }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
duration: null, duration: null,
@@ -264,7 +258,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({ expect.objectContaining({
dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'), dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'),
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
@@ -288,9 +281,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }), { expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }));
lockedPropertiesBehavior: 'skip',
});
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id, id: assetStub.image.id,
duration: null, duration: null,
@@ -319,7 +310,6 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ city: null, state: null, country: null }), expect.objectContaining({ city: null, state: null, country: null }),
{ lockedPropertiesBehavior: 'skip' },
); );
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.withLocation.id, id: assetStub.withLocation.id,
@@ -349,7 +339,6 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
{ lockedPropertiesBehavior: 'skip' },
); );
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.withLocation.id, id: assetStub.withLocation.id,
@@ -369,10 +358,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
expect.objectContaining({ latitude: null, longitude: null }),
{ lockedPropertiesBehavior: 'skip' },
);
}); });
it('should extract tags from TagsList', async () => { it('should extract tags from TagsList', async () => {
@@ -585,7 +571,6 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }),
{ lockedPropertiesBehavior: 'skip' },
); );
}); });
@@ -894,8 +879,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith({
{
assetId: assetStub.image.id, assetId: assetStub.image.id,
bitsPerSample: expect.any(Number), bitsPerSample: expect.any(Number),
autoStackId: null, autoStackId: null,
@@ -925,9 +909,7 @@ describe(MetadataService.name, () => {
country: null, country: null,
state: null, state: null,
city: null, city: null,
}, });
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: assetStub.image.id, id: assetStub.image.id,
@@ -961,7 +943,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({ expect.objectContaining({
timeZone: 'UTC+0', timeZone: 'UTC+0',
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
}); });
@@ -1122,7 +1103,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({ expect.objectContaining({
description: '', description: '',
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
mockReadTags({ ImageDescription: ' my\n description' }); mockReadTags({ ImageDescription: ' my\n description' });
@@ -1131,7 +1111,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({ expect.objectContaining({
description: 'my\n description', description: 'my\n description',
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
}); });
@@ -1144,7 +1123,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({ expect.objectContaining({
description: '1000', description: '1000',
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
}); });
@@ -1368,7 +1346,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({ expect.objectContaining({
modifyDate: expect.any(Date), modifyDate: expect.any(Date),
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
}); });
@@ -1381,7 +1358,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({ expect.objectContaining({
rating: null, rating: null,
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
}); });
@@ -1394,7 +1370,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({ expect.objectContaining({
rating: 5, rating: 5,
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
}); });
@@ -1407,7 +1382,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({ expect.objectContaining({
rating: -1, rating: -1,
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
}); });
@@ -1529,9 +1503,7 @@ describe(MetadataService.name, () => {
mockReadTags(exif); mockReadTags(exif);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected), { expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected));
lockedPropertiesBehavior: 'skip',
});
}); });
it.each([ it.each([
@@ -1557,7 +1529,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({ expect.objectContaining({
lensModel: expected, lensModel: expected,
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
}); });
}); });
@@ -1666,14 +1637,12 @@ describe(MetadataService.name, () => {
describe('handleSidecarWrite', () => { describe('handleSidecarWrite', () => {
it('should skip assets that no longer exist', async () => { it('should skip assets that no longer exist', async () => {
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0); mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0);
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed); await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed);
expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
}); });
it('should skip jobs with no metadata', async () => { it('should skip jobs with no metadata', async () => {
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]);
const asset = factory.jobAssets.sidecarWrite(); const asset = factory.jobAssets.sidecarWrite();
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Skipped); await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
@@ -1686,22 +1655,20 @@ describe(MetadataService.name, () => {
const gps = 12; const gps = 12;
const date = '2023-11-22T04:56:12.196Z'; const date = '2023-11-22T04:56:12.196Z';
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([
'description',
'latitude',
'longitude',
'dateTimeOriginal',
]);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect( await expect(
sut.handleSidecarWrite({ sut.handleSidecarWrite({
id: asset.id, id: asset.id,
description,
latitude: gps,
longitude: gps,
dateTimeOriginal: date,
}), }),
).resolves.toBe(JobStatus.Success); ).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, {
DateTimeOriginal: date,
Description: description, Description: description,
ImageDescription: description, ImageDescription: description,
DateTimeOriginal: date,
GPSLatitude: gps, GPSLatitude: gps,
GPSLongitude: gps, GPSLongitude: gps,
}); });

View File

@@ -290,7 +290,7 @@ export class MetadataService extends BaseService {
}; };
const promises: Promise<unknown>[] = [ const promises: Promise<unknown>[] = [
this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }), this.assetRepository.upsertExif(exifData),
this.assetRepository.update({ this.assetRepository.update({
id: asset.id, id: asset.id,
duration: this.getDuration(exifTags), duration: this.getDuration(exifTags),
@@ -393,34 +393,22 @@ export class MetadataService extends BaseService {
@OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar }) @OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar })
async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> { async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> {
const { id, tags } = job; const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
const asset = await this.assetJobRepository.getForSidecarWriteJob(id); const asset = await this.assetJobRepository.getForSidecarWriteJob(id);
if (!asset) { if (!asset) {
return JobStatus.Failed; return JobStatus.Failed;
} }
const lockedProperties = await this.assetJobRepository.getLockedPropertiesForMetadataExtraction(id);
const tagsList = (asset.tags || []).map((tag) => tag.value); const tagsList = (asset.tags || []).map((tag) => tag.value);
const { sidecarFile } = getAssetFiles(asset.files); const { sidecarFile } = getAssetFiles(asset.files);
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`; const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
const { description, dateTimeOriginal, latitude, longitude, rating } = _.pick(
{
description: asset.exifInfo.description,
dateTimeOriginal: asset.exifInfo.dateTimeOriginal,
latitude: asset.exifInfo.latitude,
longitude: asset.exifInfo.longitude,
rating: asset.exifInfo.rating,
},
lockedProperties,
);
const exif = _.omitBy( const exif = _.omitBy(
<Tags>{ <Tags>{
Description: description, Description: description,
ImageDescription: description, ImageDescription: description,
DateTimeOriginal: dateTimeOriginal ? String(dateTimeOriginal) : undefined, DateTimeOriginal: dateTimeOriginal,
GPSLatitude: latitude, GPSLatitude: latitude,
GPSLongitude: longitude, GPSLongitude: longitude,
Rating: rating, Rating: rating,

View File

@@ -80,8 +80,8 @@ export class PluginService extends BaseService {
this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`); this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`);
// Load external plugins // Load external plugins
if (plugins.enabled && plugins.installFolder) { if (plugins.external.allow && plugins.external.installFolder) {
await this.loadExternalPlugins(plugins.installFolder); await this.loadExternalPlugins(plugins.external.installFolder);
} }
} }

View File

@@ -115,8 +115,9 @@ export class ServerService extends BaseService {
} }
async getSystemConfig(): Promise<ServerConfigDto> { async getSystemConfig(): Promise<ServerConfigDto> {
const { setup } = this.configRepository.getEnv();
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
const isInitialized = await this.userRepository.hasAdmin(); const isInitialized = !setup.allow || (await this.userRepository.hasAdmin());
const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding); const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding);
return { return {

View File

@@ -222,6 +222,11 @@ export interface IDeleteFilesJob extends IBaseJob {
} }
export interface ISidecarWriteJob extends IEntityJob { export interface ISidecarWriteJob extends IEntityJob {
description?: string;
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
rating?: number;
tags?: true; tags?: true;
} }

View File

@@ -15,7 +15,7 @@ import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { parse } from 'pg-connection-string'; import { parse } from 'pg-connection-string';
import postgres, { Notice, PostgresError } from 'postgres'; import postgres, { Notice, PostgresError } from 'postgres';
import { columns, Exif, lockableProperties, LockableProperty, Person } from 'src/database'; import { columns, Exif, Person } from 'src/database';
import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
@@ -488,10 +488,3 @@ export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: V
} }
} }
} }
export const updateLockedColumns = <T extends Record<string, unknown> & { lockedProperties?: LockableProperty[] }>(
exif: T,
) => {
exif.lockedProperties = lockableProperties.filter((property) => property in exif);
return exif;
};

View File

@@ -202,7 +202,7 @@ export class MediumTestContext<S extends BaseService = BaseService> {
} }
async newExif(dto: Insertable<AssetExifTable>) { async newExif(dto: Insertable<AssetExifTable>) {
const result = await this.get(AssetRepository).upsertExif(dto, { lockedPropertiesBehavior: 'override' }); const result = await this.get(AssetRepository).upsertExif(dto);
return { result }; return { result };
} }

View File

@@ -268,65 +268,4 @@ describe(AssetService.name, () => {
}); });
}); });
}); });
describe('update', () => {
it('should update dateTimeOriginal', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: 'test' });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: null });
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00' }),
}),
);
});
});
describe('updateAll', () => {
it('should relatively update assets', async () => {
const { sut, ctx } = setup();
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' });
await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11 });
await expect(
ctx.database
.selectFrom('asset_exif')
.select('lockedProperties')
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] });
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({
dateTimeOriginal: '2023-11-19T18:00:00+00:00',
}),
}),
);
});
});
}); });

View File

@@ -95,7 +95,6 @@ describe(MetadataService.name, () => {
dateTimeOriginal: new Date(expected.dateTimeOriginal), dateTimeOriginal: new Date(expected.dateTimeOriginal),
timeZone: expected.timeZone, timeZone: expected.timeZone,
}), }),
{ lockedPropertiesBehavior: 'skip' },
); );
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(

View File

@@ -2,7 +2,6 @@ import { Kysely } from 'kysely';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { updateLockedColumns } from 'src/utils/database';
import { SyncTestContext } from 'test/medium.factory'; import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
import { getKyselyDB, wait } from 'test/utils'; import { getKyselyDB, wait } from 'test/utils';
@@ -289,13 +288,10 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
// update the asset // update the asset
const assetRepository = ctx.get(AssetRepository); const assetRepository = ctx.get(AssetRepository);
await assetRepository.upsertExif( await assetRepository.upsertExif({
updateLockedColumns({
assetId: asset.id, assetId: asset.id,
city: 'New City', city: 'New City',
}), });
{ lockedPropertiesBehavior: 'append' },
);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([ await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
{ {
@@ -350,13 +346,10 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
// update the asset // update the asset
const assetRepository = ctx.get(AssetRepository); const assetRepository = ctx.get(AssetRepository);
await assetRepository.upsertExif( await assetRepository.upsertExif({
updateLockedColumns({
assetId: assetDelayedExif.id, assetId: assetDelayedExif.id,
city: 'Delayed Exif', city: 'Delayed Exif',
}), });
{ lockedPropertiesBehavior: 'append' },
);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([ await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
{ {

View File

@@ -75,6 +75,10 @@ const envData: EnvData = {
corePlugin: '/build/corePlugin', corePlugin: '/build/corePlugin',
}, },
setup: {
allow: true,
},
storage: { storage: {
ignoreMountCheckErrors: false, ignoreMountCheckErrors: false,
}, },
@@ -88,9 +92,11 @@ const envData: EnvData = {
workers: [ImmichWorker.Api, ImmichWorker.Microservices], workers: [ImmichWorker.Api, ImmichWorker.Microservices],
plugins: { plugins: {
enabled: true, external: {
allow: true,
installFolder: '/app/data/plugins', installFolder: '/app/data/plugins',
}, },
},
noColor: false, noColor: false,
}; };

View File

@@ -4,7 +4,6 @@ import {
AuthApiKey, AuthApiKey,
AuthSharedLink, AuthSharedLink,
AuthUser, AuthUser,
Exif,
Library, Library,
Memory, Memory,
Partner, Partner,
@@ -320,10 +319,8 @@ const versionHistoryFactory = () => ({
version: '1.123.45', version: '1.123.45',
}); });
const assetSidecarWriteFactory = () => { const assetSidecarWriteFactory = () => ({
const id = newUuid(); id: newUuid(),
return {
id,
originalPath: '/path/to/original-path.jpg.xmp', originalPath: '/path/to/original-path.jpg.xmp',
tags: [], tags: [],
files: [ files: [
@@ -333,15 +330,7 @@ const assetSidecarWriteFactory = () => {
type: AssetFileType.Sidecar, type: AssetFileType.Sidecar,
}, },
], ],
exifInfo: { });
assetId: id,
description: 'this is a description',
latitude: 12,
longitude: 12,
dateTimeOriginal: '2023-11-22T04:56:12.196Z',
} as unknown as Exif,
};
};
const assetOcrFactory = ( const assetOcrFactory = (
ocr: { ocr: {