0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-11 01:18:24 -05:00

Merge branch 'feat/nullable-dates' of https://github.com/immich-app/immich into feat/inline-offline-check

This commit is contained in:
Jonathan Jogenfors 2025-02-06 00:16:26 +01:00
commit 7f73f2e712
13 changed files with 108 additions and 74 deletions

View file

@ -1,2 +1 @@
blank_issues_enabled: false
blank_pull_request_template_enabled: false blank_pull_request_template_enabled: false

View file

@ -1,22 +0,0 @@
## Description
<!--- Describe your changes in detail -->
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
Fixes # (issue)
## How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
- [ ] 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

36
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,36 @@
## Description
<!--- Describe your changes in detail -->
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
Fixes # (issue)
## How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
- [ ] Test A
- [ ] Test B
<details><summary><h2>Screenshots (if appropriate)</h2></summary>
<!-- Images go below this line. -->
</details>
<!-- API endpoint changes (if relevant)
## API Changes
The `/api/something` endpoint is now `/api/something-else`
-->
## 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`)

View file

@ -72,7 +72,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
</ul> </ul>
</details> </details>
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**" 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: 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:

View file

@ -113,22 +113,12 @@ const hexOrBufferToBase64 = (encoded: string | Buffer) => {
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options; 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) { if (stripMetadata) {
const sanitizedAssetResponse: SanitizedAssetResponseDto = { const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id, id: entity.id,
type: entity.type, type: entity.type,
originalMimeType: mimeTypes.lookup(entity.originalFileName), originalMimeType: mimeTypes.lookup(entity.originalFileName),
thumbhash: entity.thumbhash?.toString('base64') ?? null, thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
localDateTime: entity.localDateTime, localDateTime: entity.localDateTime,
duration: entity.duration ?? '0:00:00.00000', duration: entity.duration ?? '0:00:00.00000',
livePhotoVideoId: entity.livePhotoVideoId, livePhotoVideoId: entity.livePhotoVideoId,

View file

@ -101,13 +101,13 @@ export class AssetEntity {
@Index('idx_asset_file_created_at') @Index('idx_asset_file_created_at')
@Column({ type: 'timestamptz', nullable: true, default: null }) @Column({ type: 'timestamptz', nullable: true, default: null })
fileCreatedAt!: Date | null; fileCreatedAt!: Date;
@Column({ type: 'timestamptz', nullable: true, default: null }) @Column({ type: 'timestamptz', nullable: true, default: null })
localDateTime!: Date | null; localDateTime!: Date;
@Column({ type: 'timestamptz', nullable: true, default: null }) @Column({ type: 'timestamptz', nullable: true, default: null })
fileModifiedAt!: Date | null; fileModifiedAt!: Date;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isFavorite!: boolean; isFavorite!: boolean;
@ -180,6 +180,12 @@ export class AssetEntity {
duplicateId!: string | null; duplicateId!: string | null;
} }
export type AssetEntityPlaceholder = AssetEntity & {
fileCreatedAt: Date | null;
fileModifiedAt: Date | null;
localDateTime: Date | null;
};
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { 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')).as('exifInfo')); return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo'));
} }

View file

@ -49,6 +49,7 @@ with
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."fileCreatedAt" is not null and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null and "assets"."fileModifiedAt" is not null
and "assets"."localDateTime" is not null
order by order by
(assets."localDateTime" at time zone 'UTC')::date desc (assets."localDateTime" at time zone 'UTC')::date desc
limit limit
@ -461,8 +462,6 @@ from
where where
"assets"."ownerId" = any ($1::uuid[]) "assets"."ownerId" = any ($1::uuid[])
and "isVisible" = $2 and "isVisible" = $2
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
and "updatedAt" > $3 and "updatedAt" > $3
limit limit
$4 $4

View file

@ -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

View file

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; 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 { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { ASSET_FILE_CONFLICT_KEYS, EXIF_CONFLICT_KEYS, JOB_STATUS_CONFLICT_KEYS } from 'src/constants'; 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 { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { import {
AssetEntity, AssetEntity,
AssetEntityPlaceholder,
hasPeople, hasPeople,
searchAssetBuilder, searchAssetBuilder,
truncatedDate, truncatedDate,
@ -80,8 +81,12 @@ export class AssetRepository implements IAssetRepository {
.execute(); .execute();
} }
create(asset: Insertable<Assets>): Promise<AssetEntity> { create(asset: Insertable<Assets>): Promise<AssetEntityPlaceholder> {
return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirst() as any as Promise<AssetEntity>; return this.db
.insertInto('assets')
.values(asset)
.returningAll()
.executeTakeFirst() as any as Promise<AssetEntityPlaceholder>;
} }
createAll(assets: Insertable<Assets>[]): Promise<AssetEntity[]> { createAll(assets: Insertable<Assets>[]): Promise<AssetEntity[]> {
@ -128,6 +133,7 @@ export class AssetRepository implements IAssetRepository {
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.where('assets.fileCreatedAt', 'is not', null) .where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', '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') .orderBy(sql`(assets."localDateTime" at time zone 'UTC')::date`, 'desc')
.limit(20) .limit(20)
.as('a'), .as('a'),
@ -135,6 +141,9 @@ export class AssetRepository implements IAssetRepository {
) )
.innerJoin('exif', 'a.id', 'exif.assetId') .innerJoin('exif', 'a.id', 'exif.assetId')
.selectAll('a') .selectAll('a')
.$narrowType<{ fileCreatedAt: NotNull }>()
.$narrowType<{ fileModifiedAt: NotNull }>()
.$narrowType<{ localDateTime: NotNull }>()
.select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')), .select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')),
) )
.selectFrom('res') .selectFrom('res')
@ -857,8 +866,6 @@ export class AssetRepository implements IAssetRepository {
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
.where('assets.ownerId', '=', anyUuid(options.userIds)) .where('assets.ownerId', '=', anyUuid(options.userIds))
.where('isVisible', '=', true) .where('isVisible', '=', true)
.where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null)
.where('updatedAt', '>', options.updatedAfter) .where('updatedAt', '>', options.updatedAfter)
.limit(options.limit) .limit(options.limit)
.execute() as any as Promise<AssetEntity[]>; .execute() as any as Promise<AssetEntity[]>;

View file

@ -227,6 +227,7 @@ const getEnv = (): EnvData => {
} }
const driverOptions = { const driverOptions = {
...parsedOptions,
onnotice: (notice: Notice) => { onnotice: (notice: Notice) => {
if (notice['severity'] !== 'NOTICE') { if (notice['severity'] !== 'NOTICE') {
console.warn('Postgres notice:', notice); console.warn('Postgres notice:', notice);
@ -247,7 +248,9 @@ const getEnv = (): EnvData => {
serialize: (value: number) => value.toString(), serialize: (value: number) => value.toString(),
}, },
}, },
...parsedOptions, connection: {
TimeZone: 'UTC',
},
}; };
return { return {

View file

@ -8,7 +8,7 @@ import { readFile } from 'node:fs/promises';
import readLine from 'node:readline'; import readLine from 'node:readline';
import { citiesFile } from 'src/constants'; import { citiesFile } from 'src/constants';
import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; 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 { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
import { LogLevel, SystemMetadataKey } from 'src/enum'; import { LogLevel, SystemMetadataKey } from 'src/enum';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@ -76,17 +76,19 @@ export class MapRepository {
this.logger.log('Geodata import completed'); this.logger.log('Geodata import completed');
} }
async getMapMarkers( @GenerateSql({ params: [[DummyValue.UUID], []] })
ownerIds: string[], getMapMarkers(ownerIds: string[], albumIds: string[], options: MapMarkerSearchOptions = {}) {
albumIds: string[],
options: MapMarkerSearchOptions = {},
): Promise<MapMarker[]> {
const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
const assets = (await this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.$call(withExif) .innerJoin('exif', (builder) =>
.select('id') 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')) .leftJoin('albums_assets_assets', (join) => join.onRef('assets.id', '=', 'albums_assets_assets.assetsId'))
.where('isVisible', '=', true) .where('isVisible', '=', true)
.$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!)) .$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!))
@ -94,32 +96,21 @@ export class MapRepository {
.$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!)) .$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!))
.$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!)) .$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!))
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('exif.latitude', 'is not', null) .where((builder) => {
.where('exif.longitude', 'is not', null) const expression: Expression<SqlBool>[] = [];
.where((eb) => {
const ors: Expression<SqlBool>[] = [];
if (ownerIds.length > 0) { if (ownerIds.length > 0) {
ors.push(eb('ownerId', 'in', ownerIds)); expression.push(builder('ownerId', 'in', ownerIds));
} }
if (albumIds.length > 0) { 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') .orderBy('fileCreatedAt', 'desc')
.execute()) as any as AssetEntity[]; .execute() as Promise<MapMarker[]>;
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,
}));
} }
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> { async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {

View file

@ -37,6 +37,9 @@ export class ViewRepository {
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('originalPath', 'like', `%${normalizedPath}/%`) .where('originalPath', 'like', `%${normalizedPath}/%`)
.where('originalPath', 'not like', `%${normalizedPath}/%/%`) .where('originalPath', 'not like', `%${normalizedPath}/%/%`)
.$narrowType<{ fileCreatedAt: Date }>()
.$narrowType<{ fileModifiedAt: Date }>()
.$narrowType<{ localDateTime: Date }>()
.orderBy( .orderBy(
(eb) => eb.fn('regexp_replace', ['assets.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]), (eb) => eb.fn('regexp_replace', ['assets.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]),
'asc', 'asc',

View file

@ -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); node.addEventListener('keydown', handleKey, false);
return { return {
destroy() { destroy() {
document.removeEventListener('click', handleClick, true); document.removeEventListener('mousedown', handleClick, true);
node.removeEventListener('keydown', handleKey, false); node.removeEventListener('keydown', handleKey, false);
}, },
}; };