diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 74597b43b..ee646354d 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 7b4966184..9bf402632 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 632107ff7..5c7a8de59 100644 Binary files a/mobile/openapi/lib/api/search_api.dart and b/mobile/openapi/lib/api/search_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a96b89565..bc7f48255 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/search_statistics_response_dto.dart b/mobile/openapi/lib/model/search_statistics_response_dto.dart new file mode 100644 index 000000000..84f31373d Binary files /dev/null and b/mobile/openapi/lib/model/search_statistics_response_dto.dart differ diff --git a/mobile/openapi/lib/model/statistics_search_dto.dart b/mobile/openapi/lib/model/statistics_search_dto.dart new file mode 100644 index 000000000..0fe0770b6 Binary files /dev/null and b/mobile/openapi/lib/model/statistics_search_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0e35be2ee..352fe768f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5158,6 +5158,48 @@ ] } }, + "/search/statistics": { + "post": { + "operationId": "searchAssetStatistics", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatisticsSearchDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchStatisticsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/suggestions": { "get": { "operationId": "getSearchSuggestions", @@ -12069,6 +12111,17 @@ ], "type": "object" }, + "SearchStatisticsResponseDto": { + "properties": { + "total": { + "type": "integer" + } + }, + "required": [ + "total" + ], + "type": "object" + }, "SearchSuggestionType": { "enum": [ "country", @@ -12974,6 +13027,125 @@ }, "type": "object" }, + "StatisticsSearchDto": { + "properties": { + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + }, + "createdAfter": { + "format": "date-time", + "type": "string" + }, + "createdBefore": { + "format": "date-time", + "type": "string" + }, + "description": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "isEncoded": { + "type": "boolean" + }, + "isFavorite": { + "type": "boolean" + }, + "isMotion": { + "type": "boolean" + }, + "isNotInAlbum": { + "type": "boolean" + }, + "isOffline": { + "type": "boolean" + }, + "lensModel": { + "nullable": true, + "type": "string" + }, + "libraryId": { + "format": "uuid", + "nullable": true, + "type": "string" + }, + "make": { + "type": "string" + }, + "model": { + "nullable": true, + "type": "string" + }, + "personIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "rating": { + "maximum": 5, + "minimum": -1, + "type": "number" + }, + "state": { + "nullable": true, + "type": "string" + }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "takenAfter": { + "format": "date-time", + "type": "string" + }, + "takenBefore": { + "format": "date-time", + "type": "string" + }, + "trashedAfter": { + "format": "date-time", + "type": "string" + }, + "trashedBefore": { + "format": "date-time", + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetTypeEnum" + } + ] + }, + "updatedAfter": { + "format": "date-time", + "type": "string" + }, + "updatedBefore": { + "format": "date-time", + "type": "string" + }, + "visibility": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetVisibility" + } + ] + } + }, + "type": "object" + }, "SyncAckDeleteDto": { "properties": { "types": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index fa7504916..0308ecb9e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -995,6 +995,38 @@ export type SmartSearchDto = { withDeleted?: boolean; withExif?: boolean; }; +export type StatisticsSearchDto = { + city?: string | null; + country?: string | null; + createdAfter?: string; + createdBefore?: string; + description?: string; + deviceId?: string; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isNotInAlbum?: boolean; + isOffline?: boolean; + lensModel?: string | null; + libraryId?: string | null; + make?: string; + model?: string | null; + personIds?: string[]; + rating?: number; + state?: string | null; + tagIds?: string[]; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + "type"?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + visibility?: AssetVisibility; +}; +export type SearchStatisticsResponseDto = { + total: number; +}; export type ServerAboutResponseDto = { build?: string; buildImage?: string; @@ -2882,6 +2914,18 @@ export function searchSmart({ smartSearchDto }: { body: smartSearchDto }))); } +export function searchAssetStatistics({ statisticsSearchDto }: { + statisticsSearchDto: StatisticsSearchDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SearchStatisticsResponseDto; + }>("/search/statistics", oazapfts.json({ + ...opts, + method: "POST", + body: statisticsSearchDto + }))); +} export function getSearchSuggestions({ country, includeNull, make, model, state, $type }: { country?: string; includeNull?: boolean; diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index c51ad8e06..9bda1fcad 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -11,8 +11,10 @@ import { SearchPeopleDto, SearchPlacesDto, SearchResponseDto, + SearchStatisticsResponseDto, SearchSuggestionRequestDto, SmartSearchDto, + StatisticsSearchDto, } from 'src/dtos/search.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { SearchService } from 'src/services/search.service'; @@ -29,6 +31,13 @@ export class SearchController { return this.service.searchMetadata(auth, dto); } + @Post('statistics') + @HttpCode(HttpStatus.OK) + @Authenticated() + searchAssetStatistics(@Auth() auth: AuthDto, @Body() dto: StatisticsSearchDto): Promise { + return this.service.searchStatistics(auth, dto); + } + @Post('random') @HttpCode(HttpStatus.OK) @Authenticated() diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 579cba680..81d74e0a7 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -37,12 +37,6 @@ class BaseSearchDto { @ValidateAssetVisibility({ optional: true }) visibility?: AssetVisibility; - @ValidateBoolean({ optional: true }) - withDeleted?: boolean; - - @ValidateBoolean({ optional: true }) - withExif?: boolean; - @ValidateDate({ optional: true }) createdBefore?: Date; @@ -92,13 +86,6 @@ class BaseSearchDto { @Optional({ nullable: true, emptyToNull: true }) lensModel?: string | null; - @IsInt() - @Min(1) - @Max(1000) - @Type(() => Number) - @Optional() - size?: number; - @ValidateBoolean({ optional: true }) isNotInAlbum?: boolean; @@ -115,7 +102,22 @@ class BaseSearchDto { rating?: number; } -export class RandomSearchDto extends BaseSearchDto { +class BaseSearchWithResultsDto extends BaseSearchDto { + @ValidateBoolean({ optional: true }) + withDeleted?: boolean; + + @ValidateBoolean({ optional: true }) + withExif?: boolean; + + @IsInt() + @Min(1) + @Max(1000) + @Type(() => Number) + @Optional() + size?: number; +} + +export class RandomSearchDto extends BaseSearchWithResultsDto { @ValidateBoolean({ optional: true }) withStacked?: boolean; @@ -179,7 +181,14 @@ export class MetadataSearchDto extends RandomSearchDto { page?: number; } -export class SmartSearchDto extends BaseSearchDto { +export class StatisticsSearchDto extends BaseSearchDto { + @IsString() + @IsNotEmpty() + @Optional() + description?: string; +} + +export class SmartSearchDto extends BaseSearchWithResultsDto { @IsString() @IsNotEmpty() query!: string; @@ -299,6 +308,11 @@ export class SearchResponseDto { assets!: SearchAssetResponseDto; } +export class SearchStatisticsResponseDto { + @ApiProperty({ type: 'integer' }) + total!: number; +} + class SearchExploreItem { value!: string; data!: AssetResponseDto; diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 806fdb1c7..b68e0e177 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -20,6 +20,20 @@ limit offset $7 +-- SearchRepository.searchStatistics +select + count(*) as "total" +from + "assets" + inner join "exif" on "assets"."id" = "exif"."assetId" +where + "assets"."visibility" = $1 + and "assets"."fileCreatedAt" >= $2 + and "exif"."lensModel" = $3 + and "assets"."ownerId" = any ($4::uuid[]) + and "assets"."isFavorite" = $5 + and "assets"."deletedAt" is null + -- SearchRepository.searchRandom ( select diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 747a59c65..14150d424 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -185,6 +185,7 @@ export class SearchRepository { async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; const items = await searchAssetBuilder(this.db, options) + .selectAll('assets') .orderBy('assets.fileCreatedAt', orderDirection) .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) @@ -193,6 +194,22 @@ export class SearchRepository { return paginationHelper(items, pagination.size); } + @GenerateSql({ + params: [ + { + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + isFavorite: true, + userIds: [DummyValue.UUID], + }, + ], + }) + searchStatistics(options: AssetSearchOptions) { + return searchAssetBuilder(this.db, options) + .select((qb) => qb.fn.countAll().as('total')) + .executeTakeFirstOrThrow(); + } + @GenerateSql({ params: [ 100, @@ -209,10 +226,12 @@ export class SearchRepository { const uuid = randomUUID(); const builder = searchAssetBuilder(this.db, options); const lessThan = builder + .selectAll('assets') .where('assets.id', '<', uuid) .orderBy(sql`random()`) .limit(size); const greaterThan = builder + .selectAll('assets') .where('assets.id', '>', uuid) .orderBy(sql`random()`) .limit(size); @@ -241,6 +260,7 @@ export class SearchRepository { return this.db.transaction().execute(async (trx) => { await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.CLIP])}`.execute(trx); const items = await searchAssetBuilder(trx, options) + .selectAll('assets') .innerJoin('smart_search', 'assets.id', 'smart_search.assetId') .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) .limit(pagination.size + 1) diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 3f122b5e7..73678f05a 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -10,9 +10,11 @@ import { SearchPeopleDto, SearchPlacesDto, SearchResponseDto, + SearchStatisticsResponseDto, SearchSuggestionRequestDto, SearchSuggestionType, SmartSearchDto, + StatisticsSearchDto, } from 'src/dtos/search.dto'; import { AssetOrder, AssetVisibility } from 'src/enum'; import { BaseService } from 'src/services/base.service'; @@ -67,6 +69,15 @@ export class SearchService extends BaseService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } + async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise { + const userIds = await this.getUserIdsToSearch(auth); + + return await this.searchRepository.searchStatistics({ + ...dto, + userIds, + }); + } + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { if (dto.visibility === AssetVisibility.LOCKED) { requireElevatedPermission(auth); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 5e5c6c5fb..d8171dc95 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -291,7 +291,6 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild return kysely .withPlugin(joinDeduplicationPlugin) .selectFrom('assets') - .selectAll('assets') .where('assets.visibility', '=', visibility) .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!))