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

@@ -582,6 +582,7 @@ Class | Method | HTTP request | Description
- [SyncAssetFaceV2](doc//SyncAssetFaceV2.md)
- [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md)
- [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md)
- [SyncAssetOcrV1](doc//SyncAssetOcrV1.md)
- [SyncAssetV1](doc//SyncAssetV1.md)
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
- [SyncEntityType](doc//SyncEntityType.md)

View File

@@ -321,6 +321,7 @@ part 'model/sync_asset_face_v1.dart';
part 'model/sync_asset_face_v2.dart';
part 'model/sync_asset_metadata_delete_v1.dart';
part 'model/sync_asset_metadata_v1.dart';
part 'model/sync_asset_ocr_v1.dart';
part 'model/sync_asset_v1.dart';
part 'model/sync_auth_user_v1.dart';
part 'model/sync_entity_type.dart';

View File

@@ -688,6 +688,8 @@ class ApiClient {
return SyncAssetMetadataDeleteV1.fromJson(value);
case 'SyncAssetMetadataV1':
return SyncAssetMetadataV1.fromJson(value);
case 'SyncAssetOcrV1':
return SyncAssetOcrV1.fromJson(value);
case 'SyncAssetV1':
return SyncAssetV1.fromJson(value);
case 'SyncAuthUserV1':

View File

@@ -0,0 +1,217 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAssetOcrV1 {
/// Returns a new [SyncAssetOcrV1] instance.
SyncAssetOcrV1({
required this.assetId,
required this.boxScore,
required this.id,
required this.isVisible,
required this.text,
required this.textScore,
required this.x1,
required this.x2,
required this.x3,
required this.x4,
required this.y1,
required this.y2,
required this.y3,
required this.y4,
});
/// Asset ID
String assetId;
/// Confidence score of the bounding box
num boxScore;
/// OCR entry ID
String id;
/// Whether the OCR entry is visible
bool isVisible;
/// Recognized text content
String text;
/// Confidence score of the recognized text
num textScore;
/// Top-left X coordinate (normalized 01)
num x1;
/// Top-right X coordinate (normalized 01)
num x2;
/// Bottom-right X coordinate (normalized 01)
num x3;
/// Bottom-left X coordinate (normalized 01)
num x4;
/// Top-left Y coordinate (normalized 01)
num y1;
/// Top-right Y coordinate (normalized 01)
num y2;
/// Bottom-right Y coordinate (normalized 01)
num y3;
/// Bottom-left Y coordinate (normalized 01)
num y4;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetOcrV1 &&
other.assetId == assetId &&
other.boxScore == boxScore &&
other.id == id &&
other.isVisible == isVisible &&
other.text == text &&
other.textScore == textScore &&
other.x1 == x1 &&
other.x2 == x2 &&
other.x3 == x3 &&
other.x4 == x4 &&
other.y1 == y1 &&
other.y2 == y2 &&
other.y3 == y3 &&
other.y4 == y4;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(boxScore.hashCode) +
(id.hashCode) +
(isVisible.hashCode) +
(text.hashCode) +
(textScore.hashCode) +
(x1.hashCode) +
(x2.hashCode) +
(x3.hashCode) +
(x4.hashCode) +
(y1.hashCode) +
(y2.hashCode) +
(y3.hashCode) +
(y4.hashCode);
@override
String toString() => 'SyncAssetOcrV1[assetId=$assetId, boxScore=$boxScore, id=$id, isVisible=$isVisible, text=$text, textScore=$textScore, x1=$x1, x2=$x2, x3=$x3, x4=$x4, y1=$y1, y2=$y2, y3=$y3, y4=$y4]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'boxScore'] = this.boxScore;
json[r'id'] = this.id;
json[r'isVisible'] = this.isVisible;
json[r'text'] = this.text;
json[r'textScore'] = this.textScore;
json[r'x1'] = this.x1;
json[r'x2'] = this.x2;
json[r'x3'] = this.x3;
json[r'x4'] = this.x4;
json[r'y1'] = this.y1;
json[r'y2'] = this.y2;
json[r'y3'] = this.y3;
json[r'y4'] = this.y4;
return json;
}
/// Returns a new [SyncAssetOcrV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetOcrV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetOcrV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetOcrV1(
assetId: mapValueOfType<String>(json, r'assetId')!,
boxScore: num.parse('${json[r'boxScore']}'),
id: mapValueOfType<String>(json, r'id')!,
isVisible: mapValueOfType<bool>(json, r'isVisible')!,
text: mapValueOfType<String>(json, r'text')!,
textScore: num.parse('${json[r'textScore']}'),
x1: num.parse('${json[r'x1']}'),
x2: num.parse('${json[r'x2']}'),
x3: num.parse('${json[r'x3']}'),
x4: num.parse('${json[r'x4']}'),
y1: num.parse('${json[r'y1']}'),
y2: num.parse('${json[r'y2']}'),
y3: num.parse('${json[r'y3']}'),
y4: num.parse('${json[r'y4']}'),
);
}
return null;
}
static List<SyncAssetOcrV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetOcrV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetOcrV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetOcrV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetOcrV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetOcrV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetOcrV1-objects as value to a dart map
static Map<String, List<SyncAssetOcrV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetOcrV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetOcrV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'boxScore',
'id',
'isVisible',
'text',
'textScore',
'x1',
'x2',
'x3',
'x4',
'y1',
'y2',
'y3',
'y4',
};
}

View File

@@ -31,6 +31,7 @@ class SyncEntityType {
static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1');
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
static const assetOcrV1 = SyncEntityType._(r'AssetOcrV1');
static const partnerV1 = SyncEntityType._(r'PartnerV1');
static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1');
static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1');
@@ -82,6 +83,7 @@ class SyncEntityType {
assetExifV1,
assetMetadataV1,
assetMetadataDeleteV1,
assetOcrV1,
partnerV1,
partnerDeleteV1,
partnerAssetV1,
@@ -168,6 +170,7 @@ class SyncEntityTypeTypeTransformer {
case r'AssetExifV1': return SyncEntityType.assetExifV1;
case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1;
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
case r'AssetOcrV1': return SyncEntityType.assetOcrV1;
case r'PartnerV1': return SyncEntityType.partnerV1;
case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1;
case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1;

View File

@@ -31,6 +31,7 @@ class SyncRequestType {
static const assetsV1 = SyncRequestType._(r'AssetsV1');
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
static const assetOcrV1 = SyncRequestType._(r'AssetOcrV1');
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1');
@@ -55,6 +56,7 @@ class SyncRequestType {
assetsV1,
assetExifsV1,
assetMetadataV1,
assetOcrV1,
authUsersV1,
memoriesV1,
memoryToAssetsV1,
@@ -114,6 +116,7 @@ class SyncRequestTypeTypeTransformer {
case r'AssetsV1': return SyncRequestType.assetsV1;
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
case r'AssetOcrV1': return SyncRequestType.assetOcrV1;
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
case r'MemoriesV1': return SyncRequestType.memoriesV1;
case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1;

View File

@@ -22944,6 +22944,83 @@
],
"type": "object"
},
"SyncAssetOcrV1": {
"properties": {
"assetId": {
"description": "Asset ID",
"type": "string"
},
"boxScore": {
"description": "Confidence score of the bounding box",
"type": "number"
},
"id": {
"description": "OCR entry ID",
"type": "string"
},
"isVisible": {
"description": "Whether the OCR entry is visible",
"type": "boolean"
},
"text": {
"description": "Recognized text content",
"type": "string"
},
"textScore": {
"description": "Confidence score of the recognized text",
"type": "number"
},
"x1": {
"description": "Top-left X coordinate (normalized 01)",
"type": "number"
},
"x2": {
"description": "Top-right X coordinate (normalized 01)",
"type": "number"
},
"x3": {
"description": "Bottom-right X coordinate (normalized 01)",
"type": "number"
},
"x4": {
"description": "Bottom-left X coordinate (normalized 01)",
"type": "number"
},
"y1": {
"description": "Top-left Y coordinate (normalized 01)",
"type": "number"
},
"y2": {
"description": "Top-right Y coordinate (normalized 01)",
"type": "number"
},
"y3": {
"description": "Bottom-right Y coordinate (normalized 01)",
"type": "number"
},
"y4": {
"description": "Bottom-left Y coordinate (normalized 01)",
"type": "number"
}
},
"required": [
"assetId",
"boxScore",
"id",
"isVisible",
"text",
"textScore",
"x1",
"x2",
"x3",
"x4",
"y1",
"y2",
"y3",
"y4"
],
"type": "object"
},
"SyncAssetV1": {
"properties": {
"checksum": {
@@ -23165,6 +23242,7 @@
"AssetExifV1",
"AssetMetadataV1",
"AssetMetadataDeleteV1",
"AssetOcrV1",
"PartnerV1",
"PartnerDeleteV1",
"PartnerAssetV1",
@@ -23461,6 +23539,7 @@
"AssetsV1",
"AssetExifsV1",
"AssetMetadataV1",
"AssetOcrV1",
"AuthUsersV1",
"MemoriesV1",
"MemoryToAssetsV1",

View File

@@ -3073,6 +3073,36 @@ export type SyncAssetMetadataV1 = {
/** Value */
value: object;
};
export type SyncAssetOcrV1 = {
/** Asset ID */
assetId: string;
/** Confidence score of the bounding box */
boxScore: number;
/** OCR entry ID */
id: string;
/** Whether the OCR entry is visible */
isVisible: boolean;
/** Recognized text content */
text: string;
/** Confidence score of the recognized text */
textScore: number;
/** Top-left X coordinate (normalized 01) */
x1: number;
/** Top-right X coordinate (normalized 01) */
x2: number;
/** Bottom-right X coordinate (normalized 01) */
x3: number;
/** Bottom-left X coordinate (normalized 01) */
x4: number;
/** Top-left Y coordinate (normalized 01) */
y1: number;
/** Top-right Y coordinate (normalized 01) */
y2: number;
/** Bottom-right Y coordinate (normalized 01) */
y3: number;
/** Bottom-left Y coordinate (normalized 01) */
y4: number;
};
export type SyncAssetV1 = {
/** Checksum */
checksum: string;
@@ -7232,6 +7262,7 @@ export enum SyncEntityType {
AssetExifV1 = "AssetExifV1",
AssetMetadataV1 = "AssetMetadataV1",
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
AssetOcrV1 = "AssetOcrV1",
PartnerV1 = "PartnerV1",
PartnerDeleteV1 = "PartnerDeleteV1",
PartnerAssetV1 = "PartnerAssetV1",
@@ -7282,6 +7313,7 @@ export enum SyncRequestType {
AssetsV1 = "AssetsV1",
AssetExifsV1 = "AssetExifsV1",
AssetMetadataV1 = "AssetMetadataV1",
AssetOcrV1 = "AssetOcrV1",
AuthUsersV1 = "AuthUsersV1",
MemoriesV1 = "MemoriesV1",
MemoryToAssetsV1 = "MemoryToAssetsV1",

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([