feat: add OCR functionality and related configurations

This commit is contained in:
CoderKang 2025-06-01 22:10:43 +08:00 committed by mertalev
parent 23fb2e0fae
commit 0e8ca1c159
No known key found for this signature in database
GPG key ID: DF6ABC77AAD98C95
64 changed files with 3998 additions and 1669 deletions

View file

@ -131,7 +131,7 @@ services:
ports:
- 3003:3003
volumes:
- ../machine-learning:/usr/src/app
- ../machine-learning/immich_ml:/usr/src/immich_ml
- model-cache:/cache
env_file:
- .env

View file

@ -145,6 +145,14 @@
"machine_learning_min_detection_score_description": "Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives.",
"machine_learning_min_recognized_faces": "Minimum recognized faces",
"machine_learning_min_recognized_faces_description": "The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person.",
"machine_learning_ocr": "OCR",
"machine_learning_ocr_description": "Use machine learning to recognize text in images",
"machine_learning_ocr_enabled": "Enable OCR",
"machine_learning_ocr_enabled_description": "If disabled, images will not be encoded for text recognition.",
"machine_learning_ocr_model": "OCR model",
"machine_learning_ocr_model_description": "Choose an OCR model.",
"machine_learning_ocr_min_score": "Minimum recognition score",
"machine_learning_ocr_min_score_description": "Minimum confidence score for text to be recognized from 0-1. Lower values will recognize more text but may result in false positives.",
"machine_learning_settings": "Machine Learning Settings",
"machine_learning_settings_description": "Manage machine learning features and settings",
"machine_learning_smart_search": "Smart Search",
@ -232,6 +240,7 @@
"oauth_storage_quota_claim_description": "Automatically set the user's storage quota to the value of this claim.",
"oauth_storage_quota_default": "Default storage quota (GiB)",
"oauth_storage_quota_default_description": "Quota in GiB to be used when no claim is provided.",
"ocr_job_description": "Use machine learning to recognize text in images",
"oauth_timeout": "Request Timeout",
"oauth_timeout_description": "Timeout for requests in milliseconds",
"password_enable_description": "Login with email and password",
@ -1396,6 +1405,7 @@
"notifications": "Notifications",
"notifications_setting_description": "Manage notifications",
"oauth": "OAuth",
"ocr": "OCR",
"official_immich_resources": "Official Immich Resources",
"offline": "Offline",
"offset": "Offset",
@ -1670,6 +1680,8 @@
"search_by_description_example": "Hiking day in Sapa",
"search_by_filename": "Search by file name or extension",
"search_by_filename_example": "i.e. IMG_1234.JPG or PNG",
"search_by_ocr": "Search by OCR",
"search_by_ocr_example": "Latte",
"search_camera_make": "Search camera make...",
"search_camera_model": "Search camera model...",
"search_city": "Search city...",
@ -1686,6 +1698,7 @@
"search_filter_location_title": "Select location",
"search_filter_media_type": "Media Type",
"search_filter_media_type_title": "Select media type",
"search_filter_ocr": "Search by OCR",
"search_filter_people_title": "Select people",
"search_for": "Search for",
"search_for_existing_person": "Search for existing person",

View file

@ -145,6 +145,14 @@
"machine_learning_min_detection_score_description": "检测到人脸的最小置信分数为0-1。较低的值将检测到更多人脸但可能导致误报。",
"machine_learning_min_recognized_faces": "识别的最少人脸数",
"machine_learning_min_recognized_faces_description": "创建一个人所需识别的最少人脸数量。提高这个值可以使人脸识别更精确,但也增加了人脸未能被分配到相对应人物的可能性。",
"machine_learning_ocr": "文本识别",
"machine_learning_ocr_description": "使用机器学习识别图片中的文本",
"machine_learning_ocr_enabled": "启用文本识别",
"machine_learning_ocr_enabled_description": "如果禁用,则不会对图像编码以用于文本识别。",
"machine_learning_ocr_model": "文本识别模型",
"machine_learning_ocr_model_description": "选择一个文本识别模型。",
"machine_learning_ocr_min_score": "最低识别分数",
"machine_learning_ocr_min_score_description": "文本识别的最小置信度分数范围是0到1。较低的值将识别出更多的文本但可能导致误报。",
"machine_learning_settings": "机器学习设置",
"machine_learning_settings_description": "管理机器学习功能和设置",
"machine_learning_smart_search": "智能搜索",
@ -234,6 +242,7 @@
"oauth_storage_quota_default_description": "未提供声明时使用的配额GiB。",
"oauth_timeout": "请求超时",
"oauth_timeout_description": "请求超时(毫秒)",
"ocr_job_description": "使用机器学习识别图片中的文本",
"password_enable_description": "使用邮箱和密码登录",
"password_settings": "密码登录",
"password_settings_description": "管理密码登录设置",
@ -1396,6 +1405,7 @@
"notifications": "通知",
"notifications_setting_description": "管理通知",
"oauth": "OAuth",
"ocr": "文本识别",
"official_immich_resources": "Immich 官方资源",
"offline": "离线",
"offset": "偏移量",
@ -1670,6 +1680,8 @@
"search_by_description_example": "在沙巴徒步的日子",
"search_by_filename": "按文件名或扩展名查找",
"search_by_filename_example": "如 IMG_1234.JPG 或 PNG",
"search_by_ocr": "通过文本识别查找",
"search_by_ocr_example": "拿铁咖啡",
"search_camera_make": "按相机品牌查找...",
"search_camera_model": "按相机型号查找...",
"search_city": "按城市查找...",
@ -1686,6 +1698,7 @@
"search_filter_location_title": "选择位置",
"search_filter_media_type": "媒体类型",
"search_filter_media_type_title": "选择媒体类型",
"search_filter_ocr": "通过文本识别搜索",
"search_filter_people_title": "选择人物",
"search_for": "查找",
"search_for_existing_person": "查找已有人物",

View file

@ -136,7 +136,7 @@ FROM prod-${DEVICE} AS prod
ARG DEVICE
RUN apt-get update && \
apt-get install -y --no-install-recommends tini $(if ! [ "$DEVICE" = "openvino" ] && ! [ "$DEVICE" = "rocm" ]; then echo "libmimalloc2.0"; fi) && \
apt-get install -y --no-install-recommends tini libgl1 libglib2.0-0 libgomp1 $(if ! [ "$DEVICE" = "openvino" ] && ! [ "$DEVICE" = "rocm" ]; then echo "libmimalloc2.0"; fi) && \
apt-get autoremove -yqq && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

View file

@ -8,7 +8,7 @@ from immich_ml.schemas import ModelSource, ModelTask, ModelType
from .constants import get_model_source
from .facial_recognition.detection import FaceDetector
from .facial_recognition.recognition import FaceRecognizer
from .ocr.paddle import PaddleOCRecognizer
def get_model_class(model_name: str, model_type: ModelType, model_task: ModelTask) -> type[InferenceModel]:
source = get_model_source(model_name)
@ -28,6 +28,9 @@ def get_model_class(model_name: str, model_type: ModelType, model_task: ModelTas
case ModelSource.INSIGHTFACE, ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION:
return FaceRecognizer
case ModelSource.PADDLE, ModelType.OCR, ModelTask.OCR:
return PaddleOCRecognizer
case _:
raise ValueError(f"Unknown model combination: {source}, {model_type}, {model_task}")

View file

@ -75,6 +75,10 @@ _INSIGHTFACE_MODELS = {
}
_PADDLE_MODELS = {
"paddle",
}
SUPPORTED_PROVIDERS = [
"CUDAExecutionProvider",
"ROCMExecutionProvider",
@ -158,4 +162,7 @@ def get_model_source(model_name: str) -> ModelSource | None:
if cleaned_name in _OPENCLIP_MODELS:
return ModelSource.OPENCLIP
if cleaned_name in _PADDLE_MODELS:
return ModelSource.PADDLE
return None

View file

@ -0,0 +1,47 @@
from typing import Any
import numpy as np
from numpy.typing import NDArray
from paddleocr import PaddleOCR
from PIL import Image
from immich_ml.models.base import InferenceModel
from immich_ml.models.transforms import decode_cv2
from immich_ml.schemas import OCROutput, ModelTask, ModelType
class PaddleOCRecognizer(InferenceModel):
depends = []
identity = (ModelType.OCR, ModelTask.OCR)
def __init__(self, model_name: str, min_score: float = 0.9, **model_kwargs: Any) -> None:
self.min_score = model_kwargs.pop("minScore", min_score)
super().__init__(model_name, **model_kwargs)
self._load()
self.loaded = True
def _load(self) -> None:
try:
self.model = PaddleOCR(
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False
)
except Exception as e:
print(f"Error loading PaddleOCR model: {e}")
raise e
def _predict(self, inputs: NDArray[np.uint8] | bytes | Image.Image, **kwargs: Any) -> OCROutput:
inputs = decode_cv2(inputs)
results = self.model.predict(inputs)
valid_texts_and_scores = [
(text, score)
for result in results
for text, score in zip(result['rec_texts'], result['rec_scores'])
if score > self.min_score
]
if not valid_texts_and_scores:
return OCROutput(text="", confidence=0.0)
texts, scores = zip(*valid_texts_and_scores)
return OCROutput(
text="".join(texts),
confidence=sum(scores) / len(scores)
)

View file

@ -23,14 +23,14 @@ class BoundingBox(TypedDict):
class ModelTask(StrEnum):
FACIAL_RECOGNITION = "facial-recognition"
SEARCH = "clip"
OCR = "ocr"
class ModelType(StrEnum):
DETECTION = "detection"
RECOGNITION = "recognition"
TEXTUAL = "textual"
VISUAL = "visual"
OCR = "ocr"
class ModelFormat(StrEnum):
ARMNN = "armnn"
@ -42,7 +42,7 @@ class ModelSource(StrEnum):
INSIGHTFACE = "insightface"
MCLIP = "mclip"
OPENCLIP = "openclip"
PADDLE = "paddle"
ModelIdentity = tuple[ModelType, ModelTask]
@ -87,6 +87,11 @@ class DetectedFace(TypedDict):
FacialRecognitionOutput = list[DetectedFace]
class OCROutput(TypedDict):
text: str
confidence: float
class PipelineEntry(TypedDict):
modelName: str
options: dict[str, Any]

View file

@ -22,6 +22,8 @@ dependencies = [
"rich>=13.4.2",
"tokenizers>=0.15.0,<1.0",
"uvicorn[standard]>=0.22.0,<1.0",
"paddleocr>=3.0.0",
"setuptools>=78.1.0",
]
[dependency-groups]
@ -48,11 +50,15 @@ lint = [
dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
[project.optional-dependencies]
cpu = ["onnxruntime>=1.15.0,<2"]
cuda = ["onnxruntime-gpu>=1.17.0,<2"]
openvino = ["onnxruntime-openvino>=1.17.1,<1.19.0"]
armnn = ["onnxruntime>=1.15.0,<2"]
rknn = ["onnxruntime>=1.15.0,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
cpu = ["onnxruntime>=1.15.0,<2", "paddlepaddle==3.0.0rc1"]
cuda = ["onnxruntime-gpu>=1.17.0,<2", "paddlepaddle-gpu==3.0.0rc1"]
openvino = ["onnxruntime-openvino>=1.17.1,<1.19.0", "paddlepaddle==3.0.0rc1"]
armnn = ["onnxruntime>=1.15.0,<2", "paddlepaddle==3.0.0rc1"]
rknn = [
"onnxruntime>=1.15.0,<2",
"rknn-toolkit-lite2>=2.3.0,<3",
"paddlepaddle==3.0.0rc1",
]
rocm = []
[tool.uv]
@ -63,8 +69,20 @@ name = "cuda12"
url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/"
explicit = true
[[tool.uv.index]]
name = "paddlepaddle-cpu"
url = "https://www.paddlepaddle.org.cn/packages/stable/cpu/"
explicit = true
[[tool.uv.index]]
name = "paddlepaddle-gpu"
url = "https://www.paddlepaddle.org.cn/packages/stable/cu118/"
explicit = true
[tool.uv.sources]
onnxruntime-gpu = { index = "cuda12" }
paddlepaddle = { index = "paddlepaddle-cpu" }
paddlepaddle-gpu = { index = "paddlepaddle-gpu" }
[tool.hatch.build.targets.sdist]
include = ["immich_ml"]

3932
machine-learning/uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
enum SortOrder { asc, desc }
enum TextSearchType { context, filename, description }
enum TextSearchType { context, filename, description, ocr }
enum AssetVisibilityEnum { timeline, hidden, archive, locked }

View file

@ -176,6 +176,7 @@ class SearchFilter {
String? context;
String? filename;
String? description;
String? ocr;
String? language;
Set<PersonDto> people;
SearchLocationFilter location;
@ -190,6 +191,7 @@ class SearchFilter {
this.context,
this.filename,
this.description,
this.ocr,
this.language,
required this.people,
required this.location,
@ -203,6 +205,7 @@ class SearchFilter {
return (context == null || (context != null && context!.isEmpty)) &&
(filename == null || (filename!.isEmpty)) &&
(description == null || (description!.isEmpty)) &&
(ocr == null || (ocr!.isEmpty)) &&
people.isEmpty &&
location.country == null &&
location.state == null &&
@ -222,6 +225,7 @@ class SearchFilter {
String? filename,
String? description,
String? language,
String? ocr,
Set<PersonDto>? people,
SearchLocationFilter? location,
SearchCameraFilter? camera,
@ -234,6 +238,7 @@ class SearchFilter {
filename: filename ?? this.filename,
description: description ?? this.description,
language: language ?? this.language,
ocr: ocr ?? this.ocr,
people: people ?? this.people,
location: location ?? this.location,
camera: camera ?? this.camera,
@ -245,7 +250,7 @@ class SearchFilter {
@override
String toString() {
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)';
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)';
}
@override
@ -256,6 +261,7 @@ class SearchFilter {
other.filename == filename &&
other.description == description &&
other.language == language &&
other.ocr == ocr &&
other.people == people &&
other.location == location &&
other.camera == camera &&
@ -270,6 +276,7 @@ class SearchFilter {
filename.hashCode ^
description.hashCode ^
language.hashCode ^
ocr.hashCode ^
people.hashCode ^
location.hashCode ^
camera.hashCode ^

View file

@ -399,6 +399,22 @@ class SearchPage extends HookConsumerWidget {
case TextSearchType.description:
filter.value = filter.value.copyWith(filename: '', context: '', description: value);
break;
case TextSearchType.ocr:
filter.value = filter.value.copyWith(
filename: '',
context: '',
description: '',
ocr: value,
);
break;
case TextSearchType.ocr:
filter.value = filter.value.copyWith(
filename: '',
context: '',
description: '',
ocr: value,
);
break;
}
search();
@ -408,6 +424,7 @@ class SearchPage extends HookConsumerWidget {
TextSearchType.context => Icons.image_search_rounded,
TextSearchType.filename => Icons.abc_rounded,
TextSearchType.description => Icons.text_snippet_outlined,
TextSearchType.ocr => Icons.document_scanner_outlined
};
return Scaffold(
@ -493,6 +510,26 @@ class SearchPage extends HookConsumerWidget {
searchHintText.value = 'search_by_description_example'.tr();
},
),
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.document_scanner_outlined),
title: Text(
'search_filter_ocr'.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.ocr
? context.colorScheme.primary
: null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.ocr,
),
onPressed: () {
textSearchType.value = TextSearchType.ocr;
searchHintText.value = 'search_by_ocr_example'.tr();
},
),
],
),
),

View file

@ -194,6 +194,7 @@ Class | Method | HTTP request | Description
*SearchApi* | [**searchAssetStatistics**](doc//SearchApi.md#searchassetstatistics) | **POST** /search/statistics |
*SearchApi* | [**searchAssets**](doc//SearchApi.md#searchassets) | **POST** /search/metadata |
*SearchApi* | [**searchLargeAssets**](doc//SearchApi.md#searchlargeassets) | **POST** /search/large-assets |
*SearchApi* | [**searchOcr**](doc//SearchApi.md#searchocr) | **POST** /search/ocr |
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random |
@ -415,6 +416,8 @@ Class | Method | HTTP request | Description
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- [OAuthConfigDto](doc//OAuthConfigDto.md)
- [OAuthTokenEndpointAuthMethod](doc//OAuthTokenEndpointAuthMethod.md)
- [OcrConfig](doc//OcrConfig.md)
- [OcrSearchDto](doc//OcrSearchDto.md)
- [OnThisDayDto](doc//OnThisDayDto.md)
- [OnboardingDto](doc//OnboardingDto.md)
- [OnboardingResponseDto](doc//OnboardingResponseDto.md)

View file

@ -187,6 +187,8 @@ part 'model/o_auth_authorize_response_dto.dart';
part 'model/o_auth_callback_dto.dart';
part 'model/o_auth_config_dto.dart';
part 'model/o_auth_token_endpoint_auth_method.dart';
part 'model/ocr_config.dart';
part 'model/ocr_search_dto.dart';
part 'model/on_this_day_dto.dart';
part 'model/onboarding_dto.dart';
part 'model/onboarding_response_dto.dart';

View file

@ -577,6 +577,53 @@ class SearchApi {
return null;
}
/// Performs an HTTP 'POST /search/ocr' operation and returns the [Response].
/// Parameters:
///
/// * [OcrSearchDto] ocrSearchDto (required):
Future<Response> searchOcrWithHttpInfo(OcrSearchDto ocrSearchDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/search/ocr';
// ignore: prefer_final_locals
Object? postBody = ocrSearchDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [OcrSearchDto] ocrSearchDto (required):
Future<SearchResponseDto?> searchOcr(OcrSearchDto ocrSearchDto,) async {
final response = await searchOcrWithHttpInfo(ocrSearchDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto;
}
return null;
}
/// This endpoint requires the `person.read` permission.
///
/// Note: This method returns the HTTP [Response].

View file

@ -428,6 +428,10 @@ class ApiClient {
return OAuthConfigDto.fromJson(value);
case 'OAuthTokenEndpointAuthMethod':
return OAuthTokenEndpointAuthMethodTypeTransformer().decode(value);
case 'OcrConfig':
return OcrConfig.fromJson(value);
case 'OcrSearchDto':
return OcrSearchDto.fromJson(value);
case 'OnThisDayDto':
return OnThisDayDto.fromJson(value);
case 'OnboardingDto':

View file

@ -22,6 +22,7 @@ class AllJobStatusResponseDto {
required this.metadataExtraction,
required this.migration,
required this.notifications,
required this.ocr,
required this.search,
required this.sidecar,
required this.smartSearch,
@ -48,6 +49,8 @@ class AllJobStatusResponseDto {
JobStatusDto notifications;
JobStatusDto ocr;
JobStatusDto search;
JobStatusDto sidecar;
@ -71,6 +74,7 @@ class AllJobStatusResponseDto {
other.metadataExtraction == metadataExtraction &&
other.migration == migration &&
other.notifications == notifications &&
other.ocr == ocr &&
other.search == search &&
other.sidecar == sidecar &&
other.smartSearch == smartSearch &&
@ -90,6 +94,7 @@ class AllJobStatusResponseDto {
(metadataExtraction.hashCode) +
(migration.hashCode) +
(notifications.hashCode) +
(ocr.hashCode) +
(search.hashCode) +
(sidecar.hashCode) +
(smartSearch.hashCode) +
@ -98,7 +103,7 @@ class AllJobStatusResponseDto {
(videoConversion.hashCode);
@override
String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -111,6 +116,7 @@ class AllJobStatusResponseDto {
json[r'metadataExtraction'] = this.metadataExtraction;
json[r'migration'] = this.migration;
json[r'notifications'] = this.notifications;
json[r'ocr'] = this.ocr;
json[r'search'] = this.search;
json[r'sidecar'] = this.sidecar;
json[r'smartSearch'] = this.smartSearch;
@ -138,6 +144,7 @@ class AllJobStatusResponseDto {
metadataExtraction: JobStatusDto.fromJson(json[r'metadataExtraction'])!,
migration: JobStatusDto.fromJson(json[r'migration'])!,
notifications: JobStatusDto.fromJson(json[r'notifications'])!,
ocr: JobStatusDto.fromJson(json[r'ocr'])!,
search: JobStatusDto.fromJson(json[r'search'])!,
sidecar: JobStatusDto.fromJson(json[r'sidecar'])!,
smartSearch: JobStatusDto.fromJson(json[r'smartSearch'])!,
@ -200,6 +207,7 @@ class AllJobStatusResponseDto {
'metadataExtraction',
'migration',
'notifications',
'ocr',
'search',
'sidecar',
'smartSearch',

View file

@ -38,6 +38,7 @@ class JobName {
static const library_ = JobName._(r'library');
static const notifications = JobName._(r'notifications');
static const backupDatabase = JobName._(r'backupDatabase');
static const ocr = JobName._(r'ocr');
/// List of all possible values in this [enum][JobName].
static const values = <JobName>[
@ -56,6 +57,7 @@ class JobName {
library_,
notifications,
backupDatabase,
ocr,
];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@ -109,6 +111,7 @@ class JobNameTypeTransformer {
case r'library': return JobName.library_;
case r'notifications': return JobName.notifications;
case r'backupDatabase': return JobName.backupDatabase;
case r'ocr': return JobName.ocr;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

117
mobile/openapi/lib/model/ocr_config.dart generated Normal file
View file

@ -0,0 +1,117 @@
//
// 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 OcrConfig {
/// Returns a new [OcrConfig] instance.
OcrConfig({
required this.enabled,
required this.minScore,
required this.modelName,
});
bool enabled;
/// Minimum value: 0.1
/// Maximum value: 1
double minScore;
String modelName;
@override
bool operator ==(Object other) => identical(this, other) || other is OcrConfig &&
other.enabled == enabled &&
other.minScore == minScore &&
other.modelName == modelName;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode) +
(minScore.hashCode) +
(modelName.hashCode);
@override
String toString() => 'OcrConfig[enabled=$enabled, minScore=$minScore, modelName=$modelName]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
json[r'minScore'] = this.minScore;
json[r'modelName'] = this.modelName;
return json;
}
/// Returns a new [OcrConfig] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static OcrConfig? fromJson(dynamic value) {
upgradeDto(value, "OcrConfig");
if (value is Map) {
final json = value.cast<String, dynamic>();
return OcrConfig(
enabled: mapValueOfType<bool>(json, r'enabled')!,
minScore: (mapValueOfType<num>(json, r'minScore')!).toDouble(),
modelName: mapValueOfType<String>(json, r'modelName')!,
);
}
return null;
}
static List<OcrConfig> listFromJson(dynamic json, {bool growable = false,}) {
final result = <OcrConfig>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = OcrConfig.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, OcrConfig> mapFromJson(dynamic json) {
final map = <String, OcrConfig>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = OcrConfig.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of OcrConfig-objects as value to a dart map
static Map<String, List<OcrConfig>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<OcrConfig>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = OcrConfig.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'enabled',
'minScore',
'modelName',
};
}

View file

@ -0,0 +1,522 @@
//
// 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 OcrSearchDto {
/// Returns a new [OcrSearchDto] instance.
OcrSearchDto({
this.albumIds = const [],
this.city,
this.country,
this.createdAfter,
this.createdBefore,
this.deviceId,
this.isEncoded,
this.isFavorite,
this.isMotion,
this.isNotInAlbum,
this.isOffline,
this.lensModel,
this.libraryId,
this.make,
this.model,
required this.ocr,
this.page,
this.personIds = const [],
this.rating,
this.state,
this.tagIds = const [],
this.takenAfter,
this.takenBefore,
this.trashedAfter,
this.trashedBefore,
this.type,
this.updatedAfter,
this.updatedBefore,
this.visibility,
});
List<String> albumIds;
String? city;
String? country;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? createdAfter;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? createdBefore;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? deviceId;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isEncoded;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isMotion;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isNotInAlbum;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isOffline;
String? lensModel;
String? libraryId;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? make;
String? model;
String ocr;
/// Minimum value: 1
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? page;
List<String> personIds;
/// Minimum value: -1
/// Maximum value: 5
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? rating;
String? state;
List<String>? tagIds;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? takenAfter;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? takenBefore;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? trashedAfter;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? trashedBefore;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AssetTypeEnum? type;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? updatedAfter;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? updatedBefore;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AssetVisibility? visibility;
@override
bool operator ==(Object other) => identical(this, other) || other is OcrSearchDto &&
_deepEquality.equals(other.albumIds, albumIds) &&
other.city == city &&
other.country == country &&
other.createdAfter == createdAfter &&
other.createdBefore == createdBefore &&
other.deviceId == deviceId &&
other.isEncoded == isEncoded &&
other.isFavorite == isFavorite &&
other.isMotion == isMotion &&
other.isNotInAlbum == isNotInAlbum &&
other.isOffline == isOffline &&
other.lensModel == lensModel &&
other.libraryId == libraryId &&
other.make == make &&
other.model == model &&
other.ocr == ocr &&
other.page == page &&
_deepEquality.equals(other.personIds, personIds) &&
other.rating == rating &&
other.state == state &&
_deepEquality.equals(other.tagIds, tagIds) &&
other.takenAfter == takenAfter &&
other.takenBefore == takenBefore &&
other.trashedAfter == trashedAfter &&
other.trashedBefore == trashedBefore &&
other.type == type &&
other.updatedAfter == updatedAfter &&
other.updatedBefore == updatedBefore &&
other.visibility == visibility;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumIds.hashCode) +
(city == null ? 0 : city!.hashCode) +
(country == null ? 0 : country!.hashCode) +
(createdAfter == null ? 0 : createdAfter!.hashCode) +
(createdBefore == null ? 0 : createdBefore!.hashCode) +
(deviceId == null ? 0 : deviceId!.hashCode) +
(isEncoded == null ? 0 : isEncoded!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isMotion == null ? 0 : isMotion!.hashCode) +
(isNotInAlbum == null ? 0 : isNotInAlbum!.hashCode) +
(isOffline == null ? 0 : isOffline!.hashCode) +
(lensModel == null ? 0 : lensModel!.hashCode) +
(libraryId == null ? 0 : libraryId!.hashCode) +
(make == null ? 0 : make!.hashCode) +
(model == null ? 0 : model!.hashCode) +
(ocr.hashCode) +
(page == null ? 0 : page!.hashCode) +
(personIds.hashCode) +
(rating == null ? 0 : rating!.hashCode) +
(state == null ? 0 : state!.hashCode) +
(tagIds == null ? 0 : tagIds!.hashCode) +
(takenAfter == null ? 0 : takenAfter!.hashCode) +
(takenBefore == null ? 0 : takenBefore!.hashCode) +
(trashedAfter == null ? 0 : trashedAfter!.hashCode) +
(trashedBefore == null ? 0 : trashedBefore!.hashCode) +
(type == null ? 0 : type!.hashCode) +
(updatedAfter == null ? 0 : updatedAfter!.hashCode) +
(updatedBefore == null ? 0 : updatedBefore!.hashCode) +
(visibility == null ? 0 : visibility!.hashCode);
@override
String toString() => 'OcrSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, ocr=$ocr, page=$page, personIds=$personIds, rating=$rating, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumIds'] = this.albumIds;
if (this.city != null) {
json[r'city'] = this.city;
} else {
// json[r'city'] = null;
}
if (this.country != null) {
json[r'country'] = this.country;
} else {
// json[r'country'] = null;
}
if (this.createdAfter != null) {
json[r'createdAfter'] = this.createdAfter!.toUtc().toIso8601String();
} else {
// json[r'createdAfter'] = null;
}
if (this.createdBefore != null) {
json[r'createdBefore'] = this.createdBefore!.toUtc().toIso8601String();
} else {
// json[r'createdBefore'] = null;
}
if (this.deviceId != null) {
json[r'deviceId'] = this.deviceId;
} else {
// json[r'deviceId'] = null;
}
if (this.isEncoded != null) {
json[r'isEncoded'] = this.isEncoded;
} else {
// json[r'isEncoded'] = null;
}
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.isMotion != null) {
json[r'isMotion'] = this.isMotion;
} else {
// json[r'isMotion'] = null;
}
if (this.isNotInAlbum != null) {
json[r'isNotInAlbum'] = this.isNotInAlbum;
} else {
// json[r'isNotInAlbum'] = null;
}
if (this.isOffline != null) {
json[r'isOffline'] = this.isOffline;
} else {
// json[r'isOffline'] = null;
}
if (this.lensModel != null) {
json[r'lensModel'] = this.lensModel;
} else {
// json[r'lensModel'] = null;
}
if (this.libraryId != null) {
json[r'libraryId'] = this.libraryId;
} else {
// json[r'libraryId'] = null;
}
if (this.make != null) {
json[r'make'] = this.make;
} else {
// json[r'make'] = null;
}
if (this.model != null) {
json[r'model'] = this.model;
} else {
// json[r'model'] = null;
}
json[r'ocr'] = this.ocr;
if (this.page != null) {
json[r'page'] = this.page;
} else {
// json[r'page'] = null;
}
json[r'personIds'] = this.personIds;
if (this.rating != null) {
json[r'rating'] = this.rating;
} else {
// json[r'rating'] = null;
}
if (this.state != null) {
json[r'state'] = this.state;
} else {
// json[r'state'] = null;
}
if (this.tagIds != null) {
json[r'tagIds'] = this.tagIds;
} else {
// json[r'tagIds'] = null;
}
if (this.takenAfter != null) {
json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String();
} else {
// json[r'takenAfter'] = null;
}
if (this.takenBefore != null) {
json[r'takenBefore'] = this.takenBefore!.toUtc().toIso8601String();
} else {
// json[r'takenBefore'] = null;
}
if (this.trashedAfter != null) {
json[r'trashedAfter'] = this.trashedAfter!.toUtc().toIso8601String();
} else {
// json[r'trashedAfter'] = null;
}
if (this.trashedBefore != null) {
json[r'trashedBefore'] = this.trashedBefore!.toUtc().toIso8601String();
} else {
// json[r'trashedBefore'] = null;
}
if (this.type != null) {
json[r'type'] = this.type;
} else {
// json[r'type'] = null;
}
if (this.updatedAfter != null) {
json[r'updatedAfter'] = this.updatedAfter!.toUtc().toIso8601String();
} else {
// json[r'updatedAfter'] = null;
}
if (this.updatedBefore != null) {
json[r'updatedBefore'] = this.updatedBefore!.toUtc().toIso8601String();
} else {
// json[r'updatedBefore'] = null;
}
if (this.visibility != null) {
json[r'visibility'] = this.visibility;
} else {
// json[r'visibility'] = null;
}
return json;
}
/// Returns a new [OcrSearchDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static OcrSearchDto? fromJson(dynamic value) {
upgradeDto(value, "OcrSearchDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return OcrSearchDto(
albumIds: json[r'albumIds'] is Iterable
? (json[r'albumIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
city: mapValueOfType<String>(json, r'city'),
country: mapValueOfType<String>(json, r'country'),
createdAfter: mapDateTime(json, r'createdAfter', r''),
createdBefore: mapDateTime(json, r'createdBefore', r''),
deviceId: mapValueOfType<String>(json, r'deviceId'),
isEncoded: mapValueOfType<bool>(json, r'isEncoded'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isMotion: mapValueOfType<bool>(json, r'isMotion'),
isNotInAlbum: mapValueOfType<bool>(json, r'isNotInAlbum'),
isOffline: mapValueOfType<bool>(json, r'isOffline'),
lensModel: mapValueOfType<String>(json, r'lensModel'),
libraryId: mapValueOfType<String>(json, r'libraryId'),
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr')!,
page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
takenAfter: mapDateTime(json, r'takenAfter', r''),
takenBefore: mapDateTime(json, r'takenBefore', r''),
trashedAfter: mapDateTime(json, r'trashedAfter', r''),
trashedBefore: mapDateTime(json, r'trashedBefore', r''),
type: AssetTypeEnum.fromJson(json[r'type']),
updatedAfter: mapDateTime(json, r'updatedAfter', r''),
updatedBefore: mapDateTime(json, r'updatedBefore', r''),
visibility: AssetVisibility.fromJson(json[r'visibility']),
);
}
return null;
}
static List<OcrSearchDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <OcrSearchDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = OcrSearchDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, OcrSearchDto> mapFromJson(dynamic json) {
final map = <String, OcrSearchDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = OcrSearchDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of OcrSearchDto-objects as value to a dart map
static Map<String, List<OcrSearchDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<OcrSearchDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = OcrSearchDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'ocr',
};
}

View file

@ -21,6 +21,7 @@ class ServerFeaturesDto {
required this.map,
required this.oauth,
required this.oauthAutoLaunch,
required this.ocr,
required this.passwordLogin,
required this.reverseGeocoding,
required this.search,
@ -45,6 +46,8 @@ class ServerFeaturesDto {
bool oauthAutoLaunch;
bool ocr;
bool passwordLogin;
bool reverseGeocoding;
@ -67,6 +70,7 @@ class ServerFeaturesDto {
other.map == map &&
other.oauth == oauth &&
other.oauthAutoLaunch == oauthAutoLaunch &&
other.ocr == ocr &&
other.passwordLogin == passwordLogin &&
other.reverseGeocoding == reverseGeocoding &&
other.search == search &&
@ -85,6 +89,7 @@ class ServerFeaturesDto {
(map.hashCode) +
(oauth.hashCode) +
(oauthAutoLaunch.hashCode) +
(ocr.hashCode) +
(passwordLogin.hashCode) +
(reverseGeocoding.hashCode) +
(search.hashCode) +
@ -93,7 +98,7 @@ class ServerFeaturesDto {
(trash.hashCode);
@override
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
String toString() => 'ServerFeaturesDto[configFile=$configFile, duplicateDetection=$duplicateDetection, email=$email, facialRecognition=$facialRecognition, importFaces=$importFaces, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, ocr=$ocr, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, trash=$trash]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -105,6 +110,7 @@ class ServerFeaturesDto {
json[r'map'] = this.map;
json[r'oauth'] = this.oauth;
json[r'oauthAutoLaunch'] = this.oauthAutoLaunch;
json[r'ocr'] = this.ocr;
json[r'passwordLogin'] = this.passwordLogin;
json[r'reverseGeocoding'] = this.reverseGeocoding;
json[r'search'] = this.search;
@ -131,6 +137,7 @@ class ServerFeaturesDto {
map: mapValueOfType<bool>(json, r'map')!,
oauth: mapValueOfType<bool>(json, r'oauth')!,
oauthAutoLaunch: mapValueOfType<bool>(json, r'oauthAutoLaunch')!,
ocr: mapValueOfType<bool>(json, r'ocr')!,
passwordLogin: mapValueOfType<bool>(json, r'passwordLogin')!,
reverseGeocoding: mapValueOfType<bool>(json, r'reverseGeocoding')!,
search: mapValueOfType<bool>(json, r'search')!,
@ -192,6 +199,7 @@ class ServerFeaturesDto {
'map',
'oauth',
'oauthAutoLaunch',
'ocr',
'passwordLogin',
'reverseGeocoding',
'search',

View file

@ -19,6 +19,7 @@ class SystemConfigJobDto {
required this.metadataExtraction,
required this.migration,
required this.notifications,
required this.ocr,
required this.search,
required this.sidecar,
required this.smartSearch,
@ -38,6 +39,8 @@ class SystemConfigJobDto {
JobSettingsDto notifications;
JobSettingsDto ocr;
JobSettingsDto search;
JobSettingsDto sidecar;
@ -56,6 +59,7 @@ class SystemConfigJobDto {
other.metadataExtraction == metadataExtraction &&
other.migration == migration &&
other.notifications == notifications &&
other.ocr == ocr &&
other.search == search &&
other.sidecar == sidecar &&
other.smartSearch == smartSearch &&
@ -71,6 +75,7 @@ class SystemConfigJobDto {
(metadataExtraction.hashCode) +
(migration.hashCode) +
(notifications.hashCode) +
(ocr.hashCode) +
(search.hashCode) +
(sidecar.hashCode) +
(smartSearch.hashCode) +
@ -78,7 +83,7 @@ class SystemConfigJobDto {
(videoConversion.hashCode);
@override
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -88,6 +93,7 @@ class SystemConfigJobDto {
json[r'metadataExtraction'] = this.metadataExtraction;
json[r'migration'] = this.migration;
json[r'notifications'] = this.notifications;
json[r'ocr'] = this.ocr;
json[r'search'] = this.search;
json[r'sidecar'] = this.sidecar;
json[r'smartSearch'] = this.smartSearch;
@ -111,6 +117,7 @@ class SystemConfigJobDto {
metadataExtraction: JobSettingsDto.fromJson(json[r'metadataExtraction'])!,
migration: JobSettingsDto.fromJson(json[r'migration'])!,
notifications: JobSettingsDto.fromJson(json[r'notifications'])!,
ocr: JobSettingsDto.fromJson(json[r'ocr'])!,
search: JobSettingsDto.fromJson(json[r'search'])!,
sidecar: JobSettingsDto.fromJson(json[r'sidecar'])!,
smartSearch: JobSettingsDto.fromJson(json[r'smartSearch'])!,
@ -169,6 +176,7 @@ class SystemConfigJobDto {
'metadataExtraction',
'migration',
'notifications',
'ocr',
'search',
'sidecar',
'smartSearch',

View file

@ -17,6 +17,7 @@ class SystemConfigMachineLearningDto {
required this.duplicateDetection,
required this.enabled,
required this.facialRecognition,
required this.ocr,
this.url,
this.urls = const [],
});
@ -29,6 +30,8 @@ class SystemConfigMachineLearningDto {
FacialRecognitionConfig facialRecognition;
OcrConfig ocr;
/// This property was deprecated in v1.122.0
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -46,6 +49,7 @@ class SystemConfigMachineLearningDto {
other.duplicateDetection == duplicateDetection &&
other.enabled == enabled &&
other.facialRecognition == facialRecognition &&
other.ocr == ocr &&
other.url == url &&
_deepEquality.equals(other.urls, urls);
@ -56,11 +60,12 @@ class SystemConfigMachineLearningDto {
(duplicateDetection.hashCode) +
(enabled.hashCode) +
(facialRecognition.hashCode) +
(ocr.hashCode) +
(url == null ? 0 : url!.hashCode) +
(urls.hashCode);
@override
String toString() => 'SystemConfigMachineLearningDto[clip=$clip, duplicateDetection=$duplicateDetection, enabled=$enabled, facialRecognition=$facialRecognition, url=$url, urls=$urls]';
String toString() => 'SystemConfigMachineLearningDto[clip=$clip, duplicateDetection=$duplicateDetection, enabled=$enabled, facialRecognition=$facialRecognition, ocr=$ocr, url=$url, urls=$urls]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -68,6 +73,7 @@ class SystemConfigMachineLearningDto {
json[r'duplicateDetection'] = this.duplicateDetection;
json[r'enabled'] = this.enabled;
json[r'facialRecognition'] = this.facialRecognition;
json[r'ocr'] = this.ocr;
if (this.url != null) {
json[r'url'] = this.url;
} else {
@ -90,6 +96,7 @@ class SystemConfigMachineLearningDto {
duplicateDetection: DuplicateDetectionConfig.fromJson(json[r'duplicateDetection'])!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
facialRecognition: FacialRecognitionConfig.fromJson(json[r'facialRecognition'])!,
ocr: OcrConfig.fromJson(json[r'ocr'])!,
url: mapValueOfType<String>(json, r'url'),
urls: json[r'urls'] is Iterable
? (json[r'urls'] as Iterable).cast<String>().toList(growable: false)
@ -145,6 +152,7 @@ class SystemConfigMachineLearningDto {
'duplicateDetection',
'enabled',
'facialRecognition',
'ocr',
'urls',
};
}

View file

@ -6151,6 +6151,48 @@
"description": "This endpoint requires the `asset.read` permission."
}
},
"/search/ocr": {
"post": {
"operationId": "searchOcr",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OcrSearchDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Search"
]
}
},
"/search/person": {
"get": {
"operationId": "searchPerson",
@ -10325,6 +10367,9 @@
"notifications": {
"$ref": "#/components/schemas/JobStatusDto"
},
"ocr": {
"$ref": "#/components/schemas/JobStatusDto"
},
"search": {
"$ref": "#/components/schemas/JobStatusDto"
},
@ -10354,6 +10399,7 @@
"metadataExtraction",
"migration",
"notifications",
"ocr",
"search",
"sidecar",
"smartSearch",
@ -12006,7 +12052,8 @@
"sidecar",
"library",
"notifications",
"backupDatabase"
"backupDatabase",
"ocr"
],
"type": "string"
},
@ -12866,6 +12913,162 @@
],
"type": "string"
},
"OcrConfig": {
"properties": {
"enabled": {
"type": "boolean"
},
"minScore": {
"format": "double",
"maximum": 1,
"minimum": 0.1,
"type": "number"
},
"modelName": {
"type": "string"
}
},
"required": [
"enabled",
"minScore",
"modelName"
],
"type": "object"
},
"OcrSearchDto": {
"properties": {
"albumIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"city": {
"nullable": true,
"type": "string"
},
"country": {
"nullable": true,
"type": "string"
},
"createdAfter": {
"format": "date-time",
"type": "string"
},
"createdBefore": {
"format": "date-time",
"type": "string"
},
"deviceId": {
"type": "string"
},
"isEncoded": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
},
"isMotion": {
"type": "boolean"
},
"isNotInAlbum": {
"type": "boolean"
},
"isOffline": {
"type": "boolean"
},
"lensModel": {
"nullable": true,
"type": "string"
},
"libraryId": {
"format": "uuid",
"nullable": true,
"type": "string"
},
"make": {
"type": "string"
},
"model": {
"nullable": true,
"type": "string"
},
"ocr": {
"type": "string"
},
"page": {
"minimum": 1,
"type": "number"
},
"personIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"rating": {
"maximum": 5,
"minimum": -1,
"type": "number"
},
"state": {
"nullable": true,
"type": "string"
},
"tagIds": {
"items": {
"format": "uuid",
"type": "string"
},
"nullable": true,
"type": "array"
},
"takenAfter": {
"format": "date-time",
"type": "string"
},
"takenBefore": {
"format": "date-time",
"type": "string"
},
"trashedAfter": {
"format": "date-time",
"type": "string"
},
"trashedBefore": {
"format": "date-time",
"type": "string"
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/AssetTypeEnum"
}
]
},
"updatedAfter": {
"format": "date-time",
"type": "string"
},
"updatedBefore": {
"format": "date-time",
"type": "string"
},
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/AssetVisibility"
}
]
}
},
"required": [
"ocr"
],
"type": "object"
},
"OnThisDayDto": {
"properties": {
"year": {
@ -14002,6 +14205,9 @@
"oauthAutoLaunch": {
"type": "boolean"
},
"ocr": {
"type": "boolean"
},
"passwordLogin": {
"type": "boolean"
},
@ -14030,6 +14236,7 @@
"map",
"oauth",
"oauthAutoLaunch",
"ocr",
"passwordLogin",
"reverseGeocoding",
"search",
@ -16281,6 +16488,9 @@
"notifications": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"ocr": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"search": {
"$ref": "#/components/schemas/JobSettingsDto"
},
@ -16304,6 +16514,7 @@
"metadataExtraction",
"migration",
"notifications",
"ocr",
"search",
"sidecar",
"smartSearch",
@ -16386,6 +16597,9 @@
"facialRecognition": {
"$ref": "#/components/schemas/FacialRecognitionConfig"
},
"ocr": {
"$ref": "#/components/schemas/OcrConfig"
},
"url": {
"deprecated": true,
"description": "This property was deprecated in v1.122.0",
@ -16406,6 +16620,7 @@
"duplicateDetection",
"enabled",
"facialRecognition",
"ocr",
"urls"
],
"type": "object"

View file

@ -678,6 +678,7 @@ export type AllJobStatusResponseDto = {
metadataExtraction: JobStatusDto;
migration: JobStatusDto;
notifications: JobStatusDto;
ocr: JobStatusDto;
search: JobStatusDto;
sidecar: JobStatusDto;
smartSearch: JobStatusDto;
@ -957,6 +958,37 @@ export type SearchResponseDto = {
albums: SearchAlbumResponseDto;
assets: SearchAssetResponseDto;
};
export type OcrSearchDto = {
albumIds?: string[];
city?: string | null;
country?: string | null;
createdAfter?: string;
createdBefore?: string;
deviceId?: string;
isEncoded?: boolean;
isFavorite?: boolean;
isMotion?: boolean;
isNotInAlbum?: boolean;
isOffline?: boolean;
lensModel?: string | null;
libraryId?: string | null;
make?: string;
model?: string | null;
ocr: string;
page?: number;
personIds?: string[];
rating?: number;
state?: string | null;
tagIds?: string[] | null;
takenAfter?: string;
takenBefore?: string;
trashedAfter?: string;
trashedBefore?: string;
"type"?: AssetTypeEnum;
updatedAfter?: string;
updatedBefore?: string;
visibility?: AssetVisibility;
};
export type PlacesResponseDto = {
admin1name?: string;
admin2name?: string;
@ -1117,6 +1149,7 @@ export type ServerFeaturesDto = {
map: boolean;
oauth: boolean;
oauthAutoLaunch: boolean;
ocr: boolean;
passwordLogin: boolean;
reverseGeocoding: boolean;
search: boolean;
@ -1362,6 +1395,7 @@ export type SystemConfigJobDto = {
metadataExtraction: JobSettingsDto;
migration: JobSettingsDto;
notifications: JobSettingsDto;
ocr: JobSettingsDto;
search: JobSettingsDto;
sidecar: JobSettingsDto;
smartSearch: JobSettingsDto;
@ -1398,11 +1432,17 @@ export type FacialRecognitionConfig = {
minScore: number;
modelName: string;
};
export type OcrConfig = {
enabled: boolean;
minScore: number;
modelName: string;
};
export type SystemConfigMachineLearningDto = {
clip: ClipConfig;
duplicateDetection: DuplicateDetectionConfig;
enabled: boolean;
facialRecognition: FacialRecognitionConfig;
ocr: OcrConfig;
/** This property was deprecated in v1.122.0 */
url?: string;
urls: string[];
@ -3457,6 +3497,18 @@ export function searchAssets({ metadataSearchDto }: {
body: metadataSearchDto
})));
}
export function searchOcr({ ocrSearchDto }: {
ocrSearchDto: OcrSearchDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: SearchResponseDto;
}>("/search/ocr", oazapfts.json({
...opts,
method: "POST",
body: ocrSearchDto
})));
}
/**
* This endpoint requires the `person.read` permission.
*/
@ -4862,7 +4914,8 @@ export enum JobName {
Sidecar = "sidecar",
Library = "library",
Notifications = "notifications",
BackupDatabase = "backupDatabase"
BackupDatabase = "backupDatabase",
Ocr = "ocr"
}
export enum JobCommand {
Start = "start",

View file

@ -69,6 +69,11 @@ export interface SystemConfig {
minFaces: number;
maxDistance: number;
};
ocr: {
enabled: boolean;
modelName: string;
minScore: number;
};
};
map: {
enabled: boolean;
@ -219,6 +224,7 @@ export const defaults = Object.freeze<SystemConfig>({
[QueueName.ThumbnailGeneration]: { concurrency: 3 },
[QueueName.VideoConversion]: { concurrency: 1 },
[QueueName.Notification]: { concurrency: 5 },
[QueueName.OCR]: { concurrency: 1 },
},
logging: {
enabled: true,
@ -242,6 +248,11 @@ export const defaults = Object.freeze<SystemConfig>({
maxDistance: 0.5,
minFaces: 3,
},
ocr: {
enabled: true,
modelName: 'paddle',
minScore: 0.9,
},
},
map: {
enabled: true,

View file

@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto';
import {
LargeAssetSearchDto,
MetadataSearchDto,
OcrSearchDto,
PlacesResponseDto,
RandomSearchDto,
SearchExploreResponseDto,
@ -61,6 +62,13 @@ export class SearchController {
return this.service.searchSmart(auth, dto);
}
@Post('ocr')
@HttpCode(HttpStatus.OK)
@Authenticated()
searchOcr(@Auth() auth: AuthDto, @Body() dto: OcrSearchDto): Promise<SearchResponseDto> {
return this.service.searchOcr(auth, dto);
}
@Get('explore')
@Authenticated({ permission: Permission.AssetRead })
getExploreData(@Auth() auth: AuthDto): Promise<SearchExploreResponseDto[]> {

View file

@ -93,4 +93,7 @@ export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto>
@ApiProperty({ type: JobStatusDto })
[QueueName.BackupDatabase]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.OCR]!: JobStatusDto;
}

View file

@ -46,3 +46,12 @@ export class FacialRecognitionConfig extends ModelConfig {
@ApiProperty({ type: 'integer' })
minFaces!: number;
}
export class OcrConfig extends ModelConfig {
@IsNumber()
@Min(0.1)
@Max(1)
@Type(() => Number)
@ApiProperty({ type: 'number', format: 'double' })
minScore!: number;
}

View file

@ -218,6 +218,18 @@ export class SmartSearchDto extends BaseSearchWithResultsDto {
page?: number;
}
export class OcrSearchDto extends BaseSearchDto {
@IsString()
@IsNotEmpty()
ocr!: string;
@IsInt()
@Min(1)
@Type(() => Number)
@Optional()
page?: number;
}
export class SearchPlacesDto {
@IsString()
@IsNotEmpty()

View file

@ -171,6 +171,7 @@ export class ServerFeaturesDto {
sidecar!: boolean;
search!: boolean;
email!: boolean;
ocr!: boolean;
}
export interface ReleaseNotification {

View file

@ -16,7 +16,7 @@ import {
} from 'class-validator';
import { SystemConfig } from 'src/config';
import { PropertyLifecycle } from 'src/decorators';
import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto';
import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig, OcrConfig } from 'src/dtos/model-config.dto';
import {
AudioCodec,
CQMode,
@ -202,6 +202,12 @@ class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto>
@Type(() => JobSettingsDto)
[QueueName.FaceDetection]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.OCR]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@ -286,6 +292,11 @@ class SystemConfigMachineLearningDto {
@ValidateNested()
@IsObject()
facialRecognition!: FacialRecognitionConfig;
@Type(() => OcrConfig)
@ValidateNested()
@IsObject()
ocr!: OcrConfig;
}
enum MapTheme {

View file

@ -0,0 +1,5 @@
export class OcrEntity {
id!: string;
assetId!: string;
text!: string;
}

View file

@ -511,6 +511,7 @@ export enum QueueName {
Library = 'library',
Notification = 'notifications',
BackupDatabase = 'backupDatabase',
OCR = 'ocr',
}
export enum JobName {
@ -583,6 +584,11 @@ export enum JobName {
TagCleanup = 'TagCleanup',
VersionCheck = 'VersionCheck',
// OCR
QUEUE_OCR = 'queue-ocr',
OCR = 'ocr',
OCR_CLEANUP = 'ocr-cleanup',
}
export enum JobCommand {

View file

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpsertOcrAssetJobStatus1743429240851 implements MigrationInterface {
name = 'UpsertOcrAssetJobStatus1743429240851';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE asset_job_status
ADD COLUMN IF NOT EXISTS "ocrAt" TIMESTAMP WITH TIME ZONE;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE asset_job_status
DROP COLUMN IF EXISTS "ocrAt";
`);
}
}

View file

@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddOcrTable1743429592349 implements MigrationInterface {
name = 'AddOcrTable1743429592349';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "asset_ocr"
(
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"assetId" uuid NOT NULL,
"text" text,
CONSTRAINT "PK_asset_ocr_id" PRIMARY KEY ("id")
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_ocr" DROP CONSTRAINT "FK_asset_ocr_assetId"`);
await queryRunner.query(`DROP TABLE "asset_ocr"`);
}
}

View file

@ -348,6 +348,23 @@ export class AssetJobRepository {
.stream();
}
@GenerateSql({ params: [], stream: true })
streamForOcrJob(force?: boolean) {
return this.db
.selectFrom('assets')
.select(['assets.id'])
.$if(!force, (qb) =>
qb
.leftJoin('asset_job_status', 'asset_job_status.assetId', 'assets.id')
.where((eb) =>
eb.or([eb('asset_job_status.ocrAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]),
)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN),
)
.where('assets.deletedAt', 'is', null)
.stream();
}
@GenerateSql({ params: [DummyValue.DATE], stream: true })
streamForMigrationJob() {
return this.db.selectFrom('asset').select(['id']).where('asset.deletedAt', 'is', null).stream();

View file

@ -203,6 +203,7 @@ export class AssetRepository {
metadataExtractedAt: eb.ref('excluded.metadataExtractedAt'),
previewAt: eb.ref('excluded.previewAt'),
thumbnailAt: eb.ref('excluded.thumbnailAt'),
ocrAt: eb.ref('excluded.ocrAt'),
},
values[0],
),

View file

@ -25,6 +25,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
@ -72,6 +73,7 @@ export const repositories = [
MoveRepository,
NotificationRepository,
OAuthRepository,
OcrRepository,
PartnerRepository,
PersonRepository,
ProcessRepository,

View file

@ -220,6 +220,9 @@ export class JobRepository {
case JobName.FacialRecognitionQueueAll: {
return { jobId: JobName.FacialRecognitionQueueAll };
}
case JobName.QUEUE_OCR: {
return { jobId: JobName.QUEUE_OCR };
}
default: {
return null;
}

View file

@ -14,6 +14,7 @@ export interface BoundingBox {
export enum ModelTask {
FACIAL_RECOGNITION = 'facial-recognition',
SEARCH = 'clip',
OCR = 'ocr',
}
export enum ModelType {
@ -22,6 +23,7 @@ export enum ModelType {
RECOGNITION = 'recognition',
TEXTUAL = 'textual',
VISUAL = 'visual',
OCR = 'ocr',
}
export type ModelPayload = { imagePath: string } | { text: string };
@ -29,7 +31,7 @@ export type ModelPayload = { imagePath: string } | { text: string };
type ModelOptions = { modelName: string };
export type FaceDetectionOptions = ModelOptions & { minScore: number };
export type OcrOptions = ModelOptions & { minScore: number };
type VisualResponse = { imageHeight: number; imageWidth: number };
export type ClipVisualRequest = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: ModelOptions } };
export type ClipVisualResponse = { [ModelTask.SEARCH]: string } & VisualResponse;
@ -37,6 +39,14 @@ export type ClipVisualResponse = { [ModelTask.SEARCH]: string } & VisualResponse
export type ClipTextualRequest = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: ModelOptions } };
export type ClipTextualResponse = { [ModelTask.SEARCH]: string };
export type OCR = {
text: string;
confidence: number;
};
export type OcrRequest = { [ModelTask.OCR]: { [ModelType.OCR]: ModelOptions & { options: { minScore: number } } } };
export type OcrResponse = { [ModelTask.OCR]: OCR } & VisualResponse;
export type FacialRecognitionRequest = {
[ModelTask.FACIAL_RECOGNITION]: {
[ModelType.DETECTION]: ModelOptions & { options: { minScore: number } };
@ -52,7 +62,7 @@ export interface Face {
export type FacialRecognitionResponse = { [ModelTask.FACIAL_RECOGNITION]: Face[] } & VisualResponse;
export type DetectedFaces = { faces: Face[] } & VisualResponse;
export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest;
export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest | OcrRequest;
export type TextEncodingOptions = ModelOptions & { language?: string };
@Injectable()
@ -192,4 +202,10 @@ export class MachineLearningRepository {
return formData;
}
async ocr(urls: string[], imagePath: string, { modelName, minScore }: OcrOptions) {
const request = { [ModelTask.OCR]: { [ModelType.OCR]: { modelName, options: { minScore } } } };
const response = await this.predict<OcrResponse>(urls, { imagePath }, request);
return response[ModelTask.OCR];
}
}

View file

@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { Kysely, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { OcrEntity } from 'src/entities/ocr.entity';
@Injectable()
export class OcrRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
getOcrById(id: string): Promise<OcrEntity | null> {
return this.db
.selectFrom('asset_ocr')
.selectAll('asset_ocr')
.where('asset_ocr.assetId', '=', id)
.executeTakeFirst() as Promise<OcrEntity | null>;
}
async insertOcrData(assetId: string, text: string): Promise<void> {
await this.db
.insertInto('asset_ocr')
.values({ assetId, text })
.execute();
}
async deleteAllOcr(): Promise<void> {
await sql`truncate ${sql.table('asset_ocr')}`.execute(this.db);
}
getAllOcr(options: Partial<OcrEntity> = {}): AsyncIterableIterator<OcrEntity> {
return this.db
.selectFrom('asset_ocr')
.selectAll('asset_ocr')
.$if(!!options.assetId, (qb) => qb.where('asset_ocr.assetId', '=', options.assetId!))
.stream() as AsyncIterableIterator<OcrEntity>;
}
@GenerateSql()
async getLatestOcrDate(): Promise<string | undefined> {
const result = (await this.db
.selectFrom('asset_job_status')
.select((eb) => sql`${eb.fn.max('asset_job_status.ocrAt')}::text`.as('latestDate'))
.executeTakeFirst()) as { latestDate: string } | undefined;
return result?.latestDate;
}
async updateOcrData(id: string, ocrData: string): Promise<void> {
await this.db
.updateTable('asset_ocr')
.set({ text: ocrData })
.where('id', '=', id)
.execute();
}
getOcrWithoutText(): Promise<OcrEntity[]> {
return this.db
.selectFrom('asset_ocr')
.selectAll('asset_ocr')
.where('text', 'is', null)
.execute() as Promise<OcrEntity[]>;
}
async delete(ocr: OcrEntity[]): Promise<void> {
await this.db
.deleteFrom('asset_ocr')
.where('id', 'in', ocr.map((o) => o.id))
.execute();
}
}

View file

@ -84,6 +84,11 @@ export interface SearchEmbeddingOptions {
userIds: string[];
}
export interface SearchOcrOptions {
ocr: string;
userIds: string[];
}
export interface SearchPeopleOptions {
personIds?: string[];
}
@ -129,6 +134,8 @@ export type SmartSearchOptions = SearchDateOptions &
SearchPeopleOptions &
SearchTagOptions;
export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number };
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
@ -300,6 +307,35 @@ export class SearchRepository {
return this.db.selectFrom('smart_search').selectAll().where('assetId', '=', assetId).executeTakeFirst();
}
@GenerateSql({
params: [
{ page: 1, size: 100 },
{
userIds: [DummyValue.UUID],
ocr: DummyValue.STRING,
},
],
})
async searchOcr(pagination: SearchPaginationOptions, options: OcrSearchOptions) {
if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) {
throw new Error(`Invalid value for 'size': ${pagination.size}`);
}
const items = await this.db
.selectFrom('asset_ocr')
.selectAll()
.innerJoin('assets', 'assets.id', 'asset_ocr.assetId')
.where('assets.ownerId', '=', anyUuid(options.userIds))
.where('asset_ocr.text', 'ilike', `%${options.ocr}%`)
.limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size)
.execute() as any;
const hasNextPage = items.length > pagination.size;
items.splice(pagination.size);
return { items, hasNextPage };
}
@GenerateSql({
params: [
{

View file

@ -0,0 +1,13 @@
import { Column, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@Table('asset_ocr')
export class AssetOcrTable {
@PrimaryGeneratedColumn()
id!: string;
@Column({ type: 'uuid' })
assetId!: string;
@CreateDateColumn()
text!: string;
}

View file

@ -32,6 +32,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
@ -134,6 +135,7 @@ export class BaseService {
protected moveRepository: MoveRepository,
protected notificationRepository: NotificationRepository,
protected oauthRepository: OAuthRepository,
protected ocrRepository: OcrRepository,
protected partnerRepository: PartnerRepository,
protected personRepository: PersonRepository,
protected processRepository: ProcessRepository,

View file

@ -20,6 +20,7 @@ import { MemoryService } from 'src/services/memory.service';
import { MetadataService } from 'src/services/metadata.service';
import { NotificationAdminService } from 'src/services/notification-admin.service';
import { NotificationService } from 'src/services/notification.service';
import { OcrService } from 'src/services/ocr.service';
import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
import { SearchService } from 'src/services/search.service';
@ -64,6 +65,7 @@ export const services = [
MetadataService,
NotificationService,
NotificationAdminService,
OcrService,
PartnerService,
PersonService,
SearchService,

View file

@ -237,6 +237,14 @@ export class JobService extends BaseService {
return this.jobRepository.queue({ name: JobName.DatabaseBackup, data: { force } });
}
case QueueName.OCR: {
return this.jobRepository.queue({ name: JobName.QUEUE_OCR, data: { force } });
}
case QueueName.OCR: {
return this.jobRepository.queue({ name: JobName.QUEUE_OCR, data: { force } });
}
default: {
throw new BadRequestException(`Invalid job name: ${name}`);
}
@ -300,6 +308,7 @@ export class JobService extends BaseService {
if (config.nightlyTasks.clusterNewFaces) {
jobs.push({ name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } });
{ name: JobName.QUEUE_OCR, data: { force: false, nightly: true } },
}
await this.jobRepository.queueAll(jobs);
@ -353,6 +362,7 @@ export class JobService extends BaseService {
const jobs: JobItem[] = [
{ name: JobName.SmartSearch, data: item.data },
{ name: JobName.AssetDetectFaces, data: item.data },
{ name: JobName.OCR, data: item.data },
];
if (asset.type === AssetType.Video) {

View file

View file

@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { OnJob } from 'src/decorators';
import {
JobName,
JobStatus,
QueueName,
} from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util';
import { isOcrEnabled } from 'src/utils/misc';
@Injectable()
export class OcrService extends BaseService {
@OnJob({ name: JobName.OCR_CLEANUP, queue: QueueName.BACKGROUND_TASK })
async handleOcrCleanup(): Promise<JobStatus> {
const ocr = await this.ocrRepository.getOcrWithoutText();
await this.ocrRepository.delete(ocr);
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.QUEUE_OCR, queue: QueueName.OCR })
async handleQueueOcr({ force, nightly }: JobOf<JobName.QUEUE_OCR>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: false });
if (!isOcrEnabled(machineLearning)) {
return JobStatus.SKIPPED;
}
if (force) {
await this.ocrRepository.deleteAllOcr();
}
let jobs: JobItem[] = [];
const assets = this.assetJobRepository.streamForOcrJob(force);
for await (const asset of assets) {
jobs.push({ name: JobName.OCR, data: { id: asset.id } });
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(jobs);
jobs = [];
}
}
await this.jobRepository.queueAll(jobs);
if (force === undefined) {
await this.jobRepository.queue({ name: JobName.OCR_CLEANUP });
}
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.OCR, queue: QueueName.OCR })
async handleOcr({ id }: JobOf<JobName.OCR>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true });
if (!isOcrEnabled(machineLearning)) {
return JobStatus.SKIPPED;
}
const relations = { files: true };
const asset = await this.assetRepository.getById(id, relations);
if (!asset) {
return JobStatus.FAILED;
}
if (!asset.files) {
return JobStatus.FAILED;
}
const { previewFile } = getAssetFiles(asset.files);
if (!previewFile) {
return JobStatus.FAILED;
}
const ocrResults = await this.machineLearningRepository.ocr(
machineLearning.urls,
previewFile.path,
machineLearning.ocr
);
if (!ocrResults || ocrResults.text.length === 0) {
this.logger.warn(`No OCR results for document ${id}`);
await this.assetRepository.upsertJobStatus({
assetId: asset.id,
ocrAt: new Date(),
});
return JobStatus.SUCCESS;
}
this.logger.debug(`OCR ${id} has OCR results`);
const ocr = await this.ocrRepository.getOcrById(id);
if (ocr) {
this.logger.debug(`Updating OCR for ${id}`);
await this.ocrRepository.updateOcrData(id, ocrResults.text);
} else {
this.logger.debug(`Inserting OCR for ${id}`);
await this.ocrRepository.insertOcrData(id, ocrResults.text);
}
await this.assetRepository.upsertJobStatus({
assetId: asset.id,
ocrAt: new Date(),
});
this.logger.debug(`Processed OCR for ${id}`);
return JobStatus.SUCCESS;
}
}

View file

@ -7,6 +7,7 @@ import {
LargeAssetSearchDto,
mapPlaces,
MetadataSearchDto,
OcrSearchDto,
PlacesResponseDto,
RandomSearchDto,
SearchPeopleDto,
@ -22,7 +23,7 @@ import { AssetOrder, AssetVisibility, Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { requireElevatedPermission } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util';
import { isSmartSearchEnabled } from 'src/utils/misc';
import { isSmartSearchEnabled, isOcrEnabled } from 'src/utils/misc';
@Injectable()
export class SearchService extends BaseService {
@ -145,6 +146,23 @@ export class SearchService extends BaseService {
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
}
async searchOcr(auth: AuthDto, dto: OcrSearchDto): Promise<SearchResponseDto> {
const { machineLearning } = await this.getConfig({ withCache: false });
if (!isOcrEnabled(machineLearning)) {
throw new BadRequestException('OCR is not enabled');
}
const userIds = await this.getUserIdsToSearch(auth);
const page = dto.page ?? 1;
const size = dto.size || 250;
const { items, hasNextPage } = await this.searchRepository.searchOcr(
{ page, size },
{ ...dto, userIds },
);
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
}
async getAssetsByCity(auth: AuthDto): Promise<AssetResponseDto[]> {
const userIds = await this.getUserIdsToSearch(auth);
const assets = await this.searchRepository.getAssetsByCity(userIds);

View file

@ -19,7 +19,7 @@ import { UserStatsQueryResponse } from 'src/repositories/user.repository';
import { BaseService } from 'src/services/base.service';
import { asHumanReadable } from 'src/utils/bytes';
import { mimeTypes } from 'src/utils/mime-types';
import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc';
import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isOcrEnabled, isSmartSearchEnabled } from 'src/utils/misc';
@Injectable()
export class ServerService extends BaseService {
@ -97,6 +97,7 @@ export class ServerService extends BaseService {
trash: trash.enabled,
oauth: oauth.enabled,
oauthAutoLaunch: oauth.autoLaunch,
ocr: isOcrEnabled(machineLearning),
passwordLogin: passwordLogin.enabled,
configFile: !!configFile,
email: notifications.smtp.enabled,

View file

@ -367,7 +367,12 @@ export type JobItem =
| { name: JobName.NotifyUserSignup; data: INotifySignupJob }
// Version check
| { name: JobName.VersionCheck; data: IBaseJob };
| { name: JobName.VersionCheck; data: IBaseJob }
// OCR
| { name: JobName.QUEUE_OCR; data: INightlyJob }
| { name: JobName.OCR; data: IEntityJob }
| { name: JobName.OCR_CLEANUP; data?: IBaseJob };
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];

View file

@ -95,6 +95,8 @@ export const unsetDeep = (object: unknown, key: string) => {
const isMachineLearningEnabled = (machineLearning: SystemConfig['machineLearning']) => machineLearning.enabled;
export const isSmartSearchEnabled = (machineLearning: SystemConfig['machineLearning']) =>
isMachineLearningEnabled(machineLearning) && machineLearning.clip.enabled;
export const isOcrEnabled = (machineLearning: SystemConfig['machineLearning']) =>
isMachineLearningEnabled(machineLearning) && machineLearning.ocr.enabled;
export const isFacialRecognitionEnabled = (machineLearning: SystemConfig['machineLearning']) =>
isMachineLearningEnabled(machineLearning) && machineLearning.facialRecognition.enabled;
export const isDuplicateDetectionEnabled = (machineLearning: SystemConfig['machineLearning']) =>

View file

@ -19,6 +19,7 @@
mdiTable,
mdiTagFaces,
mdiVideo,
mdiOcr,
} from '@mdi/js';
import type { Component } from 'svelte';
import { t } from 'svelte-i18n';
@ -124,6 +125,14 @@
handleCommand: handleConfirmCommand,
disabled: !$featureFlags.facialRecognition,
},
[JobName.Ocr]: {
icon: mdiOcr,
title: $getJobName(JobName.Ocr),
subtitle: $t('admin.ocr_job_description'),
allText: $t('all'),
missingText: $t('missing'),
disabled: !$featureFlags.ocr,
},
[JobName.VideoConversion]: {
icon: mdiVideo,
title: $getJobName(JobName.VideoConversion),

View file

@ -31,6 +31,7 @@
JobName.VideoConversion,
JobName.StorageTemplateMigration,
JobName.Migration,
JobName.Ocr,
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -218,6 +218,42 @@
</div>
</SettingAccordion>
<SettingAccordion
key="ocr"
title={$t('admin.machine_learning_ocr')}
subtitle={$t('admin.machine_learning_ocr_description')}
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_ocr_enabled')}
subtitle={$t('admin.machine_learning_ocr_enabled_description')}
bind:checked={config.machineLearning.ocr.enabled}
disabled={disabled || !config.machineLearning.enabled}
/>
<SettingSelect
label={$t('admin.machine_learning_ocr_model')}
desc={$t('admin.machine_learning_ocr_model_description')}
name="ocr-model"
bind:value={config.machineLearning.ocr.modelName}
options={[
{ value: 'paddle', text: 'paddle' },
]}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.ocr.enabled}
isEdited={config.machineLearning.ocr.modelName !== savedConfig.machineLearning.ocr.modelName}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_ocr_min_score')}
bind:value={config.machineLearning.ocr.minScore}
step="0.1"
min={0.1}
max={1}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.ocr.enabled}
/>
</div>
</SettingAccordion>
<SettingButtonsRow
onReset={(options) => onReset({ ...options, configKeys: ['machineLearning'] })}
onSave={() => onSave({ machineLearning: config.machineLearning })}

View file

@ -8,7 +8,7 @@
import { handlePromiseError } from '$lib/utils';
import { generateId } from '$lib/utils/generate-id';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import type { MetadataSearchDto, OcrSearchDto, SmartSearchDto } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import { onDestroy, tick } from 'svelte';
@ -18,7 +18,7 @@
interface Props {
value?: string;
grayTheme: boolean;
searchQuery?: MetadataSearchDto | SmartSearchDto;
searchQuery?: MetadataSearchDto | SmartSearchDto | OcrSearchDto;
}
let { value = $bindable(''), grayTheme, searchQuery = {} }: Props = $props();
@ -39,7 +39,7 @@
searchStore.isSearchEnabled = false;
});
const handleSearch = async (payload: SmartSearchDto | MetadataSearchDto) => {
const handleSearch = async (payload: SmartSearchDto | MetadataSearchDto | OcrSearchDto) => {
const params = getMetadataSearchQuery(payload);
closeDropdown();
@ -107,7 +107,10 @@
const onSubmit = () => {
const searchType = getSearchType();
let payload: SmartSearchDto | MetadataSearchDto = {} as SmartSearchDto | MetadataSearchDto;
let payload: SmartSearchDto | MetadataSearchDto | OcrSearchDto = {} as
| SmartSearchDto
| MetadataSearchDto
| OcrSearchDto;
switch (searchType) {
case 'smart': {
@ -122,6 +125,10 @@
payload = { description: value } as MetadataSearchDto;
break;
}
case 'ocr': {
payload = { ocr: value } as OcrSearchDto;
break;
}
}
handlePromiseError(handleSearch(payload));
@ -171,7 +178,7 @@
onSubmit();
};
function getSearchType(): 'smart' | 'metadata' | 'description' {
function getSearchType(): 'smart' | 'metadata' | 'description' | 'ocr' {
const searchType = localStorage.getItem('searchQueryType');
switch (searchType) {
case 'smart': {
@ -183,6 +190,9 @@
case 'description': {
return 'description';
}
case 'ocr': {
return 'ocr';
}
default: {
return 'smart';
}
@ -201,6 +211,9 @@
case 'description': {
return $t('description');
}
case 'ocr': {
return $t('ocr');
}
}
}
</script>

View file

@ -4,7 +4,7 @@
interface Props {
query: string | undefined;
queryType?: 'smart' | 'metadata' | 'description';
queryType?: 'smart' | 'metadata' | 'description' | 'ocr';
}
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
@ -28,6 +28,7 @@
bind:group={queryType}
value="description"
/>
<RadioButton name="query-type" id="ocr-radio" label={$t('ocr')} bind:group={queryType} value="ocr" />
</div>
</fieldset>
@ -63,4 +64,15 @@
bind:value={query}
aria-labelledby="description-label"
/>
{:else if queryType === 'ocr'}
<label for="ocr-input" class="immich-form-label">{$t('search_by_ocr')}</label>
<input
class="immich-form-input hover:cursor-text w-full !mt-1"
type="text"
id="ocr-input"
name="ocr"
placeholder={$t('search_by_ocr_example')}
bind:value={query}
aria-labelledby="ocr-label"
/>
{/if}

View file

@ -136,9 +136,15 @@ export enum QueryType {
SMART = 'smart',
METADATA = 'metadata',
DESCRIPTION = 'description',
OCR = 'ocr',
}
export const validQueryTypes = new Set([QueryType.SMART, QueryType.METADATA, QueryType.DESCRIPTION]);
export const validQueryTypes = new Set([
QueryType.SMART,
QueryType.METADATA,
QueryType.DESCRIPTION,
QueryType.OCR,
]);
export const locales = [
{ code: 'af-ZA', name: 'Afrikaans (South Africa)' },

View file

@ -6,7 +6,8 @@
export type SearchFilter = {
query: string;
queryType: 'smart' | 'metadata' | 'description';
ocr: string;
queryType: 'smart' | 'metadata' | 'description' | 'ocr';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string> | null;
location: SearchLocationFilter;
@ -33,15 +34,15 @@
import { preferences } from '$lib/stores/user.store';
import { parseUtcDate } from '$lib/utils/date-time';
import { generateId } from '$lib/utils/generate-id';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type OcrSearchDto, type SmartSearchDto } from '@immich/sdk';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiTune } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
searchQuery: MetadataSearchDto | SmartSearchDto;
onClose: (search?: SmartSearchDto | MetadataSearchDto) => void;
searchQuery: MetadataSearchDto | SmartSearchDto | OcrSearchDto;
onClose: (search?: SmartSearchDto | MetadataSearchDto | OcrSearchDto) => void;
}
let { searchQuery, onClose }: Props = $props();
@ -74,6 +75,7 @@
let filter: SearchFilter = $state({
query,
ocr: 'ocr' in searchQuery ? searchQuery.ocr : '',
queryType: defaultQueryType(),
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds:
@ -112,6 +114,7 @@
const resetForm = () => {
filter = {
query: '',
ocr: '',
queryType: defaultQueryType(), // retain from localStorage or default
personIds: new SvelteSet(),
tagIds: new SvelteSet(),
@ -138,8 +141,9 @@
const query = filter.query || undefined;
let payload: SmartSearchDto | MetadataSearchDto = {
let payload: SmartSearchDto | MetadataSearchDto | OcrSearchDto = {
query: filter.queryType === 'smart' ? query : undefined,
ocr: filter.queryType === 'ocr' ? query : undefined,
originalFileName: filter.queryType === 'metadata' ? query : undefined,
description: filter.queryType === 'description' ? query : undefined,
country: filter.location.country,

View file

@ -26,6 +26,7 @@ export const featureFlags = writable<FeatureFlags>({
configFile: false,
trash: true,
email: false,
ocr: true,
});
export type ServerConfig = ServerConfigDto & { loaded: boolean };

View file

@ -161,6 +161,7 @@ export const getJobName = derived(t, ($t) => {
[JobName.Library]: $t('external_libraries'),
[JobName.Notifications]: $t('notifications'),
[JobName.BackupDatabase]: $t('admin.backup_database'),
[JobName.Ocr]: $t('admin.machine_learning_ocr'),
};
return names[jobName];

View file

@ -41,7 +41,9 @@
getPerson,
getTagById,
type MetadataSearchDto,
type OcrSearchDto,
searchAssets,
searchOcr,
searchSmart,
type SmartSearchDto,
} from '@immich/sdk';
@ -68,7 +70,7 @@
const assetInteraction = new AssetInteraction();
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'> & Pick<OcrSearchDto, 'ocr'>;
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
@ -164,7 +166,9 @@
try {
const { albums, assets } =
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
('ocr' in searchDto
? await searchOcr({ ocrSearchDto: searchDto })
: 'query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
? await searchSmart({ smartSearchDto: searchDto })
: await searchAssets({ metadataSearchDto: searchDto });
@ -211,6 +215,7 @@
originalFileName: $t('file_name'),
description: $t('description'),
queryAssetId: $t('query_asset_id'),
ocr: $t('ocr'),
};
return keyMap[key] || key;
}