Merge branch 'main' of https://github.com/immich-app/immich into chore/originals-in-asset-files

This commit is contained in:
Jonathan Jogenfors
2025-12-02 20:58:50 +01:00
102 changed files with 1537 additions and 856 deletions
@@ -85,19 +85,6 @@ describe(AssetMediaController.name, () => {
expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON']));
});
it('should validate iCloudId is a string', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: JSON.stringify([{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 123 } }]),
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata.0.value.iCloudId must be a string']));
});
it('should require `deviceAssetId`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
-7
View File
@@ -154,13 +154,6 @@ export type StorageAsset = {
encodedVideoPath: string | null;
};
export type SidecarWriteAsset = {
id: string;
sidecarPath: string | null;
originalPath: string;
tags: Array<{ value: string }>;
};
export type Stack = {
id: string;
primaryAssetId: string;
+2 -14
View File
@@ -19,7 +19,6 @@ import {
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { AssetMetadata, AssetMetadataItem } from 'src/types';
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
export class DeviceIdDto {
@@ -154,23 +153,12 @@ export class AssetMetadataUpsertDto {
items!: AssetMetadataUpsertItemDto[];
}
export class AssetMetadataUpsertItemDto implements AssetMetadataItem {
export class AssetMetadataUpsertItemDto {
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
key!: AssetMetadataKey;
@IsObject()
@ValidateNested()
@Type((options) => {
switch (options?.object.key) {
case AssetMetadataKey.MobileApp: {
return AssetMetadataMobileAppDto;
}
default: {
return Object;
}
}
})
value!: AssetMetadata[AssetMetadataKey];
value!: object;
}
export class AssetMetadataMobileAppDto {
+27 -17
View File
@@ -484,21 +484,26 @@ select
"asset_exif"."fileSizeInByte",
(
select
"asset_file"."path"
coalesce(json_agg(agg), '[]')
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
limit
$2
) as "sidecarPath"
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."deletedAt" is null
and "asset"."id" = $3
and "asset"."id" = $2
-- AssetJobRepository.streamForStorageTemplateJob
select
@@ -515,15 +520,20 @@ select
"asset_exif"."fileSizeInByte",
(
select
"asset_file"."path"
coalesce(json_agg(agg), '[]')
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
limit
$2
) as "sidecarPath"
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
+28
View File
@@ -216,6 +216,34 @@ from
limit
3
-- AssetRepository.getForCopy
select
"id",
"stackId",
"originalPath",
"isFavorite",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
) as agg
) as "files"
from
"asset"
where
"id" = $1::uuid
limit
$2
-- AssetRepository.getById
select
"asset".*
@@ -6,7 +6,6 @@ import { Asset, columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { StorageAsset } from 'src/types';
import {
anyUuid,
asUuid,
@@ -324,15 +323,13 @@ export class AssetJobRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getForStorageTemplateJob(id: string): Promise<StorageAsset | undefined> {
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst() as Promise<
StorageAsset | undefined
>;
getForStorageTemplateJob(id: string) {
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForStorageTemplateJob() {
return this.storageTemplateAssetQuery().stream() as AsyncIterableIterator<StorageAsset>;
return this.storageTemplateAssetQuery().stream();
}
@GenerateSql({ params: [DummyValue.DATE], stream: true })
+31 -10
View File
@@ -11,7 +11,6 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AssetMetadataItem } from 'src/types';
import {
anyUuid,
asUuid,
@@ -228,7 +227,7 @@ export class AssetRepository {
.execute();
}
upsertMetadata(id: string, items: AssetMetadataItem[]) {
upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) {
return this.db
.insertInto('asset_metadata')
.values(items.map((item) => ({ assetId: id, ...item })))
@@ -256,8 +255,23 @@ export class AssetRepository {
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
}
create(asset: Insertable<AssetTable>) {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
create(asset: Insertable<AssetTable>, files?: Insertable<AssetFileTable>[]) {
return this.db.transaction().execute(async (trx) => {
const createdAsset = await trx.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
if (files && files.length > 0) {
const values = files.map((f) => ({ ...f, assetId: createdAsset.id }));
await trx.insertInto('asset_file').values(values).returningAll().execute();
}
const assetWithFiles = await trx
.selectFrom('asset')
.selectAll('asset')
.select(withOriginals)
.where('asset.id', '=', asUuid(createdAsset.id))
.executeTakeFirstOrThrow();
return assetWithFiles;
});
}
createAll(assets: Insertable<AssetTable>[]) {
@@ -403,6 +417,16 @@ export class AssetRepository {
return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute();
}
getForCopy(id: string) {
return this.db
.selectFrom('asset')
.select(['id', 'stackId', 'isFavorite'])
.select(withFiles)
.where('id', '=', asUuid(id))
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(
id: string,
@@ -488,6 +512,7 @@ export class AssetRepository {
return this.db
.selectFrom('asset')
.selectAll('asset')
.select(withOriginals)
.where('ownerId', '=', asUuid(ownerId))
.where('checksum', '=', checksum)
.$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null)))
@@ -854,12 +879,8 @@ export class AssetRepository {
.execute();
}
async deleteFile(file: Pick<Selectable<AssetFileTable>, 'assetId' | 'type'>): Promise<void> {
await this.db
.deleteFrom('asset_file')
.where('assetId', '=', asUuid(file.assetId))
.where('type', '=', file.type)
.execute();
async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise<void> {
await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute();
}
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
+2 -2
View File
@@ -45,12 +45,12 @@ export class OcrRepository {
textScore: DummyValue.NUMBER,
},
],
DummyValue.STRING,
],
})
upsert(assetId: string, ocrDataList: Insertable<AssetOcrTable>[]) {
upsert(assetId: string, ocrDataList: Insertable<AssetOcrTable>[], searchText: string) {
let query = this.db.with('deleted_ocr', (db) => db.deleteFrom('asset_ocr').where('assetId', '=', assetId));
if (ocrDataList.length > 0) {
const searchText = ocrDataList.map((item) => item.text.trim()).join(' ');
(query as any) = query
.with('inserted_ocr', (db) => db.insertInto('asset_ocr').values(ocrDataList))
.with('inserted_search', (db) =>
@@ -0,0 +1,31 @@
import { Kysely, sql } from 'kysely';
import { tokenizeForSearch } from 'src/utils/database';
export async function up(db: Kysely<any>): Promise<void> {
await sql`truncate ${sql.table('ocr_search')}`.execute(db);
let lastAssetId: string | undefined;
while (true) {
const rows = await db
.selectFrom('asset_ocr')
.select(['assetId', sql<string>`string_agg(text, ' ')`.as('text')])
.$if(lastAssetId !== undefined, (qb) => qb.where('assetId', '>', lastAssetId))
.groupBy('assetId')
.orderBy('assetId')
.limit(5000)
.execute();
if (rows.length === 0) {
break;
}
await db
.insertInto('ocr_search')
.values(rows.map(({ assetId, text }) => ({ assetId, text: tokenizeForSearch(text).join(' ') })))
.execute();
lastAssetId = rows.at(-1)!.assetId;
}
}
export async function down(): Promise<void> {}
@@ -0,0 +1,24 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`INSERT INTO "asset_file" ("assetId", "path", "type")
SELECT
id, "sidecarPath", 'sidecar'
FROM "asset"
WHERE "sidecarPath" IS NOT NULL AND "sidecarPath" != '';`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "sidecarPath";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" ADD "sidecarPath" character varying;`.execute(db);
await sql`
UPDATE "asset"
SET "sidecarPath" = "asset_file"."path"
FROM "asset_file"
WHERE "asset"."id" = "asset_file"."assetId" AND "asset_file"."type" = 'sidecar';
`.execute(db);
await sql`DELETE FROM "asset_file" WHERE "type" = 'sidecar';`.execute(db);
}
@@ -12,7 +12,6 @@ import {
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
import { AssetMetadata, AssetMetadataItem } from 'src/types';
@UpdatedAtTrigger('asset_metadata_updated_at')
@Table('asset_metadata')
@@ -22,7 +21,7 @@ import { AssetMetadata, AssetMetadataItem } from 'src/types';
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class AssetMetadataTable<T extends keyof AssetMetadata = AssetMetadataKey> implements AssetMetadataItem<T> {
export class AssetMetadataTable {
@ForeignKeyColumn(() => AssetTable, {
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
@@ -33,10 +32,10 @@ export class AssetMetadataTable<T extends keyof AssetMetadata = AssetMetadataKey
assetId!: string;
@PrimaryColumn({ type: 'character varying' })
key!: T;
key!: AssetMetadataKey;
@Column({ type: 'jsonb' })
value!: AssetMetadata[T];
value!: object;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@@ -187,7 +187,6 @@ const existingAsset = Object.freeze({
const sidecarAsset = Object.freeze({
...existingAsset,
sidecarPath: 'sidecar-path',
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
}) as MapAsset;
+1 -1
View File
@@ -366,7 +366,7 @@ export class AssetMediaService extends BaseService {
});
await (sidecarPath
? this.assetRepository.upsertFile({ assetId, path: sidecarPath, type: AssetFileType.Sidecar })
? 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));
+10 -17
View File
@@ -207,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, { files: true });
const targetAsset = await this.assetRepository.getById(targetId, { files: true });
const sourceAsset = await this.assetRepository.getForCopy(sourceId);
const targetAsset = await this.assetRepository.getForCopy(targetId);
if (!sourceAsset || !targetAsset) {
throw new BadRequestException('Both assets must exist');
@@ -262,27 +262,20 @@ export class AssetService extends BaseService {
sourceAsset,
targetAsset,
}: {
sourceAsset: { files?: AssetFile[] };
targetAsset: { id: string; files?: AssetFile[]; originalPath: string };
sourceAsset: { files: AssetFile[] };
targetAsset: { id: string; files: AssetFile[]; originalPath: string };
}) {
if (!sourceAsset.files) {
const { sidecarFile: sourceFile } = getAssetFiles(sourceAsset.files);
if (!sourceFile?.path) {
return;
}
const sourceSidecarPath = getAssetFiles(sourceAsset.files).sidecarFile?.path;
if (!sourceSidecarPath) {
return;
const { sidecarFile: targetFile } = getAssetFiles(targetAsset.files ?? []);
if (targetFile?.path) {
await this.storageRepository.unlink(targetFile.path);
}
if (targetAsset.files) {
const targetSidecar = getAssetFiles(targetAsset.files).sidecarFile;
if (targetSidecar) {
await this.storageRepository.unlink(targetSidecar.path);
}
}
await this.storageRepository.copyFile(sourceSidecarPath, `${targetAsset.originalPath}.xmp`);
await this.storageRepository.copyFile(sourceFile.path, `${targetAsset.originalPath}.xmp`);
await this.assetRepository.upsertFile({
assetId: targetAsset.id,
path: `${targetAsset.originalPath}.xmp`,
+8 -1
View File
@@ -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);
}
+8 -4
View File
@@ -167,6 +167,8 @@ export class MediaService extends BaseService {
return JobStatus.Skipped;
}
const { originalFile } = getAssetFiles(asset.files);
let generated: {
previewPath: string;
thumbnailPath: string;
@@ -174,9 +176,11 @@ export class MediaService extends BaseService {
thumbhash: Buffer;
};
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
generated = await this.generateVideoThumbnails(asset);
this.logger.verbose(`Thumbnail generation for video ${id} ${originalFile.path}`);
generated = await this.generateVideoThumbnails(asset, originalFile.path);
} else if (asset.type === AssetType.Image) {
generated = await this.generateImageThumbnails(asset);
this.logger.verbose(`Thumbnail generation for image ${id} ${originalFile.path}`);
generated = await this.generateImageThumbnails(asset, originalFile.path);
} else {
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
return JobStatus.Skipped;
@@ -421,13 +425,13 @@ export class MediaService extends BaseService {
};
}
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
private async generateVideoThumbnails(asset: ThumbnailPathEntity, originalPath: string) {
const { image, ffmpeg } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format);
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format);
this.storageCore.ensureFolders(previewPath);
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(originalPath);
const mainVideoStream = this.getMainStream(videoStreams);
if (!mainVideoStream) {
throw new Error(`No video streams found for asset ${asset.id}`);
+4 -13
View File
@@ -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(
+1 -1
View File
@@ -1651,7 +1651,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,
+58 -89
View File
@@ -224,11 +224,7 @@ export class MetadataService extends BaseService {
return;
}
const originalFile = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null;
if (!originalFile) {
this.logger.warn(`Asset ${asset.id} has no original file, skipping metadata extraction`);
return;
}
const { originalFile } = getAssetFiles(asset.files);
const [exifTags, stats] = await Promise.all([
this.getExifTags(asset),
@@ -307,11 +303,11 @@ export class MetadataService extends BaseService {
];
if (this.isMotionPhoto(asset, exifTags)) {
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
promises.push(this.applyMotionPhotos(asset, originalFile.path, exifTags, dates, stats));
}
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
promises.push(this.applyTaggedFaces(asset, exifTags));
promises.push(this.applyTaggedFaces(asset, originalFile.path, exifTags));
}
await Promise.all(promises);
@@ -357,7 +353,7 @@ export class MetadataService extends BaseService {
}
let sidecarPath = null;
for (const candidate of this.getSidecarCandidates(asset.files)) {
for (const candidate of this.getSidecarCandidates(asset)) {
const exists = await this.storageRepository.checkFileExists(candidate, constants.R_OK);
if (!exists) {
continue;
@@ -367,12 +363,12 @@ export class MetadataService extends BaseService {
break;
}
const existingSidecar = asset.files?.find((file) => file.type === AssetFileType.Sidecar) ?? null;
const { originalFile, sidecarFile } = getAssetFiles(asset.files);
const isChanged = sidecarPath !== existingSidecar?.path;
const isChanged = sidecarPath !== sidecarFile?.path;
this.logger.debug(
`Sidecar check found old=${existingSidecar?.path}, 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}: ${originalFile.path}`,
);
if (!isChanged) {
@@ -406,14 +402,9 @@ export class MetadataService extends BaseService {
const tagsList = (asset.tags || []).map((tag) => tag.value);
const existingSidecar = asset.files?.find((file) => file.type === AssetFileType.Sidecar) ?? null;
const original = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null;
const { originalFile, sidecarFile } = getAssetFiles(asset.files);
if (!original) {
throw new Error(`Asset ${asset.id} has no original file`);
}
const sidecarPath = existingSidecar?.path || `${original.path}.xmp`; // prefer file.jpg.xmp by default
const sidecarPath = sidecarFile?.path || `${originalFile.path}.xmp`; // prefer file.jpg.xmp by default
const exif = _.omitBy(
<Tags>{
Description: description,
@@ -440,21 +431,16 @@ export class MetadataService extends BaseService {
return JobStatus.Success;
}
private getSidecarCandidates(files: AssetFile[] | null) {
const original = files?.find((file) => file.type === AssetFileType.Original);
if (!original) {
return [];
}
private getSidecarCandidates({ id, files }: { id: string; files: AssetFile[] }) {
const candidates: string[] = [];
const existingSidecar = files?.find((file) => file.type === AssetFileType.Sidecar);
const { originalFile, sidecarFile } = getAssetFiles(files);
if (existingSidecar) {
candidates.push(existingSidecar.path);
if (sidecarFile?.path) {
candidates.push(sidecarFile.path);
}
const assetPath = parse(original.path);
const assetPath = parse(originalFile.path);
candidates.push(
// IMG_123.jpg.xmp
@@ -482,26 +468,8 @@ export class MetadataService extends BaseService {
return { width, height };
}
private async getExifTags(asset: { id; files: AssetFile[]; type: AssetType }): Promise<ImmichTags> {
const originalFile = asset.files?.find((file) => file.type === AssetFileType.Original) ?? null;
if (!originalFile) {
throw new Error(`Asset ${asset.id} has no original file`);
}
if (asset.type === AssetType.Image) {
const hasSidecar = asset.files?.some(({ type }) => type === AssetFileType.Sidecar);
if (!hasSidecar) {
return this.metadataRepository.readTags(originalFile.path);
}
}
if (asset.files && asset.files.length > 1) {
throw new Error(`Asset ${originalFile.path} has multiple sidecar files`);
}
const sidecarFile = asset.files ? getAssetFiles(asset.files).sidecarFile : undefined;
private async getExifTags(asset: { files: AssetFile[]; type: AssetType }): Promise<ImmichTags> {
const { originalFile, sidecarFile } = getAssetFiles(asset.files);
const [mediaTags, sidecarTags, videoTags] = await Promise.all([
this.metadataRepository.readTags(originalFile.path),
@@ -632,21 +600,29 @@ export class MetadataService extends BaseService {
if (!motionAsset) {
try {
const motionAssetId = this.cryptoRepository.randomUUID();
motionAsset = await this.assetRepository.create({
id: motionAssetId,
libraryId: asset.libraryId,
type: AssetType.Video,
fileCreatedAt: dates.dateTimeOriginal,
fileModifiedAt: stats.mtime,
localDateTime: dates.localDateTime,
checksum,
ownerId: asset.ownerId,
files: [{ type: AssetFileType.Original, path: StorageCore.getAndroidMotionPath(asset, motionAssetId) }],
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
visibility: AssetVisibility.Hidden,
deviceAssetId: 'NONE',
deviceId: 'NONE',
});
motionAsset = await this.assetRepository.create(
{
id: motionAssetId,
libraryId: asset.libraryId,
type: AssetType.Video,
fileCreatedAt: dates.dateTimeOriginal,
fileModifiedAt: stats.mtime,
localDateTime: dates.localDateTime,
checksum,
ownerId: asset.ownerId,
originalFileName: `${parse(originalPath).name}.mp4`,
visibility: AssetVisibility.Hidden,
deviceAssetId: 'NONE',
deviceId: 'NONE',
},
[
{
type: AssetFileType.Original,
assetId: motionAssetId,
path: StorageCore.getAndroidMotionPath(asset, motionAssetId),
},
],
);
isNewMotionAsset = true;
@@ -660,16 +636,18 @@ export class MetadataService extends BaseService {
motionAsset = await this.assetRepository.getByChecksum(checksumQuery);
if (!motionAsset) {
this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${asset.originalPath}`);
this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${originalPath}`);
return;
}
}
}
const { originalFile: originalMotionFile } = getAssetFiles(motionAsset.files);
if (!isNewMotionAsset) {
this.logger.debugFn(() => {
const base64Checksum = checksum.toString('base64');
return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${asset.originalPath}`;
return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${originalPath}`;
});
}
@@ -699,22 +677,18 @@ export class MetadataService extends BaseService {
}
// write extracted motion video to disk, especially if the encoded-video folder has been deleted
const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath);
const existsOnDisk = await this.storageRepository.checkFileExists(originalMotionFile.path);
if (!existsOnDisk) {
this.storageCore.ensureFolders(motionAsset.originalPath);
await this.storageRepository.createFile(motionAsset.originalPath, video);
this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`);
this.storageCore.ensureFolders(originalMotionFile.path);
await this.storageRepository.createFile(originalMotionFile.path, video);
this.logger.log(`Wrote motion photo video to ${originalMotionFile.path}`);
await this.handleMetadataExtraction({ id: motionAsset.id });
await this.jobRepository.queue({ name: JobName.AssetEncodeVideo, data: { id: motionAsset.id } });
}
this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`);
this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${originalPath}`);
} catch (error: Error | any) {
this.logger.error(
`Failed to extract motion video for ${asset.id}: ${asset.originalPath}: ${error}`,
error?.stack,
);
this.logger.error(`Failed to extract motion video for ${asset.id}: ${originalPath}: ${error}`, error?.stack);
}
}
@@ -799,7 +773,8 @@ export class MetadataService extends BaseService {
}
private async applyTaggedFaces(
asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string },
asset: { id: string; ownerId: string; faces: AssetFace[] },
originalPath: string,
tags: ImmichTags,
) {
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
@@ -853,13 +828,11 @@ export class MetadataService extends BaseService {
const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.Exif).map((face) => face.id);
if (facesToRemove.length > 0) {
this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${asset.originalPath}`);
this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${originalPath}`);
}
if (facesToAdd.length > 0) {
this.logger.debug(
`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${asset.originalPath}`,
);
this.logger.debug(`Creating ${facesToAdd.length} faces from metadata for asset ${asset.id}: ${originalPath}`);
}
if (facesToRemove.length > 0 || facesToAdd.length > 0) {
@@ -880,9 +853,7 @@ 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}`,
);
this.logger.verbose(`Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${originalPath}`);
// timezone
let timeZone = exifTags.tz ?? null;
@@ -893,11 +864,9 @@ export class MetadataService extends BaseService {
}
if (timeZone) {
this.logger.verbose(
`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`,
);
this.logger.verbose(`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${originalPath}`);
} else {
this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`);
this.logger.debug(`No timezone information found for asset ${asset.id}: ${originalPath}`);
}
let dateTimeOriginal = dateTime?.toDateTime();
@@ -923,12 +892,12 @@ export class MetadataService extends BaseService {
),
);
this.logger.debug(
`No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
`No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${originalPath}`,
);
dateTimeOriginal = localDateTime = earliestDate;
}
this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${asset.originalPath}`);
this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${originalPath}`);
return {
timeZone,
+127 -40
View File
@@ -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');
});
});
});
});
+9 -4
View File
@@ -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(' ') };
}
}
@@ -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, getAssetFiles } from 'src/utils/asset.util';
import { getLivePhotoMotionFilename } from 'src/utils/file';
const storageTokens = {
@@ -137,7 +147,8 @@ export class StorageTemplateService extends BaseService {
const user = await this.userRepository.get(asset.ownerId, {});
const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || asset.id;
await this.moveAsset(asset, { storageLabel, filename });
const { originalFile } = getAssetFiles(asset.files);
await this.moveAsset({ originalPath: originalFile.path, ...asset }, { storageLabel, filename });
// move motion part of live photo
if (asset.livePhotoVideoId) {
@@ -145,8 +156,12 @@ export class StorageTemplateService extends BaseService {
if (!livePhotoVideo) {
return JobStatus.Failed;
}
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
const { originalFile: livePhotoOriginalFile } = getAssetFiles(livePhotoVideo.files);
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoOriginalFile.path);
await this.moveAsset(
{ originalPath: livePhotoOriginalFile.path, ...livePhotoVideo },
{ storageLabel, filename: motionFilename },
);
}
return JobStatus.Success;
}
@@ -170,7 +185,8 @@ export class StorageTemplateService extends BaseService {
const user = users.find((user) => user.id === asset.ownerId);
const storageLabel = user?.storageLabel || null;
const filename = asset.originalFileName || asset.id;
await this.moveAsset(asset, { storageLabel, filename });
const { originalFile } = getAssetFiles(asset.files);
await this.moveAsset({ originalPath: originalFile.path, ...asset }, { storageLabel, filename });
}
this.logger.debug('Cleaning up empty directories...');
@@ -196,7 +212,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 +229,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,
+2 -12
View File
@@ -1,10 +1,9 @@
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
import { Asset } from 'src/database';
import { Asset, AssetFile } from 'src/database';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetMetadataKey,
AssetOrder,
AssetType,
DatabaseSslMode,
@@ -476,8 +475,8 @@ export type StorageAsset = {
fileCreatedAt: Date;
originalPath: string;
originalFileName: string;
sidecarPath: string | null;
fileSizeInByte: number | null;
files: AssetFile[];
};
export type OnThisDayData = { year: number };
@@ -563,12 +562,3 @@ export interface UserMetadata extends Record<UserMetadataKey, Record<string, any
[UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string };
[UserMetadataKey.Onboarding]: { isOnboarded: boolean };
}
export type AssetMetadataItem<T extends keyof AssetMetadata = AssetMetadataKey> = {
key: T;
value: AssetMetadata[T];
};
export interface AssetMetadata extends Record<AssetMetadataKey, Record<string, any>> {
[AssetMetadataKey.MobileApp]: { iCloudId: string };
}
+7
View File
@@ -18,6 +18,13 @@ export const getAssetFile = (files: AssetFile[], type: AssetFileType | Generated
};
export const getAssetFiles = (files: AssetFile[]) => ({
originalFile: (() => {
const file = getAssetFile(files, AssetFileType.Original);
if (!file?.path) {
throw new BadRequestException(`Asset has no original file`); // TODO: should we throw a specific error here that can be caught higher up?
}
return file;
})(),
fullsizeFile: getAssetFile(files, AssetFileType.FullSize),
previewFile: getAssetFile(files, AssetFileType.Preview),
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),
+41 -1
View File
@@ -320,6 +320,46 @@ export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: stri
);
}
const isCJK = (c: number): boolean =>
(c >= 0x4e_00 && c <= 0x9f_ff) ||
(c >= 0xac_00 && c <= 0xd7_af) ||
(c >= 0x30_40 && c <= 0x30_9f) ||
(c >= 0x30_a0 && c <= 0x30_ff) ||
(c >= 0x34_00 && c <= 0x4d_bf);
export const tokenizeForSearch = (text: string): string[] => {
/* eslint-disable unicorn/prefer-code-point */
const tokens: string[] = [];
let i = 0;
while (i < text.length) {
const c = text.charCodeAt(i);
if (c <= 32) {
i++;
continue;
}
const start = i;
if (isCJK(c)) {
while (i < text.length && isCJK(text.charCodeAt(i))) {
i++;
}
if (i - start === 1) {
tokens.push(text[start]);
} else {
for (let k = start; k < i - 1; k++) {
tokens.push(text[k] + text[k + 1]);
}
}
} else {
while (i < text.length && text.charCodeAt(i) > 32 && !isCJK(text.charCodeAt(i))) {
i++;
}
tokens.push(text.slice(start, i));
}
}
return tokens;
};
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
@@ -405,7 +445,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.ocr, (qb) =>
qb
.innerJoin('ocr_search', 'asset.id', 'ocr_search.assetId')
.where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${options.ocr!})`),
.where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(options.ocr!).join(' ')})`),
)
.$if(!!options.type, (qb) => qb.where('asset.type', '=', options.type!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
+3 -1
View File
@@ -64,7 +64,7 @@ export const assetStub = {
originalPath: '/original/path.jpg',
originalFileName: 'IMG_123.jpg',
fileSizeInByte: 12_345,
sidecarPath: null,
files: [],
...asset,
}),
noResizePath: Object.freeze({
@@ -553,6 +553,7 @@ export const assetStub = {
fileSizeInByte: 100_000,
timeZone: `America/New_York`,
},
files: [] as AssetFile[],
libraryId: null,
visibility: AssetVisibility.Hidden,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }),
@@ -589,6 +590,7 @@ export const assetStub = {
fileSizeInByte: 25_000,
timeZone: `America/New_York`,
},
files: [] as AssetFile[],
libraryId: null,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
@@ -82,7 +82,11 @@ describe(MetadataService.name, () => {
process.env.TZ = serverTimeZone ?? undefined;
const { filePath } = await createTestFile(exifData);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ id: 'asset-1', originalPath: filePath } as any);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
id: 'asset-1',
originalPath: filePath,
files: [],
} as any);
await sut.handleMetadataExtraction({ id: 'asset-1' });
@@ -10,6 +10,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
updateAllExif: vitest.fn(),
updateDateTimeOriginal: vitest.fn().mockResolvedValue([]),
upsertJobStatus: vitest.fn(),
getForCopy: vitest.fn(),
getByDayOfYear: vitest.fn(),
getByIds: vitest.fn().mockResolvedValue([]),
getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]),
+1 -5
View File
@@ -8,7 +8,6 @@ import {
Memory,
Partner,
Session,
SidecarWriteAsset,
User,
UserAdmin,
} from 'src/database';
@@ -246,7 +245,6 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
originalFileName: 'IMG_123.jpg',
originalPath: `/data/12/34/IMG_123.jpg`,
ownerId: newUuid(),
sidecarPath: null,
stackId: null,
thumbhash: null,
type: AssetType.Image,
@@ -321,9 +319,8 @@ const versionHistoryFactory = () => ({
version: '1.123.45',
});
const assetSidecarWriteFactory = (asset: Partial<SidecarWriteAsset> = {}) => ({
const assetSidecarWriteFactory = () => ({
id: newUuid(),
sidecarPath: '/path/to/original-path.jpg.xmp',
originalPath: '/path/to/original-path.jpg.xmp',
tags: [],
files: [
@@ -333,7 +330,6 @@ const assetSidecarWriteFactory = (asset: Partial<SidecarWriteAsset> = {}) => ({
type: AssetFileType.Sidecar,
},
],
...asset,
});
const assetOcrFactory = (