mirror of
https://github.com/immich-app/immich.git
synced 2025-12-28 09:14:59 +03:00
merge: remote-tracking branch 'immich/main' into feat/integrity-checks-izzy
This commit is contained in:
@@ -669,7 +669,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should not allow a shared user with viewer access to add assets', async () => {
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([]));
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set());
|
||||
mocks.album.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -174,7 +174,6 @@ const assetEntity = Object.freeze({
|
||||
longitude: 10.703_075,
|
||||
},
|
||||
livePhotoVideoId: null,
|
||||
sidecarPath: null,
|
||||
} as MapAsset);
|
||||
|
||||
const existingAsset = Object.freeze({
|
||||
@@ -188,7 +187,6 @@ const existingAsset = Object.freeze({
|
||||
|
||||
const sidecarAsset = Object.freeze({
|
||||
...existingAsset,
|
||||
sidecarPath: 'sidecar-path',
|
||||
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
|
||||
}) as MapAsset;
|
||||
|
||||
@@ -721,18 +719,22 @@ describe(AssetMediaService.name, () => {
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: existingAsset.id,
|
||||
sidecarPath: null,
|
||||
originalFileName: 'photo1.jpeg',
|
||||
originalPath: 'fake_path/photo1.jpeg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sidecarPath: null,
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetId: existingAsset.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
@@ -769,6 +771,13 @@ describe(AssetMediaService.name, () => {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetId: existingAsset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
type: AssetFileType.Sidecar,
|
||||
}),
|
||||
);
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
@@ -798,6 +807,12 @@ describe(AssetMediaService.name, () => {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetId: existingAsset.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
}),
|
||||
);
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
@@ -827,6 +842,9 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [updatedFile.originalPath, undefined] },
|
||||
|
||||
@@ -21,7 +21,16 @@ import {
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
CacheControl,
|
||||
JobName,
|
||||
Permission,
|
||||
StorageFolder,
|
||||
} from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { UploadFile, UploadRequest } from 'src/types';
|
||||
@@ -354,9 +363,12 @@ export class AssetMediaService extends BaseService {
|
||||
duration: dto.duration || null,
|
||||
|
||||
livePhotoVideoId: null,
|
||||
sidecarPath: sidecarPath || null,
|
||||
});
|
||||
|
||||
await (sidecarPath
|
||||
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
|
||||
: 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.jobRepository.queue({
|
||||
@@ -384,7 +396,6 @@ export class AssetMediaService extends BaseService {
|
||||
localDateTime: asset.localDateTime,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
sidecarPath: asset.sidecarPath,
|
||||
});
|
||||
|
||||
const { size } = await this.storageRepository.stat(created.originalPath);
|
||||
@@ -414,7 +425,6 @@ export class AssetMediaService extends BaseService {
|
||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: dto.filename || file.originalName,
|
||||
sidecarPath: sidecarFile?.originalPath,
|
||||
});
|
||||
|
||||
if (dto.metadata) {
|
||||
@@ -422,6 +432,11 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
|
||||
@@ -585,8 +585,6 @@ describe(AssetService.name, () => {
|
||||
'/uploads/user-id/webp/path.ext',
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
'/uploads/user-id/fullsize/path.webp',
|
||||
assetWithFace.encodedVideoPath,
|
||||
assetWithFace.sidecarPath,
|
||||
assetWithFace.originalPath,
|
||||
],
|
||||
},
|
||||
@@ -648,8 +646,6 @@ describe(AssetService.name, () => {
|
||||
'/uploads/user-id/webp/path.ext',
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
'/uploads/user-id/fullsize/path.webp',
|
||||
undefined,
|
||||
undefined,
|
||||
'fake_path/asset_1.jpeg',
|
||||
],
|
||||
},
|
||||
@@ -676,8 +672,6 @@ describe(AssetService.name, () => {
|
||||
'/uploads/user-id/webp/path.ext',
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
'/uploads/user-id/fullsize/path.webp',
|
||||
undefined,
|
||||
undefined,
|
||||
'fake_path/asset_1.jpeg',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import _ from 'lodash';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
@@ -18,7 +19,16 @@ import {
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import { AssetMetadataKey, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetMetadataKey,
|
||||
AssetStatus,
|
||||
AssetVisibility,
|
||||
JobName,
|
||||
JobStatus,
|
||||
Permission,
|
||||
QueueName,
|
||||
} from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
||||
import { requireElevatedPermission } from 'src/utils/access';
|
||||
@@ -197,8 +207,8 @@ export class AssetService extends BaseService {
|
||||
}: AssetCopyDto,
|
||||
) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetCopy, ids: [sourceId, targetId] });
|
||||
const sourceAsset = await this.assetRepository.getById(sourceId);
|
||||
const targetAsset = await this.assetRepository.getById(targetId);
|
||||
const sourceAsset = await this.assetRepository.getForCopy(sourceId);
|
||||
const targetAsset = await this.assetRepository.getForCopy(targetId);
|
||||
|
||||
if (!sourceAsset || !targetAsset) {
|
||||
throw new BadRequestException('Both assets must exist');
|
||||
@@ -252,19 +262,25 @@ export class AssetService extends BaseService {
|
||||
sourceAsset,
|
||||
targetAsset,
|
||||
}: {
|
||||
sourceAsset: { sidecarPath: string | null };
|
||||
targetAsset: { id: string; sidecarPath: string | null; originalPath: string };
|
||||
sourceAsset: { files: AssetFile[] };
|
||||
targetAsset: { id: string; files: AssetFile[]; originalPath: string };
|
||||
}) {
|
||||
if (!sourceAsset.sidecarPath) {
|
||||
const { sidecarFile: sourceFile } = getAssetFiles(sourceAsset.files);
|
||||
if (!sourceFile?.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetAsset.sidecarPath) {
|
||||
await this.storageRepository.unlink(targetAsset.sidecarPath);
|
||||
const { sidecarFile: targetFile } = getAssetFiles(targetAsset.files ?? []);
|
||||
if (targetFile?.path) {
|
||||
await this.storageRepository.unlink(targetFile.path);
|
||||
}
|
||||
|
||||
await this.storageRepository.copyFile(sourceAsset.sidecarPath, `${targetAsset.originalPath}.xmp`);
|
||||
await this.assetRepository.update({ id: targetAsset.id, sidecarPath: `${targetAsset.originalPath}.xmp` });
|
||||
await this.storageRepository.copyFile(sourceFile.path, `${targetAsset.originalPath}.xmp`);
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: targetAsset.id,
|
||||
path: `${targetAsset.originalPath}.xmp`,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: targetAsset.id } });
|
||||
}
|
||||
|
||||
@@ -344,14 +360,14 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files ?? []);
|
||||
const { fullsizeFile, previewFile, thumbnailFile, sidecarFile } = getAssetFiles(asset.files ?? []);
|
||||
const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
|
||||
|
||||
if (deleteOnDisk) {
|
||||
files.push(asset.sidecarPath, asset.originalPath);
|
||||
if (deleteOnDisk && !asset.isOffline) {
|
||||
files.push(sidecarFile?.path, asset.originalPath);
|
||||
}
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files } });
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: files.filter(Boolean) } });
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export class BackupService extends BaseService {
|
||||
const newBackupStyle = file.match(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/);
|
||||
return oldBackupStyle || newBackupStyle;
|
||||
})
|
||||
.sort()
|
||||
.toSorted()
|
||||
.toReversed();
|
||||
|
||||
const toDelete = backups.slice(config.keepLastAmount);
|
||||
|
||||
@@ -278,7 +278,7 @@ describe(LibraryService.name, () => {
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
|
||||
|
||||
const response = await sut.handleQueueSyncAssets({ id: library.id });
|
||||
|
||||
@@ -296,7 +296,7 @@ describe(LibraryService.name, () => {
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
|
||||
|
||||
const response = await sut.handleQueueSyncAssets({ id: library.id });
|
||||
|
||||
@@ -311,7 +311,7 @@ describe(LibraryService.name, () => {
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external]));
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(0) });
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 0n });
|
||||
mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external]));
|
||||
|
||||
const response = await sut.handleQueueSyncAssets({ id: library.id });
|
||||
|
||||
@@ -223,7 +223,14 @@ export class LibraryService extends BaseService {
|
||||
ownerId: dto.ownerId,
|
||||
name: dto.name ?? 'New External Library',
|
||||
importPaths: dto.importPaths ?? [],
|
||||
exclusionPatterns: dto.exclusionPatterns ?? ['**/@eaDir/**', '**/._*', '**/#recycle/**', '**/#snapshot/**'],
|
||||
exclusionPatterns: dto.exclusionPatterns ?? [
|
||||
'**/@eaDir/**',
|
||||
'**/._*',
|
||||
'**/#recycle/**',
|
||||
'**/#snapshot/**',
|
||||
'**/.stversions/**',
|
||||
'**/.stfolder/**',
|
||||
],
|
||||
});
|
||||
return mapLibrary(library);
|
||||
}
|
||||
|
||||
@@ -174,8 +174,10 @@ export class MediaService extends BaseService {
|
||||
thumbhash: Buffer;
|
||||
};
|
||||
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
||||
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateVideoThumbnails(asset);
|
||||
} else if (asset.type === AssetType.Image) {
|
||||
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateImageThumbnails(asset);
|
||||
} else {
|
||||
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
||||
@@ -561,7 +563,7 @@ export class MediaService extends BaseService {
|
||||
private getMainStream<T extends VideoStreamInfo | AudioStreamInfo>(streams: T[]): T {
|
||||
return streams
|
||||
.filter((stream) => stream.codecName !== 'unknown')
|
||||
.sort((stream1, stream2) => stream2.bitrate - stream1.bitrate)[0];
|
||||
.toSorted((stream1, stream2) => stream2.bitrate - stream1.bitrate)[0];
|
||||
}
|
||||
|
||||
private getTranscodeTarget(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
|
||||
import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util';
|
||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||
|
||||
const DAYS = 3;
|
||||
|
||||
@@ -15,15 +15,6 @@ export class MemoryService extends BaseService {
|
||||
@OnJob({ name: JobName.MemoryGenerate, queue: QueueName.BackgroundTask })
|
||||
async onMemoriesCreate() {
|
||||
const users = await this.userRepository.getList({ withDeleted: false });
|
||||
const usersIds = await Promise.all(
|
||||
users.map((user) =>
|
||||
getMyPartnerIds({
|
||||
userId: user.id,
|
||||
repository: this.partnerRepository,
|
||||
timelineEnabled: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await this.databaseRepository.withLock(DatabaseLock.MemoryCreation, async () => {
|
||||
const state = await this.systemMetadataRepository.get(SystemMetadataKey.MemoriesState);
|
||||
@@ -38,7 +29,7 @@ export class MemoryService extends BaseService {
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
|
||||
await Promise.all(users.map((owner) => this.createOnThisDayMemories(owner.id, target)));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`);
|
||||
}
|
||||
@@ -51,10 +42,10 @@ export class MemoryService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
private async createOnThisDayMemories(ownerId: string, userIds: string[], target: DateTime) {
|
||||
private async createOnThisDayMemories(ownerId: string, target: DateTime) {
|
||||
const showAt = target.startOf('day').toISO();
|
||||
const hideAt = target.endOf('day').toISO();
|
||||
const memories = await this.assetRepository.getByDayOfYear([ownerId, ...userIds], target);
|
||||
const memories = await this.assetRepository.getByDayOfYear([ownerId], target);
|
||||
await Promise.all(
|
||||
memories.map(({ year, assets }) =>
|
||||
this.memoryRepository.create(
|
||||
|
||||
@@ -4,7 +4,16 @@ import { randomBytes } from 'node:crypto';
|
||||
import { Stats } from 'node:fs';
|
||||
import { defaults } from 'src/config';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
ExifOrientation,
|
||||
ImmichWorker,
|
||||
JobName,
|
||||
JobStatus,
|
||||
SourceType,
|
||||
} from 'src/enum';
|
||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
@@ -15,17 +24,24 @@ import { tagStub } from 'test/fixtures/tag.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const removeNonSidecarFiles = (asset: any) => {
|
||||
return {
|
||||
...asset,
|
||||
files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar),
|
||||
};
|
||||
};
|
||||
|
||||
const forSidecarJob = (
|
||||
asset: {
|
||||
id?: string;
|
||||
originalPath?: string;
|
||||
sidecarPath?: string | null;
|
||||
files?: { id: string; type: AssetFileType; path: string }[];
|
||||
} = {},
|
||||
) => {
|
||||
return {
|
||||
id: factory.uuid(),
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
sidecarPath: null,
|
||||
files: [],
|
||||
...asset,
|
||||
};
|
||||
};
|
||||
@@ -166,7 +182,7 @@ describe(MetadataService.name, () => {
|
||||
it('should handle a date in a sidecar file', async () => {
|
||||
const originalDate = new Date('2023-11-21T16:13:17.517Z');
|
||||
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@@ -185,7 +201,7 @@ describe(MetadataService.name, () => {
|
||||
it('should take the file modification date when missing exif and earlier than creation date', async () => {
|
||||
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
|
||||
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: fileModifiedAt,
|
||||
@@ -211,7 +227,7 @@ describe(MetadataService.name, () => {
|
||||
it('should take the file creation date when missing exif and earlier than modification date', async () => {
|
||||
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
|
||||
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: fileModifiedAt,
|
||||
@@ -234,7 +250,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should determine dateTimeOriginal regardless of the server time zone', async () => {
|
||||
process.env.TZ = 'America/Los_Angeles';
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
|
||||
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@@ -252,7 +268,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle lists of numbers', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: assetStub.image.fileModifiedAt,
|
||||
@@ -305,7 +321,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should apply reverse geocoding', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
|
||||
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
@@ -334,7 +350,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should discard latitude and longitude on null island', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
|
||||
mockReadTags({
|
||||
GPSLatitude: 0,
|
||||
GPSLongitude: 0,
|
||||
@@ -346,7 +362,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract tags from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mockReadTags({ TagsList: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -356,7 +372,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract hierarchy from TagsList', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -376,7 +392,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract tags from Keywords as a string', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mockReadTags({ Keywords: 'Parent' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -386,7 +402,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract tags from Keywords as a list', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mockReadTags({ Keywords: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -396,7 +412,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract tags from Keywords as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -407,7 +423,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract hierarchal tags from Keywords', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mockReadTags({ Keywords: 'Parent/Child' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -426,7 +442,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should ignore Keywords when TagsList is present', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -445,7 +461,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
|
||||
@@ -466,7 +482,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
|
||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
|
||||
@@ -1018,7 +1034,10 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should use Duration from exif', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
originalPath: '/original/path.webp',
|
||||
});
|
||||
mockReadTags({ Duration: 123 }, {});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@@ -1030,8 +1049,16 @@ describe(MetadataService.name, () => {
|
||||
it('should prefer Duration from exif over sidecar', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
sidecarPath: '/path/to/something',
|
||||
originalPath: '/original/path.webp',
|
||||
files: [
|
||||
{
|
||||
id: 'some-id',
|
||||
type: AssetFileType.Sidecar,
|
||||
path: '/path/to/something',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockReadTags({ Duration: 123 }, { Duration: 456 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
@@ -1040,6 +1067,16 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
|
||||
});
|
||||
|
||||
it('should ignore all Duration tags for definitely static images', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.imageDng);
|
||||
mockReadTags({ Duration: 123 }, { Duration: 456 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.imageDng.id });
|
||||
|
||||
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null }));
|
||||
});
|
||||
|
||||
it('should ignore Duration from exif for videos', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
|
||||
mockReadTags({ Duration: 123 }, {});
|
||||
@@ -1536,18 +1573,25 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should detect a new sidecar at .jpg.xmp', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', files: [] });
|
||||
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: `/path/to/IMG_123.jpg.xmp` });
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
path: '/path/to/IMG_123.jpg.xmp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect a new sidecar at .xmp', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
|
||||
const asset = forSidecarJob({
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [],
|
||||
});
|
||||
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
|
||||
@@ -1555,33 +1599,44 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: '/path/to/IMG_123.xmp' });
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
path: '/path/to/IMG_123.xmp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should unset sidecar path if file does not exist anymore', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg.xmp' });
|
||||
it('should unset sidecar path if file no longer exist', async () => {
|
||||
const asset = forSidecarJob({
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
|
||||
});
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(false);
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: null });
|
||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith({ assetId: asset.id, type: AssetFileType.Sidecar });
|
||||
});
|
||||
|
||||
it('should do nothing if the sidecar file still exists', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg' });
|
||||
const asset = forSidecarJob({
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
|
||||
});
|
||||
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSidecarWrite', () => {
|
||||
it('should skip assets that do not exist anymore', async () => {
|
||||
it('should skip assets that no longer exist', async () => {
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0);
|
||||
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed);
|
||||
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
|
||||
@@ -1610,7 +1665,7 @@ describe(MetadataService.name, () => {
|
||||
dateTimeOriginal: date,
|
||||
}),
|
||||
).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.sidecarPath, {
|
||||
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, {
|
||||
Description: description,
|
||||
ImageDescription: description,
|
||||
DateTimeOriginal: date,
|
||||
|
||||
@@ -8,9 +8,10 @@ import { constants } from 'node:fs/promises';
|
||||
import { join, parse } from 'node:path';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset, AssetFace } from 'src/database';
|
||||
import { Asset, AssetFace, AssetFile } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
DatabaseLock,
|
||||
@@ -29,7 +30,9 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { getAssetFiles } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isFaceImportEnabled } from 'src/utils/misc';
|
||||
import { upsertTags } from 'src/utils/tag';
|
||||
|
||||
@@ -37,7 +40,6 @@ import { upsertTags } from 'src/utils/tag';
|
||||
const EXIF_DATE_TAGS: Array<keyof ImmichTags> = [
|
||||
'SubSecDateTimeOriginal',
|
||||
'SubSecCreateDate',
|
||||
'SubSecMediaCreateDate',
|
||||
'DateTimeOriginal',
|
||||
'CreationDate',
|
||||
'CreateDate',
|
||||
@@ -360,17 +362,21 @@ export class MetadataService extends BaseService {
|
||||
break;
|
||||
}
|
||||
|
||||
const isChanged = sidecarPath !== asset.sidecarPath;
|
||||
const { sidecarFile } = getAssetFiles(asset.files);
|
||||
|
||||
const isChanged = sidecarPath !== sidecarFile?.path;
|
||||
|
||||
this.logger.debug(
|
||||
`Sidecar check found old=${asset.sidecarPath}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`,
|
||||
`Sidecar check found old=${sidecarFile?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
|
||||
if (!isChanged) {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
await this.assetRepository.update({ id: asset.id, sidecarPath });
|
||||
await (sidecarPath === null
|
||||
? this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar })
|
||||
: this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath }));
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
@@ -395,7 +401,9 @@ export class MetadataService extends BaseService {
|
||||
|
||||
const tagsList = (asset.tags || []).map((tag) => tag.value);
|
||||
|
||||
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
|
||||
const { sidecarFile } = getAssetFiles(asset.files);
|
||||
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
|
||||
|
||||
const exif = _.omitBy(
|
||||
<Tags>{
|
||||
Description: description,
|
||||
@@ -415,18 +423,19 @@ export class MetadataService extends BaseService {
|
||||
|
||||
await this.metadataRepository.writeTags(sidecarPath, exif);
|
||||
|
||||
if (!asset.sidecarPath) {
|
||||
await this.assetRepository.update({ id, sidecarPath });
|
||||
if (asset.files.length === 0) {
|
||||
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: sidecarPath });
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
private getSidecarCandidates({ sidecarPath, originalPath }: { sidecarPath: string | null; originalPath: string }) {
|
||||
private getSidecarCandidates({ files, originalPath }: { files: AssetFile[]; originalPath: string }) {
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (sidecarPath) {
|
||||
candidates.push(sidecarPath);
|
||||
const { sidecarFile } = getAssetFiles(files);
|
||||
if (sidecarFile?.path) {
|
||||
candidates.push(sidecarFile.path);
|
||||
}
|
||||
|
||||
const assetPath = parse(originalPath);
|
||||
@@ -457,14 +466,12 @@ export class MetadataService extends BaseService {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
private async getExifTags(asset: {
|
||||
originalPath: string;
|
||||
sidecarPath: string | null;
|
||||
type: AssetType;
|
||||
}): Promise<ImmichTags> {
|
||||
private async getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }): Promise<ImmichTags> {
|
||||
const { sidecarFile } = getAssetFiles(asset.files);
|
||||
|
||||
const [mediaTags, sidecarTags, videoTags] = await Promise.all([
|
||||
this.metadataRepository.readTags(asset.originalPath),
|
||||
asset.sidecarPath ? this.metadataRepository.readTags(asset.sidecarPath) : null,
|
||||
sidecarFile ? this.metadataRepository.readTags(sidecarFile.path) : null,
|
||||
asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null,
|
||||
]);
|
||||
|
||||
@@ -480,7 +487,8 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
|
||||
// prefer duration from video tags
|
||||
if (videoTags) {
|
||||
// don't save duration if asset is definitely not an animated image (see e.g. CR3 with Duration: 1s)
|
||||
if (videoTags || !mimeTypes.isPossiblyAnimatedImage(asset.originalPath)) {
|
||||
delete mediaTags.Duration;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,21 @@ describe(OcrService.name, () => {
|
||||
({ sut, mocks } = newTestService(OcrService));
|
||||
|
||||
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
|
||||
mocks.assetJob.getForOcr.mockResolvedValue({
|
||||
visibility: AssetVisibility.Timeline,
|
||||
previewFile: assetStub.image.files[1].path,
|
||||
});
|
||||
});
|
||||
|
||||
const mockOcrResult = (...texts: string[]) => {
|
||||
mocks.machineLearning.ocr.mockResolvedValue({
|
||||
box: texts.flatMap((_, i) => Array.from({ length: 8 }, (_, j) => i * 10 + j)),
|
||||
boxScore: texts.map(() => 0.9),
|
||||
text: texts,
|
||||
textScore: texts.map(() => 0.95),
|
||||
});
|
||||
};
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
@@ -72,10 +85,6 @@ describe(OcrService.name, () => {
|
||||
text: ['One Two Three', 'Four Five'],
|
||||
textScore: [0.95, 0.85],
|
||||
});
|
||||
mocks.assetJob.getForOcr.mockResolvedValue({
|
||||
visibility: AssetVisibility.Timeline,
|
||||
previewFile: assetStub.image.files[1].path,
|
||||
});
|
||||
|
||||
expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success);
|
||||
|
||||
@@ -88,36 +97,40 @@ describe(OcrService.name, () => {
|
||||
maxResolution: 736,
|
||||
}),
|
||||
);
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [
|
||||
{
|
||||
assetId: assetStub.image.id,
|
||||
boxScore: 0.9,
|
||||
text: 'One Two Three',
|
||||
textScore: 0.95,
|
||||
x1: 10,
|
||||
y1: 20,
|
||||
x2: 30,
|
||||
y2: 40,
|
||||
x3: 50,
|
||||
y3: 60,
|
||||
x4: 70,
|
||||
y4: 80,
|
||||
},
|
||||
{
|
||||
assetId: assetStub.image.id,
|
||||
boxScore: 0.8,
|
||||
text: 'Four Five',
|
||||
textScore: 0.85,
|
||||
x1: 90,
|
||||
y1: 100,
|
||||
x2: 110,
|
||||
y2: 120,
|
||||
x3: 130,
|
||||
y3: 140,
|
||||
x4: 150,
|
||||
y4: 160,
|
||||
},
|
||||
]);
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(
|
||||
assetStub.image.id,
|
||||
[
|
||||
{
|
||||
assetId: assetStub.image.id,
|
||||
boxScore: 0.9,
|
||||
text: 'One Two Three',
|
||||
textScore: 0.95,
|
||||
x1: 10,
|
||||
y1: 20,
|
||||
x2: 30,
|
||||
y2: 40,
|
||||
x3: 50,
|
||||
y3: 60,
|
||||
x4: 70,
|
||||
y4: 80,
|
||||
},
|
||||
{
|
||||
assetId: assetStub.image.id,
|
||||
boxScore: 0.8,
|
||||
text: 'Four Five',
|
||||
textScore: 0.85,
|
||||
x1: 90,
|
||||
y1: 100,
|
||||
x2: 110,
|
||||
y2: 120,
|
||||
x3: 130,
|
||||
y3: 140,
|
||||
x4: 150,
|
||||
y4: 160,
|
||||
},
|
||||
],
|
||||
'One Two Three Four Five',
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply config settings', async () => {
|
||||
@@ -133,11 +146,7 @@ describe(OcrService.name, () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.machineLearning.ocr.mockResolvedValue({ box: [], boxScore: [], text: [], textScore: [] });
|
||||
mocks.assetJob.getForOcr.mockResolvedValue({
|
||||
visibility: AssetVisibility.Timeline,
|
||||
previewFile: assetStub.image.files[1].path,
|
||||
});
|
||||
mockOcrResult();
|
||||
|
||||
expect(await sut.handleOcr({ id: assetStub.image.id })).toEqual(JobStatus.Success);
|
||||
|
||||
@@ -150,7 +159,7 @@ describe(OcrService.name, () => {
|
||||
maxResolution: 1500,
|
||||
}),
|
||||
);
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, []);
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, [], '');
|
||||
});
|
||||
|
||||
it('should skip invisible assets', async () => {
|
||||
@@ -173,5 +182,83 @@ describe(OcrService.name, () => {
|
||||
expect(mocks.machineLearning.ocr).not.toHaveBeenCalled();
|
||||
expect(mocks.ocr.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('search tokenization', () => {
|
||||
it('should generate bigrams for Chinese text', async () => {
|
||||
mockOcrResult('機器學習');
|
||||
|
||||
await sut.handleOcr({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 器學 學習');
|
||||
});
|
||||
|
||||
it('should generate bigrams for Japanese text', async () => {
|
||||
mockOcrResult('テスト');
|
||||
|
||||
await sut.handleOcr({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'テス スト');
|
||||
});
|
||||
|
||||
it('should generate bigrams for Korean text', async () => {
|
||||
mockOcrResult('한국어');
|
||||
|
||||
await sut.handleOcr({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '한국 국어');
|
||||
});
|
||||
|
||||
it('should pass through Latin text unchanged', async () => {
|
||||
mockOcrResult('Hello World');
|
||||
|
||||
await sut.handleOcr({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World');
|
||||
});
|
||||
|
||||
it('should handle mixed CJK and Latin text', async () => {
|
||||
mockOcrResult('機器學習Model');
|
||||
|
||||
await sut.handleOcr({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 器學 學習 Model');
|
||||
});
|
||||
|
||||
it('should handle year followed by CJK', async () => {
|
||||
mockOcrResult('2024年レポート');
|
||||
|
||||
await sut.handleOcr({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(
|
||||
assetStub.image.id,
|
||||
expect.any(Array),
|
||||
'2024 年レ レポ ポー ート',
|
||||
);
|
||||
});
|
||||
|
||||
it('should join multiple OCR boxes', async () => {
|
||||
mockOcrResult('機器', 'Learning');
|
||||
|
||||
await sut.handleOcr({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), '機器 Learning');
|
||||
});
|
||||
|
||||
it('should normalize whitespace', async () => {
|
||||
mockOcrResult(' Hello World ');
|
||||
|
||||
await sut.handleOcr({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'Hello World');
|
||||
});
|
||||
|
||||
it('should keep single CJK characters', async () => {
|
||||
mockOcrResult('A', '中', 'B');
|
||||
|
||||
await sut.handleOcr({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.ocr.upsert).toHaveBeenCalledWith(assetStub.image.id, expect.any(Array), 'A 中 B');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { OCR } from 'src/repositories/machine-learning.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { tokenizeForSearch } from 'src/utils/database';
|
||||
import { isOcrEnabled } from 'src/utils/misc';
|
||||
|
||||
@Injectable()
|
||||
@@ -53,8 +54,8 @@ export class OcrService extends BaseService {
|
||||
}
|
||||
|
||||
const ocrResults = await this.machineLearningRepository.ocr(asset.previewFile, machineLearning.ocr);
|
||||
|
||||
await this.ocrRepository.upsert(id, this.parseOcrResults(id, ocrResults));
|
||||
const { ocrDataList, searchText } = this.parseOcrResults(id, ocrResults);
|
||||
await this.ocrRepository.upsert(id, ocrDataList, searchText);
|
||||
|
||||
await this.assetRepository.upsertJobStatus({ assetId: id, ocrAt: new Date() });
|
||||
|
||||
@@ -64,7 +65,9 @@ export class OcrService extends BaseService {
|
||||
|
||||
private parseOcrResults(id: string, { box, boxScore, text, textScore }: OCR) {
|
||||
const ocrDataList = [];
|
||||
const searchTokens = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const rawText = text[i];
|
||||
const boxOffset = i * 8;
|
||||
ocrDataList.push({
|
||||
assetId: id,
|
||||
@@ -78,9 +81,11 @@ export class OcrService extends BaseService {
|
||||
y4: box[boxOffset + 7],
|
||||
boxScore: boxScore[i],
|
||||
textScore: textScore[i],
|
||||
text: text[i],
|
||||
text: rawText,
|
||||
});
|
||||
searchTokens.push(...tokenizeForSearch(rawText));
|
||||
}
|
||||
return ocrDataList;
|
||||
|
||||
return { ocrDataList, searchText: searchTokens.join(' ') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,9 +247,9 @@ export class PluginService extends BaseService {
|
||||
|
||||
private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise<boolean> {
|
||||
for (const workflowFilter of workflowFilters) {
|
||||
const filter = await this.pluginRepository.getFilter(workflowFilter.filterId);
|
||||
const filter = await this.pluginRepository.getFilter(workflowFilter.pluginFilterId);
|
||||
if (!filter) {
|
||||
this.logger.error(`Filter ${workflowFilter.filterId} not found`);
|
||||
this.logger.error(`Filter ${workflowFilter.pluginFilterId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -291,9 +291,9 @@ export class PluginService extends BaseService {
|
||||
|
||||
private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise<void> {
|
||||
for (const workflowAction of workflowActions) {
|
||||
const action = await this.pluginRepository.getAction(workflowAction.actionId);
|
||||
const action = await this.pluginRepository.getAction(workflowAction.pluginActionId);
|
||||
if (!action) {
|
||||
throw new Error(`Action ${workflowAction.actionId} not found`);
|
||||
throw new Error(`Action ${workflowAction.pluginActionId} not found`);
|
||||
}
|
||||
|
||||
const pluginInstance = this.loadedPlugins.get(action.pluginId);
|
||||
|
||||
@@ -6,10 +6,20 @@ import sanitize from 'sanitize-filename';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
|
||||
import { AssetPathType, AssetType, DatabaseLock, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
AssetType,
|
||||
DatabaseLock,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
StorageFolder,
|
||||
} from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobOf, StorageAsset } from 'src/types';
|
||||
import { getAssetFile } from 'src/utils/asset.util';
|
||||
import { getLivePhotoMotionFilename } from 'src/utils/file';
|
||||
|
||||
const storageTokens = {
|
||||
@@ -196,7 +206,7 @@ export class StorageTemplateService extends BaseService {
|
||||
}
|
||||
|
||||
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
|
||||
const { id, sidecarPath, originalPath, checksum, fileSizeInByte } = asset;
|
||||
const { id, originalPath, checksum, fileSizeInByte } = asset;
|
||||
const oldPath = originalPath;
|
||||
const newPath = await this.getTemplatePath(asset, metadata);
|
||||
|
||||
@@ -213,6 +223,8 @@ export class StorageTemplateService extends BaseService {
|
||||
newPath,
|
||||
assetInfo: { sizeInBytes: fileSizeInByte, checksum },
|
||||
});
|
||||
|
||||
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path;
|
||||
if (sidecarPath) {
|
||||
await this.storageCore.moveFile({
|
||||
entityId: id,
|
||||
|
||||
@@ -217,7 +217,7 @@ describe(TagService.name, () => {
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should handle invalid ids', async () => {
|
||||
mocks.tag.getAssetIds.mockResolvedValue(new Set([]));
|
||||
mocks.tag.getAssetIds.mockResolvedValue(new Set());
|
||||
await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
|
||||
{ id: 'asset-1', success: false, error: 'no_permission' },
|
||||
]);
|
||||
|
||||
@@ -78,13 +78,13 @@ export class WorkflowService extends BaseService {
|
||||
}
|
||||
|
||||
private async validateAndMapFilters(
|
||||
filters: Array<{ filterId: string; filterConfig?: any }>,
|
||||
filters: Array<{ pluginFilterId: string; filterConfig?: any }>,
|
||||
requiredContext: PluginContext,
|
||||
) {
|
||||
for (const dto of filters) {
|
||||
const filter = await this.pluginRepository.getFilter(dto.filterId);
|
||||
const filter = await this.pluginRepository.getFilter(dto.pluginFilterId);
|
||||
if (!filter) {
|
||||
throw new BadRequestException(`Invalid filter ID: ${dto.filterId}`);
|
||||
throw new BadRequestException(`Invalid filter ID: ${dto.pluginFilterId}`);
|
||||
}
|
||||
|
||||
if (!filter.supportedContexts.includes(requiredContext)) {
|
||||
@@ -95,20 +95,20 @@ export class WorkflowService extends BaseService {
|
||||
}
|
||||
|
||||
return filters.map((dto, index) => ({
|
||||
filterId: dto.filterId,
|
||||
pluginFilterId: dto.pluginFilterId,
|
||||
filterConfig: dto.filterConfig || null,
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
private async validateAndMapActions(
|
||||
actions: Array<{ actionId: string; actionConfig?: any }>,
|
||||
actions: Array<{ pluginActionId: string; actionConfig?: any }>,
|
||||
requiredContext: PluginContext,
|
||||
) {
|
||||
for (const dto of actions) {
|
||||
const action = await this.pluginRepository.getAction(dto.actionId);
|
||||
const action = await this.pluginRepository.getAction(dto.pluginActionId);
|
||||
if (!action) {
|
||||
throw new BadRequestException(`Invalid action ID: ${dto.actionId}`);
|
||||
throw new BadRequestException(`Invalid action ID: ${dto.pluginActionId}`);
|
||||
}
|
||||
if (!action.supportedContexts.includes(requiredContext)) {
|
||||
throw new BadRequestException(
|
||||
@@ -118,7 +118,7 @@ export class WorkflowService extends BaseService {
|
||||
}
|
||||
|
||||
return actions.map((dto, index) => ({
|
||||
actionId: dto.actionId,
|
||||
pluginActionId: dto.pluginActionId,
|
||||
actionConfig: dto.actionConfig || null,
|
||||
order: index,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user