feat(server): ocr sync

This commit is contained in:
Yaros
2026-02-25 11:31:44 +01:00
parent d94d9600a7
commit 811d3e1c33
15 changed files with 449 additions and 0 deletions

View File

@@ -437,6 +437,23 @@ export const columns = {
'asset_exif.rating',
'asset_exif.fps',
],
syncAssetOcr: [
'id',
'assetId',
'x1',
'y1',
'x2',
'y2',
'x3',
'y3',
'x4',
'y4',
'text',
'boxScore',
'textScore',
'updateId',
'isVisible',
],
exif: [
'asset_exif.assetId',
'asset_exif.autoStackId',

View File

@@ -218,6 +218,51 @@ export class SyncAssetExifV1 {
fps!: number | null;
}
@ExtraModel()
export class SyncAssetOcrV1 {
@ApiProperty({ description: 'OCR entry ID' })
id!: string;
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
@ApiProperty({ description: 'Top-left X coordinate (normalized 01)' })
x1!: number;
@ApiProperty({ description: 'Top-left Y coordinate (normalized 01)' })
y1!: number;
@ApiProperty({ description: 'Top-right X coordinate (normalized 01)' })
x2!: number;
@ApiProperty({ description: 'Top-right Y coordinate (normalized 01)' })
y2!: number;
@ApiProperty({ description: 'Bottom-right X coordinate (normalized 01)' })
x3!: number;
@ApiProperty({ description: 'Bottom-right Y coordinate (normalized 01)' })
y3!: number;
@ApiProperty({ description: 'Bottom-left X coordinate (normalized 01)' })
x4!: number;
@ApiProperty({ description: 'Bottom-left Y coordinate (normalized 01)' })
y4!: number;
@ApiProperty({ description: 'Confidence score of the bounding box' })
boxScore!: number;
@ApiProperty({ description: 'Confidence score of the recognized text' })
textScore!: number;
@ApiProperty({ description: 'Recognized text content' })
text!: string;
@ApiProperty({ description: 'Whether the OCR entry is visible' })
isVisible!: boolean;
}
@ExtraModel()
export class SyncAssetMetadataV1 {
@ApiProperty({ description: 'Asset ID' })
@@ -480,6 +525,7 @@ export type SyncItem = {
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AssetOcrV1]: SyncAssetOcrV1;
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;

View File

@@ -721,6 +721,7 @@ export enum SyncRequestType {
AssetsV1 = 'AssetsV1',
AssetExifsV1 = 'AssetExifsV1',
AssetMetadataV1 = 'AssetMetadataV1',
AssetOcrV1 = 'AssetOcrV1',
AuthUsersV1 = 'AuthUsersV1',
MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1',
@@ -747,6 +748,7 @@ export enum SyncEntityType {
AssetExifV1 = 'AssetExifV1',
AssetMetadataV1 = 'AssetMetadataV1',
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
AssetOcrV1 = 'AssetOcrV1',
PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1',

View File

@@ -55,6 +55,7 @@ export class SyncRepository {
assetExif: AssetExifSync;
assetFace: AssetFaceSync;
assetMetadata: AssetMetadataSync;
assetOcr: AssetOcrSync;
authUser: AuthUserSync;
memory: MemorySync;
memoryToAsset: MemoryToAssetSync;
@@ -77,6 +78,7 @@ export class SyncRepository {
this.assetExif = new AssetExifSync(this.db);
this.assetFace = new AssetFaceSync(this.db);
this.assetMetadata = new AssetMetadataSync(this.db);
this.assetOcr = new AssetOcrSync(this.db);
this.authUser = new AuthUserSync(this.db);
this.memory = new MemorySync(this.db);
this.memoryToAsset = new MemoryToAssetSync(this.db);
@@ -772,3 +774,14 @@ class AssetMetadataSync extends BaseSync {
.stream();
}
}
class AssetOcrSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
getUpserts(options: SyncQueryOptions, userId: string) {
return this.upsertQuery('asset_ocr', options)
.select(columns.syncAssetOcr)
.innerJoin('asset', 'asset.id', 'asset_ocr.assetId')
.where('asset.ownerId', '=', userId)
.stream();
}
}

View File

@@ -0,0 +1,11 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_ocr" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`CREATE INDEX "asset_ocr_updateId_idx" ON "asset_ocr" ("updateId");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "asset_ocr_updateId_idx";`.execute(db);
await sql`ALTER TABLE "asset_ocr" DROP COLUMN "updateId";`.execute(db);
}

View File

@@ -1,4 +1,5 @@
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
import { UpdateIdColumn } from 'src/decorators';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('asset_ocr')
@@ -45,4 +46,7 @@ export class AssetOcrTable {
@Column({ type: 'boolean', default: true })
isVisible!: Generated<boolean>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View File

@@ -193,6 +193,7 @@ export class SyncService extends BaseService {
[SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap),
[SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap),
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
[SyncRequestType.AssetOcrV1]: () => this.syncAssetOcrV1(options, response, checkpointMap, auth),
};
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
@@ -855,6 +856,23 @@ export class SyncService extends BaseService {
}
}
private async syncAssetOcrV1(
options: SyncQueryOptions,
response: Writable,
checkpointMap: CheckpointMap,
auth: AuthDto,
) {
const upsertType = SyncEntityType.AssetOcrV1;
const upserts = this.syncRepository.assetOcr.getUpserts(
{ ...options, ack: checkpointMap[upsertType] },
auth.user.id,
);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
const { type, sessionId, createId } = item;
await this.syncCheckpointRepository.upsertAll([