Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions mobile/openapi/lib/api/timeline_api.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -13475,6 +13475,16 @@
"type": "string"
}
},
{
"name": "bbox",
"required": false,
"in": "query",
"description": "Bounding box coordinates as west,south,east,north (WGS84)",
"schema": {
"example": "11.075683,49.416711,11.117589,49.454875",
"type": "string"
}
},
{
"name": "isFavorite",
"required": false,
Expand Down Expand Up @@ -13651,6 +13661,16 @@
"type": "string"
}
},
{
"name": "bbox",
"required": false,
"in": "query",
"description": "Bounding box coordinates as west,south,east,north (WGS84)",
"schema": {
"example": "11.075683,49.416711,11.117589,49.454875",
"type": "string"
}
},
{
"name": "isFavorite",
"required": false,
Expand Down
8 changes: 6 additions & 2 deletions open-api/typescript-sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6421,8 +6421,9 @@ export function tagAssets({ id, bulkIdsDto }: {
/**
* Get time bucket
*/
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
albumId?: string;
bbox?: string;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
Expand All @@ -6442,6 +6443,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
data: TimeBucketAssetResponseDto;
}>(`/timeline/bucket${QS.query(QS.explode({
albumId,
bbox,
isFavorite,
isTrashed,
key,
Expand All @@ -6462,8 +6464,9 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
/**
* Get time buckets
*/
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
albumId?: string;
bbox?: string;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
Expand All @@ -6482,6 +6485,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
data: TimeBucketsResponseDto[];
}>(`/timeline/buckets${QS.query(QS.explode({
albumId,
bbox,
isFavorite,
isTrashed,
key,
Expand Down
25 changes: 25 additions & 0 deletions server/src/dtos/bbox.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsLatitude, IsLongitude } from 'class-validator';
import { IsGreaterThanOrEqualTo } from 'src/validation';

export class BBoxDto {
@ApiProperty({ format: 'double', description: 'West longitude (-180 to 180)' })
@IsLongitude()
west!: number;

@ApiProperty({ format: 'double', description: 'South latitude (-90 to 90)' })
@IsLatitude()
south!: number;

@ApiProperty({
format: 'double',
description: 'East longitude (-180 to 180). May be less than west when crossing the antimeridian.',
})
@IsLongitude()
east!: number;

@ApiProperty({ format: 'double', description: 'North latitude (-90 to 90). Must be >= south.' })
@IsLatitude()
@IsGreaterThanOrEqualTo('south')
north!: number;
}
6 changes: 5 additions & 1 deletion server/src/dtos/time-bucket.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';

import { IsString } from 'class-validator';
import type { BBoxDto } from 'src/dtos/bbox.dto';
import { AssetOrder, AssetVisibility } from 'src/enum';
import { ValidateBBox } from 'src/utils/bbox';
import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';

export class TimeBucketDto {
Expand Down Expand Up @@ -59,6 +60,9 @@ export class TimeBucketDto {
description: 'Include location data in the response',
})
withCoordinates?: boolean;

@ValidateBBox({ optional: true })
bbox?: BBoxDto;
}

export class TimeBucketAssetDto extends TimeBucketDto {
Expand Down
74 changes: 73 additions & 1 deletion server/src/repositories/asset.repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
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';
Expand Down Expand Up @@ -36,6 +46,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;
Expand Down Expand Up @@ -64,6 +81,7 @@ interface AssetBuilderOptions {
assetType?: AssetType;
visibility?: AssetVisibility;
withCoordinates?: boolean;
bbox?: BoundingBox;
}

export interface TimeBucketOptions extends AssetBuilderOptions {
Expand Down Expand Up @@ -120,6 +138,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>) {}
Expand Down Expand Up @@ -651,6 +697,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) =>
Expand Down Expand Up @@ -725,6 +785,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) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Kysely, sql } from 'kysely';

export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE INDEX "IDX_asset_exif_gist_earthcoord" ON "asset_exif" USING gist (ll_to_earth_public(latitude, longitude));`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_asset_exif_gist_earthcoord', '{"type":"index","name":"IDX_asset_exif_gist_earthcoord","sql":"CREATE INDEX \\"IDX_asset_exif_gist_earthcoord\\" ON \\"asset_exif\\" USING gist (ll_to_earth_public(latitude, longitude));"}'::jsonb);`.execute(db);
}

export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "IDX_asset_exif_gist_earthcoord";`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_asset_exif_gist_earthcoord';`.execute(db);
}
16 changes: 15 additions & 1 deletion server/src/schema/tables/asset-exif.table.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools';
import {
Column,
ForeignKeyColumn,
Generated,
Index,
Int8,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { LockableProperty } from 'src/database';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetTable } from 'src/schema/tables/asset.table';

@Table('asset_exif')
@Index({
name: 'IDX_asset_exif_gist_earthcoord',
using: 'gist',
expression: 'll_to_earth_public(latitude, longitude)',
})
@UpdatedAtTrigger('asset_exif_updatedAt')
export class AssetExifTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true })
Expand Down
18 changes: 18 additions & 0 deletions server/src/services/timeline.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ describe(TimelineService.name, () => {
userIds: [authStub.admin.user.id],
});
});

it('should pass bbox options to repository when all bbox fields are provided', async () => {
mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);

await sut.getTimeBuckets(authStub.admin, {
bbox: {
west: -70,
south: -30,
east: 120,
north: 55,
},
});

expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
userIds: [authStub.admin.user.id],
bbox: { west: -70, south: -30, east: 120, north: 55 },
});
});
});

describe('getTimeBucket', () => {
Expand Down
Loading
Loading