diff --git a/.github/PULL_REQUEST_TEMPLATE/config.yml b/.github/PULL_REQUEST_TEMPLATE/config.yml
index 6663b04cbc63f..4172e3df95270 100644
--- a/.github/PULL_REQUEST_TEMPLATE/config.yml
+++ b/.github/PULL_REQUEST_TEMPLATE/config.yml
@@ -1,2 +1 @@
-blank_issues_enabled: false
blank_pull_request_template_enabled: false
diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
deleted file mode 100644
index 83a365eab9d8e..0000000000000
--- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
+++ /dev/null
@@ -1,22 +0,0 @@
-## Description
-
-
-
-
-Fixes # (issue)
-
-
-## How Has This Been Tested?
-
-
-
-- [ ] Test A
-- [ ] Test B
-
-## Screenshots (if appropriate):
-
-
-## Checklist:
-
-- [ ] I have performed a self-review of my own code
-- [ ] I have made corresponding changes to the documentation if applicable
\ No newline at end of file
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000000000..5d4290fd7bdb9
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,36 @@
+## Description
+
+
+
+
+
+Fixes # (issue)
+
+## How Has This Been Tested?
+
+
+
+- [ ] Test A
+- [ ] Test B
+
+Screenshots (if appropriate)
+
+
+
+
+
+
+
+## Checklist:
+
+- [ ] I have performed a self-review of my own code
+- [ ] I have made corresponding changes to the documentation if applicable
+- [ ] I have no unrelated changes in the PR.
+- [ ] I have confirmed that any new dependencies are strictly necessary.
+- [ ] I have written tests for new code (if applicable)
+- [ ] I have followed naming conventions/patterns in the surrounding code
+- [ ] All code in `src/services` uses repositories implementations for database calls, filesystem operations, etc.
+- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services`)
diff --git a/docs/docs/install/unraid.md b/docs/docs/install/unraid.md
index d6dde4e8c584e..776eef7eb93eb 100644
--- a/docs/docs/install/unraid.md
+++ b/docs/docs/install/unraid.md
@@ -72,7 +72,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
-5. Click "**Save Changes**", you will be promoted to edit stack UI labels, just leave this blank and click "**Ok**"
+5. Click "**Save Changes**", you will be prompted to edit stack UI labels, just leave this blank and click "**Ok**"
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following:
diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts
index 19070f2264782..9a963e1e98097 100644
--- a/server/src/dtos/asset-response.dto.ts
+++ b/server/src/dtos/asset-response.dto.ts
@@ -113,22 +113,12 @@ const hexOrBufferToBase64 = (encoded: string | Buffer) => {
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
- if (entity.localDateTime === null) {
- throw new Error(`Asset ${entity.id} has no localDateTime`);
- }
- if (entity.fileCreatedAt === null) {
- throw new Error(`Asset ${entity.id} has no fileCreatedAt`);
- }
- if (entity.fileModifiedAt === null) {
- throw new Error(`Asset ${entity.id} has no fileModifiedAt`);
- }
-
if (stripMetadata) {
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
originalMimeType: mimeTypes.lookup(entity.originalFileName),
- thumbhash: entity.thumbhash?.toString('base64') ?? null,
+ thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
localDateTime: entity.localDateTime,
duration: entity.duration ?? '0:00:00.00000',
livePhotoVideoId: entity.livePhotoVideoId,
diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts
index 742ff67b2d7e3..0677675fdd307 100644
--- a/server/src/entities/asset.entity.ts
+++ b/server/src/entities/asset.entity.ts
@@ -101,13 +101,13 @@ export class AssetEntity {
@Index('idx_asset_file_created_at')
@Column({ type: 'timestamptz', nullable: true, default: null })
- fileCreatedAt!: Date | null;
+ fileCreatedAt!: Date;
@Column({ type: 'timestamptz', nullable: true, default: null })
- localDateTime!: Date | null;
+ localDateTime!: Date;
@Column({ type: 'timestamptz', nullable: true, default: null })
- fileModifiedAt!: Date | null;
+ fileModifiedAt!: Date;
@Column({ type: 'boolean', default: false })
isFavorite!: boolean;
@@ -180,6 +180,12 @@ export class AssetEntity {
duplicateId!: string | null;
}
+export type AssetEntityPlaceholder = AssetEntity & {
+ fileCreatedAt: Date | null;
+ fileModifiedAt: Date | null;
+ localDateTime: Date | null;
+};
+
export function withExif(qb: SelectQueryBuilder) {
return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo'));
}
diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql
index de196de74786b..a8a2e00b681ec 100644
--- a/server/src/queries/asset.repository.sql
+++ b/server/src/queries/asset.repository.sql
@@ -49,6 +49,7 @@ with
and "assets"."deletedAt" is null
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
+ and "assets"."localDateTime" is not null
order by
(assets."localDateTime" at time zone 'UTC')::date desc
limit
@@ -461,8 +462,6 @@ from
where
"assets"."ownerId" = any ($1::uuid[])
and "isVisible" = $2
- and "assets"."fileCreatedAt" is not null
- and "assets"."fileModifiedAt" is not null
and "updatedAt" > $3
limit
$4
diff --git a/server/src/queries/map.repository.sql b/server/src/queries/map.repository.sql
new file mode 100644
index 0000000000000..8b508b68efed5
--- /dev/null
+++ b/server/src/queries/map.repository.sql
@@ -0,0 +1,22 @@
+-- NOTE: This file is auto generated by ./sql-generator
+
+-- MapRepository.getMapMarkers
+select
+ "id",
+ "exif"."latitude" as "lat",
+ "exif"."longitude" as "lon",
+ "exif"."city",
+ "exif"."state",
+ "exif"."country"
+from
+ "assets"
+ inner join "exif" on "assets"."id" = "exif"."assetId"
+ and "exif"."latitude" is not null
+ and "exif"."longitude" is not null
+ left join "albums_assets_assets" on "assets"."id" = "albums_assets_assets"."assetsId"
+where
+ "isVisible" = $1
+ and "deletedAt" is null
+ and "ownerId" in ($2)
+order by
+ "fileCreatedAt" desc
diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts
index ecc17036ed867..1af9e2fb4e0d6 100644
--- a/server/src/repositories/asset.repository.ts
+++ b/server/src/repositories/asset.repository.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { Insertable, Kysely, UpdateResult, Updateable, sql } from 'kysely';
+import { Insertable, Kysely, NotNull, UpdateResult, Updateable, sql } from 'kysely';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { ASSET_FILE_CONFLICT_KEYS, EXIF_CONFLICT_KEYS, JOB_STATUS_CONFLICT_KEYS } from 'src/constants';
@@ -7,6 +7,7 @@ import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import {
AssetEntity,
+ AssetEntityPlaceholder,
hasPeople,
searchAssetBuilder,
truncatedDate,
@@ -80,8 +81,12 @@ export class AssetRepository implements IAssetRepository {
.execute();
}
- create(asset: Insertable): Promise {
- return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirst() as any as Promise;
+ create(asset: Insertable): Promise {
+ return this.db
+ .insertInto('assets')
+ .values(asset)
+ .returningAll()
+ .executeTakeFirst() as any as Promise;
}
createAll(assets: Insertable[]): Promise {
@@ -128,6 +133,7 @@ export class AssetRepository implements IAssetRepository {
.where('assets.deletedAt', 'is', null)
.where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null)
+ .where('assets.localDateTime', 'is not', null)
.orderBy(sql`(assets."localDateTime" at time zone 'UTC')::date`, 'desc')
.limit(20)
.as('a'),
@@ -135,6 +141,9 @@ export class AssetRepository implements IAssetRepository {
)
.innerJoin('exif', 'a.id', 'exif.assetId')
.selectAll('a')
+ .$narrowType<{ fileCreatedAt: NotNull }>()
+ .$narrowType<{ fileModifiedAt: NotNull }>()
+ .$narrowType<{ localDateTime: NotNull }>()
.select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')),
)
.selectFrom('res')
@@ -857,8 +866,6 @@ export class AssetRepository implements IAssetRepository {
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
.where('assets.ownerId', '=', anyUuid(options.userIds))
.where('isVisible', '=', true)
- .where('assets.fileCreatedAt', 'is not', null)
- .where('assets.fileModifiedAt', 'is not', null)
.where('updatedAt', '>', options.updatedAfter)
.limit(options.limit)
.execute() as any as Promise;
diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts
index a2af1b61b35c5..5b04914dac2a1 100644
--- a/server/src/repositories/config.repository.ts
+++ b/server/src/repositories/config.repository.ts
@@ -227,6 +227,7 @@ const getEnv = (): EnvData => {
}
const driverOptions = {
+ ...parsedOptions,
onnotice: (notice: Notice) => {
if (notice['severity'] !== 'NOTICE') {
console.warn('Postgres notice:', notice);
@@ -247,7 +248,9 @@ const getEnv = (): EnvData => {
serialize: (value: number) => value.toString(),
},
},
- ...parsedOptions,
+ connection: {
+ TimeZone: 'UTC',
+ },
};
return {
diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts
index af24b0c94ea7f..fcfa74a5d0ab6 100644
--- a/server/src/repositories/map.repository.ts
+++ b/server/src/repositories/map.repository.ts
@@ -8,7 +8,7 @@ import { readFile } from 'node:fs/promises';
import readLine from 'node:readline';
import { citiesFile } from 'src/constants';
import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
-import { AssetEntity, withExif } from 'src/entities/asset.entity';
+import { DummyValue, GenerateSql } from 'src/decorators';
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
import { LogLevel, SystemMetadataKey } from 'src/enum';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -76,17 +76,19 @@ export class MapRepository {
this.logger.log('Geodata import completed');
}
- async getMapMarkers(
- ownerIds: string[],
- albumIds: string[],
- options: MapMarkerSearchOptions = {},
- ): Promise {
+ @GenerateSql({ params: [[DummyValue.UUID], []] })
+ getMapMarkers(ownerIds: string[], albumIds: string[], options: MapMarkerSearchOptions = {}) {
const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
- const assets = (await this.db
+ return this.db
.selectFrom('assets')
- .$call(withExif)
- .select('id')
+ .innerJoin('exif', (builder) =>
+ builder
+ .onRef('assets.id', '=', 'exif.assetId')
+ .on('exif.latitude', 'is not', null)
+ .on('exif.longitude', 'is not', null),
+ )
+ .select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country'])
.leftJoin('albums_assets_assets', (join) => join.onRef('assets.id', '=', 'albums_assets_assets.assetsId'))
.where('isVisible', '=', true)
.$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!))
@@ -94,32 +96,21 @@ export class MapRepository {
.$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!))
.$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!))
.where('deletedAt', 'is', null)
- .where('exif.latitude', 'is not', null)
- .where('exif.longitude', 'is not', null)
- .where((eb) => {
- const ors: Expression[] = [];
+ .where((builder) => {
+ const expression: Expression[] = [];
if (ownerIds.length > 0) {
- ors.push(eb('ownerId', 'in', ownerIds));
+ expression.push(builder('ownerId', 'in', ownerIds));
}
if (albumIds.length > 0) {
- ors.push(eb('albums_assets_assets.albumsId', 'in', albumIds));
+ expression.push(builder('albums_assets_assets.albumsId', 'in', albumIds));
}
- return eb.or(ors);
+ return builder.or(expression);
})
.orderBy('fileCreatedAt', 'desc')
- .execute()) as any as AssetEntity[];
-
- return assets.map((asset) => ({
- id: asset.id,
- lat: asset.exifInfo!.latitude!,
- lon: asset.exifInfo!.longitude!,
- city: asset.exifInfo!.city,
- state: asset.exifInfo!.state,
- country: asset.exifInfo!.country,
- }));
+ .execute() as Promise;
}
async reverseGeocode(point: GeoPoint): Promise {
diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts
index f24b1bac6e4d0..932daf8868813 100644
--- a/server/src/repositories/view-repository.ts
+++ b/server/src/repositories/view-repository.ts
@@ -37,6 +37,9 @@ export class ViewRepository {
.where('deletedAt', 'is', null)
.where('originalPath', 'like', `%${normalizedPath}/%`)
.where('originalPath', 'not like', `%${normalizedPath}/%/%`)
+ .$narrowType<{ fileCreatedAt: Date }>()
+ .$narrowType<{ fileModifiedAt: Date }>()
+ .$narrowType<{ localDateTime: Date }>()
.orderBy(
(eb) => eb.fn('regexp_replace', ['assets.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]),
'asc',
diff --git a/web/src/lib/actions/click-outside.ts b/web/src/lib/actions/click-outside.ts
index 1a421f1f5625e..92775546aae6d 100644
--- a/web/src/lib/actions/click-outside.ts
+++ b/web/src/lib/actions/click-outside.ts
@@ -35,12 +35,12 @@ export function clickOutside(node: HTMLElement, options: Options = {}): ActionRe
}
};
- document.addEventListener('click', handleClick, true);
+ document.addEventListener('mousedown', handleClick, true);
node.addEventListener('keydown', handleKey, false);
return {
destroy() {
- document.removeEventListener('click', handleClick, true);
+ document.removeEventListener('mousedown', handleClick, true);
node.removeEventListener('keydown', handleKey, false);
},
};