Merge branch 'main' into feat/mobile-ocr

This commit is contained in:
Yaros
2026-03-16 13:00:41 +01:00
539 changed files with 24437 additions and 10200 deletions
+15 -3
View File
@@ -1,12 +1,22 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
import {
ExpressionBuilder,
Insertable,
Kysely,
NotNull,
Selectable,
ShallowDehydrateObject,
sql,
Updateable,
} from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns, Exif } from 'src/database';
import { columns } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { withDefaultVisibility } from 'src/utils/database';
export interface AlbumAssetCount {
@@ -56,7 +66,9 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
.selectFrom('asset')
.selectAll('asset')
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) => eb.table('asset_exif').$castTo<Exif>().as('exifInfo'))
.select((eb) =>
eb.table('asset_exif').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
)
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
.whereRef('album_asset.albumId', '=', 'album.id')
.where('asset.deletedAt', 'is', null)
+30 -12
View File
@@ -9,7 +9,6 @@ import { DB } from 'src/schema';
import {
anyUuid,
asUuid,
toJson,
withDefaultVisibility,
withEdits,
withExif,
@@ -105,7 +104,7 @@ export class AssetJobRepository {
getForMigrationJob(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath'])
.select(['asset.id', 'asset.ownerId'])
.select(withFiles)
.where('asset.id', '=', id)
.executeTakeFirst();
@@ -269,7 +268,6 @@ export class AssetJobRepository {
'asset.libraryId',
'asset.ownerId',
'asset.livePhotoVideoId',
'asset.encodedVideoPath',
'asset.originalPath',
'asset.isOffline',
])
@@ -296,7 +294,12 @@ export class AssetJobRepository {
.as('stack_result'),
(join) => join.onTrue(),
)
.select((eb) => toJson(eb, 'stack_result').as('stack'))
.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();
}
@@ -306,11 +309,21 @@ export class AssetJobRepository {
return this.db
.selectFrom('asset')
.select(['asset.id'])
.where('asset.type', '=', AssetType.Video)
.where('asset.type', '=', sql.lit(AssetType.Video))
.$if(!force, (qb) =>
qb
.where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')]))
.where('asset.visibility', '!=', AssetVisibility.Hidden),
.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.visibility', '!=', sql.lit(AssetVisibility.Hidden)),
)
.where('asset.deletedAt', 'is', null)
.stream();
@@ -320,9 +333,10 @@ export class AssetJobRepository {
getForVideoConversion(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath'])
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
.select(withFiles)
.where('asset.id', '=', id)
.where('asset.type', '=', AssetType.Video)
.where('asset.type', '=', sql.lit(AssetType.Video))
.executeTakeFirst();
}
@@ -353,6 +367,7 @@ export class AssetJobRepository {
'asset.checksum',
'asset.originalPath',
'asset.isExternal',
'asset.visibility',
'asset.originalFileName',
'asset.livePhotoVideoId',
'asset.fileCreatedAt',
@@ -367,13 +382,16 @@ export class AssetJobRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getForStorageTemplateJob(id: string) {
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
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().stream();
return this.storageTemplateAssetQuery().where('asset.visibility', '!=', AssetVisibility.Hidden).stream();
}
@GenerateSql({ params: [DummyValue.DATE], stream: true })
+103 -11
View File
@@ -1,5 +1,16 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
import {
ExpressionBuilder,
Insertable,
Kysely,
NotNull,
Selectable,
SelectQueryBuilder,
ShallowDehydrateObject,
sql,
Updateable,
UpdateResult,
} from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
@@ -25,6 +36,7 @@ import {
withExif,
withFaces,
withFacesAndPeople,
withFilePath,
withFiles,
withLibrary,
withOwner,
@@ -36,6 +48,13 @@ 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;
@@ -64,6 +83,7 @@ interface AssetBuilderOptions {
assetType?: AssetType;
visibility?: AssetVisibility;
withCoordinates?: boolean;
bbox?: BoundingBox;
}
export interface TimeBucketOptions extends AssetBuilderOptions {
@@ -120,6 +140,34 @@ interface GetByIdsRelations {
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>) {}
@@ -358,7 +406,7 @@ export class AssetRepository {
(qb) =>
qb
.selectFrom('asset')
.selectAll('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))
@@ -377,9 +425,7 @@ export class AssetRepository {
.as('a'),
(join) => join.onTrue(),
)
.innerJoin('asset_exif', 'a.id', 'asset_exif.assetId')
.selectAll('a')
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')),
.selectAll('a'),
)
.selectFrom('res')
.select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))
@@ -510,7 +556,11 @@ export class AssetRepository {
eb
.selectFrom('asset as stacked')
.selectAll('stack')
.select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets'))
.select((eb) =>
eb
.fn<ShallowDehydrateObject<Selectable<AssetTable>>>('array_agg', [eb.table('stacked')])
.as('assets'),
)
.whereRef('stacked.stackId', '=', 'stack.id')
.whereRef('stacked.id', '!=', 'stack.primaryAssetId')
.where('stacked.deletedAt', 'is', null)
@@ -519,7 +569,7 @@ export class AssetRepository {
.as('stacked_assets'),
(join) => join.on('stack.id', 'is not', null),
)
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack')),
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')),
),
)
.$if(!!files, (qb) => qb.select(withFiles))
@@ -651,6 +701,20 @@ export class AssetRepository {
.select(truncatedDate<Date>().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) =>
@@ -686,6 +750,7 @@ export class AssetRepository {
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
@@ -725,6 +790,18 @@ export class AssetRepository {
.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(), '=', timeBucket.replace(/^[+-]/, ''))
.$if(!!options.albumId, (qb) =>
qb.where((eb) =>
@@ -771,7 +848,8 @@ export class AssetRepository {
)
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.orderBy('asset.fileCreatedAt', options.order ?? 'desc'),
.orderBy(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order)
.orderBy('asset.fileCreatedAt', order),
)
.with('agg', (qb) =>
qb
@@ -942,8 +1020,21 @@ export class AssetRepository {
.execute();
}
async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise<void> {
await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).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> {
@@ -1062,7 +1153,8 @@ export class AssetRepository {
async getForVideo(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.encodedVideoPath', 'asset.originalPath'])
.select(['asset.originalPath'])
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
.where('asset.id', '=', id)
.where('asset.type', '=', AssetType.Video)
.executeTakeFirst();
@@ -22,6 +22,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode
import { DB } from 'src/schema';
import { immich_uuid_v7 } from 'src/schema/functions';
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
import { vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation';
@@ -288,7 +289,11 @@ export class DatabaseRepository {
}
async getSchemaDrift() {
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
const source = schemaFromCode({
overrides: true,
namingStrategy: 'default',
uuidFunction: (version) => (version === 7 ? `${immich_uuid_v7.name}()` : 'uuid_generate_v4()'),
});
const { database } = this.configRepository.getEnv();
const target = await schemaFromDatabase({ connection: database.config });
@@ -426,7 +431,6 @@ export class DatabaseRepository {
.updateTable('asset')
.set((eb) => ({
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
}))
.execute();
@@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Kysely, NotNull, sql } from 'kysely';
import { Kysely, NotNull, Selectable, ShallowDehydrateObject, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, VectorIndex } from 'src/enum';
import { probes } from 'src/repositories/database.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { anyUuid, asUuid, withDefaultVisibility } from 'src/utils/database';
interface DuplicateSearch {
@@ -39,15 +39,15 @@ export class DuplicateRepository {
qb
.selectFrom('asset_exif')
.selectAll('asset')
.select((eb) => eb.table('asset_exif').as('exifInfo'))
.select((eb) =>
eb.table('asset_exif').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
)
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('asset2'),
(join) => join.onTrue(),
)
.select('asset.duplicateId')
.select((eb) =>
eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo<MapAsset[]>().as('assets'),
)
.select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').as('assets'))
.where('asset.ownerId', '=', asUuid(userId))
.where('asset.duplicateId', 'is not', null)
.$narrowType<{ duplicateId: NotNull }>()
@@ -162,6 +162,7 @@ export class EmailRepository {
host: options.host,
port: options.port,
tls: { rejectUnauthorized: !options.ignoreCert },
secure: options.secure,
auth:
options.username || options.password
? {
+1 -1
View File
@@ -107,7 +107,7 @@ export class MediaRepository {
ExposureTime: tags.exposureTime,
ProfileDescription: tags.profileDescription,
ColorSpace: tags.colorspace,
Rating: tags.rating,
Rating: tags.rating === null ? 0 : tags.rating,
// specially convert Orientation to numeric Orientation# for exiftool
'Orientation#': tags.orientation ? Number(tags.orientation) : undefined,
};
@@ -72,6 +72,8 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
AndroidMake?: string;
AndroidModel?: string;
DeviceManufacturer?: string;
DeviceModelName?: string;
}
@Injectable()
+10 -1
View File
@@ -70,7 +70,16 @@ export class OAuthRepository {
try {
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
let profile: OAuthProfile;
const tokenClaims = tokens.claims();
if (tokenClaims && 'email' in tokenClaims) {
this.logger.debug('Using ID token claims instead of userinfo endpoint');
profile = tokenClaims as OAuthProfile;
} else {
profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
}
if (!profile.sub) {
throw new Error('Unexpected profile response, no `sub`');
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Kysely, OrderByDirection, Selectable, sql } from 'kysely';
import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { randomUUID } from 'node:crypto';
import { DummyValue, GenerateSql } from 'src/decorators';
@@ -433,7 +433,7 @@ export class SearchRepository {
.select((eb) =>
eb
.fn('to_jsonb', [eb.table('asset_exif')])
.$castTo<Selectable<AssetExifTable>>()
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>()
.as('exifInfo'),
)
.orderBy('asset_exif.city')
@@ -1,13 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkType } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
export type SharedLinkSearchOptions = {
@@ -106,11 +107,15 @@ export class SharedLinkRepository {
.select((eb) =>
eb.fn
.coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`)
.$castTo<MapAsset[]>()
.$castTo<
(ShallowDehydrateObject<Selectable<AssetTable>> & {
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
})[]
>()
.as('assets'),
)
.groupBy(['shared_link.id', sql`"album".*`])
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
.select((eb) => eb.fn.toJson(eb.table('album')).$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
.where('shared_link.id', '=', id)
.where('shared_link.userId', '=', userId)
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
@@ -124,19 +129,18 @@ export class SharedLinkRepository {
.selectFrom('shared_link')
.selectAll('shared_link')
.where('shared_link.userId', '=', userId)
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id')
.leftJoinLateral(
(eb) =>
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('asset')
.select((eb) => eb.fn.jsonAgg('asset').as('assets'))
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
.selectFrom('shared_link_asset')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.as('assets'),
(join) => join.onTrue(),
.selectAll('asset')
.orderBy('asset.fileCreatedAt', 'asc')
.limit(1),
).as('assets'),
)
.select('assets.assets')
.$narrowType<{ assets: NotNull }>()
.leftJoinLateral(
(eb) =>
eb
@@ -174,12 +178,11 @@ export class SharedLinkRepository {
.as('album'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
.select((eb) => eb.fn.toJson('album').$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
.orderBy('shared_link.createdAt', 'desc')
.distinctOn(['shared_link.createdAt'])
.execute();
}
@@ -246,6 +249,21 @@ export class SharedLinkRepository {
await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute();
}
@ChunkedArray({ paramIndex: 1 })
async addAssets(id: string, assetIds: string[]) {
if (assetIds.length === 0) {
return [];
}
return await this.db
.insertInto('shared_link_asset')
.values(assetIds.map((assetId) => ({ assetId, sharedLinkId: id })))
.onConflict((oc) => oc.doNothing())
.returning(['shared_link_asset.assetId'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
private getSharedLinks(id: string) {
return this.db
.selectFrom('shared_link')
@@ -269,7 +287,11 @@ export class SharedLinkRepository {
.select((eb) =>
eb.fn
.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`)
.$castTo<MapAsset[]>()
.$castTo<
(ShallowDehydrateObject<Selectable<AssetTable>> & {
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
})[]
>()
.as('assets'),
)
.groupBy('shared_link.id')