mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' of https://github.com/immich-app/immich into chore/originals-in-asset-files
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(' ') };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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!))
|
||||
|
||||
Vendored
+3
-1
@@ -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([]),
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user