immich/server/src/utils/database.ts

526 lines
20 KiB
TypeScript
Raw Permalink Normal View History

import { createPostgres, DatabaseConnectionParams } from '@immich/sql-tools';
import {
2026-01-09 17:59:52 -05:00
AliasedRawBuilder,
2025-04-18 23:39:56 +02:00
DeduplicateJoinsPlugin,
Expression,
ExpressionBuilder,
2025-04-18 23:39:56 +02:00
Kysely,
KyselyConfig,
NotNull,
Selectable,
2025-04-18 23:39:56 +02:00
SelectQueryBuilder,
ShallowDehydrateObject,
sql,
} from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
2025-04-18 23:39:56 +02:00
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Notice, PostgresError } from 'postgres';
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
2026-01-09 17:59:52 -05:00
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
2025-04-18 23:39:56 +02:00
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
2025-06-30 13:19:16 -04:00
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types';
export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => {
return {
dialect: new PostgresJSDialect({
postgres: createPostgres({
connection,
onNotice: (notice: Notice) => {
if (notice['severity'] !== 'NOTICE') {
console.warn('Postgres notice:', notice);
}
},
}),
}),
log(event) {
if (event.level === 'error') {
if (isAssetChecksumConstraint(event.error)) {
return;
}
console.error('Query failed :', {
durationMs: event.queryDurationMillis,
error: event.error,
sql: event.query.sql,
params: event.query.parameters,
});
}
},
};
};
2023-12-08 11:15:46 -05:00
2025-01-09 11:15:41 -05:00
export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uuid`;
2025-01-09 11:15:41 -05:00
export const anyUuid = (ids: string[]) => sql<string>`any(${`{${ids}}`}::uuid[])`;
export const asVector = (embedding: number[]) => sql<string>`${`[${embedding}]`}::vector`;
export const unnest = (array: string[]) => sql<Record<string, string>>`unnest(array[${sql.join(array)}]::text[])`;
export const removeUndefinedKeys = <T extends object>(update: T, template: unknown) => {
for (const key in update) {
if ((template as T)[key] === undefined) {
delete update[key];
}
}
return update;
};
2025-04-18 23:39:56 +02:00
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
export const VIDEO_STREAM_SESSION_PK_CONSTRAINT = 'video_stream_session_pkey';
2025-04-18 23:39:56 +02:00
export const isAssetChecksumConstraint = (error: unknown) =>
(error as PostgresError)?.constraint_name === ASSET_CHECKSUM_CONSTRAINT;
export const isVideoStreamSessionPkConstraint = (error: unknown) =>
(error as PostgresError)?.constraint_name === VIDEO_STREAM_SESSION_PK_CONSTRAINT;
2025-07-14 10:13:06 -04:00
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
2025-07-15 14:50:13 -04:00
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
}
// TODO come up with a better query that only selects the fields we need
2025-07-14 10:13:06 -04:00
export function withExif<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
2025-04-18 23:39:56 +02:00
return qb
2025-07-14 10:13:06 -04:00
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) =>
eb.fn
.toJson(eb.table('asset_exif'))
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>> | null>()
.as('exifInfo'),
);
2025-04-18 23:39:56 +02:00
}
2025-07-14 10:13:06 -04:00
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
2025-04-18 23:39:56 +02:00
return qb
2025-07-14 10:13:06 -04:00
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo'))
.$narrowType<{ exifInfo: NotNull }>();
2025-04-18 23:39:56 +02:00
}
export const dummy = sql`(select 1)`.as('dummy');
export function withAudioStream(eb: ExpressionBuilder<DB, 'asset_exif' | 'asset_audio'>) {
return jsonObjectFrom(
eb
.selectFrom(dummy)
.select(['asset_audio.index', 'asset_audio.codecName', 'asset_audio.profile', 'asset_audio.bitrate'])
.where('asset_audio.assetId', 'is not', sql.lit(null))
.$castTo<AudioStreamInfo | null>(),
);
}
export function withVideoStream(eb: ExpressionBuilder<DB, 'asset_exif' | 'asset_video'>) {
return jsonObjectFrom(
eb
.selectFrom(dummy)
.select((eb) => [
'asset_video.index',
'asset_video.codecName',
'asset_video.profile',
'asset_video.level',
'asset_video.bitrate',
'asset_exif.exifImageWidth as width',
'asset_exif.exifImageHeight as height',
'asset_video.pixelFormat',
'asset_video.frameCount',
'asset_exif.fps as frameRate',
'asset_video.timeBase',
eb
.case()
.when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate90CW.toString()))
.then(sql.lit(-90))
.when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate270CW.toString()))
.then(sql.lit(90))
.when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate180.toString()))
.then(sql.lit(180))
.else(0)
.end()
.as('rotation'),
'asset_video.colorPrimaries',
'asset_video.colorMatrix',
'asset_video.colorTransfer',
'asset_video.dvProfile',
'asset_video.dvLevel',
'asset_video.dvBlSignalCompatibilityId',
])
.where('asset_video.assetId', 'is not', sql.lit(null)),
).$castTo<(VideoStreamInfo & { timeBase: number }) | null>();
}
export function withVideoFormat(eb: ExpressionBuilder<DB, 'asset' | 'asset_video'>) {
return jsonObjectFrom(
eb
.selectFrom(dummy)
.select(['asset_video.formatName', 'asset_video.formatLongName', 'asset.duration', 'asset_video.bitrate'])
.where('asset_video.assetId', 'is not', sql.lit(null)),
).$castTo<VideoFormat | null>();
}
export function withVideoPackets(eb: ExpressionBuilder<DB, 'asset' | 'asset_keyframe'>) {
return jsonObjectFrom(
eb
.selectFrom(dummy)
.where('asset_keyframe.assetId', 'is not', sql.lit(null))
.select([
'asset_keyframe.pts as keyframePts',
'asset_keyframe.accDuration as keyframeAccDuration',
'asset_keyframe.ownDuration as keyframeOwnDuration',
'asset_keyframe.totalDuration',
'asset_keyframe.packetCount',
'asset_keyframe.outputFrames',
]),
).$castTo<VideoPacketInfo | null>();
}
2025-07-14 10:13:06 -04:00
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
2025-04-18 23:39:56 +02:00
return qb
2025-07-14 10:13:06 -04:00
.leftJoin('smart_search', 'asset.id', 'smart_search.assetId')
.select((eb) => jsonObjectFrom(eb.table('smart_search')).as('smartSearch'));
2025-04-18 23:39:56 +02:00
}
2026-01-09 17:59:52 -05:00
export function withFaces(eb: ExpressionBuilder<DB, 'asset'>, withHidden?: boolean, withDeletedFace?: boolean) {
2025-04-18 23:39:56 +02:00
return jsonArrayFrom(
eb
2025-07-14 10:13:06 -04:00
.selectFrom('asset_face')
.selectAll('asset_face')
.whereRef('asset_face.assetId', '=', 'asset.id')
2026-01-09 17:59:52 -05:00
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', '=', true)),
2025-04-18 23:39:56 +02:00
).as('faces');
}
2025-07-14 10:13:06 -04:00
export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
2025-04-18 23:39:56 +02:00
return jsonArrayFrom(
eb
2025-07-14 10:13:06 -04:00
.selectFrom('asset_file')
2025-04-18 23:39:56 +02:00
.select(columns.assetFiles)
2025-07-14 10:13:06 -04:00
.whereRef('asset_file.assetId', '=', 'asset.id')
.$if(!!type, (qb) => qb.where('asset_file.type', '=', type!)),
2025-04-18 23:39:56 +02:00
).as('files');
}
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType, isEdited = false) {
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 22:09:55 +08:00
return eb
.selectFrom('asset_file')
.select('asset_file.path')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', sql.lit(type))
.where('asset_file.isEdited', '=', sql.lit(isEdited));
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 22:09:55 +08:00
}
2026-01-09 17:59:52 -05:00
export function withFacesAndPeople(
eb: ExpressionBuilder<DB, 'asset'>,
withHidden?: boolean,
withDeletedFace?: boolean,
) {
2025-04-18 23:39:56 +02:00
return jsonArrayFrom(
eb
2025-07-14 10:13:06 -04:00
.selectFrom('asset_face')
2025-04-18 23:39:56 +02:00
.leftJoinLateral(
(eb) =>
2025-07-14 10:13:06 -04:00
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'),
2025-04-18 23:39:56 +02:00
(join) => join.onTrue(),
)
2025-07-14 10:13:06 -04:00
.selectAll('asset_face')
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
2025-07-14 10:13:06 -04:00
.whereRef('asset_face.assetId', '=', 'asset.id')
2026-01-09 17:59:52 -05:00
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),
2025-04-18 23:39:56 +02:00
).as('faces');
}
2025-07-14 10:13:06 -04:00
export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds: string[]) {
2025-04-18 23:39:56 +02:00
return qb.innerJoin(
(eb) =>
eb
2025-07-14 10:13:06 -04:00
.selectFrom('asset_face')
2025-04-18 23:39:56 +02:00
.select('assetId')
.where('personId', '=', anyUuid(personIds!))
.where('deletedAt', 'is', null)
2026-01-09 17:59:52 -05:00
.where('isVisible', 'is', true)
2025-04-18 23:39:56 +02:00
.groupBy('assetId')
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
.as('has_people'),
2025-07-14 10:13:06 -04:00
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
2025-04-18 23:39:56 +02:00
);
}
2025-07-14 10:13:06 -04:00
export function inAlbums<O>(qb: SelectQueryBuilder<DB, 'asset', O>, albumIds: string[]) {
return qb.innerJoin(
(eb) =>
eb
2025-07-14 10:13:06 -04:00
.selectFrom('album_asset')
.select('assetId')
.where('albumId', '=', anyUuid(albumIds!))
.groupBy('assetId')
.having((eb) => eb.fn.count('albumId').distinct(), '=', albumIds.length)
.as('has_album'),
(join) => join.onRef('has_album.assetId', '=', 'asset.id'),
);
}
2025-07-14 10:13:06 -04:00
export function hasTags<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagIds: string[]) {
2025-04-18 23:39:56 +02:00
return qb.innerJoin(
(eb) =>
eb
.selectFrom('tag_asset')
.select('assetId')
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
2025-07-14 10:13:06 -04:00
.where('tag_closure.id_ancestor', '=', anyUuid(tagIds))
.groupBy('assetId')
2025-07-14 10:13:06 -04:00
.having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '>=', tagIds.length)
2025-04-18 23:39:56 +02:00
.as('has_tags'),
(join) => join.onRef('has_tags.assetId', '=', 'asset.id'),
2025-04-18 23:39:56 +02:00
);
}
2025-07-14 10:13:06 -04:00
export function withOwner(eb: ExpressionBuilder<DB, 'asset'>) {
return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'asset.ownerId')).as(
2025-04-18 23:39:56 +02:00
'owner',
);
}
2025-07-14 10:13:06 -04:00
export function withLibrary(eb: ExpressionBuilder<DB, 'asset'>) {
2025-04-18 23:39:56 +02:00
return jsonObjectFrom(
2025-07-14 10:13:06 -04:00
eb.selectFrom('library').selectAll('library').whereRef('library.id', '=', 'asset.libraryId'),
2025-04-18 23:39:56 +02:00
).as('library');
}
2025-07-14 10:13:06 -04:00
export function withTags(eb: ExpressionBuilder<DB, 'asset'>) {
2025-04-18 23:39:56 +02:00
return jsonArrayFrom(
eb
2025-07-14 10:13:06 -04:00
.selectFrom('tag')
2025-04-18 23:39:56 +02:00
.select(columns.tag)
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
.whereRef('asset.id', '=', 'tag_asset.assetId'),
2025-04-18 23:39:56 +02:00
).as('tags');
}
export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt) {
return sql<O>`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
2025-04-18 23:39:56 +02:00
}
2025-07-14 10:13:06 -04:00
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {
2025-04-18 23:39:56 +02:00
return qb.where((eb) =>
eb.exists(
eb
2025-07-14 10:13:06 -04:00
.selectFrom('tag_closure')
.innerJoin('tag_asset', 'tag_asset.tagId', 'tag_closure.id_descendant')
.whereRef('tag_asset.assetId', '=', 'asset.id')
2025-07-14 10:13:06 -04:00
.where('tag_closure.id_ancestor', '=', tagId),
2025-04-18 23:39:56 +02:00
),
);
}
feat(server): lighter buckets (#17831) * feat(web): lighter timeline buckets * GalleryViewer * weird ssr * Remove generics from AssetInteraction * ensure keys on getAssetInfo, alt-text * empty - trigger ci * re-add alt-text * test fix * update tests * tests * missing import * feat(server): lighter buckets * fix: flappy e2e test * lint * revert settings * unneeded cast * fix after merge * Adapt web client to consume new server response format * test * missing import * lint * Use nulls, make-sql * openapi battle * date->string * tests * tests * lint/tests * lint * test * push aggregation to query * openapi * stack as tuple * openapi * update references to description * update alt text tests * update sql * update sql * update timeline tests * linting, fix expected response * string tuple * fix spec * fix * silly generator * rename patch * minimize sorting * review * lint * lint * sql * test * avoid abbreviations * review comment - type safety in test * merge conflicts * lint * lint/abbreviations * remove unncessary code * review comments * sql * re-add package-lock * use booleans, fix visibility in openapi spec, less cursed controller * update sql * no need to use sql template * array access actually doesn't seem to matter * remove redundant code * re-add sql decorator * unused type * remove null assertions * bad merge * Fix test * shave * extra clean shave * use decorator for content type * redundant types * redundant comment * update comment * unnecessary res --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-05-19 17:40:48 -04:00
const isCJK = (c: number): boolean =>
(c >= 0x4e_00 && c <= 0x9f_ff) ||
(c >= 0xac_00 && c <= 0xd7_af) ||
(c >= 0x30_40 && c <= 0x30_9f) ||
(c >= 0x30_a0 && c <= 0x30_ff) ||
(c >= 0x34_00 && c <= 0x4d_bf);
export const tokenizeForSearch = (text: string): string[] => {
/* eslint-disable unicorn/prefer-code-point */
const tokens: string[] = [];
let i = 0;
while (i < text.length) {
const c = text.charCodeAt(i);
if (c <= 32) {
i++;
continue;
}
const start = i;
if (isCJK(c)) {
while (i < text.length && isCJK(text.charCodeAt(i))) {
i++;
}
if (i - start === 1) {
tokens.push(text[start]);
} else {
for (let k = start; k < i - 1; k++) {
tokens.push(text[k] + text[k + 1]);
}
}
} else {
while (i < text.length && text.charCodeAt(i) > 32 && !isCJK(text.charCodeAt(i))) {
i++;
}
tokens.push(text.slice(start, i));
}
}
return tokens;
};
2026-01-09 17:59:52 -05:00
// needed to properly type the return with the EditActionItem discriminated union type
type AliasedEditActions = AliasedRawBuilder<AssetEditActionItem[], 'edits'>;
export function withEdits(eb: ExpressionBuilder<DB, 'asset'>): AliasedEditActions {
return jsonArrayFrom(
eb
.selectFrom('asset_edit')
.select(['asset_edit.action', 'asset_edit.parameters'])
.whereRef('asset_edit.assetId', '=', 'asset.id'),
).as('edits') as AliasedEditActions;
}
2025-04-18 23:39:56 +02:00
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
2025-07-15 14:50:13 -04:00
const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility;
2025-04-18 23:39:56 +02:00
return kysely
.withPlugin(joinDeduplicationPlugin)
2025-07-14 10:13:06 -04:00
.selectFrom('asset')
.where('asset.visibility', '=', visibility)
.$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!))
2025-04-18 23:39:56 +02:00
.$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!))
.$if(options.tagIds === null, (qb) =>
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('tag_asset').whereRef('assetId', '=', 'asset.id')))),
)
2025-04-18 23:39:56 +02:00
.$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!))
2025-07-14 10:13:06 -04:00
.$if(!!options.createdBefore, (qb) => qb.where('asset.createdAt', '<=', options.createdBefore!))
.$if(!!options.createdAfter, (qb) => qb.where('asset.createdAt', '>=', options.createdAfter!))
.$if(!!options.updatedBefore, (qb) => qb.where('asset.updatedAt', '<=', options.updatedBefore!))
.$if(!!options.updatedAfter, (qb) => qb.where('asset.updatedAt', '>=', options.updatedAfter!))
.$if(!!options.trashedBefore, (qb) => qb.where('asset.deletedAt', '<=', options.trashedBefore!))
.$if(!!options.trashedAfter, (qb) => qb.where('asset.deletedAt', '>=', options.trashedAfter!))
.$if(!!options.takenBefore, (qb) => qb.where('asset.fileCreatedAt', '<=', options.takenBefore!))
.$if(!!options.takenAfter, (qb) => qb.where('asset.fileCreatedAt', '>=', options.takenAfter!))
2025-04-18 23:39:56 +02:00
.$if(options.city !== undefined, (qb) =>
qb
2025-07-14 10:13:06 -04:00
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('asset_exif.city', options.city === null ? 'is' : '=', options.city!),
2025-04-18 23:39:56 +02:00
)
.$if(options.state !== undefined, (qb) =>
qb
2025-07-14 10:13:06 -04:00
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('asset_exif.state', options.state === null ? 'is' : '=', options.state!),
2025-04-18 23:39:56 +02:00
)
.$if(options.country !== undefined, (qb) =>
qb
2025-07-14 10:13:06 -04:00
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('asset_exif.country', options.country === null ? 'is' : '=', options.country!),
2025-04-18 23:39:56 +02:00
)
.$if(options.make !== undefined, (qb) =>
qb
2025-07-14 10:13:06 -04:00
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('asset_exif.make', options.make === null ? 'is' : '=', options.make!),
2025-04-18 23:39:56 +02:00
)
.$if(options.model !== undefined, (qb) =>
qb
2025-07-14 10:13:06 -04:00
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('asset_exif.model', options.model === null ? 'is' : '=', options.model!),
2025-04-18 23:39:56 +02:00
)
.$if(options.lensModel !== undefined, (qb) =>
qb
2025-07-14 10:13:06 -04:00
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('asset_exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!),
2025-04-18 23:39:56 +02:00
)
.$if(options.rating !== undefined, (qb) =>
qb
2025-07-14 10:13:06 -04:00
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('asset_exif.rating', options.rating === null ? 'is' : '=', options.rating!),
2025-04-18 23:39:56 +02:00
)
2025-07-14 10:13:06 -04:00
.$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!))
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(!!options.encodedVideoPath, (qb) =>
qb
.innerJoin('asset_file', (join) =>
join
.onRef('asset.id', '=', 'asset_file.assetId')
.on('asset_file.type', '=', AssetFileType.EncodedVideo)
.on('asset_file.isEdited', '=', false),
)
.where('asset_file.path', '=', options.encodedVideoPath!),
)
2025-04-18 23:39:56 +02:00
.$if(!!options.originalPath, (qb) =>
2025-07-14 10:13:06 -04:00
qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`),
2025-04-18 23:39:56 +02:00
)
.$if(!!options.originalFileName, (qb) =>
qb.where(
2025-07-14 10:13:06 -04:00
sql`f_unaccent(asset."originalFileName")`,
2025-04-18 23:39:56 +02:00
'ilike',
sql`'%' || f_unaccent(${options.originalFileName}) || '%'`,
),
)
.$if(!!options.description, (qb) =>
qb
2025-07-14 10:13:06 -04:00
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where(sql`f_unaccent(asset_exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`),
2025-04-18 23:39:56 +02:00
)
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 22:09:55 +08:00
.$if(!!options.ocr, (qb) =>
qb
.innerJoin('ocr_search', 'asset.id', 'ocr_search.assetId')
.where(() => sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(options.ocr!).join(' ')})`),
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 22:09:55 +08:00
)
2025-07-14 10:13:06 -04:00
.$if(!!options.type, (qb) => qb.where('asset.type', '=', options.type!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!))
2025-04-18 23:39:56 +02:00
.$if(options.isEncoded !== undefined, (qb) =>
qb.where((eb) => {
const exists = eb.exists((eb) =>
eb
.selectFrom('asset_file')
.whereRef('assetId', '=', 'asset.id')
.where('type', '=', AssetFileType.EncodedVideo),
);
return options.isEncoded ? exists : eb.not(exists);
}),
2025-04-18 23:39:56 +02:00
)
.$if(options.isMotion !== undefined, (qb) =>
2025-07-14 10:13:06 -04:00
qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null),
2025-04-18 23:39:56 +02:00
)
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
2025-04-18 23:39:56 +02:00
)
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
2025-04-18 23:39:56 +02:00
.$if(!!options.withExif, withExifInner)
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
2025-07-14 10:13:06 -04:00
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
2025-04-18 23:39:56 +02:00
}
export type ReindexVectorIndexOptions = { indexName: string; lists?: number };
type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions;
export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: VectorIndexQueryOptions): string {
switch (vectorExtension) {
2025-07-15 14:50:13 -04:00
case DatabaseExtension.VectorChord: {
return `
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING vchordrq (embedding vector_cosine_ops) WITH (options = $$
residual_quantization = false
[build.internal]
lists = [${lists ?? 1}]
spherical_centroids = true
build_threads = 4
sampling_factor = 1024
$$)`;
}
2025-07-15 14:50:13 -04:00
case DatabaseExtension.Vector: {
return `
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table}
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 300, m = 16)`;
}
default: {
throw new Error(`Unsupported vector extension: '${vectorExtension}'`);
}
}
}
export const updateLockedColumns = <T extends Record<string, unknown> & { lockedProperties?: LockableProperty[] }>(
exif: T,
) => {
exif.lockedProperties = lockableProperties.filter((property) => property in exif);
return exif;
};