diff --git a/server/src/database.ts b/server/src/database.ts
index 6e66cb4d3d..27094958ed 100644
--- a/server/src/database.ts
+++ b/server/src/database.ts
@@ -1,7 +1,6 @@
 import { Selectable } from 'kysely';
-import { Albums, AssetJobStatus as DatabaseAssetJobStatus, Exif as DatabaseExif } from 'src/db';
+import { Albums, Exif as DatabaseExif } from 'src/db';
 import { MapAsset } from 'src/dtos/asset-response.dto';
-import { AssetEntity } from 'src/entities/asset.entity';
 import {
   AlbumUserRole,
   AssetFileType,
@@ -265,10 +264,6 @@ export type AssetFace = {
   person?: Person | null;
 };
 
-export type AssetJobStatus = Selectable<DatabaseAssetJobStatus> & {
-  asset: AssetEntity;
-};
-
 const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
 
 export const columns = {
diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts
deleted file mode 100644
index da291292e7..0000000000
--- a/server/src/entities/asset.entity.ts
+++ /dev/null
@@ -1,272 +0,0 @@
-import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely';
-import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
-import { AssetFace, AssetFile, AssetJobStatus, columns, Exif, Person, Stack, Tag, User } from 'src/database';
-import { DB } from 'src/db';
-import { MapAsset } from 'src/dtos/asset-response.dto';
-import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
-import { TimeBucketSize } from 'src/repositories/asset.repository';
-import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
-import { anyUuid, asUuid, toJson } from 'src/utils/database';
-
-export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
-
-export class AssetEntity {
-  id!: string;
-  deviceAssetId!: string;
-  owner!: User;
-  ownerId!: string;
-  libraryId?: string | null;
-  deviceId!: string;
-  type!: AssetType;
-  status!: AssetStatus;
-  originalPath!: string;
-  files!: AssetFile[];
-  thumbhash!: Buffer | null;
-  encodedVideoPath!: string | null;
-  createdAt!: Date;
-  updatedAt!: Date;
-  updateId?: string;
-  deletedAt!: Date | null;
-  fileCreatedAt!: Date;
-  localDateTime!: Date;
-  fileModifiedAt!: Date;
-  isFavorite!: boolean;
-  isArchived!: boolean;
-  isExternal!: boolean;
-  isOffline!: boolean;
-  checksum!: Buffer; // sha1 checksum
-  duration!: string | null;
-  isVisible!: boolean;
-  livePhotoVideo!: MapAsset | null;
-  livePhotoVideoId!: string | null;
-  originalFileName!: string;
-  sidecarPath!: string | null;
-  exifInfo?: Exif;
-  tags?: Tag[];
-  faces!: AssetFace[];
-  stackId?: string | null;
-  stack?: Stack | null;
-  jobStatus?: AssetJobStatus;
-  duplicateId!: string | null;
-}
-
-// TODO come up with a better query that only selects the fields we need
-export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
-  return qb
-    .leftJoin('exif', 'assets.id', 'exif.assetId')
-    .select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif | null>().as('exifInfo'));
-}
-
-export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
-  return qb
-    .innerJoin('exif', 'assets.id', 'exif.assetId')
-    .select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif>().as('exifInfo'));
-}
-
-export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
-  return qb
-    .leftJoin('smart_search', 'assets.id', 'smart_search.assetId')
-    .select((eb) => toJson(eb, 'smart_search').as('smartSearch'));
-}
-
-export function withFaces(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) {
-  return jsonArrayFrom(
-    eb
-      .selectFrom('asset_faces')
-      .selectAll('asset_faces')
-      .whereRef('asset_faces.assetId', '=', 'assets.id')
-      .$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)),
-  ).as('faces');
-}
-
-export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileType) {
-  return jsonArrayFrom(
-    eb
-      .selectFrom('asset_files')
-      .select(columns.assetFiles)
-      .whereRef('asset_files.assetId', '=', 'assets.id')
-      .$if(!!type, (qb) => qb.where('asset_files.type', '=', type!)),
-  ).as('files');
-}
-
-export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) {
-  return jsonArrayFrom(
-    eb
-      .selectFrom('asset_faces')
-      .leftJoinLateral(
-        (eb) =>
-          eb.selectFrom('person').selectAll('person').whereRef('asset_faces.personId', '=', 'person.id').as('person'),
-        (join) => join.onTrue(),
-      )
-      .selectAll('asset_faces')
-      .select((eb) => eb.table('person').$castTo<Person>().as('person'))
-      .whereRef('asset_faces.assetId', '=', 'assets.id')
-      .$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)),
-  ).as('faces');
-}
-
-export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds: string[]) {
-  return qb.innerJoin(
-    (eb) =>
-      eb
-        .selectFrom('asset_faces')
-        .select('assetId')
-        .where('personId', '=', anyUuid(personIds!))
-        .where('deletedAt', 'is', null)
-        .groupBy('assetId')
-        .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
-        .as('has_people'),
-    (join) => join.onRef('has_people.assetId', '=', 'assets.id'),
-  );
-}
-
-export function hasTags<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagIds: string[]) {
-  return qb.innerJoin(
-    (eb) =>
-      eb
-        .selectFrom('tag_asset')
-        .select('assetsId')
-        .innerJoin('tags_closure', 'tag_asset.tagsId', 'tags_closure.id_descendant')
-        .where('tags_closure.id_ancestor', '=', anyUuid(tagIds))
-        .groupBy('assetsId')
-        .having((eb) => eb.fn.count('tags_closure.id_ancestor').distinct(), '>=', tagIds.length)
-        .as('has_tags'),
-    (join) => join.onRef('has_tags.assetsId', '=', 'assets.id'),
-  );
-}
-
-export function withOwner(eb: ExpressionBuilder<DB, 'assets'>) {
-  return jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'assets.ownerId')).as(
-    'owner',
-  );
-}
-
-export function withLibrary(eb: ExpressionBuilder<DB, 'assets'>) {
-  return jsonObjectFrom(
-    eb.selectFrom('libraries').selectAll('libraries').whereRef('libraries.id', '=', 'assets.libraryId'),
-  ).as('library');
-}
-
-export function withTags(eb: ExpressionBuilder<DB, 'assets'>) {
-  return jsonArrayFrom(
-    eb
-      .selectFrom('tags')
-      .select(columns.tag)
-      .innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId')
-      .whereRef('assets.id', '=', 'tag_asset.assetsId'),
-  ).as('tags');
-}
-
-export function truncatedDate<O>(size: TimeBucketSize) {
-  return sql<O>`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`;
-}
-
-export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) {
-  return qb.where((eb) =>
-    eb.exists(
-      eb
-        .selectFrom('tags_closure')
-        .innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant')
-        .whereRef('tag_asset.assetsId', '=', 'assets.id')
-        .where('tags_closure.id_ancestor', '=', tagId),
-    ),
-  );
-}
-
-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.isArchived ??= options.withArchived ? undefined : false;
-  options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
-  return kysely
-    .withPlugin(joinDeduplicationPlugin)
-    .selectFrom('assets')
-    .selectAll('assets')
-    .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!))
-    .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!))
-    .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!))
-    .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!))
-    .$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore!))
-    .$if(!!options.updatedAfter, (qb) => qb.where('assets.updatedAt', '>=', options.updatedAfter!))
-    .$if(!!options.trashedBefore, (qb) => qb.where('assets.deletedAt', '<=', options.trashedBefore!))
-    .$if(!!options.trashedAfter, (qb) => qb.where('assets.deletedAt', '>=', options.trashedAfter!))
-    .$if(!!options.takenBefore, (qb) => qb.where('assets.fileCreatedAt', '<=', options.takenBefore!))
-    .$if(!!options.takenAfter, (qb) => qb.where('assets.fileCreatedAt', '>=', options.takenAfter!))
-    .$if(options.city !== undefined, (qb) =>
-      qb
-        .innerJoin('exif', 'assets.id', 'exif.assetId')
-        .where('exif.city', options.city === null ? 'is' : '=', options.city!),
-    )
-    .$if(options.state !== undefined, (qb) =>
-      qb
-        .innerJoin('exif', 'assets.id', 'exif.assetId')
-        .where('exif.state', options.state === null ? 'is' : '=', options.state!),
-    )
-    .$if(options.country !== undefined, (qb) =>
-      qb
-        .innerJoin('exif', 'assets.id', 'exif.assetId')
-        .where('exif.country', options.country === null ? 'is' : '=', options.country!),
-    )
-    .$if(options.make !== undefined, (qb) =>
-      qb
-        .innerJoin('exif', 'assets.id', 'exif.assetId')
-        .where('exif.make', options.make === null ? 'is' : '=', options.make!),
-    )
-    .$if(options.model !== undefined, (qb) =>
-      qb
-        .innerJoin('exif', 'assets.id', 'exif.assetId')
-        .where('exif.model', options.model === null ? 'is' : '=', options.model!),
-    )
-    .$if(options.lensModel !== undefined, (qb) =>
-      qb
-        .innerJoin('exif', 'assets.id', 'exif.assetId')
-        .where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!),
-    )
-    .$if(options.rating !== undefined, (qb) =>
-      qb
-        .innerJoin('exif', 'assets.id', 'exif.assetId')
-        .where('exif.rating', options.rating === null ? 'is' : '=', options.rating!),
-    )
-    .$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!))
-    .$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!))
-    .$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!))
-    .$if(!!options.id, (qb) => qb.where('assets.id', '=', asUuid(options.id!)))
-    .$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', asUuid(options.libraryId!)))
-    .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
-    .$if(!!options.encodedVideoPath, (qb) => qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath!))
-    .$if(!!options.originalPath, (qb) =>
-      qb.where(sql`f_unaccent(assets."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`),
-    )
-    .$if(!!options.originalFileName, (qb) =>
-      qb.where(
-        sql`f_unaccent(assets."originalFileName")`,
-        'ilike',
-        sql`'%' || f_unaccent(${options.originalFileName}) || '%'`,
-      ),
-    )
-    .$if(!!options.description, (qb) =>
-      qb
-        .innerJoin('exif', 'assets.id', 'exif.assetId')
-        .where(sql`f_unaccent(exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`),
-    )
-    .$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!))
-    .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
-    .$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!))
-    .$if(options.isVisible !== undefined, (qb) => qb.where('assets.isVisible', '=', options.isVisible!))
-    .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
-    .$if(options.isEncoded !== undefined, (qb) =>
-      qb.where('assets.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null),
-    )
-    .$if(options.isMotion !== undefined, (qb) =>
-      qb.where('assets.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null),
-    )
-    .$if(!!options.isNotInAlbum, (qb) =>
-      qb.where((eb) =>
-        eb.not(eb.exists((eb) => eb.selectFrom('albums_assets_assets').whereRef('assetsId', '=', 'assets.id'))),
-      ),
-    )
-    .$if(!!options.withExif, withExifInner)
-    .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople))
-    .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
-}
diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts
index 1bf08e81f5..4a2d52566f 100644
--- a/server/src/repositories/asset-job.repository.ts
+++ b/server/src/repositories/asset-job.repository.ts
@@ -5,10 +5,9 @@ import { InjectKysely } from 'nestjs-kysely';
 import { columns } from 'src/database';
 import { DB } from 'src/db';
 import { DummyValue, GenerateSql } from 'src/decorators';
-import { withExifInner, withFaces, withFiles } from 'src/entities/asset.entity';
 import { AssetFileType } from 'src/enum';
 import { StorageAsset } from 'src/types';
-import { anyUuid, asUuid } from 'src/utils/database';
+import { anyUuid, asUuid, withExifInner, withFaces, withFiles } from 'src/utils/database';
 
 @Injectable()
 export class AssetJobRepository {
diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts
index 7fb7056ba7..7a68ba907f 100644
--- a/server/src/repositories/asset.repository.ts
+++ b/server/src/repositories/asset.repository.ts
@@ -6,11 +6,16 @@ import { Stack } from 'src/database';
 import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
 import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
 import { MapAsset } from 'src/dtos/asset-response.dto';
+import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
+import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository';
 import {
-  AssetEntity,
+  anyUuid,
+  asUuid,
   hasPeople,
+  removeUndefinedKeys,
   searchAssetBuilder,
   truncatedDate,
+  unnest,
   withExif,
   withFaces,
   withFacesAndPeople,
@@ -20,10 +25,7 @@ import {
   withSmartSearch,
   withTagId,
   withTags,
-} from 'src/entities/asset.entity';
-import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
-import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository';
-import { anyUuid, asUuid, removeUndefinedKeys, unnest } from 'src/utils/database';
+} from 'src/utils/database';
 import { globToSqlPattern } from 'src/utils/misc';
 import { PaginationOptions, paginationHelper } from 'src/utils/pagination';
 
@@ -128,8 +130,6 @@ export interface AssetGetByChecksumOptions {
   libraryId?: string;
 }
 
-export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
-
 export interface GetByIdsRelations {
   exifInfo?: boolean;
   faces?: { person?: boolean; withDeleted?: boolean };
@@ -493,13 +493,13 @@ export class AssetRepository {
   }
 
   @GenerateSql({ params: [DummyValue.UUID, [DummyValue.BUFFER]] })
-  getByChecksums(userId: string, checksums: Buffer[]): Promise<AssetEntity[]> {
+  getByChecksums(userId: string, checksums: Buffer[]) {
     return this.db
       .selectFrom('assets')
       .select(['id', 'checksum', 'deletedAt'])
       .where('ownerId', '=', asUuid(userId))
       .where('checksum', 'in', checksums)
-      .execute() as any as Promise<AssetEntity[]>;
+      .execute();
   }
 
   @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts
index 95c350fe34..5c1ebae69d 100644
--- a/server/src/repositories/search.repository.ts
+++ b/server/src/repositories/search.repository.ts
@@ -5,9 +5,8 @@ import { randomUUID } from 'node:crypto';
 import { DB, Exif } from 'src/db';
 import { DummyValue, GenerateSql } from 'src/decorators';
 import { MapAsset } from 'src/dtos/asset-response.dto';
-import { searchAssetBuilder } from 'src/entities/asset.entity';
 import { AssetStatus, AssetType } from 'src/enum';
-import { anyUuid, asUuid } from 'src/utils/database';
+import { anyUuid, asUuid, searchAssetBuilder } from 'src/utils/database';
 import { isValidInteger } from 'src/validation';
 
 export interface SearchResult<T> {
diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts
index ae2303e9e2..e32933065c 100644
--- a/server/src/repositories/view-repository.ts
+++ b/server/src/repositories/view-repository.ts
@@ -2,8 +2,7 @@ import { Kysely } from 'kysely';
 import { InjectKysely } from 'nestjs-kysely';
 import { DB } from 'src/db';
 import { DummyValue, GenerateSql } from 'src/decorators';
-import { withExif } from 'src/entities/asset.entity';
-import { asUuid } from 'src/utils/database';
+import { asUuid, withExif } from 'src/utils/database';
 
 export class ViewRepository {
   constructor(@InjectKysely() private db: Kysely<DB>) {}
diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts
index 9a9670cd0e..19ec8d2ef4 100644
--- a/server/src/schema/tables/asset.table.ts
+++ b/server/src/schema/tables/asset.table.ts
@@ -1,5 +1,4 @@
 import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
-import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity';
 import { AssetStatus, AssetType } from 'src/enum';
 import { assets_status_enum } from 'src/schema/enums';
 import { assets_delete_audit } from 'src/schema/functions';
@@ -17,6 +16,7 @@ import {
   Table,
   UpdateDateColumn,
 } from 'src/sql-tools';
+import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
 
 @Table('assets')
 @UpdatedAtTrigger('assets_updated_at')
diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts
index a49230f852..d25067f1c9 100644
--- a/server/src/services/asset-media.service.spec.ts
+++ b/server/src/services/asset-media.service.spec.ts
@@ -9,10 +9,10 @@ import { AssetFile } from 'src/database';
 import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
 import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
 import { MapAsset } from 'src/dtos/asset-response.dto';
-import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
 import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum';
 import { AuthRequest } from 'src/middleware/auth.guard';
 import { AssetMediaService } from 'src/services/asset-media.service';
+import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
 import { ImmichFileResponse } from 'src/utils/file';
 import { assetStub } from 'test/fixtures/asset.stub';
 import { authStub } from 'test/fixtures/auth.stub';
@@ -820,8 +820,8 @@ describe(AssetMediaService.name, () => {
       const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
 
       mocks.asset.getByChecksums.mockResolvedValue([
-        { id: 'asset-1', checksum: file1 } as AssetEntity,
-        { id: 'asset-2', checksum: file2 } as AssetEntity,
+        { id: 'asset-1', checksum: file1, deletedAt: null },
+        { id: 'asset-2', checksum: file2, deletedAt: null },
       ]);
 
       await expect(
@@ -857,7 +857,7 @@ describe(AssetMediaService.name, () => {
       const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
       const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
 
-      mocks.asset.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]);
+      mocks.asset.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1, deletedAt: null }]);
 
       await expect(
         sut.bulkUploadCheck(authStub.admin, {
diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts
index de40d8b304..78e23fa802 100644
--- a/server/src/services/asset-media.service.ts
+++ b/server/src/services/asset-media.service.ts
@@ -21,13 +21,13 @@ import {
   UploadFieldName,
 } from 'src/dtos/asset-media.dto';
 import { AuthDto } from 'src/dtos/auth.dto';
-import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity';
 import { AssetStatus, AssetType, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
 import { AuthRequest } from 'src/middleware/auth.guard';
 import { BaseService } from 'src/services/base.service';
 import { UploadFile } from 'src/types';
 import { requireUploadAccess } from 'src/utils/access';
 import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
+import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
 import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
 import { mimeTypes } from 'src/utils/mime-types';
 import { fromChecksum } from 'src/utils/request';
diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts
index 7ce8b1ab46..d55c58d9af 100644
--- a/server/src/services/memory.service.spec.ts
+++ b/server/src/services/memory.service.spec.ts
@@ -15,6 +15,14 @@ describe(MemoryService.name, () => {
     expect(sut).toBeDefined();
   });
 
+  describe('onMemoryCleanup', () => {
+    it('should clean up memories', async () => {
+      mocks.memory.cleanup.mockResolvedValue([]);
+      await sut.onMemoriesCleanup();
+      expect(mocks.memory.cleanup).toHaveBeenCalled();
+    });
+  });
+
   describe('search', () => {
     it('should search memories', async () => {
       const [userId] = newUuids();
diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts
index 18699edf9a..d87ccbde1d 100644
--- a/server/src/services/search.service.spec.ts
+++ b/server/src/services/search.service.spec.ts
@@ -39,6 +39,29 @@ describe(SearchService.name, () => {
     });
   });
 
+  describe('searchPlaces', () => {
+    it('should search places', async () => {
+      mocks.search.searchPlaces.mockResolvedValue([
+        {
+          id: 42,
+          name: 'my place',
+          latitude: 420,
+          longitude: 69,
+          admin1Code: null,
+          admin1Name: null,
+          admin2Code: null,
+          admin2Name: null,
+          alternateNames: null,
+          countryCode: 'US',
+          modificationDate: new Date(),
+        },
+      ]);
+
+      await sut.searchPlaces({ name: 'place' });
+      expect(mocks.search.searchPlaces).toHaveBeenCalledWith('place');
+    });
+  });
+
   describe('getExploreData', () => {
     it('should get assets by city and tag', async () => {
       mocks.asset.getAssetIdByCity.mockResolvedValue({
diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts
index a9c7b09c5a..1af0aa4b4e 100644
--- a/server/src/utils/database.ts
+++ b/server/src/utils/database.ts
@@ -1,15 +1,24 @@
 import {
+  DeduplicateJoinsPlugin,
   Expression,
   ExpressionBuilder,
   ExpressionWrapper,
+  Kysely,
   KyselyConfig,
   Nullable,
   Selectable,
+  SelectQueryBuilder,
   Simplify,
   sql,
 } from 'kysely';
 import { PostgresJSDialect } from 'kysely-postgres-js';
+import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
 import postgres, { Notice } from 'postgres';
+import { columns, Exif, Person } from 'src/database';
+import { DB } from 'src/db';
+import { AssetFileType } from 'src/enum';
+import { TimeBucketSize } from 'src/repositories/asset.repository';
+import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
 
 type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
 
@@ -112,3 +121,225 @@ export function toJson<DB, TB extends keyof DB & string, T extends TB | Expressi
     >
   >;
 }
+
+export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
+// TODO come up with a better query that only selects the fields we need
+
+export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
+  return qb
+    .leftJoin('exif', 'assets.id', 'exif.assetId')
+    .select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif | null>().as('exifInfo'));
+}
+
+export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
+  return qb
+    .innerJoin('exif', 'assets.id', 'exif.assetId')
+    .select((eb) => eb.fn.toJson(eb.table('exif')).$castTo<Exif>().as('exifInfo'));
+}
+
+export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
+  return qb
+    .leftJoin('smart_search', 'assets.id', 'smart_search.assetId')
+    .select((eb) => toJson(eb, 'smart_search').as('smartSearch'));
+}
+
+export function withFaces(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) {
+  return jsonArrayFrom(
+    eb
+      .selectFrom('asset_faces')
+      .selectAll('asset_faces')
+      .whereRef('asset_faces.assetId', '=', 'assets.id')
+      .$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)),
+  ).as('faces');
+}
+
+export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileType) {
+  return jsonArrayFrom(
+    eb
+      .selectFrom('asset_files')
+      .select(columns.assetFiles)
+      .whereRef('asset_files.assetId', '=', 'assets.id')
+      .$if(!!type, (qb) => qb.where('asset_files.type', '=', type!)),
+  ).as('files');
+}
+
+export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>, withDeletedFace?: boolean) {
+  return jsonArrayFrom(
+    eb
+      .selectFrom('asset_faces')
+      .leftJoinLateral(
+        (eb) =>
+          eb.selectFrom('person').selectAll('person').whereRef('asset_faces.personId', '=', 'person.id').as('person'),
+        (join) => join.onTrue(),
+      )
+      .selectAll('asset_faces')
+      .select((eb) => eb.table('person').$castTo<Person>().as('person'))
+      .whereRef('asset_faces.assetId', '=', 'assets.id')
+      .$if(!withDeletedFace, (qb) => qb.where('asset_faces.deletedAt', 'is', null)),
+  ).as('faces');
+}
+
+export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'assets', O>, personIds: string[]) {
+  return qb.innerJoin(
+    (eb) =>
+      eb
+        .selectFrom('asset_faces')
+        .select('assetId')
+        .where('personId', '=', anyUuid(personIds!))
+        .where('deletedAt', 'is', null)
+        .groupBy('assetId')
+        .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
+        .as('has_people'),
+    (join) => join.onRef('has_people.assetId', '=', 'assets.id'),
+  );
+}
+
+export function hasTags<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagIds: string[]) {
+  return qb.innerJoin(
+    (eb) =>
+      eb
+        .selectFrom('tag_asset')
+        .select('assetsId')
+        .innerJoin('tags_closure', 'tag_asset.tagsId', 'tags_closure.id_descendant')
+        .where('tags_closure.id_ancestor', '=', anyUuid(tagIds))
+        .groupBy('assetsId')
+        .having((eb) => eb.fn.count('tags_closure.id_ancestor').distinct(), '>=', tagIds.length)
+        .as('has_tags'),
+    (join) => join.onRef('has_tags.assetsId', '=', 'assets.id'),
+  );
+}
+
+export function withOwner(eb: ExpressionBuilder<DB, 'assets'>) {
+  return jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'assets.ownerId')).as(
+    'owner',
+  );
+}
+
+export function withLibrary(eb: ExpressionBuilder<DB, 'assets'>) {
+  return jsonObjectFrom(
+    eb.selectFrom('libraries').selectAll('libraries').whereRef('libraries.id', '=', 'assets.libraryId'),
+  ).as('library');
+}
+
+export function withTags(eb: ExpressionBuilder<DB, 'assets'>) {
+  return jsonArrayFrom(
+    eb
+      .selectFrom('tags')
+      .select(columns.tag)
+      .innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId')
+      .whereRef('assets.id', '=', 'tag_asset.assetsId'),
+  ).as('tags');
+}
+
+export function truncatedDate<O>(size: TimeBucketSize) {
+  return sql<O>`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`;
+}
+
+export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) {
+  return qb.where((eb) =>
+    eb.exists(
+      eb
+        .selectFrom('tags_closure')
+        .innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant')
+        .whereRef('tag_asset.assetsId', '=', 'assets.id')
+        .where('tags_closure.id_ancestor', '=', tagId),
+    ),
+  );
+}
+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.isArchived ??= options.withArchived ? undefined : false;
+  options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
+  return kysely
+    .withPlugin(joinDeduplicationPlugin)
+    .selectFrom('assets')
+    .selectAll('assets')
+    .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!))
+    .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!))
+    .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!))
+    .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!))
+    .$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore!))
+    .$if(!!options.updatedAfter, (qb) => qb.where('assets.updatedAt', '>=', options.updatedAfter!))
+    .$if(!!options.trashedBefore, (qb) => qb.where('assets.deletedAt', '<=', options.trashedBefore!))
+    .$if(!!options.trashedAfter, (qb) => qb.where('assets.deletedAt', '>=', options.trashedAfter!))
+    .$if(!!options.takenBefore, (qb) => qb.where('assets.fileCreatedAt', '<=', options.takenBefore!))
+    .$if(!!options.takenAfter, (qb) => qb.where('assets.fileCreatedAt', '>=', options.takenAfter!))
+    .$if(options.city !== undefined, (qb) =>
+      qb
+        .innerJoin('exif', 'assets.id', 'exif.assetId')
+        .where('exif.city', options.city === null ? 'is' : '=', options.city!),
+    )
+    .$if(options.state !== undefined, (qb) =>
+      qb
+        .innerJoin('exif', 'assets.id', 'exif.assetId')
+        .where('exif.state', options.state === null ? 'is' : '=', options.state!),
+    )
+    .$if(options.country !== undefined, (qb) =>
+      qb
+        .innerJoin('exif', 'assets.id', 'exif.assetId')
+        .where('exif.country', options.country === null ? 'is' : '=', options.country!),
+    )
+    .$if(options.make !== undefined, (qb) =>
+      qb
+        .innerJoin('exif', 'assets.id', 'exif.assetId')
+        .where('exif.make', options.make === null ? 'is' : '=', options.make!),
+    )
+    .$if(options.model !== undefined, (qb) =>
+      qb
+        .innerJoin('exif', 'assets.id', 'exif.assetId')
+        .where('exif.model', options.model === null ? 'is' : '=', options.model!),
+    )
+    .$if(options.lensModel !== undefined, (qb) =>
+      qb
+        .innerJoin('exif', 'assets.id', 'exif.assetId')
+        .where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!),
+    )
+    .$if(options.rating !== undefined, (qb) =>
+      qb
+        .innerJoin('exif', 'assets.id', 'exif.assetId')
+        .where('exif.rating', options.rating === null ? 'is' : '=', options.rating!),
+    )
+    .$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!))
+    .$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!))
+    .$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!))
+    .$if(!!options.id, (qb) => qb.where('assets.id', '=', asUuid(options.id!)))
+    .$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', asUuid(options.libraryId!)))
+    .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
+    .$if(!!options.encodedVideoPath, (qb) => qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath!))
+    .$if(!!options.originalPath, (qb) =>
+      qb.where(sql`f_unaccent(assets."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`),
+    )
+    .$if(!!options.originalFileName, (qb) =>
+      qb.where(
+        sql`f_unaccent(assets."originalFileName")`,
+        'ilike',
+        sql`'%' || f_unaccent(${options.originalFileName}) || '%'`,
+      ),
+    )
+    .$if(!!options.description, (qb) =>
+      qb
+        .innerJoin('exif', 'assets.id', 'exif.assetId')
+        .where(sql`f_unaccent(exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`),
+    )
+    .$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!))
+    .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
+    .$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!))
+    .$if(options.isVisible !== undefined, (qb) => qb.where('assets.isVisible', '=', options.isVisible!))
+    .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
+    .$if(options.isEncoded !== undefined, (qb) =>
+      qb.where('assets.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null),
+    )
+    .$if(options.isMotion !== undefined, (qb) =>
+      qb.where('assets.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null),
+    )
+    .$if(!!options.isNotInAlbum, (qb) =>
+      qb.where((eb) =>
+        eb.not(eb.exists((eb) => eb.selectFrom('albums_assets_assets').whereRef('assetsId', '=', 'assets.id'))),
+      ),
+    )
+    .$if(!!options.withExif, withExifInner)
+    .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople))
+    .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
+}