Files
immich/server/src/services/server.service.ts
Kang 02b29046b3 feat: ocr (#18836)
* feat: add OCR functionality and related configurations

* chore: update labeler configuration for machine learning files

* feat(i18n): enhance OCR model descriptions and add orientation classification and unwarping features

* chore: update Dockerfile to include ccache for improved build performance

* feat(ocr): enhance OCR model configuration with orientation classification and unwarping options, update PaddleOCR integration, and improve response structure

* refactor(ocr): remove OCR_CLEANUP job from enum and type definitions

* refactor(ocr): remove obsolete OCR entity and migration files, and update asset job status and schema to accommodate new OCR table structure

* refactor(ocr): update OCR schema and response structure to use individual coordinates instead of bounding box, and adjust related service and repository files

* feat: enhance OCR configuration and functionality

- Updated OCR settings to include minimum detection box score, minimum detection score, and minimum recognition score.
- Refactored PaddleOCRecognizer to utilize new scoring parameters.
- Introduced new database tables for asset OCR data and search functionality.
- Modified related services and repositories to support the new OCR features.
- Updated translations for improved clarity in settings UI.

* sql changes

* use rapidocr

* change dto

* update web

* update lock

* update api

* store positions as normalized floats

* match column order in db

* update admin ui settings descriptions

fix max resolution key

set min threshold to 0.1

fix bind

* apply config correctly, adjust defaults

* unnecessary model type

* unnecessary sources

* fix(ocr): switch RapidOCR lang type from LangDet to LangRec

* fix(ocr): expose lang_type (LangRec.CH) and font_path on OcrOptions for RapidOCR

* fix(ocr): make OCR text search case- and accent-insensitive using ILIKE + unaccent

* fix(ocr): add OCR search fields

* fix: Add OCR database migration and update ML prediction logic.

* trigrams are already case insensitive

* add tests

* format

* update migrations

* wrong uuid function

* linting

* maybe fix medium tests

* formatting

* fix weblate check

* openapi

* sql

* minor fixes

* maybe fix medium tests part 2

* passing medium tests

* format web

* readd sql

* format dart

* disabled in e2e

* chore: translation ordering

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-10-27 14:09:55 +00:00

200 lines
7.0 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { serverVersion } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import {
ServerAboutResponseDto,
ServerApkLinksDto,
ServerConfigDto,
ServerFeaturesDto,
ServerMediaTypesResponseDto,
ServerPingResponse,
ServerStatsResponseDto,
ServerStorageResponseDto,
UsageByUserDto,
} from 'src/dtos/server.dto';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
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,
isOcrEnabled,
isSmartSearchEnabled,
} from 'src/utils/misc';
@Injectable()
export class ServerService extends BaseService {
@OnEvent({ name: 'AppBootstrap' })
async onBootstrap(): Promise<void> {
const featureFlags = await this.getFeatures();
if (featureFlags.configFile) {
await this.systemMetadataRepository.set(SystemMetadataKey.AdminOnboarding, {
isOnboarded: true,
});
}
this.logger.log(`Feature Flags: ${JSON.stringify(await this.getFeatures(), null, 2)}`);
}
async getAboutInfo(): Promise<ServerAboutResponseDto> {
const version = `v${serverVersion.toString()}`;
const { buildMetadata } = this.configRepository.getEnv();
const buildVersions = await this.serverInfoRepository.getBuildVersions();
const licensed = await this.systemMetadataRepository.get(SystemMetadataKey.License);
return {
version,
versionUrl: `https://github.com/immich-app/immich/releases/tag/${version}`,
licensed: !!licensed,
...buildMetadata,
...buildVersions,
};
}
getApkLinks(): ServerApkLinksDto {
const baseUrl = `https://github.com/immich-app/immich/releases/download/v${serverVersion.toString()}`;
return {
arm64v8a: `${baseUrl}/app-arm64-v8a-release.apk`,
armeabiv7a: `${baseUrl}/app-armeabi-v7a-release.apk`,
universal: `${baseUrl}/app-release.apk`,
x86_64: `${baseUrl}/app-x86_64-release.apk`,
};
}
async getStorage(): Promise<ServerStorageResponseDto> {
const libraryBase = StorageCore.getBaseFolder(StorageFolder.Library);
const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase);
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
const serverInfo = new ServerStorageResponseDto();
serverInfo.diskAvailable = asHumanReadable(diskInfo.available);
serverInfo.diskSize = asHumanReadable(diskInfo.total);
serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free);
serverInfo.diskAvailableRaw = diskInfo.available;
serverInfo.diskSizeRaw = diskInfo.total;
serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
serverInfo.diskUsagePercentage = Number.parseFloat(usagePercentage);
return serverInfo;
}
ping(): ServerPingResponse {
return { res: 'pong' };
}
async getFeatures(): Promise<ServerFeaturesDto> {
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } =
await this.getConfig({ withCache: false });
const { configFile } = this.configRepository.getEnv();
return {
smartSearch: isSmartSearchEnabled(machineLearning),
facialRecognition: isFacialRecognitionEnabled(machineLearning),
duplicateDetection: isDuplicateDetectionEnabled(machineLearning),
map: map.enabled,
reverseGeocoding: reverseGeocoding.enabled,
importFaces: metadata.faces.import,
sidecar: true,
search: true,
trash: trash.enabled,
oauth: oauth.enabled,
oauthAutoLaunch: oauth.autoLaunch,
ocr: isOcrEnabled(machineLearning),
passwordLogin: passwordLogin.enabled,
configFile: !!configFile,
email: notifications.smtp.enabled,
};
}
async getTheme() {
const { theme } = await this.getConfig({ withCache: false });
return theme;
}
async getSystemConfig(): Promise<ServerConfigDto> {
const config = await this.getConfig({ withCache: false });
const isInitialized = await this.userRepository.hasAdmin();
const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding);
return {
loginPageMessage: config.server.loginPageMessage,
trashDays: config.trash.days,
userDeleteDelay: config.user.deleteDelay,
oauthButtonText: config.oauth.buttonText,
isInitialized,
isOnboarded: onboarding?.isOnboarded || false,
externalDomain: config.server.externalDomain,
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
};
}
async getStatistics(): Promise<ServerStatsResponseDto> {
const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
const serverStats = new ServerStatsResponseDto();
for (const user of userStats) {
const usage = new UsageByUserDto();
usage.userId = user.userId;
usage.userName = user.userName;
usage.photos = user.photos;
usage.videos = user.videos;
usage.usage = user.usage;
usage.usagePhotos = user.usagePhotos;
usage.usageVideos = user.usageVideos;
usage.quotaSizeInBytes = user.quotaSizeInBytes;
serverStats.photos += usage.photos;
serverStats.videos += usage.videos;
serverStats.usage += usage.usage;
serverStats.usagePhotos += usage.usagePhotos;
serverStats.usageVideos += usage.usageVideos;
serverStats.usageByUser.push(usage);
}
return serverStats;
}
getSupportedMediaTypes(): ServerMediaTypesResponseDto {
return {
video: Object.keys(mimeTypes.video),
image: Object.keys(mimeTypes.image),
sidecar: Object.keys(mimeTypes.sidecar),
};
}
async deleteLicense(): Promise<void> {
await this.systemMetadataRepository.delete(SystemMetadataKey.License);
}
async getLicense(): Promise<LicenseResponseDto> {
const license = await this.systemMetadataRepository.get(SystemMetadataKey.License);
if (!license) {
throw new NotFoundException();
}
return license;
}
async setLicense(dto: LicenseKeyDto): Promise<LicenseResponseDto> {
if (!dto.licenseKey.startsWith('IMSV-')) {
throw new BadRequestException('Invalid license key');
}
const { licensePublicKey } = this.configRepository.getEnv();
const licenseValid = this.cryptoRepository.verifySha256(dto.licenseKey, dto.activationKey, licensePublicKey.server);
if (!licenseValid) {
throw new BadRequestException('Invalid license key');
}
const licenseData = { ...dto, activatedAt: new Date() };
await this.systemMetadataRepository.set(SystemMetadataKey.License, licenseData);
return licenseData;
}
}