1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(server): random assets API (#4184)

* feat(server): get random assets API

* Fix tests

* Use correct validation annotation

* Fix offset use in query

* Update API specs

* Fix typo

* Random assets e2e tests

* Improve e2e tests
This commit is contained in:
Daniele Ricci 2023-09-23 17:28:55 +02:00 committed by GitHub
parent fc64be6603
commit 014d164d99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 313 additions and 3 deletions

View File

@ -6303,6 +6303,49 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {number} [count]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getRandom: async (count?: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/random`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (count !== undefined) {
localVarQueryParameter['count'] = count;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -7043,6 +7086,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {number} [count]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getRandom(count?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getRandom(count, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {TimeBucketSize} size
@ -7318,6 +7371,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise<Array<MemoryLaneResponseDto>> {
return localVarFp.getMemoryLane(requestParameters.timestamp, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiGetRandomRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getRandom(requestParameters.count, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.
@ -7752,6 +7814,20 @@ export interface AssetApiGetMemoryLaneRequest {
readonly timestamp: string
}
/**
* Request parameters for getRandom operation in AssetApi.
* @export
* @interface AssetApiGetRandomRequest
*/
export interface AssetApiGetRandomRequest {
/**
*
* @type {number}
* @memberof AssetApiGetRandom
*/
readonly count?: number
}
/**
* Request parameters for getTimeBuckets operation in AssetApi.
* @export
@ -8244,6 +8320,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timestamp, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiGetRandomRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getRandom(requestParameters.count, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1510,6 +1510,50 @@
]
}
},
"/asset/random": {
"get": {
"operationId": "getRandom",
"parameters": [
{
"name": "count",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Asset"
]
}
},
"/asset/search": {
"post": {
"operationId": "searchAsset",

View File

@ -78,6 +78,7 @@ export interface IAssetRepository {
getByUserId(pagination: PaginationOptions, userId: string): Paginated<AssetEntity>;
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated<AssetEntity>;
getRandom(userId: string, count: number): Promise<AssetEntity[]>;
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getByLibraryId(libraryIds: string[]): Promise<AssetEntity[]>;

View File

@ -284,6 +284,11 @@ export class AssetService {
return mapStats(stats);
}
async getRandom(authUser: AuthUserDto, count: number): Promise<AssetResponseDto[]> {
const assets = await this.assetRepository.getRandom(authUser.id, count);
return assets.map((a) => mapAsset(a));
}
async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id);

View File

@ -1,4 +1,5 @@
import { IsBoolean, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
import { Optional } from '../../domain.util';
import { BulkIdsDto } from '../response-dto';
@ -25,3 +26,11 @@ export class UpdateAssetDto {
@IsString()
description?: string;
}
export class RandomAssetsDto {
@Optional()
@IsInt()
@IsPositive()
@Type(() => Number)
count?: number;
}

View File

@ -13,6 +13,7 @@ import {
MapMarkerResponseDto,
MemoryLaneDto,
MemoryLaneResponseDto,
RandomAssetsDto,
TimeBucketAssetDto,
TimeBucketDto,
TimeBucketResponseDto,
@ -41,6 +42,11 @@ export class AssetController {
return this.service.getMemoryLane(authUser, dto);
}
@Get('random')
getRandom(@AuthUser() authUser: AuthUserDto, @Query() dto: RandomAssetsDto): Promise<AssetResponseDto[]> {
return this.service.getRandom(authUser, dto.count ?? 1);
}
@SharedLinkRoute()
@Post('download/info')
getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Body() dto: DownloadInfoDto): Promise<DownloadResponseDto> {

View File

@ -429,6 +429,17 @@ export class AssetRepository implements IAssetRepository {
return result;
}
getRandom(ownerId: string, count: number): Promise<AssetEntity[]> {
// can't use queryBuilder because of custom OFFSET clause
return this.repository.query(
`SELECT *
FROM assets
WHERE "ownerId" = $1
OFFSET FLOOR(RANDOM() * (SELECT GREATEST(COUNT(*) - 1, 0) FROM ASSETS)) LIMIT $2`,
[ownerId, count],
);
}
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
const truncateValue = truncateMap[options.size];

View File

@ -1,4 +1,11 @@
import { IAssetRepository, IFaceRepository, IPersonRepository, LoginResponseDto, TimeBucketSize } from '@app/domain';
import {
AssetResponseDto,
IAssetRepository,
IFaceRepository,
IPersonRepository,
LoginResponseDto,
TimeBucketSize,
} from '@app/domain';
import { AppModule, AssetController } from '@app/immich';
import { AssetEntity, AssetType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
@ -322,7 +329,7 @@ describe(`${AssetController.name} (e2e)`, () => {
});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/album/statistics');
const { status, body } = await request(server).get('/asset/statistics');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
@ -378,6 +385,58 @@ describe(`${AssetController.name} (e2e)`, () => {
});
});
describe('GET /asset/random', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/asset/random');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return 1 random assets', async () => {
const { status, body } = await request(server)
.get('/asset/random')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(user1.userId);
// assets owned by user1
expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id);
// assets owned by user2
expect(assets[0].id).not.toBe(asset4.id);
});
it('should return 2 random assets', async () => {
const { status, body } = await request(server)
.get('/asset/random?count=2')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(2);
for (const asset of assets) {
expect(asset.ownerId).toBe(user1.userId);
// assets owned by user1
expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id);
// assets owned by user2
expect(asset.id).not.toBe(asset4.id);
}
});
it('should return error', async () => {
const { status } = await request(server)
.get('/asset/random?count=ABC')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
});
});
describe('GET /asset/time-buckets', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH });

View File

@ -11,6 +11,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
getWithout: jest.fn(),
getByChecksum: jest.fn(),
getWith: jest.fn(),
getRandom: jest.fn(),
getFirstAssetForAlbumId: jest.fn(),
getLastUpdatedAssetForAlbumId: jest.fn(),
getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),

View File

@ -6303,6 +6303,49 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {number} [count]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getRandom: async (count?: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/random`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (count !== undefined) {
localVarQueryParameter['count'] = count;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -7043,6 +7086,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {number} [count]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getRandom(count?: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getRandom(count, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {TimeBucketSize} size
@ -7318,6 +7371,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise<Array<MemoryLaneResponseDto>> {
return localVarFp.getMemoryLane(requestParameters.timestamp, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiGetRandomRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getRandom(requestParameters.count, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.
@ -7752,6 +7814,20 @@ export interface AssetApiGetMemoryLaneRequest {
readonly timestamp: string
}
/**
* Request parameters for getRandom operation in AssetApi.
* @export
* @interface AssetApiGetRandomRequest
*/
export interface AssetApiGetRandomRequest {
/**
*
* @type {number}
* @memberof AssetApiGetRandom
*/
readonly count?: number
}
/**
* Request parameters for getTimeBuckets operation in AssetApi.
* @export
@ -8244,6 +8320,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timestamp, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiGetRandomRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getRandom(requestParameters: AssetApiGetRandomRequest = {}, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getRandom(requestParameters.count, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiGetTimeBucketsRequest} requestParameters Request parameters.