mirror-immich/server/src/repositories/asset.repository.ts

1202 lines
44 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import {
ExpressionBuilder,
Insertable,
Kysely,
NotNull,
Selectable,
SelectQueryBuilder,
sql,
Updateable,
UpdateResult,
} from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import {
anyUuid,
asUuid,
hasPeople,
removeUndefinedKeys,
truncatedDate,
unnest,
withDefaultVisibility,
withEdits,
withExif,
withFaces,
withFacesAndPeople,
withFilePath,
withFiles,
withLibrary,
withOwner,
withSmartSearch,
withTagId,
withTags,
} from 'src/utils/database';
import { globToSqlPattern } from 'src/utils/misc';
export type AssetStats = Record<AssetType, number>;
export interface BoundingBox {
west: number;
south: number;
east: number;
north: number;
}
interface AssetStatsOptions {
isFavorite?: boolean;
isTrashed?: boolean;
visibility?: AssetVisibility;
}
interface LivePhotoSearchOptions {
ownerId: string;
libraryId?: string | null;
livePhotoCID: string;
otherAssetId: string;
type: AssetType;
}
interface AssetBuilderOptions {
isFavorite?: boolean;
isTrashed?: boolean;
isDuplicate?: boolean;
albumId?: string;
tagId?: string;
personId?: string;
userIds?: string[];
withStacked?: boolean;
exifInfo?: boolean;
status?: AssetStatus;
assetType?: AssetType;
visibility?: AssetVisibility;
withCoordinates?: boolean;
bbox?: BoundingBox;
}
export interface TimeBucketOptions extends AssetBuilderOptions {
order?: AssetOrder;
orderBy?: AssetOrderBy;
}
export interface TimeBucketItem {
timeBucket: string;
count: number;
}
export interface YearMonthDay {
day: number;
month: number;
year: number;
}
interface AssetExploreFieldOptions {
maxFields: number;
minAssetsPerField: number;
}
interface AssetGetByChecksumOptions {
ownerId: string;
checksum: Buffer;
libraryId?: string;
}
interface GetByIdsRelations {
exifInfo?: boolean;
faces?: { person?: boolean; withDeleted?: boolean };
files?: boolean;
library?: boolean;
owner?: boolean;
smartSearch?: boolean;
stack?: { assets?: boolean };
tags?: boolean;
edits?: boolean;
}
type UpsertExifOptions = {
exif: Insertable<AssetExifTable>;
audio?: Insertable<AssetAudioTable>;
video?: Insertable<AssetVideoTable>;
keyframes?: Insertable<AssetKeyframeTable>;
lockedPropertiesBehavior: 'override' | 'append' | 'skip';
};
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
const getBoundingCircle = (bbox: BoundingBox) => {
const { west, south, east, north } = bbox;
const eastUnwrapped = west <= east ? east : east + 360;
const centerLongitude = (((west + eastUnwrapped) / 2 + 540) % 360) - 180;
const centerLatitude = (south + north) / 2;
const radius = sql<number>`greatest(
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${west})),
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${east})),
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${west})),
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${east}))
)`;
return { centerLatitude, centerLongitude, radius };
};
const withBoundingBox = <T>(qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', T>, bbox: BoundingBox) => {
const { west, south, east, north } = bbox;
const withLatitude = qb.where('asset_exif.latitude', '>=', south).where('asset_exif.latitude', '<=', north);
if (west <= east) {
return withLatitude.where('asset_exif.longitude', '>=', west).where('asset_exif.longitude', '<=', east);
}
return withLatitude.where((eb) =>
eb.or([eb('asset_exif.longitude', '>=', west), eb('asset_exif.longitude', '<=', east)]),
);
};
@Injectable()
export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({
params: [
{
exif: { dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] },
lockedPropertiesBehavior: 'append',
},
],
})
async upsertExif({ exif, audio, video, keyframes, lockedPropertiesBehavior }: UpsertExifOptions): Promise<void> {
let query = this.db;
if (audio) {
(query as any) = this.db.with('audio', (qb) =>
qb
.insertInto('asset_audio')
.values(audio)
.onConflict((oc) =>
oc.column('assetId').doUpdateSet(({ ref }) => ({
bitrate: ref('asset_audio.bitrate'),
index: ref('asset_audio.index'),
profile: ref('asset_audio.profile'),
codecName: ref('asset_audio.codecName'),
})),
),
);
}
if (video) {
(query as any) = query.with('video', (qb) =>
qb
.insertInto('asset_video')
.values(video)
.onConflict((oc) =>
oc.column('assetId').doUpdateSet(({ ref }) => ({
bitrate: ref('asset_video.bitrate'),
timeBase: ref('asset_video.timeBase'),
index: ref('asset_video.index'),
profile: ref('asset_video.profile'),
level: ref('asset_video.level'),
colorPrimaries: ref('asset_video.colorPrimaries'),
colorTransfer: ref('asset_video.colorTransfer'),
colorMatrix: ref('asset_video.colorMatrix'),
dvProfile: ref('asset_video.dvProfile'),
dvLevel: ref('asset_video.dvLevel'),
dvBlSignalCompatibilityId: ref('asset_video.dvBlSignalCompatibilityId'),
codecName: ref('asset_video.codecName'),
formatName: ref('asset_video.formatName'),
formatLongName: ref('asset_video.formatLongName'),
pixelFormat: ref('asset_video.pixelFormat'),
})),
),
);
}
if (keyframes) {
(query as any) = query.with('keyframe', (qb) =>
qb
.insertInto('asset_keyframe')
.values(keyframes)
.onConflict((oc) =>
oc.column('assetId').doUpdateSet(({ ref }) => ({
pts: ref('asset_keyframe.pts'),
accDuration: ref('asset_keyframe.accDuration'),
ownDuration: ref('asset_keyframe.ownDuration'),
totalDuration: ref('asset_keyframe.totalDuration'),
packetCount: ref('asset_keyframe.packetCount'),
outputFrames: ref('asset_keyframe.outputFrames'),
})),
),
);
}
await query
.insertInto('asset_exif')
.values(exif)
.onConflict((oc) =>
oc.column('assetId').doUpdateSet((eb) => {
const updateLocked = <T extends keyof AssetExifTable>(col: T) => eb.ref(`excluded.${col}`);
const skipLocked = <T extends keyof AssetExifTable>(col: T) =>
eb
.case()
.when(sql`${col}`, '=', eb.fn.any('asset_exif.lockedProperties'))
.then(eb.ref(`asset_exif.${col}`))
.else(eb.ref(`excluded.${col}`))
.end();
const ref = lockedPropertiesBehavior === 'skip' ? skipLocked : updateLocked;
return {
...removeUndefinedKeys(
{
description: ref('description'),
exifImageWidth: ref('exifImageWidth'),
exifImageHeight: ref('exifImageHeight'),
fileSizeInByte: ref('fileSizeInByte'),
orientation: ref('orientation'),
dateTimeOriginal: ref('dateTimeOriginal'),
modifyDate: ref('modifyDate'),
timeZone: ref('timeZone'),
latitude: ref('latitude'),
longitude: ref('longitude'),
projectionType: ref('projectionType'),
city: ref('city'),
livePhotoCID: ref('livePhotoCID'),
autoStackId: ref('autoStackId'),
state: ref('state'),
country: ref('country'),
make: ref('make'),
model: ref('model'),
lensModel: ref('lensModel'),
fNumber: ref('fNumber'),
focalLength: ref('focalLength'),
iso: ref('iso'),
exposureTime: ref('exposureTime'),
profileDescription: ref('profileDescription'),
colorspace: ref('colorspace'),
bitsPerSample: ref('bitsPerSample'),
rating: ref('rating'),
fps: ref('fps'),
tags: ref('tags'),
lockedProperties:
lockedPropertiesBehavior === 'append'
? distinctLocked(eb, exif.lockedProperties ?? null)
: ref('lockedProperties'),
},
exif,
),
};
}),
)
.execute();
}
@GenerateSql({ params: [[DummyValue.UUID], { model: DummyValue.STRING }] })
@Chunked()
async updateAllExif(ids: string[], options: Updateable<AssetExifTable>): Promise<void> {
if (ids.length === 0) {
return;
}
await this.db
.updateTable('asset_exif')
.set((eb) => ({
...options,
lockedProperties: distinctLocked(eb, Object.keys(options) as LockableProperty[]),
}))
.where('assetId', 'in', ids)
.execute();
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] })
@Chunked()
updateDateTimeOriginal(ids: string[], delta?: number, timeZone?: string) {
return this.db
.updateTable('asset_exif')
.set((eb) => ({
dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`,
timeZone,
lockedProperties: distinctLocked(eb, ['dateTimeOriginal', 'timeZone']),
}))
.where('assetId', 'in', ids)
.returning(['assetId', 'dateTimeOriginal', 'timeZone'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, ['description']] })
unlockProperties(assetId: string, properties: LockableProperty[]) {
return this.db
.updateTable('asset_exif')
.where('assetId', '=', assetId)
.set((eb) => ({
lockedProperties: sql`nullif(array(select distinct property from unnest(${eb.ref('asset_exif.lockedProperties')}) property where not property = any(${properties})), '{}')`,
}))
.execute();
}
async upsertJobStatus(...jobStatus: Insertable<AssetJobStatusTable>[]): Promise<void> {
if (jobStatus.length === 0) {
return;
}
const values = jobStatus.map((row) => ({ ...row, assetId: asUuid(row.assetId) }));
await this.db
.insertInto('asset_job_status')
.values(values)
.onConflict((oc) =>
oc.column('assetId').doUpdateSet((eb) =>
removeUndefinedKeys(
{
duplicatesDetectedAt: eb.ref('excluded.duplicatesDetectedAt'),
facesRecognizedAt: eb.ref('excluded.facesRecognizedAt'),
metadataExtractedAt: eb.ref('excluded.metadataExtractedAt'),
ocrAt: eb.ref('excluded.ocrAt'),
},
values[0],
),
),
)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getMetadata(assetId: string) {
return this.db
.selectFrom('asset_metadata')
.select(['key', 'value', 'updatedAt'])
.where('assetId', '=', assetId)
.execute();
}
upsertMetadata(id: string, items: Array<{ key: string; value: Record<string, unknown> }>) {
if (items.length === 0) {
return [];
}
return this.db
.insertInto('asset_metadata')
.values(items.map((item) => ({ assetId: id, ...item })))
.onConflict((oc) =>
oc
.columns(['assetId', 'key'])
.doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })),
)
.returning(['key', 'value', 'updatedAt'])
.execute();
}
upsertBulkMetadata(items: Insertable<AssetMetadataTable>[]) {
return this.db
.insertInto('asset_metadata')
.values(items)
.onConflict((oc) =>
oc
.columns(['assetId', 'key'])
.doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })),
)
.returning(['assetId', 'key', 'value', 'updatedAt'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getMetadataByKey(assetId: string, key: string) {
return this.db
.selectFrom('asset_metadata')
.select(['key', 'value', 'updatedAt'])
.where('assetId', '=', assetId)
.where('key', '=', key)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async deleteMetadataByKey(id: string, key: string) {
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, key: DummyValue.STRING }]] })
async deleteBulkMetadata(items: Array<{ assetId: string; key: string }>) {
if (items.length === 0) {
return;
}
await this.db.transaction().execute(async (tx) => {
for (const { assetId, key } of items) {
await tx.deleteFrom('asset_metadata').where('assetId', '=', assetId).where('key', '=', key).execute();
}
});
}
create(asset: Insertable<AssetTable>) {
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
}
@ChunkedArray({ chunkSize: 4000 })
async createAll(assets: Insertable<AssetTable>[]) {
const ids = await this.db.insertInto('asset').values(assets).returning('id').execute();
return ids.map(({ id }) => id);
}
@GenerateSql({ params: [DummyValue.UUID, { year: 2000, day: 1, month: 1 }] })
getByDayOfYear(ownerIds: string[], { year, day, month }: YearMonthDay) {
return this.db
.with('res', (qb) =>
qb
.with('today', (qb) =>
qb
.selectFrom((eb) =>
eb
.fn('generate_series', [
sql`(select date_part('year', min(("localDateTime" at time zone 'UTC')::date))::int from asset)`,
sql`${year - 1}`,
])
.as('year'),
)
.select((eb) => eb.fn('make_date', [sql`year::int`, sql`${month}::int`, sql`${day}::int`]).as('date')),
)
.selectFrom('today')
.innerJoinLateral(
(qb) =>
qb
.selectFrom('asset')
.select(['asset.id', 'asset.localDateTime'])
.innerJoin('asset_job_status', 'asset.id', 'asset_job_status.assetId')
.where(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`)
.where('asset.ownerId', '=', anyUuid(ownerIds))
.where('asset.visibility', '=', AssetVisibility.Timeline)
.where((eb) =>
eb.exists((qb) =>
qb
.selectFrom('asset_file')
.whereRef('assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.Preview),
),
)
.where('asset.deletedAt', 'is', null)
.orderBy(sql`(asset."localDateTime" at time zone 'UTC')::date`, 'desc')
.limit(20)
.as('a'),
(join) => join.onTrue(),
)
.selectAll('a'),
)
.selectFrom('res')
.select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))
.select((eb) => eb.fn.jsonAgg(eb.table('res')).as('assets'))
.groupBy(sql`("localDateTime" at time zone 'UTC')::date`)
.orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc')
.execute();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByIds(ids: string[]) {
return this.db.selectFrom('asset').selectAll('asset').where('asset.id', '=', anyUuid(ids)).execute();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByIdsWithAllRelationsButStacks(ids: string[]) {
return this.db
.selectFrom('asset')
.selectAll('asset')
.select(withFacesAndPeople)
.select(withTags)
.$call(withExif)
.where('asset.id', '=', anyUuid(ids))
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteAll(ownerId: string): Promise<void> {
await this.db.deleteFrom('asset').where('ownerId', '=', ownerId).execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
return this.db
.selectFrom('asset')
.selectAll('asset')
.where('libraryId', '=', asUuid(libraryId))
.where('originalPath', '=', originalPath)
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getLivePhotoCount(motionId: string): Promise<number> {
const [{ count }] = await this.db
.selectFrom('asset')
.select((eb) => eb.fn.countAll<number>().as('count'))
.where('livePhotoVideoId', '=', asUuid(motionId))
.execute();
return count;
}
@GenerateSql()
getFileSamples() {
return this.db.selectFrom('asset_file').select(['assetId', 'path']).limit(sql.lit(3)).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForCopy(id: string) {
return this.db
.selectFrom('asset')
.select(['id', 'stackId', 'originalPath', 'isFavorite'])
.select(withFiles)
.where('id', '=', asUuid(id))
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(
id: string,
{ exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {},
) {
return this.db
.selectFrom('asset')
.selectAll('asset')
.where('asset.id', '=', asUuid(id))
.$if(!!exifInfo, withExif)
.$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces).$narrowType<{ faces: NotNull }>())
.$if(!!library, (qb) => qb.select(withLibrary))
.$if(!!owner, (qb) => qb.select(withOwner))
.$if(!!smartSearch, withSmartSearch)
.$if(!!stack, (qb) =>
qb
.leftJoin('stack', 'stack.id', 'asset.stackId')
.$if(!stack!.assets, (qb) =>
qb.select((eb) => eb.fn.toJson(eb.table('stack')).$castTo<Stack | null>().as('stack')),
)
.$if(!!stack!.assets, (qb) =>
qb
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset as stacked')
.selectAll('stack')
.select(
sql<unknown[]>`array_agg(to_json(stacked) ORDER BY stacked."fileCreatedAt" ASC)`.as('assets'),
)
.whereRef('stacked.stackId', '=', 'stack.id')
.whereRef('stacked.id', '!=', 'stack.primaryAssetId')
.where('stacked.deletedAt', 'is', null)
.where('stacked.visibility', '=', AssetVisibility.Timeline)
.groupBy('stack.id')
.as('stacked_assets'),
(join) => join.on('stack.id', 'is not', null),
)
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')),
),
)
.$if(!!files, (qb) => qb.select(withFiles))
.$if(!!tags, (qb) => qb.select(withTags))
.$if(!!edits, (qb) => qb.select(withEdits))
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [[DummyValue.UUID], {}] })
@Chunked()
async updateAll(ids: string[], options: Updateable<AssetTable>): Promise<void> {
if (ids.length === 0) {
return;
}
await this.db.updateTable('asset').set(options).where('id', '=', anyUuid(ids)).execute();
}
async updateByLibraryId(libraryId: string, options: Updateable<AssetTable>): Promise<void> {
await this.db.updateTable('asset').set(options).where('libraryId', '=', asUuid(libraryId)).execute();
}
async update(asset: Updateable<AssetTable> & { id: string }) {
const value = omitBy(asset, isUndefined);
delete value.id;
if (!isEmpty(value)) {
return this.db
.with('asset', (qb) => qb.updateTable('asset').set(asset).where('id', '=', asUuid(asset.id)).returningAll())
.selectFrom('asset')
.selectAll('asset')
.$call(withExif)
.$call((qb) => qb.select(withFacesAndPeople))
.$call((qb) => qb.select(withEdits))
.executeTakeFirst();
}
return this.getById(asset.id, { exifInfo: true, faces: { person: true }, edits: true });
}
async remove(asset: { id: string }): Promise<void> {
await this.db.deleteFrom('asset').where('id', '=', asUuid(asset.id)).execute();
}
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, libraryId: DummyValue.UUID, checksum: DummyValue.BUFFER }] })
getByChecksum({ ownerId, libraryId, checksum }: AssetGetByChecksumOptions) {
return this.db
.selectFrom('asset')
.selectAll('asset')
.where('ownerId', '=', asUuid(ownerId))
.where('checksum', '=', checksum)
.$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null)))
.limit(1)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.BUFFER]] })
getByChecksums(userId: string, checksums: Buffer[]) {
return this.db
.selectFrom('asset')
.select(['id', 'checksum', 'deletedAt'])
.where('ownerId', '=', asUuid(userId))
.where('checksum', 'in', checksums)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
async getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined> {
const asset = await this.db
.selectFrom('asset')
.select('id')
.where('ownerId', '=', asUuid(ownerId))
.where('checksum', '=', checksum)
.where('libraryId', 'is', null)
.limit(1)
.executeTakeFirst();
return asset?.id;
}
findLivePhotoMatch(options: LivePhotoSearchOptions) {
const { ownerId, otherAssetId, livePhotoCID, type } = options;
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.ownerId'])
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('id', '!=', asUuid(otherAssetId))
.where('ownerId', '=', asUuid(ownerId))
.where('type', '=', type)
.where('asset_exif.livePhotoCID', '=', livePhotoCID)
.limit(1)
.executeTakeFirst();
}
getStatistics(ownerId: string, { visibility, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
return this.db
.selectFrom('asset')
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.Audio).as(AssetType.Audio))
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.Image).as(AssetType.Image))
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.Video).as(AssetType.Video))
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.Other).as(AssetType.Other))
.where('ownerId', '=', asUuid(ownerId))
.$if(visibility === undefined, withDefaultVisibility)
.$if(!!visibility, (qb) => qb.where('asset.visibility', '=', visibility!))
.$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!))
.$if(!!isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.where('deletedAt', isTrashed ? 'is not' : 'is', null)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [{}] })
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
return this.db
.with('asset', (qb) =>
qb
.selectFrom('asset')
.select(truncatedDate<Date>(options.orderBy).as('timeBucket'))
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(!!options.bbox, (qb) => {
const bbox = options.bbox!;
const circle = getBoundingCircle(bbox);
const withBoundingCircle = qb
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where(
sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`,
'@>',
sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`,
);
return withBoundingBox(withBoundingCircle, bbox);
})
.$if(options.visibility === undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
.$if(!!options.albumId, (qb) =>
qb
.innerJoin('album_asset', 'asset.id', 'album_asset.assetId')
.where('album_asset.albumId', '=', asUuid(options.albumId!)),
)
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.withStacked, (qb) =>
qb
.leftJoin('stack', (join) =>
join.onRef('stack.id', '=', 'asset.stackId').onRef('stack.primaryAssetId', '=', 'asset.id'),
)
.where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])),
)
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) =>
qb.where('asset.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
)
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)),
)
.selectFrom('asset')
.select(sql<string>`("timeBucket" AT TIME ZONE 'UTC')::date::text`.as('timeBucket'))
.select((eb) => eb.fn.countAll<number>().as('count'))
.groupBy('timeBucket')
.orderBy('timeBucket', options.order ?? 'desc')
.execute() as any as Promise<TimeBucketItem[]>;
}
@GenerateSql({
params: [DummyValue.TIME_BUCKET, { withStacked: true }, { user: { id: DummyValue.UUID } }],
})
getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) {
const order = options.order ?? 'desc';
const query = this.db
.with('cte', (qb) =>
qb
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) => [
'asset.duration',
'asset.id',
'asset.visibility',
sql`asset."isFavorite" and asset."ownerId" = ${auth.user.id}`.as('isFavorite'),
sql`asset.type = 'IMAGE'`.as('isImage'),
sql`asset."deletedAt" is not null`.as('isTrashed'),
'asset.livePhotoVideoId',
sql`extract(epoch from (asset."localDateTime" AT TIME ZONE 'UTC' - asset."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as(
'localOffsetHours',
),
'asset.ownerId',
'asset.status',
sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'),
sql`asset."createdAt" at time zone 'utc'`.as('createdAt'),
eb.fn('encode', ['asset.thumbhash', sql.lit('base64')]).as('thumbhash'),
'asset_exif.projectionType',
eb.fn
.coalesce(
eb
.case()
.when(sql`asset."height" = 0 or asset."width" = 0`)
.then(eb.lit(1))
.else(sql`round(asset."width"::numeric / asset."height"::numeric, 3)`)
.end(),
eb.lit(1),
)
.as('ratio'),
])
.$if(!auth.sharedLink || auth.sharedLink.showExif, (qb) =>
qb.select(['asset_exif.city', 'asset_exif.country']),
)
.$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude']))
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility == undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
.$if(!!options.bbox, (qb) => {
const bbox = options.bbox!;
const circle = getBoundingCircle(bbox);
const withBoundingCircle = qb.where(
sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`,
'@>',
sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`,
);
return withBoundingBox(withBoundingCircle, bbox);
})
.where(truncatedDate(options.orderBy), '=', timeBucket.replace(/^[+-]/, ''))
.$if(!!options.albumId, (qb) =>
qb.where((eb) =>
eb.exists(
eb
.selectFrom('album_asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('album_asset.albumId', '=', asUuid(options.albumId!)),
),
),
)
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) =>
qb
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('stack')
.whereRef('stack.id', '=', 'asset.stackId')
.whereRef('stack.primaryAssetId', '!=', 'asset.id'),
),
),
)
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset as stacked')
.select(sql`array[stacked."stackId"::text, count('stacked')::text]`.as('stack'))
.whereRef('stacked.stackId', '=', 'asset.stackId')
.where('stacked.deletedAt', 'is', null)
.where('stacked.visibility', '=', AssetVisibility.Timeline)
.groupBy('stacked.stackId')
.as('stacked_assets'),
(join) => join.onTrue(),
)
.select('stack'),
)
.$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) =>
qb.where('asset.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
)
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.orderBy(
options.orderBy == AssetOrderBy.CreatedAt
? sql`"createdAt"`
: sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`,
order,
)
.orderBy('asset.fileCreatedAt', order),
)
.with('agg', (qb) =>
qb
.selectFrom('cte')
.select((eb) => [
eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'),
eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'),
eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'),
eb.fn.coalesce(eb.fn('array_agg', ['isFavorite']), sql.lit('{}')).as('isFavorite'),
eb.fn.coalesce(eb.fn('array_agg', ['isImage']), sql.lit('{}')).as('isImage'),
// TODO: isTrashed is redundant as it will always be all true or false depending on the options
eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'),
eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
eb.fn.coalesce(eb.fn('array_agg', ['fileCreatedAt']), sql.lit('{}')).as('fileCreatedAt'),
eb.fn.coalesce(eb.fn('array_agg', ['localOffsetHours']), sql.lit('{}')).as('localOffsetHours'),
eb.fn.coalesce(eb.fn('array_agg', ['createdAt']), sql.lit('{}')).as('createdAt'),
eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'),
eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),
eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'),
])
.$if(!auth.sharedLink || auth.sharedLink.showExif, (qb) =>
qb.select((eb) => [
eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
]),
)
.$if(!!options.withCoordinates, (qb) =>
qb.select((eb) => [
eb.fn.coalesce(eb.fn('array_agg', ['latitude']), sql.lit('{}')).as('latitude'),
eb.fn.coalesce(eb.fn('array_agg', ['longitude']), sql.lit('{}')).as('longitude'),
]),
)
.$if(!!options.withStacked, (qb) =>
qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')),
),
)
.selectFrom('agg')
.select(sql<string>`to_json(agg)::text`.as('assets'));
return query.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
async getAssetIdByCity(ownerId: string, { minAssetsPerField, maxFields }: AssetExploreFieldOptions) {
const items = await this.db
.with('cities', (qb) =>
qb
.selectFrom('asset_exif')
.select('city')
.where('city', 'is not', null)
.groupBy('city')
.having((eb) => eb.fn('count', [eb.ref('assetId')]), '>=', minAssetsPerField),
)
.selectFrom('asset')
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.innerJoin('cities', 'asset_exif.city', 'cities.city')
.distinctOn('asset_exif.city')
.select(['assetId as data', 'asset_exif.city as value'])
.$narrowType<{ value: NotNull }>()
.where('ownerId', '=', asUuid(ownerId))
.where('visibility', '=', AssetVisibility.Timeline)
.where('type', '=', AssetType.Image)
.where('deletedAt', 'is', null)
.limit(maxFields)
.execute();
return { fieldName: 'exifInfo.city', items };
}
@GenerateSql({ params: [DummyValue.UUID, 12] })
async getRecentlyCreatedAssetIds(ownerId: string, maxAssets: number) {
const items = await this.db
.selectFrom('asset')
.select(['id as data', 'createdAt as value'])
.where('ownerId', '=', asUuid(ownerId))
.where('asset.visibility', '=', AssetVisibility.Timeline)
.where('type', '=', AssetType.Image)
.where('deletedAt', 'is', null)
.orderBy('value', 'desc')
.limit(maxAssets)
.execute();
return { fieldName: 'createdAt', items };
}
async upsertFile(
file: Pick<
Insertable<AssetFileTable>,
'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive' | 'isTransparent'
>,
): Promise<void> {
await this.db
.insertInto('asset_file')
.values(file)
.onConflict((oc) =>
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'),
})),
)
.execute();
}
async upsertFiles(
files: Pick<
Insertable<AssetFileTable>,
'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive' | 'isTransparent'
>[],
): Promise<void> {
if (files.length === 0) {
return;
}
await this.db
.insertInto('asset_file')
.values(files)
.onConflict((oc) =>
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'),
isProgressive: eb.ref('excluded.isProgressive'),
isTransparent: eb.ref('excluded.isTransparent'),
})),
)
.execute();
}
async deleteFile({
assetId,
type,
edited,
}: {
assetId: string;
type: AssetFileType;
edited?: boolean;
}): Promise<void> {
await this.db
.deleteFrom('asset_file')
.where('assetId', '=', asUuid(assetId))
.where('type', '=', type)
.$if(edited !== undefined, (qb) => qb.where('isEdited', '=', edited!))
.execute();
}
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
if (files.length === 0) {
return;
}
await this.db
.deleteFrom('asset_file')
.where('id', '=', anyUuid(files.map((file) => file.id)))
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING], [DummyValue.STRING]] })
async detectOfflineExternalAssets(
libraryId: string,
importPaths: string[],
exclusionPatterns: string[],
): Promise<UpdateResult> {
const paths = importPaths.map((importPath) => `${importPath}%`);
const exclusions = exclusionPatterns.map((pattern) => globToSqlPattern(pattern));
return this.db
.updateTable('asset')
.set({
isOffline: true,
deletedAt: new Date(),
})
.where('isOffline', '=', false)
.where('isExternal', '=', true)
.where('libraryId', '=', asUuid(libraryId))
.where((eb) =>
eb.or([
eb.not(eb.or(paths.map((path) => eb('originalPath', 'like', path)))),
eb.or(exclusions.map((path) => eb('originalPath', 'like', path))),
]),
)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] })
async filterNewExternalAssetPaths(libraryId: string, paths: string[]): Promise<string[]> {
const result = await this.db
.selectFrom(unnest(paths).as('path'))
.select('path')
.where((eb) =>
eb.not(
eb.exists(
this.db
.selectFrom('asset')
.select('originalPath')
.whereRef('asset.originalPath', '=', eb.ref('path'))
.where('libraryId', '=', asUuid(libraryId))
.where('isExternal', '=', true),
),
),
)
.execute();
return result.map((row) => row.path as string);
}
async getLibraryAssetCount(libraryId: string): Promise<number> {
const { count } = await this.db
.selectFrom('asset')
.select((eb) => eb.fn.countAll<number>().as('count'))
.where('libraryId', '=', asUuid(libraryId))
.executeTakeFirstOrThrow();
return count;
}
private buildGetForOriginal(ids: string[], isEdited: boolean) {
return this.db
.selectFrom('asset')
.select('asset.id')
.select('originalFileName')
.where('asset.id', 'in', ids)
.$if(isEdited, (qb) =>
qb
.leftJoin('asset_file', (join) =>
join
.onRef('asset.id', '=', 'asset_file.assetId')
.on('asset_file.isEdited', '=', true)
.on('asset_file.type', '=', AssetFileType.FullSize),
)
.select('asset_file.path as editedPath'),
)
.select('originalPath');
}
@GenerateSql({ params: [DummyValue.UUID, true] })
getForOriginal(id: string, isEdited: boolean) {
return this.buildGetForOriginal([id], isEdited).executeTakeFirstOrThrow();
}
@GenerateSql({ params: [[DummyValue.UUID], true] })
getForOriginals(ids: string[], isEdited: boolean) {
return this.buildGetForOriginal(ids, isEdited).execute();
}
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Preview, true] })
async getForThumbnail(id: string, type: AssetFileType, isEdited: boolean) {
return this.db
.selectFrom('asset')
.where('asset.id', '=', id)
.leftJoin('asset_file', (join) =>
join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', type),
)
.select(['asset.originalPath', 'asset.originalFileName', 'asset_file.path as path'])
.orderBy('asset_file.isEdited', isEdited ? 'desc' : 'asc')
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getForVideo(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.originalPath'])
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
.where('asset.id', '=', id)
.where('asset.type', '=', AssetType.Video)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getForOcr(id: string) {
return this.db
.selectFrom('asset')
.where('asset.id', '=', id)
.select(withEdits)
.innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id'))
.select(['asset_exif.exifImageWidth', 'asset_exif.exifImageHeight', 'asset_exif.orientation'])
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getForEdit(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.type', 'asset.livePhotoVideoId', 'asset.originalPath', 'asset.originalFileName'])
.where('asset.id', '=', id)
.innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id'))
.select([
'asset_exif.exifImageWidth',
'asset_exif.exifImageHeight',
'asset_exif.orientation',
'asset_exif.projectionType',
])
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getForMetadataExtractionTags(id: string) {
return this.db
.selectFrom('asset_exif')
.select('asset_exif.tags')
.where('asset_exif.assetId', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getForFaces(id: string) {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id'))
.select(['asset_exif.exifImageHeight', 'asset_exif.exifImageWidth', 'asset_exif.orientation'])
.select(withEdits)
.where('asset.id', '=', id)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getForUpdateTags(id: string) {
return this.db
.selectFrom('asset')
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('tag')
.select('tag.value')
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
.whereRef('asset.id', '=', 'tag_asset.assetId'),
).as('tags'),
)
.where('asset.id', '=', id)
.executeTakeFirstOrThrow();
}
}