merge: remote-tracking branch 'immich/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-01-06 14:19:34 +00:00
368 changed files with 18243 additions and 6152 deletions

View File

@@ -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 });

View File

@@ -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' } }]);
});
});

View File

@@ -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 } });
}
}
}

View File

@@ -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');

View File

@@ -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}$/),

View File

@@ -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,
});

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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);
});

View File

@@ -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(',') || [];

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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) {