Files
Immich/server/src/repositories/asset-job.repository.ts
T
Valentin AgachiandCodex c3933169de feat(server): add OCR extraction workflow filter (#32464)
feat(server): add OCR workflow filter

(cherry picked from commit 2e0eb35e93dcf8468398d7015daa5ab231c7dd9c)
2026-10-29 20:41:01 +03:00

478 lines
15 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { type Kysely, sql } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database.js';
import { DummyValue, GenerateSql } from 'src/decorators.js';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum.js';
import { DB } from 'src/schema/index.js';
import {
anyUuid,
asUuid,
withAudioStream,
withDefaultVisibility,
withEdits,
withExif,
withExifInner,
withFaces,
withFilePath,
withFiles,
withVideoFormat,
withVideoStream,
} from 'src/utils/database.js';
import { mimeTypes } from 'src/utils/mime-types.js';
@Injectable()
export class AssetJobRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
getForSearchDuplicatesJob(id: string) {
return this.db
.selectFrom('asset')
.where('asset.id', '=', asUuid(id))
.leftJoin('smart_search', 'asset.id', 'smart_search.assetId')
.select(['id', 'type', 'ownerId', 'duplicateId', 'stackId', 'visibility', 'smart_search.embedding'])
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForSidecarWriteJob(id: string) {
return this.db
.selectFrom('asset')
.where('asset.id', '=', asUuid(id))
.select(['id', 'originalPath'])
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.$call(withExifInner)
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForSidecarCheckJob(id: string) {
return this.db
.selectFrom('asset')
.where('asset.id', '=', asUuid(id))
.select(['id', 'originalPath'])
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [{ force: false, fullsizeEnabled: true }], stream: true })
streamForThumbnailJob(options: { force: boolean | undefined; fullsizeEnabled: boolean }) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.isEdited'])
.where('asset.deletedAt', 'is', null)
.where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden))
.$if(!options.force, (qb) =>
qb
// If there aren't any entries, metadata extraction hasn't run yet which is required for thumbnails
.innerJoin('asset_job_status', 'asset_job_status.assetId', 'asset.id')
.where(({ and, eb, exists, not, or, selectFrom }) => {
const file = (type: AssetFileType) =>
selectFrom('asset_file').whereRef('assetId', '=', 'asset.id').where('type', '=', sql.lit(type));
const conditions = [
not(exists(file(AssetFileType.Thumbnail))),
not(exists(file(AssetFileType.Preview))),
and([
eb('asset.isEdited', '=', sql.lit(true)),
not(exists(file(AssetFileType.FullSize).where('asset_file.isEdited', '=', sql.lit(true)))),
]),
eb('asset.thumbhash', 'is', null),
];
if (options.fullsizeEnabled) {
const isWebUnsupported = sql.join(
Object.keys(mimeTypes.webUnsupportedImage).map((ext) => sql.lit(`%${ext}`)),
);
conditions.push(
and([
not(exists(file(AssetFileType.FullSize))),
eb(sql`f_unaccent(asset."originalFileName")`, 'like', sql`any(array[${isWebUnsupported}]::text[])`),
]),
);
}
return or(conditions);
}),
)
.stream();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForMigrationJob(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.ownerId'])
.select(withFiles)
.where('asset.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForGenerateThumbnailJob(id: string) {
return this.db
.selectFrom('asset')
.select([
'asset.id',
'asset.visibility',
'asset.originalFileName',
'asset.originalPath',
'asset.ownerId',
'asset.thumbhash',
'asset.type',
])
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('asset_file')
.select(columns.assetFilesForThumbnail)
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]),
).as('files'),
)
.select(withEdits)
.$call(withExifInner)
.leftJoin('asset_video', 'asset_video.assetId', 'asset.id')
.select((eb) => withVideoStream(eb).as('videoStream'))
.select((eb) => withVideoFormat(eb).as('format'))
.where('asset.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForMetadataExtraction(id: string) {
return this.db
.selectFrom('asset')
.select(columns.asset)
.select(withFaces)
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.innerJoin('user', 'user.id', 'asset.ownerId')
.select(['user.clusterGroupId'])
.where('asset.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getLockedPropertiesForMetadataExtraction(assetId: string) {
return this.db
.selectFrom('asset_exif')
.select('asset_exif.lockedProperties')
.where('asset_exif.assetId', '=', assetId)
.executeTakeFirst()
.then((row) => row?.lockedProperties ?? []);
}
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Thumbnail] })
getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) {
return this.db
.selectFrom('asset_file')
.select(columns.assetFiles)
.where('asset_file.assetId', '=', id)
.$if(!!fileType, (qb) => qb.where('asset_file.type', '=', fileType!))
.execute();
}
private assetsWithPreviews() {
return this.db
.selectFrom('asset')
.where('asset.visibility', '!=', AssetVisibility.Hidden)
.where('asset.deletedAt', 'is', null)
.innerJoin('asset_job_status as job_status', 'assetId', 'asset.id')
.where((eb) =>
eb.exists((qb) =>
qb
.selectFrom('asset_file')
.whereRef('assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.Preview),
),
);
}
@GenerateSql({ params: [], stream: true })
streamForSearchDuplicates(force?: boolean) {
return this.db
.selectFrom('asset')
.select(['asset.id'])
.where('asset.deletedAt', 'is', null)
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
.$call(withDefaultVisibility)
.$if(!force, (qb) =>
qb
.innerJoin('asset_job_status as job_status', 'job_status.assetId', 'asset.id')
.where('job_status.duplicatesDetectedAt', 'is', null),
)
.stream();
}
@GenerateSql({ params: [], stream: true })
streamForEncodeClip(force?: boolean) {
return this.assetsWithPreviews()
.select(['asset.id'])
.$if(!force, (qb) =>
qb.where((eb) => eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'asset.id')))),
)
.stream();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForClipEncoding(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.visibility'])
.select((eb) => withFiles(eb, AssetFileType.Preview))
.where('asset.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForDetectFacesJob(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.visibility'])
.$call(withExifInner)
.select((eb) => withFaces(eb, true, true))
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('asset_file')
.select(columns.assetFiles)
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', sql.lit(AssetFileType.Preview))
.orderBy('asset_file.isEdited', 'desc')
.limit(sql.lit(1)),
).as('previewFile'),
)
.where('asset.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForOcr(id: string) {
return this.db
.selectFrom('asset')
.select((eb) => ['asset.ownerId', 'asset.visibility', withFilePath(eb, AssetFileType.Preview).as('previewFile')])
.where('asset.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
getForSyncAssets(ids: string[]) {
return this.db
.selectFrom('asset')
.select([
'asset.id',
'asset.isOffline',
'asset.libraryId',
'asset.originalPath',
'asset.status',
'asset.fileModifiedAt',
])
.where('asset.id', '=', anyUuid(ids))
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForAssetDeletion(id: string) {
return this.db
.selectFrom('asset')
.select([
'asset.id',
'asset.visibility',
'asset.libraryId',
'asset.ownerId',
'asset.livePhotoVideoId',
'asset.originalPath',
'asset.isOffline',
])
.$call(withExif)
.select(withFiles)
.leftJoinLateral(
(eb) =>
eb
.selectFrom('stack')
.whereRef('stack.id', '=', 'asset.stackId')
.select((eb) => [
'stack.id',
'stack.primaryAssetId',
jsonArrayFrom(
eb
.selectFrom('asset as stack_asset')
.select(['stack_asset.id'])
.whereRef('stack_asset.stackId', '=', 'stack.id')
.whereRef('stack_asset.id', '!=', 'stack.primaryAssetId')
.where('stack_asset.visibility', '=', sql.val(AssetVisibility.Timeline))
.where('stack_asset.status', '!=', sql.val(AssetStatus.Deleted)),
).as('assets'),
])
.as('stack_result'),
(join) => join.onTrue(),
)
.select((eb) =>
eb.fn
.toJson(eb.table('stack_result'))
.$castTo<{ id: string; primaryAssetId: string; assets: { id: string }[] } | null>()
.as('stack'),
)
.where('asset.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForVideoConversion(force?: boolean) {
return this.db
.selectFrom('asset')
.select(['asset.id'])
.where('asset.type', '=', sql.lit(AssetType.Video))
.$if(!force, (qb) =>
qb.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('asset_file')
.select('asset_file.id')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', sql.lit(AssetFileType.EncodedVideo)),
),
),
),
)
.where('asset.deletedAt', 'is', null)
.stream();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForVideoConversion(id: string) {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.innerJoin('asset_video', 'asset_video.assetId', 'asset.id')
.leftJoin('asset_audio', 'asset_audio.assetId', 'asset.id')
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
.select(withFiles)
.select((eb) => withAudioStream(eb).as('audioStream'))
.select((eb) => withVideoStream(eb).$notNull().as('videoStream'))
.select((eb) => withVideoFormat(eb).$notNull().as('format'))
.where('asset.id', '=', id)
.where('asset.type', '=', sql.lit(AssetType.Video))
.executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForMetadataExtraction(force?: boolean) {
return this.db
.selectFrom('asset')
.select(['asset.id'])
.$if(!force, (qb) =>
qb
.leftJoin('asset_job_status', 'asset_job_status.assetId', 'asset.id')
.where((eb) =>
eb.or([eb('asset_job_status.metadataExtractedAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]),
),
)
.where('asset.deletedAt', 'is', null)
.stream();
}
private storageTemplateAssetQuery() {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select([
'asset.id',
'asset.ownerId',
'asset.type',
'asset.checksum',
'asset.originalPath',
'asset.isExternal',
'asset.visibility',
'asset.originalFileName',
'asset.livePhotoVideoId',
'asset.fileCreatedAt',
'asset_exif.timeZone',
'asset_exif.fileSizeInByte',
'asset_exif.make',
'asset_exif.model',
'asset_exif.lensModel',
])
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.where('asset.deletedAt', 'is', null);
}
@GenerateSql({ params: [DummyValue.UUID] })
getForStorageTemplateJob(id: string, options?: { includeHidden?: boolean }) {
return this.storageTemplateAssetQuery()
.where('asset.id', '=', id)
.$if(!options?.includeHidden, (qb) => qb.where('asset.visibility', '!=', AssetVisibility.Hidden))
.executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForStorageTemplateJob() {
return this.storageTemplateAssetQuery().where('asset.visibility', '!=', AssetVisibility.Hidden).stream();
}
@GenerateSql({ params: [DummyValue.DATE], stream: true })
streamForDeletedJob(trashedBefore: Date) {
return this.db
.selectFrom('asset')
.select(['id', 'isOffline'])
.where('asset.deletedAt', '<=', trashedBefore)
.stream();
}
@GenerateSql({ params: [], stream: true })
streamForSidecar(force?: boolean) {
return this.db
.selectFrom('asset')
.select(['asset.id'])
.$if(!force, (qb) =>
qb.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('asset_file')
.select('asset_file.id')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.Sidecar),
),
),
),
)
.stream();
}
@GenerateSql({ params: [], stream: true })
streamForDetectFacesJob(force?: boolean) {
return this.assetsWithPreviews()
.$if(force === false, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
.select(['asset.id'])
.orderBy('asset.fileCreatedAt', 'desc')
.stream();
}
@GenerateSql({ params: [], stream: true })
streamForOcrJob(force?: boolean) {
return this.db
.selectFrom('asset')
.select(['asset.id'])
.$if(!force, (qb) =>
qb
.innerJoin('asset_job_status', 'asset_job_status.assetId', 'asset.id')
.where('asset_job_status.ocrAt', 'is', null),
)
.where('asset.deletedAt', 'is', null)
.where('asset.visibility', '!=', AssetVisibility.Hidden)
.stream();
}
@GenerateSql({ params: [DummyValue.DATE], stream: true })
streamForMigrationJob() {
return this.db.selectFrom('asset').select(['id']).where('asset.deletedAt', 'is', null).stream();
}
}