mirror of
https://github.com/immich-app/immich.git
synced 2026-03-26 11:50:53 +03:00
merge: remote-tracking branch 'immich/main' into feat/integrity-checks-izzy
This commit is contained in:
@@ -370,7 +370,10 @@ export class AssetMediaService extends BaseService {
|
||||
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
|
||||
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId, fileSizeInByte: file.size },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.AssetExtractMetadata,
|
||||
data: { id: assetId, source: 'upload' },
|
||||
@@ -399,7 +402,10 @@ export class AssetMediaService extends BaseService {
|
||||
});
|
||||
|
||||
const { size } = await this.storageRepository.stat(created.originalPath);
|
||||
await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size });
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId: created.id, fileSizeInByte: size },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
|
||||
return created;
|
||||
}
|
||||
@@ -440,7 +446,10 @@ export class AssetMediaService extends BaseService {
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId: asset.id, fileSizeInByte: file.size },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
|
||||
await this.eventRepository.emit('AssetCreate', { asset });
|
||||
|
||||
|
||||
@@ -225,7 +225,10 @@ describe(AssetService.name, () => {
|
||||
|
||||
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{ assetId: 'asset-1', description: 'Test description', lockedProperties: ['description'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should update the exif rating', async () => {
|
||||
@@ -235,7 +238,14 @@ describe(AssetService.name, () => {
|
||||
|
||||
await sut.update(authStub.admin, 'asset-1', { rating: 3 });
|
||||
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{
|
||||
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 () => {
|
||||
@@ -427,9 +437,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalled();
|
||||
expect(mocks.asset.updateAllExif).toHaveBeenCalledWith(['asset-1'], { latitude: 0, longitude: 0 });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.SidecarWrite, data: { id: 'asset-1', latitude: 0, longitude: 0 } },
|
||||
]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: 'asset-1' } }]);
|
||||
});
|
||||
|
||||
it('should update exif table if latitude field is provided', async () => {
|
||||
@@ -450,9 +458,7 @@ describe(AssetService.name, () => {
|
||||
latitude: 30,
|
||||
longitude: 50,
|
||||
});
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.SidecarWrite, data: { id: 'asset-1', dateTimeOriginal, latitude: 30, longitude: 50 } },
|
||||
]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: 'asset-1' } }]);
|
||||
});
|
||||
|
||||
it('should update Assets table if duplicateId is provided as null', async () => {
|
||||
@@ -482,18 +488,7 @@ describe(AssetService.name, () => {
|
||||
timeZone,
|
||||
});
|
||||
expect(mocks.asset.updateDateTimeOriginal).toHaveBeenCalledWith(['asset-1'], dateTimeRelative, timeZone);
|
||||
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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarWrite, data: { id: 'asset-1' } }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,9 +30,10 @@ import {
|
||||
QueueName,
|
||||
} from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { requireElevatedPermission } from 'src/utils/access';
|
||||
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
|
||||
import { updateLockedColumns } from 'src/utils/database';
|
||||
|
||||
@Injectable()
|
||||
export class AssetService extends BaseService {
|
||||
@@ -142,56 +143,40 @@ export class AssetService extends BaseService {
|
||||
} = dto;
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
|
||||
|
||||
const assetDto = { isFavorite, visibility, duplicateId };
|
||||
const exifDto = { latitude, longitude, rating, description, dateTimeOriginal };
|
||||
const assetDto = _.omitBy({ isFavorite, visibility, duplicateId }, _.isUndefined);
|
||||
const exifDto = _.omitBy(
|
||||
{
|
||||
latitude,
|
||||
longitude,
|
||||
rating,
|
||||
description,
|
||||
dateTimeOriginal,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
|
||||
|
||||
const isExifChanged = Object.values(exifDto).some((v) => v !== undefined);
|
||||
if (isExifChanged) {
|
||||
if (Object.keys(exifDto).length > 0) {
|
||||
await this.assetRepository.updateAllExif(ids, exifDto);
|
||||
}
|
||||
|
||||
const assets =
|
||||
(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);
|
||||
}
|
||||
|
||||
return {
|
||||
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,
|
||||
},
|
||||
})),
|
||||
);
|
||||
if (
|
||||
(dateTimeRelative !== undefined && dateTimeRelative !== 0) ||
|
||||
timeZone !== undefined ||
|
||||
extractedTimeZone?.type === 'fixed'
|
||||
) {
|
||||
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone ?? extractedTimeZone?.name);
|
||||
}
|
||||
|
||||
const isAssetChanged = Object.values(assetDto).some((v) => v !== undefined);
|
||||
if (isAssetChanged) {
|
||||
if (Object.keys(assetDto).length > 0) {
|
||||
await this.assetRepository.updateAll(ids, assetDto);
|
||||
|
||||
if (visibility === AssetVisibility.Locked) {
|
||||
await this.albumRepository.removeAssetsFromAll(ids);
|
||||
}
|
||||
}
|
||||
|
||||
if (visibility === AssetVisibility.Locked) {
|
||||
await this.albumRepository.removeAssetsFromAll(ids);
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(ids.map((id) => ({ name: JobName.SidecarWrite, data: { id } })));
|
||||
}
|
||||
|
||||
async copy(
|
||||
@@ -456,12 +441,37 @@ export class AssetService extends BaseService {
|
||||
return asset;
|
||||
}
|
||||
|
||||
private async updateExif(dto: ISidecarWriteJob) {
|
||||
private async updateExif(dto: {
|
||||
id: string;
|
||||
description?: string;
|
||||
dateTimeOriginal?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
rating?: number;
|
||||
}) {
|
||||
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
|
||||
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
|
||||
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
|
||||
const writes = _.omitBy(
|
||||
{
|
||||
description,
|
||||
dateTimeOriginal,
|
||||
timeZone: extractedTimeZone?.type === 'fixed' ? extractedTimeZone.name : undefined,
|
||||
latitude,
|
||||
longitude,
|
||||
rating,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
|
||||
if (Object.keys(writes).length > 0) {
|
||||
await this.assetRepository.upsertExif({ assetId: id, ...writes });
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id, ...writes } });
|
||||
await this.assetRepository.upsertExif(
|
||||
updateLockedColumns({
|
||||
assetId: id,
|
||||
...writes,
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,11 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
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();
|
||||
if (adminUser) {
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
|
||||
@@ -89,6 +89,7 @@ describe(CliService.name, () => {
|
||||
alreadyDisabled: true,
|
||||
});
|
||||
|
||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -99,6 +100,7 @@ describe(CliService.name, () => {
|
||||
alreadyDisabled: false,
|
||||
});
|
||||
|
||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: false,
|
||||
});
|
||||
@@ -114,6 +116,7 @@ describe(CliService.name, () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
|
||||
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
@@ -126,6 +129,7 @@ describe(CliService.name, () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.app.sendOneShotAppRestart).toHaveBeenCalled();
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: true,
|
||||
secret: expect.stringMatching(/^\w{128}$/),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
@@ -55,8 +55,7 @@ export class CliService extends BaseService {
|
||||
|
||||
const state = { isMaintenanceMode: false as const };
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
|
||||
|
||||
sendOneShotAppRestart(state);
|
||||
await this.appRepository.sendOneShotAppRestart(state);
|
||||
|
||||
return {
|
||||
alreadyDisabled: false,
|
||||
@@ -89,7 +88,7 @@ export class CliService extends BaseService {
|
||||
secret,
|
||||
});
|
||||
|
||||
sendOneShotAppRestart({
|
||||
await this.appRepository.sendOneShotAppRestart({
|
||||
isMaintenanceMode: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { MaintenanceModeState } from 'src/types';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
||||
@@ -31,7 +32,10 @@ export class MaintenanceService extends BaseService {
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AppRestart', server: true })
|
||||
onRestart(): void {
|
||||
onRestart(event: ArgOf<'AppRestart'>, ack?: (ok: 'ok') => void): void {
|
||||
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
|
||||
|
||||
ack?.('ok');
|
||||
this.appRepository.exitApp();
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ export class MediaService extends BaseService {
|
||||
async handleGenerateThumbnails({ id }: JobOf<JobName.AssetGenerateThumbnails>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
||||
if (!asset) {
|
||||
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`);
|
||||
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export class MemoryService extends BaseService {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.log(`Creating memories for ${target.toISO()}`);
|
||||
try {
|
||||
await Promise.all(users.map((owner) => this.createOnThisDayMemories(owner.id, target)));
|
||||
} catch (error) {
|
||||
|
||||
@@ -187,7 +187,9 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.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.objectContaining({
|
||||
id: assetStub.image.id,
|
||||
@@ -214,6 +216,7 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dateTimeOriginal: fileModifiedAt }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.image.id,
|
||||
@@ -238,7 +241,10 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt }));
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dateTimeOriginal: fileCreatedAt }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.image.id,
|
||||
duration: null,
|
||||
@@ -258,6 +264,7 @@ describe(MetadataService.name, () => {
|
||||
expect.objectContaining({
|
||||
dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
@@ -281,7 +288,9 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: 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({
|
||||
id: assetStub.image.id,
|
||||
duration: null,
|
||||
@@ -310,6 +319,7 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ city: null, state: null, country: null }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.withLocation.id,
|
||||
@@ -339,6 +349,7 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: assetStub.withLocation.id,
|
||||
@@ -358,7 +369,10 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ latitude: null, longitude: null }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should extract tags from TagsList', async () => {
|
||||
@@ -571,6 +585,7 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -879,37 +894,40 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({
|
||||
assetId: assetStub.image.id,
|
||||
bitsPerSample: expect.any(Number),
|
||||
autoStackId: null,
|
||||
colorspace: tags.ColorSpace,
|
||||
dateTimeOriginal: dateForTest,
|
||||
description: tags.ImageDescription,
|
||||
exifImageHeight: null,
|
||||
exifImageWidth: null,
|
||||
exposureTime: tags.ExposureTime,
|
||||
fNumber: null,
|
||||
fileSizeInByte: 123_456,
|
||||
focalLength: tags.FocalLength,
|
||||
fps: null,
|
||||
iso: tags.ISO,
|
||||
latitude: null,
|
||||
lensModel: tags.LensModel,
|
||||
livePhotoCID: tags.MediaGroupUUID,
|
||||
longitude: null,
|
||||
make: tags.Make,
|
||||
model: tags.Model,
|
||||
modifyDate: expect.any(Date),
|
||||
orientation: tags.Orientation?.toString(),
|
||||
profileDescription: tags.ProfileDescription,
|
||||
projectionType: 'EQUIRECTANGULAR',
|
||||
timeZone: tags.tz,
|
||||
rating: tags.Rating,
|
||||
country: null,
|
||||
state: null,
|
||||
city: null,
|
||||
});
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
{
|
||||
assetId: assetStub.image.id,
|
||||
bitsPerSample: expect.any(Number),
|
||||
autoStackId: null,
|
||||
colorspace: tags.ColorSpace,
|
||||
dateTimeOriginal: dateForTest,
|
||||
description: tags.ImageDescription,
|
||||
exifImageHeight: null,
|
||||
exifImageWidth: null,
|
||||
exposureTime: tags.ExposureTime,
|
||||
fNumber: null,
|
||||
fileSizeInByte: 123_456,
|
||||
focalLength: tags.FocalLength,
|
||||
fps: null,
|
||||
iso: tags.ISO,
|
||||
latitude: null,
|
||||
lensModel: tags.LensModel,
|
||||
livePhotoCID: tags.MediaGroupUUID,
|
||||
longitude: null,
|
||||
make: tags.Make,
|
||||
model: tags.Model,
|
||||
modifyDate: expect.any(Date),
|
||||
orientation: tags.Orientation?.toString(),
|
||||
profileDescription: tags.ProfileDescription,
|
||||
projectionType: 'EQUIRECTANGULAR',
|
||||
timeZone: tags.tz,
|
||||
rating: tags.Rating,
|
||||
country: null,
|
||||
state: null,
|
||||
city: null,
|
||||
},
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: assetStub.image.id,
|
||||
@@ -943,6 +961,7 @@ describe(MetadataService.name, () => {
|
||||
expect.objectContaining({
|
||||
timeZone: 'UTC+0',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1103,6 +1122,7 @@ describe(MetadataService.name, () => {
|
||||
expect.objectContaining({
|
||||
description: '',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
|
||||
mockReadTags({ ImageDescription: ' my\n description' });
|
||||
@@ -1111,6 +1131,7 @@ describe(MetadataService.name, () => {
|
||||
expect.objectContaining({
|
||||
description: 'my\n description',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1123,6 +1144,7 @@ describe(MetadataService.name, () => {
|
||||
expect.objectContaining({
|
||||
description: '1000',
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1346,6 +1368,7 @@ describe(MetadataService.name, () => {
|
||||
expect.objectContaining({
|
||||
modifyDate: expect.any(Date),
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1358,6 +1381,7 @@ describe(MetadataService.name, () => {
|
||||
expect.objectContaining({
|
||||
rating: null,
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1370,6 +1394,7 @@ describe(MetadataService.name, () => {
|
||||
expect.objectContaining({
|
||||
rating: 5,
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1382,6 +1407,7 @@ describe(MetadataService.name, () => {
|
||||
expect.objectContaining({
|
||||
rating: -1,
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1503,7 +1529,9 @@ describe(MetadataService.name, () => {
|
||||
mockReadTags(exif);
|
||||
|
||||
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([
|
||||
@@ -1529,6 +1557,7 @@ describe(MetadataService.name, () => {
|
||||
expect.objectContaining({
|
||||
lensModel: expected,
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1637,12 +1666,14 @@ describe(MetadataService.name, () => {
|
||||
|
||||
describe('handleSidecarWrite', () => {
|
||||
it('should skip assets that no longer exist', async () => {
|
||||
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]);
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0);
|
||||
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed);
|
||||
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip jobs with no metadata', async () => {
|
||||
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]);
|
||||
const asset = factory.jobAssets.sidecarWrite();
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
|
||||
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
|
||||
@@ -1655,20 +1686,22 @@ describe(MetadataService.name, () => {
|
||||
const gps = 12;
|
||||
const date = '2023-11-22T04:56:12.196Z';
|
||||
|
||||
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([
|
||||
'description',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'dateTimeOriginal',
|
||||
]);
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
|
||||
await expect(
|
||||
sut.handleSidecarWrite({
|
||||
id: asset.id,
|
||||
description,
|
||||
latitude: gps,
|
||||
longitude: gps,
|
||||
dateTimeOriginal: date,
|
||||
}),
|
||||
).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, {
|
||||
DateTimeOriginal: date,
|
||||
Description: description,
|
||||
ImageDescription: description,
|
||||
DateTimeOriginal: date,
|
||||
GPSLatitude: gps,
|
||||
GPSLongitude: gps,
|
||||
});
|
||||
|
||||
@@ -290,7 +290,7 @@ export class MetadataService extends BaseService {
|
||||
};
|
||||
|
||||
const promises: Promise<unknown>[] = [
|
||||
this.assetRepository.upsertExif(exifData),
|
||||
this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }),
|
||||
this.assetRepository.update({
|
||||
id: asset.id,
|
||||
duration: this.getDuration(exifTags),
|
||||
@@ -366,9 +366,13 @@ export class MetadataService extends BaseService {
|
||||
|
||||
const isChanged = sidecarPath !== sidecarFile?.path;
|
||||
|
||||
this.logger.debug(
|
||||
`Sidecar check found old=${sidecarFile?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
if (sidecarFile?.path || sidecarPath) {
|
||||
this.logger.debug(
|
||||
`Sidecar check found old=${sidecarFile?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.verbose(`No sidecars found for asset ${asset.id}: ${asset.originalPath}`);
|
||||
}
|
||||
|
||||
if (!isChanged) {
|
||||
return JobStatus.Skipped;
|
||||
@@ -393,22 +397,34 @@ export class MetadataService extends BaseService {
|
||||
|
||||
@OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar })
|
||||
async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> {
|
||||
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
|
||||
const { id, tags } = job;
|
||||
const asset = await this.assetJobRepository.getForSidecarWriteJob(id);
|
||||
if (!asset) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const lockedProperties = await this.assetJobRepository.getLockedPropertiesForMetadataExtraction(id);
|
||||
const tagsList = (asset.tags || []).map((tag) => tag.value);
|
||||
|
||||
const { sidecarFile } = getAssetFiles(asset.files);
|
||||
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(
|
||||
<Tags>{
|
||||
Description: description,
|
||||
ImageDescription: description,
|
||||
DateTimeOriginal: dateTimeOriginal,
|
||||
DateTimeOriginal: dateTimeOriginal ? String(dateTimeOriginal) : undefined,
|
||||
GPSLatitude: latitude,
|
||||
GPSLongitude: longitude,
|
||||
Rating: rating,
|
||||
@@ -846,9 +862,13 @@ export class MetadataService extends BaseService {
|
||||
const result = firstDateTime(exifTags);
|
||||
const tag = result?.tag;
|
||||
const dateTime = result?.dateTime;
|
||||
this.logger.verbose(
|
||||
`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
if (dateTime) {
|
||||
this.logger.verbose(
|
||||
`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.verbose(`No exif date time information found for asset ${asset.id}: ${asset.originalPath}`);
|
||||
}
|
||||
|
||||
// timezone
|
||||
let timeZone = exifTags.tz ?? null;
|
||||
|
||||
@@ -6,8 +6,9 @@ import { join } from 'node:path';
|
||||
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
|
||||
import { pluginTriggers } from 'src/plugins';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { PluginHostFunctions } from 'src/services/plugin-host.functions';
|
||||
@@ -50,6 +51,10 @@ export class PluginService extends BaseService {
|
||||
await this.loadPlugins();
|
||||
}
|
||||
|
||||
getTriggers(): PluginTriggerResponseDto[] {
|
||||
return pluginTriggers;
|
||||
}
|
||||
|
||||
//
|
||||
// CRUD operations for plugins
|
||||
//
|
||||
@@ -80,8 +85,8 @@ export class PluginService extends BaseService {
|
||||
this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`);
|
||||
|
||||
// Load external plugins
|
||||
if (plugins.enabled && plugins.installFolder) {
|
||||
await this.loadExternalPlugins(plugins.installFolder);
|
||||
if (plugins.external.allow && plugins.external.installFolder) {
|
||||
await this.loadExternalPlugins(plugins.external.installFolder);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,8 +115,9 @@ export class ServerService extends BaseService {
|
||||
}
|
||||
|
||||
async getSystemConfig(): Promise<ServerConfigDto> {
|
||||
const { setup } = this.configRepository.getEnv();
|
||||
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);
|
||||
|
||||
return {
|
||||
|
||||
@@ -55,7 +55,8 @@ describe(SharedLinkService.name, () => {
|
||||
},
|
||||
});
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
|
||||
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
|
||||
const response = await sut.getMine(authDto, {});
|
||||
expect(response.assets[0]).toMatchObject({ hasMetadata: false });
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
mapSharedLink,
|
||||
mapSharedLinkWithoutMetadata,
|
||||
SharedLinkCreateDto,
|
||||
SharedLinkEditDto,
|
||||
SharedLinkPasswordDto,
|
||||
@@ -19,10 +18,10 @@ import { getExternalDomain, OpenGraphTags } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
export class SharedLinkService extends BaseService {
|
||||
async getAll(auth: AuthDto, { albumId }: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> {
|
||||
async getAll(auth: AuthDto, { id, albumId }: SharedLinkSearchDto): Promise<SharedLinkResponseDto[]> {
|
||||
return this.sharedLinkRepository
|
||||
.getAll({ userId: auth.user.id, albumId })
|
||||
.then((links) => links.map((link) => mapSharedLink(link)));
|
||||
.getAll({ userId: auth.user.id, id, albumId })
|
||||
.then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false })));
|
||||
}
|
||||
|
||||
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
|
||||
@@ -31,7 +30,7 @@ export class SharedLinkService extends BaseService {
|
||||
}
|
||||
|
||||
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
|
||||
const response = this.mapToSharedLink(sharedLink, { withExif: sharedLink.showExif });
|
||||
const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
|
||||
if (sharedLink.password) {
|
||||
response.token = this.validateAndRefreshToken(sharedLink, dto);
|
||||
}
|
||||
@@ -41,7 +40,7 @@ export class SharedLinkService extends BaseService {
|
||||
|
||||
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
|
||||
const sharedLink = await this.findOrFail(auth.user.id, id);
|
||||
return this.mapToSharedLink(sharedLink, { withExif: true });
|
||||
return mapSharedLink(sharedLink, { stripAssetMetadata: false });
|
||||
}
|
||||
|
||||
async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
|
||||
@@ -81,7 +80,7 @@ export class SharedLinkService extends BaseService {
|
||||
slug: dto.slug || null,
|
||||
});
|
||||
|
||||
return this.mapToSharedLink(sharedLink, { withExif: true });
|
||||
return mapSharedLink(sharedLink, { stripAssetMetadata: false });
|
||||
} catch (error) {
|
||||
this.handleError(error);
|
||||
}
|
||||
@@ -108,7 +107,7 @@ export class SharedLinkService extends BaseService {
|
||||
showExif: dto.showMetadata,
|
||||
slug: dto.slug || null,
|
||||
});
|
||||
return this.mapToSharedLink(sharedLink, { withExif: true });
|
||||
return mapSharedLink(sharedLink, { stripAssetMetadata: false });
|
||||
} catch (error) {
|
||||
this.handleError(error);
|
||||
}
|
||||
@@ -214,10 +213,6 @@ export class SharedLinkService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private mapToSharedLink(sharedLink: SharedLink, { withExif }: { withExif: boolean }) {
|
||||
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
|
||||
}
|
||||
|
||||
private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string {
|
||||
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
|
||||
const sharedLinkTokens = dto.token?.split(',') || [];
|
||||
|
||||
@@ -84,6 +84,7 @@ describe(StorageTemplateService.name, () => {
|
||||
'{{y}}/{{y}}-{{MM}}/{{assetId}}',
|
||||
'{{y}}/{{y}}-{{WW}}/{{assetId}}',
|
||||
'{{album}}/{{filename}}',
|
||||
'{{make}}/{{model}}/{{lensModel}}/{{filename}}',
|
||||
],
|
||||
secondOptions: ['s', 'ss', 'SSS'],
|
||||
weekOptions: ['W', 'WW'],
|
||||
@@ -615,6 +616,39 @@ describe(StorageTemplateService.name, () => {
|
||||
);
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should migrate live photo motion video alongside the still image', async () => {
|
||||
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
|
||||
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([stillAsset]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
|
||||
|
||||
mocks.move.create.mockResolvedValueOnce({
|
||||
id: '123',
|
||||
entityId: stillAsset.id,
|
||||
pathType: AssetPathType.Original,
|
||||
oldPath: stillAsset.originalPath,
|
||||
newPath: newStillPicturePath,
|
||||
});
|
||||
|
||||
mocks.move.create.mockResolvedValueOnce({
|
||||
id: '124',
|
||||
entityId: motionAsset.id,
|
||||
pathType: AssetPathType.Original,
|
||||
oldPath: motionAsset.originalPath,
|
||||
newPath: newMotionPicturePath,
|
||||
});
|
||||
|
||||
await sut.handleMigration();
|
||||
|
||||
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
||||
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(motionAsset.id);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
|
||||
});
|
||||
});
|
||||
|
||||
describe('file rename correctness', () => {
|
||||
|
||||
@@ -53,6 +53,7 @@ const storagePresets = [
|
||||
'{{y}}/{{y}}-{{MM}}/{{assetId}}',
|
||||
'{{y}}/{{y}}-{{WW}}/{{assetId}}',
|
||||
'{{album}}/{{filename}}',
|
||||
'{{make}}/{{model}}/{{lensModel}}/{{filename}}',
|
||||
];
|
||||
|
||||
export interface MoveAssetMetadata {
|
||||
@@ -67,6 +68,9 @@ interface RenderMetadata {
|
||||
albumName: string | null;
|
||||
albumStartDate: Date | null;
|
||||
albumEndDate: Date | null;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
lensModel: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -115,6 +119,9 @@ export class StorageTemplateService extends BaseService {
|
||||
albumName: 'album',
|
||||
albumStartDate: new Date(),
|
||||
albumEndDate: new Date(),
|
||||
make: 'FUJIFILM',
|
||||
model: 'X-T50',
|
||||
lensModel: 'XF27mm F2.8 R WR',
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`);
|
||||
@@ -181,6 +188,15 @@ export class StorageTemplateService extends BaseService {
|
||||
const storageLabel = user?.storageLabel || null;
|
||||
const filename = asset.originalFileName || asset.id;
|
||||
await this.moveAsset(asset, { storageLabel, filename });
|
||||
|
||||
// move motion part of live photo
|
||||
if (asset.livePhotoVideoId) {
|
||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
|
||||
if (livePhotoVideo) {
|
||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Cleaning up empty directories...');
|
||||
@@ -301,6 +317,9 @@ export class StorageTemplateService extends BaseService {
|
||||
albumName,
|
||||
albumStartDate,
|
||||
albumEndDate,
|
||||
make: asset.make,
|
||||
model: asset.model,
|
||||
lensModel: asset.lensModel,
|
||||
});
|
||||
const fullPath = path.normalize(path.join(rootPath, storagePath));
|
||||
let destination = `${fullPath}.${extension}`;
|
||||
@@ -365,7 +384,7 @@ export class StorageTemplateService extends BaseService {
|
||||
}
|
||||
|
||||
private render(template: HandlebarsTemplateDelegate<any>, options: RenderMetadata) {
|
||||
const { filename, extension, asset, albumName, albumStartDate, albumEndDate } = options;
|
||||
const { filename, extension, asset, albumName, albumStartDate, albumEndDate, make, model, lensModel } = options;
|
||||
const substitutions: Record<string, string> = {
|
||||
filename,
|
||||
ext: extension,
|
||||
@@ -375,6 +394,9 @@ export class StorageTemplateService extends BaseService {
|
||||
assetIdShort: asset.id.slice(-12),
|
||||
//just throw into the root if it doesn't belong to an album
|
||||
album: (albumName && sanitize(albumName.replaceAll(/\.+/g, ''))) || '',
|
||||
make: make ?? '',
|
||||
model: model ?? '',
|
||||
lensModel: lensModel ?? '',
|
||||
};
|
||||
|
||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
@@ -16,10 +16,10 @@ import { BaseService } from 'src/services/base.service';
|
||||
@Injectable()
|
||||
export class WorkflowService extends BaseService {
|
||||
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
|
||||
const trigger = this.getTriggerOrFail(dto.triggerType);
|
||||
const context = this.getContextForTrigger(dto.triggerType);
|
||||
|
||||
const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context);
|
||||
const actionInserts = await this.validateAndMapActions(dto.actions, trigger.context);
|
||||
const filterInserts = await this.validateAndMapFilters(dto.filters, context);
|
||||
const actionInserts = await this.validateAndMapActions(dto.actions, context);
|
||||
|
||||
const workflow = await this.workflowRepository.createWorkflow(
|
||||
{
|
||||
@@ -56,11 +56,11 @@ export class WorkflowService extends BaseService {
|
||||
}
|
||||
|
||||
const workflow = await this.findOrFail(id);
|
||||
const trigger = this.getTriggerOrFail(workflow.triggerType);
|
||||
const context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType);
|
||||
|
||||
const { filters, actions, ...workflowUpdate } = dto;
|
||||
const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context));
|
||||
const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context));
|
||||
const filterInserts = filters && (await this.validateAndMapFilters(filters, context));
|
||||
const actionInserts = actions && (await this.validateAndMapActions(actions, context));
|
||||
|
||||
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
|
||||
id,
|
||||
@@ -124,12 +124,12 @@ export class WorkflowService extends BaseService {
|
||||
}));
|
||||
}
|
||||
|
||||
private getTriggerOrFail(triggerType: PluginTriggerType) {
|
||||
const trigger = pluginTriggers.find((t) => t.type === triggerType);
|
||||
private getContextForTrigger(type: PluginTriggerType) {
|
||||
const trigger = pluginTriggers.find((t) => t.type === type);
|
||||
if (!trigger) {
|
||||
throw new BadRequestException(`Invalid trigger type: ${triggerType}`);
|
||||
throw new BadRequestException(`Invalid trigger type: ${type}`);
|
||||
}
|
||||
return trigger;
|
||||
return trigger.contextType;
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
|
||||
Reference in New Issue
Block a user