feat(server): add /search/statistics resource (#18885)

This commit is contained in:
Jonathan Gilbert 2025-06-07 11:12:53 +10:00 committed by GitHub
parent ecb16d9907
commit fb4be6e231
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 299 additions and 16 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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": {

View file

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

View file

@ -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<SearchStatisticsResponseDto> {
return this.service.searchStatistics(auth, dto);
}
@Post('random')
@HttpCode(HttpStatus.OK)
@Authenticated()

View file

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

View file

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

View file

@ -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<number>().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)

View file

@ -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<SearchStatisticsResponseDto> {
const userIds = await this.getUserIdsToSearch(auth);
return await this.searchRepository.searchStatistics({
...dto,
userIds,
});
}
async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise<AssetResponseDto[]> {
if (dto.visibility === AssetVisibility.LOCKED) {
requireElevatedPermission(auth);

View file

@ -291,7 +291,6 @@ export function searchAssetBuilder(kysely: Kysely<DB>, 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!))